# 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> tracks; final List> artists; final List> 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>? tracks, List>? artists, List>? 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 { 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 _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((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 createState() => _SearchDesktopPageState(); } class _SearchDesktopPageState extends ConsumerState { 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 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 createState() => _SearchMobilePageState(); } class _SearchMobilePageState extends ConsumerState { 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 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 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 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 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 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