Backend: - FastAPI avec PostgreSQL et Redis - Authentification JWT complète - API REST pour musique, playlists, recherche - Streaming audio via yt-dlp - SQLAlchemy 2.0 async Frontend: - Flutter avec thème néon cyberpunk - State management Riverpod - Layout adaptatif desktop/mobile - Lecteur audio avec mini-player Infrastructure: - Docker Compose (PostgreSQL + Redis) - Scripts d'installation automatisés - Scripts de build pour exécutables Fichiers ajoutés: - BUILD_CLIENT_*.bat/sh: Scripts de compilation - BUILD_CLIENT_README.md: Documentation compilation - CHECK_FLUTTER.sh: Vérificateur d'environnement - requirements.txt mis à jour pour Python 3.13 - Modèles SQLAlchemy corrigés (metadata -> extra_metadata) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
26 KiB
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
/// 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
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
final searchProvider = StateNotifierProvider<SearchNotifier, SearchState>((ref) {
final musicApiService = ref.watch(musicApiServiceProvider);
return SearchNotifier(musicApiService);
});
Step 4: Commit
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
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
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)
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
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
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
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
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
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:
export 'search_track_card.dart';
export 'search_artist_card.dart';
export 'search_album_card.dart';
Step 5: Commit
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
// 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
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
// 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
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
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
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
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
export 'home/home_page.dart';
export 'home/mobile_home_page.dart';
export 'auth/login_page.dart';
export 'search/search_page.dart';
Step 2: Commit
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
# 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
git add docs/search-feature.md
git commit -m "docs: add search feature documentation"
Testing Checklist
Before marking this feature complete, verify:
# 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
- Library Page - Liked songs, albums, artists
- Player Enhancements - Queue view, shuffle, repeat
- Artist Details Page - Full artist info, tracks, albums
- Album Details Page - Tracklist, play all
- Settings Page - User profile, audio quality, cache management