9c504d2c3d
Features: - Frontend Flutter avec thème néon cyberpunk - Backend FastAPI avec streaming YouTube - Base de données PostgreSQL + Redis - Authentification JWT complète - Recherche multi-source (DB + YouTube) - Playlists CRUD avec drag & drop - Queue management - Settings avec audio quality - Interface adaptative (Desktop + Mobile) Tech Stack: - Frontend: Flutter 3.2+, Riverpod - Backend: Python 3.11+, FastAPI - Database: PostgreSQL 15+ - Cache: Redis 7+ - Streaming: yt-dlp + FFmpeg 🚀 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
1053 lines
26 KiB
Markdown
1053 lines
26 KiB
Markdown
# Search Page Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a complete search page with real-time search, results display, and filtering for tracks, artists, and albums.
|
|
|
|
**Architecture:** Search input with debouncing → API call → Results grouped by type (tracks/artists/albums) → Tap to view details or play. Uses existing MusicApiService and follows neon cyberpunk theme.
|
|
|
|
**Tech Stack:** Flutter, Riverpod, MusicApiService (already exists), Debouncer utility
|
|
|
|
---
|
|
|
|
## Task 1: Create Search State and Notifier
|
|
|
|
**Files:**
|
|
- Create: `frontend/lib/presentation/providers/search_provider.dart`
|
|
|
|
**Step 1: Write the search state model**
|
|
|
|
```dart
|
|
/// Search state
|
|
class SearchState {
|
|
final String query;
|
|
final bool isSearching;
|
|
final List<Map<String, dynamic>> tracks;
|
|
final List<Map<String, dynamic>> artists;
|
|
final List<Map<String, dynamic>> albums;
|
|
final String? error;
|
|
|
|
const SearchState({
|
|
this.query = '',
|
|
this.isSearching = false,
|
|
this.tracks = const [],
|
|
this.artists = const [],
|
|
this.albums = const [],
|
|
this.error,
|
|
});
|
|
|
|
SearchState copyWith({
|
|
String? query,
|
|
bool? isSearching,
|
|
List<Map<String, dynamic>>? tracks,
|
|
List<Map<String, dynamic>>? artists,
|
|
List<Map<String, dynamic>>? albums,
|
|
String? error,
|
|
}) {
|
|
return SearchState(
|
|
query: query ?? this.query,
|
|
isSearching: isSearching ?? this.isSearching,
|
|
tracks: tracks ?? this.tracks,
|
|
artists: artists ?? this.artists,
|
|
albums: albums ?? this.albums,
|
|
error: error,
|
|
);
|
|
}
|
|
|
|
int get totalResults => tracks.length + artists.length + albums.length;
|
|
}
|
|
```
|
|
|
|
**Step 2: Write the search notifier with debouncing**
|
|
|
|
```dart
|
|
class SearchNotifier extends StateNotifier<SearchState> {
|
|
SearchNotifier(this._musicApiService) : super(const SearchState());
|
|
|
|
final MusicApiService _musicApiService;
|
|
Timer? _debounceTimer;
|
|
|
|
static const _debounceDuration = Duration(milliseconds: 500);
|
|
|
|
void search(String query) {
|
|
if (query.trim().isEmpty) {
|
|
state = const SearchState();
|
|
_debounceTimer?.cancel();
|
|
return;
|
|
}
|
|
|
|
_debounceTimer?.cancel();
|
|
state = state.copyWith(query: query, isSearching: true);
|
|
|
|
_debounceTimer = Timer(_debounceDuration, () => _performSearch(query));
|
|
}
|
|
|
|
Future<void> _performSearch(String query) async {
|
|
try {
|
|
final results = await _musicApiService.search(
|
|
query,
|
|
type: 'all',
|
|
limit: 20,
|
|
);
|
|
|
|
state = SearchState(
|
|
query: query,
|
|
tracks: results['tracks'] ?? [],
|
|
artists: results['artists'] ?? [],
|
|
albums: results['albums'] ?? [],
|
|
);
|
|
} catch (e) {
|
|
state = SearchState(
|
|
query: query,
|
|
error: e.toString(),
|
|
);
|
|
} finally {
|
|
// Keep isSearching false if this was the latest search
|
|
if (state.query == query) {
|
|
state = state.copyWith(isSearching: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void clear() {
|
|
_debounceTimer?.cancel();
|
|
state = const SearchState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounceTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Create the provider**
|
|
|
|
```dart
|
|
final searchProvider = StateNotifierProvider<SearchNotifier, SearchState>((ref) {
|
|
final musicApiService = ref.watch(musicApiServiceProvider);
|
|
return SearchNotifier(musicApiService);
|
|
});
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/lib/presentation/providers/search_provider.dart
|
|
git commit -m "feat: add search state management with debouncing"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create Search Page UI
|
|
|
|
**Files:**
|
|
- Create: `frontend/lib/presentation/pages/search/search_page.dart`
|
|
- Create: `frontend/lib/presentation/pages/search/search_desktop_page.dart`
|
|
- Create: `frontend/lib/presentation/pages/search/search_mobile_page.dart`
|
|
- Modify: `frontend/lib/presentation/adaptive/adaptive_layout.dart` (add search route handling)
|
|
|
|
**Step 1: Write the main search page widget**
|
|
|
|
```dart
|
|
class SearchPage extends ConsumerWidget {
|
|
const SearchPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
if (constraints.maxWidth >= 800) {
|
|
return const SearchDesktopPage();
|
|
} else {
|
|
return const SearchMobilePage();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Write desktop search page**
|
|
|
|
```dart
|
|
class SearchDesktopPage extends ConsumerStatefulWidget {
|
|
const SearchDesktopPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<SearchDesktopPage> createState() => _SearchDesktopPageState();
|
|
}
|
|
|
|
class _SearchDesktopPageState extends ConsumerState<SearchDesktopPage> {
|
|
final _controller = TextEditingController();
|
|
final _focusNode = FocusNode();
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
// Search bar
|
|
Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: _buildSearchBar(),
|
|
),
|
|
// Results
|
|
Expanded(
|
|
child: _buildResults(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceVariant,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(
|
|
color: AppColors.cyan.withOpacity(0.2),
|
|
width: 2,
|
|
),
|
|
),
|
|
child: TextField(
|
|
controller: _controller,
|
|
focusNode: _focusNode,
|
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 16),
|
|
decoration: InputDecoration(
|
|
hintText: 'What do you want to listen to?',
|
|
hintStyle: TextStyle(color: AppColors.muted),
|
|
prefixIcon: const Icon(Icons.search, color: AppColors.cyan),
|
|
suffixIcon: _controller.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, color: AppColors.muted),
|
|
onPressed: () {
|
|
_controller.clear();
|
|
ref.read(searchProvider.notifier).clear();
|
|
},
|
|
)
|
|
: null,
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
),
|
|
onChanged: (value) {
|
|
ref.read(searchProvider.notifier).search(value);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResults() {
|
|
final searchState = ref.watch(searchProvider);
|
|
|
|
if (searchState.query.isEmpty) {
|
|
return _buildEmptyState();
|
|
}
|
|
|
|
if (searchState.isSearching) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: AppColors.cyan),
|
|
);
|
|
}
|
|
|
|
if (searchState.error != null) {
|
|
return _buildErrorState(searchState.error!);
|
|
}
|
|
|
|
if (searchState.totalResults == 0) {
|
|
return _buildNoResultsState();
|
|
}
|
|
|
|
return CustomScrollView(
|
|
slivers: [
|
|
// Tracks section
|
|
if (searchState.tracks.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildSection('Tracks', searchState.tracks),
|
|
),
|
|
|
|
// Artists section
|
|
if (searchState.artists.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildSection('Artists', searchState.artists),
|
|
),
|
|
|
|
// Albums section
|
|
if (searchState.albums.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildSection('Albums', searchState.albums),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSection(String title, List<dynamic> items) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.cyan,
|
|
),
|
|
),
|
|
),
|
|
GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4,
|
|
mainAxisSpacing: 16,
|
|
crossAxisSpacing: 16,
|
|
childAspectRatio: 1,
|
|
),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
return _buildResultCard(items[index]);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildResultCard(dynamic item) {
|
|
// Implementation depends on item type
|
|
return Card(
|
|
child: Center(child: Text(item.toString())),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.search,
|
|
size: 64,
|
|
color: AppColors.muted,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Search for your favorite music',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: AppColors.muted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorState(String error) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
size: 64,
|
|
color: AppColors.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Something went wrong',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: AppColors.error,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
error,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.muted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNoResultsState() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.music_off,
|
|
size: 64,
|
|
color: AppColors.muted,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No results found',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: AppColors.muted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Write mobile search page (simplified layout)**
|
|
|
|
```dart
|
|
class SearchMobilePage extends ConsumerStatefulWidget {
|
|
const SearchMobilePage({super.key});
|
|
|
|
@override
|
|
ConsumerState<SearchMobilePage> createState() => _SearchMobilePageState();
|
|
}
|
|
|
|
class _SearchMobilePageState extends ConsumerState<SearchMobilePage> {
|
|
final _controller = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
// Search bar
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: _buildSearchBar(),
|
|
),
|
|
// Results (same as desktop but different layout)
|
|
Expanded(
|
|
child: _buildResults(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar() {
|
|
// Similar to desktop but smaller
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceVariant,
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
child: TextField(
|
|
controller: _controller,
|
|
onChanged: (value) {
|
|
ref.read(searchProvider.notifier).search(value);
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText: 'Search...',
|
|
prefixIcon: const Icon(Icons.search, color: AppColors.cyan),
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResults() {
|
|
// Reuse desktop logic but with 2-column grid
|
|
final searchState = ref.watch(searchProvider);
|
|
|
|
if (searchState.query.isEmpty) {
|
|
return _buildEmptyState();
|
|
}
|
|
|
|
// ... similar to desktop
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return Center(
|
|
child: Icon(Icons.search, size: 48, color: AppColors.muted),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Update adaptive layout to include search**
|
|
|
|
```dart
|
|
Widget _buildCurrentPage(String page) {
|
|
switch (page) {
|
|
case 'home':
|
|
return const HomePage();
|
|
case 'search':
|
|
return const SearchPage(); // Add this
|
|
case 'library':
|
|
return const _PlaceholderPage(title: 'Library');
|
|
case 'settings':
|
|
return const _PlaceholderPage(title: 'Settings');
|
|
default:
|
|
return const HomePage();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/lib/presentation/pages/search/
|
|
git commit -m "feat: add search page with desktop and mobile layouts"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Create Search Result Cards
|
|
|
|
**Files:**
|
|
- Create: `frontend/lib/presentation/widgets/search/search_track_card.dart`
|
|
- Create: `frontend/lib/presentation/widgets/search/search_artist_card.dart`
|
|
- Create: `frontend/lib/presentation/widgets/search/search_album_card.dart`
|
|
|
|
**Step 1: Write track search result card**
|
|
|
|
```dart
|
|
class SearchTrackCard extends StatelessWidget {
|
|
final Map<String, dynamic> track;
|
|
final VoidCallback? onTap;
|
|
|
|
const SearchTrackCard({
|
|
required this.track,
|
|
this.onTap,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: AppColors.primaryGradient,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: AppColors.cyan.withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Thumbnail or icon
|
|
Expanded(
|
|
child: Icon(
|
|
Icons.music_note,
|
|
color: AppColors.onBackground.withOpacity(0.8),
|
|
size: 32,
|
|
),
|
|
),
|
|
// Track info
|
|
Text(
|
|
track['title'] ?? 'Unknown Track',
|
|
style: const TextStyle(
|
|
color: AppColors.onBackground,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 16,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
track['artist'] ?? 'Unknown Artist',
|
|
style: const TextStyle(
|
|
color: AppColors.onBackground.withOpacity(0.7),
|
|
fontSize: 14,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Write artist search result card**
|
|
|
|
```dart
|
|
class SearchArtistCard extends StatelessWidget {
|
|
final Map<String, dynamic> artist;
|
|
final VoidCallback? onTap;
|
|
|
|
const SearchArtistCard({
|
|
required this.artist,
|
|
this.onTap,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: AppColors.violet.withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Artist image or placeholder
|
|
Expanded(
|
|
child: Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
gradient: AppColors.accentGradient,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(11),
|
|
),
|
|
),
|
|
child: Icon(
|
|
Icons.person,
|
|
color: AppColors.onBackground.withOpacity(0.8),
|
|
size: 40,
|
|
),
|
|
),
|
|
),
|
|
// Artist name
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Text(
|
|
artist['name'] ?? 'Unknown Artist',
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 14,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Write album search result card**
|
|
|
|
```dart
|
|
class SearchAlbumCard extends StatelessWidget {
|
|
final Map<String, dynamic> album;
|
|
final VoidCallback? onTap;
|
|
|
|
const SearchAlbumCard({
|
|
required this.album,
|
|
this.onTap,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: AppColors.rose.withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Album cover or placeholder
|
|
Expanded(
|
|
child: Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
gradient: AppColors.fullGradient,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(11),
|
|
),
|
|
),
|
|
child: Icon(
|
|
Icons.album,
|
|
color: AppColors.onBackground.withOpacity(0.8),
|
|
size: 40,
|
|
),
|
|
),
|
|
),
|
|
// Album info
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
album['title'] ?? 'Unknown Album',
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 14,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (album['artist'] != null)
|
|
Text(
|
|
album['artist']!,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 12,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Export widgets**
|
|
|
|
Create `frontend/lib/presentation/widgets/search/search_widgets.dart`:
|
|
|
|
```dart
|
|
export 'search_track_card.dart';
|
|
export 'search_artist_card.dart';
|
|
export 'search_album_card.dart';
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/lib/presentation/widgets/search/
|
|
git commit -m "feat: add search result cards for tracks, artists, albums"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Connect Search Results to Player
|
|
|
|
**Files:**
|
|
- Modify: `frontend/lib/presentation/pages/search/search_desktop_page.dart`
|
|
- Modify: `frontend/lib/presentation/providers/music_provider.dart` (add queue management)
|
|
|
|
**Step 1: Add play on tap functionality to search cards**
|
|
|
|
```dart
|
|
// In _buildResultCard method
|
|
Widget _buildResultCard(dynamic item) {
|
|
final playerNotifier = ref.read(playerProvider.notifier);
|
|
|
|
if (item.containsKey('title') && item.containsKey('artist')) {
|
|
// It's a track
|
|
return SearchTrackCard(
|
|
track: item,
|
|
onTap: () => _playTrack(item, playerNotifier),
|
|
);
|
|
} else if (item.containsKey('name')) {
|
|
// It's an artist or album
|
|
return SearchArtistCard(
|
|
artist: item,
|
|
onTap: () => _showArtistDetails(item),
|
|
);
|
|
}
|
|
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
void _playTrack(Map<String, dynamic> track, PlayerNotifier playerNotifier) {
|
|
// Add to queue and play
|
|
playerNotifier.setQueue([
|
|
Track(
|
|
id: track['id'] ?? '',
|
|
title: track['title'] ?? '',
|
|
artist: track['artist'],
|
|
duration: track['duration'],
|
|
imageUrl: track['image_url'],
|
|
youtubeId: track['youtube_id'],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
]);
|
|
|
|
playerNotifier.play();
|
|
}
|
|
|
|
void _showArtistDetails(Map<String, dynamic> artist) {
|
|
// Navigate to artist details (not implemented yet)
|
|
print('Show artist: ${artist['name']}');
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/lib/presentation/pages/search/
|
|
git commit -m "feat: connect search results to player"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Add Search to Navigation
|
|
|
|
**Files:**
|
|
- Modify: `frontend/lib/presentation/widgets/desktop/desktop_sidebar.dart`
|
|
- Modify: `frontend/lib/presentation/adaptive/adaptive_layout.dart`
|
|
|
|
**Step 1: Update nav items to include search**
|
|
|
|
```dart
|
|
// In adaptive_layout.dart
|
|
final List<_NavItem> _navItems = const [
|
|
_NavItem(page: 'home', label: 'Home', icon: Icons.home_outlined),
|
|
_NavItem(page: 'search', label: 'Search', icon: Icons.search_outlined),
|
|
_NavItem(page: 'library', label: 'Library', icon: Icons.library_music_outlined),
|
|
_NavItem(page: 'settings', label: 'Settings', icon: Icons.settings_outlined),
|
|
];
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/lib/presentation/adaptive/
|
|
git commit -m "feat: add search to navigation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Create Search Page Tests
|
|
|
|
**Files:**
|
|
- Create: `frontend/test/presentation/providers/search_provider_test.dart`
|
|
- Create: `frontend/test/presentation/pages/search/search_page_test.dart`
|
|
|
|
**Step 1: Write search provider tests**
|
|
|
|
```dart
|
|
void main() {
|
|
group('SearchNotifier', () {
|
|
late SearchNotifier notifier;
|
|
late MockMusicApiService mockApi;
|
|
|
|
setUp(() {
|
|
mockApi = MockMusicApiService();
|
|
notifier = SearchNotifier(mockApi);
|
|
});
|
|
|
|
tearDown(() {
|
|
notifier.dispose();
|
|
});
|
|
|
|
test('initial state is empty', () {
|
|
expect(notifier.state.query, '');
|
|
expect(notifier.state.isSearching, false);
|
|
expect(notifier.state.tracks, isEmpty);
|
|
});
|
|
|
|
test('search updates state with results', () async {
|
|
when(mockApi.search('test query'))
|
|
.thenAnswer((_) async => {
|
|
'tracks': [
|
|
{'id': '1', 'title': 'Test Track', 'artist': 'Test Artist'},
|
|
],
|
|
'artists': [],
|
|
'albums': [],
|
|
'total': 1,
|
|
});
|
|
|
|
notifier.search('test query');
|
|
|
|
await Future.delayed(const Duration(milliseconds: 600));
|
|
|
|
expect(notifier.state.query, 'test query');
|
|
expect(notifier.state.tracks.length, 1);
|
|
expect(notifier.state.tracks.first['title'], 'Test Track');
|
|
});
|
|
|
|
test('clear resets state', () {
|
|
notifier.search('test');
|
|
notifier.clear();
|
|
|
|
expect(notifier.state.query, '');
|
|
expect(notifier.state.isSearching, false);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
**Step 2: Write search page widget tests**
|
|
|
|
```dart
|
|
void main() {
|
|
testWidgets('SearchPage shows desktop layout on wide screen', (tester) async {
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
data: const MediaQueryData(size: Size(1200, 800)),
|
|
child: MaterialApp(
|
|
home: ProviderScope(
|
|
overrides: [
|
|
searchProvider.overrideWithProvider((ref) => SearchNotifier(mockApi)),
|
|
],
|
|
child: const SearchPage(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.text('What do you want to listen to?'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('SearchPage shows mobile layout on narrow screen', (tester) async {
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
data: const MediaQueryData(size: Size(400, 800)),
|
|
child: MaterialApp(
|
|
home: ProviderScope(
|
|
overrides: [
|
|
searchProvider.overrideWithProvider((ref) => SearchNotifier(mockApi)),
|
|
],
|
|
child: const SearchPage(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.text('Search...'), findsOneWidget);
|
|
});
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/test/
|
|
git commit -m "test: add search page and provider tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Update Export Files
|
|
|
|
**Files:**
|
|
- Modify: `frontend/lib/presentation/pages/pages.dart` (create if doesn't exist)
|
|
|
|
**Step 1: Create pages export file**
|
|
|
|
```dart
|
|
export 'home/home_page.dart';
|
|
export 'home/mobile_home_page.dart';
|
|
export 'auth/login_page.dart';
|
|
export 'search/search_page.dart';
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/lib/presentation/pages/pages.dart
|
|
git commit -m "chore: export search page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Documentation
|
|
|
|
**Files:**
|
|
- Create: `docs/search-feature.md`
|
|
|
|
**Step 1: Write documentation**
|
|
|
|
```markdown
|
|
# Search Feature
|
|
|
|
## Overview
|
|
Real-time search with debouncing across tracks, artists, and albums.
|
|
|
|
## Usage
|
|
- Access from sidebar (desktop) or bottom nav (mobile)
|
|
- Type to search with 500ms debounce
|
|
- Results grouped by type
|
|
- Tap track to play immediately
|
|
- Tap artist/album for details (TODO)
|
|
|
|
## Components
|
|
- `SearchProvider` - State management
|
|
- `SearchPage` - Adaptive UI
|
|
- `SearchTrackCard`, `SearchArtistCard`, `SearchAlbumCard` - Result cards
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add docs/search-feature.md
|
|
git commit -m "docs: add search feature documentation"
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
Before marking this feature complete, verify:
|
|
|
|
```bash
|
|
# Backend API is running
|
|
curl http://localhost:8000/api/v1/music/search?q=test
|
|
|
|
# Frontend search works
|
|
flutter test test/presentation/providers/search_provider_test.dart
|
|
flutter test test/presentation/pages/search/search_page_test.dart
|
|
|
|
# Manual test
|
|
flutter run
|
|
# Navigate to search, type query, verify results
|
|
# Tap track, verify it plays
|
|
```
|
|
|
|
---
|
|
|
|
## Related Files
|
|
|
|
- API: `frontend/lib/infrastructure/datasources/remote/music_api_service.dart`
|
|
- Provider: `frontend/lib/presentation/providers/music_provider.dart`
|
|
- Theme: `frontend/lib/core/theme/colors.dart`
|
|
- Design: `docs/plans/2025-01-18-ui-ux-design.md`
|
|
|
|
---
|
|
|
|
## Next Features After Search
|
|
|
|
1. **Library Page** - Liked songs, albums, artists
|
|
2. **Player Enhancements** - Queue view, shuffle, repeat
|
|
3. **Artist Details Page** - Full artist info, tracks, albums
|
|
4. **Album Details Page** - Tracklist, play all
|
|
5. **Settings Page** - User profile, audio quality, cache management
|