From 6bc8946fae87e498660ca199a9d7080e7af5f723 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 17 Dec 2025 22:34:07 +0800 Subject: [PATCH] :iphone: Responsive library screen --- lib/ui/screens/library_screen.dart | 348 +++++++++++++++-------------- 1 file changed, 186 insertions(+), 162 deletions(-) diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index e981c11..4da8fe8 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -12,6 +12,7 @@ import 'package:groovybox/ui/tabs/albums_tab.dart'; import 'package:groovybox/ui/tabs/playlists_tab.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart' as p; +import 'package:styled_widget/styled_widget.dart'; class LibraryScreen extends HookConsumerWidget { const LibraryScreen({super.key}); @@ -49,6 +50,7 @@ class LibraryScreen extends HookConsumerWidget { final searchQuery = useState(''); final isSelectionMode = selectedTrackIds.value.isNotEmpty; final isLargeScreen = MediaQuery.of(context).size.width > 600; + final isExtraLargeScreen = MediaQuery.of(context).size.width > 800; final selectedTab = isLargeScreen ? useState(0) : null; void toggleSelection(int id) { @@ -104,8 +106,20 @@ class LibraryScreen extends HookConsumerWidget { ], ) : AppBar( - centerTitle: true, - title: const Text('Library'), + title: isLargeScreen + ? Row( + children: [ + const Gap(4), + Image.asset( + 'assets/images/icon.jpg', + width: 32, + height: 32, + ).clipRRect(all: 8).padding(vertical: 16), + const Gap(12), + Text('GroovyBox', style: TextStyle(fontSize: 18)), + ], + ) + : const Text('Library'), actions: [ IconButton( icon: const Icon(Icons.add_circle_outline), @@ -144,6 +158,7 @@ class LibraryScreen extends HookConsumerWidget { } // Import lyrics if any + if (!context.mounted) return; if (lyricsPaths.isNotEmpty) { await _batchImportLyricsFromPaths( context, @@ -158,35 +173,44 @@ class LibraryScreen extends HookConsumerWidget { const Gap(8), ], ), - body: Row( + body: Column( children: [ - NavigationRail( - selectedIndex: selectedTab!.value, - onDestinationSelected: (index) => selectedTab.value = index, - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.audiotrack), - label: Text('Tracks'), - ), - NavigationRailDestination( - icon: Icon(Icons.album), - label: Text('Albums'), - ), - NavigationRailDestination( - icon: Icon(Icons.queue_music), - label: Text('Playlists'), - ), - ], - ), + const Divider(height: 1), Expanded( - child: _buildTabContent( - selectedTab.value, - ref, - repo, - selectedTrackIds, - searchQuery, - toggleSelection, - isSelectionMode, + child: Row( + children: [ + NavigationRail( + extended: isExtraLargeScreen, + selectedIndex: selectedTab!.value, + onDestinationSelected: (index) => selectedTab.value = index, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.audiotrack), + label: Text('Tracks'), + ), + NavigationRailDestination( + icon: Icon(Icons.album), + label: Text('Albums'), + ), + NavigationRailDestination( + icon: Icon(Icons.queue_music), + label: Text('Playlists'), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: _buildTabContent( + selectedTab.value, + ref, + repo, + selectedTrackIds, + searchQuery, + toggleSelection, + isSelectionMode, + ), + ), + ], ), ), ], @@ -364,149 +388,149 @@ class LibraryScreen extends HookConsumerWidget { return const Center(child: Text('No tracks match your search.')); } - return Column( + return Stack( children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: MediaQuery.of(context).size.width * 0.04, - vertical: 16.0, + ListView.builder( + padding: EdgeInsets.only( + bottom: 72 + MediaQuery.paddingOf(context).bottom, + top: 80, ), - child: TextField( - onChanged: (value) => searchQuery.value = value, - decoration: const InputDecoration( - hintText: 'Search tracks...', - prefixIcon: Icon(Icons.search), - ), - ), - ), - Expanded( - child: ListView.builder( - padding: EdgeInsets.only( - bottom: 72 + MediaQuery.paddingOf(context).bottom, - ), - itemCount: filteredTracks.length, - itemBuilder: (context, index) { - final track = filteredTracks[index]; - final isSelected = selectedTrackIds.value.contains(track.id); + itemCount: filteredTracks.length, + itemBuilder: (context, index) { + final track = filteredTracks[index]; + final isSelected = selectedTrackIds.value.contains(track.id); - if (isSelectionMode) { - return ListTile( - selected: isSelected, - selectedTileColor: Colors.white10, - leading: Checkbox( - value: isSelected, - onChanged: (_) => toggleSelection(track.id), - ), - title: Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '${track.artist ?? 'Unknown Artist'} • ${_formatDuration(track.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => toggleSelection(track.id), - ); - } - - return Dismissible( - key: Key('track_${track.id}'), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - child: const Icon(Icons.delete, color: Colors.white), + if (isSelectionMode) { + return ListTile( + selected: isSelected, + selectedTileColor: Colors.white10, + leading: Checkbox( + value: isSelected, + onChanged: (_) => toggleSelection(track.id), ), - confirmDismiss: (direction) async { - return await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Delete Track?'), - content: Text( - 'Are you sure you want to delete "${track.title}"? This cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => - Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => - Navigator.of(context).pop(true), - style: TextButton.styleFrom( - foregroundColor: Colors.red, - ), - child: const Text('Delete'), - ), - ], - ); - }, - ); - }, - onDismissed: (direction) { - ref - .read(trackRepositoryProvider.notifier) - .deleteTrack(track.id); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Deleted "${track.title}"')), - ); - }, - child: ListTile( - leading: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(8), - image: track.artUri != null - ? DecorationImage( - image: FileImage(File(track.artUri!)), - fit: BoxFit.cover, - ) - : null, + title: Text( + track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${track.artist ?? 'Unknown Artist'} • ${_formatDuration(track.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => toggleSelection(track.id), + ); + } + + return Dismissible( + key: Key('track_${track.id}'), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (direction) async { + return await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete Track?'), + content: Text( + 'Are you sure you want to delete "${track.title}"? This cannot be undone.', ), - child: track.artUri == null - ? const Icon( - Icons.music_note, - color: Colors.white54, + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + onDismissed: (direction) { + ref + .read(trackRepositoryProvider.notifier) + .deleteTrack(track.id); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted "${track.title}"')), + ); + }, + child: ListTile( + leading: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(8), + image: track.artUri != null + ? DecorationImage( + image: FileImage(File(track.artUri!)), + fit: BoxFit.cover, ) : null, ), + child: track.artUri == null + ? const Icon( + Icons.music_note, + color: Colors.white54, + ) + : null, ), - title: Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '${track.artist ?? 'Unknown Artist'} • ${_formatDuration(track.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: isSelectionMode - ? null - : IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - _showTrackOptions(context, ref, track); - }, - ), - onTap: () { - final audio = ref.read(audioHandlerProvider); - audio.playTrack(track); - }, - onLongPress: () { - // Enter selection mode - toggleSelection(track.id); - }, ), - ); - }, + title: Text( + track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${track.artist ?? 'Unknown Artist'} • ${_formatDuration(track.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: isSelectionMode + ? null + : IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + _showTrackOptions(context, ref, track); + }, + ), + onTap: () { + final audio = ref.read(audioHandlerProvider); + audio.playTrack(track); + }, + onLongPress: () { + // Enter selection mode + toggleSelection(track.id); + }, + ), + ); + }, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: SearchBar( + onChanged: (value) => searchQuery.value = value, + hintText: 'Search tracks...', + leading: const Icon(Icons.search), + padding: WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 24), + ), + ), ), ), ],