📱 Responsive library screen

This commit is contained in:
2025-12-17 22:34:07 +08:00
parent 935e77421e
commit 6bc8946fae

View File

@@ -12,6 +12,7 @@ import 'package:groovybox/ui/tabs/albums_tab.dart';
import 'package:groovybox/ui/tabs/playlists_tab.dart'; import 'package:groovybox/ui/tabs/playlists_tab.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:styled_widget/styled_widget.dart';
class LibraryScreen extends HookConsumerWidget { class LibraryScreen extends HookConsumerWidget {
const LibraryScreen({super.key}); const LibraryScreen({super.key});
@@ -49,6 +50,7 @@ class LibraryScreen extends HookConsumerWidget {
final searchQuery = useState<String>(''); final searchQuery = useState<String>('');
final isSelectionMode = selectedTrackIds.value.isNotEmpty; final isSelectionMode = selectedTrackIds.value.isNotEmpty;
final isLargeScreen = MediaQuery.of(context).size.width > 600; final isLargeScreen = MediaQuery.of(context).size.width > 600;
final isExtraLargeScreen = MediaQuery.of(context).size.width > 800;
final selectedTab = isLargeScreen ? useState<int>(0) : null; final selectedTab = isLargeScreen ? useState<int>(0) : null;
void toggleSelection(int id) { void toggleSelection(int id) {
@@ -104,8 +106,20 @@ class LibraryScreen extends HookConsumerWidget {
], ],
) )
: AppBar( : AppBar(
centerTitle: true, title: isLargeScreen
title: const Text('Library'), ? 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: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add_circle_outline), icon: const Icon(Icons.add_circle_outline),
@@ -144,6 +158,7 @@ class LibraryScreen extends HookConsumerWidget {
} }
// Import lyrics if any // Import lyrics if any
if (!context.mounted) return;
if (lyricsPaths.isNotEmpty) { if (lyricsPaths.isNotEmpty) {
await _batchImportLyricsFromPaths( await _batchImportLyricsFromPaths(
context, context,
@@ -158,35 +173,44 @@ class LibraryScreen extends HookConsumerWidget {
const Gap(8), const Gap(8),
], ],
), ),
body: Row( body: Column(
children: [ children: [
NavigationRail( const Divider(height: 1),
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'),
),
],
),
Expanded( Expanded(
child: _buildTabContent( child: Row(
selectedTab.value, children: [
ref, NavigationRail(
repo, extended: isExtraLargeScreen,
selectedTrackIds, selectedIndex: selectedTab!.value,
searchQuery, onDestinationSelected: (index) => selectedTab.value = index,
toggleSelection, destinations: const [
isSelectionMode, 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 const Center(child: Text('No tracks match your search.'));
} }
return Column( return Stack(
children: [ children: [
Padding( ListView.builder(
padding: EdgeInsets.symmetric( padding: EdgeInsets.only(
horizontal: MediaQuery.of(context).size.width * 0.04, bottom: 72 + MediaQuery.paddingOf(context).bottom,
vertical: 16.0, top: 80,
), ),
child: TextField( itemCount: filteredTracks.length,
onChanged: (value) => searchQuery.value = value, itemBuilder: (context, index) {
decoration: const InputDecoration( final track = filteredTracks[index];
hintText: 'Search tracks...', final isSelected = selectedTrackIds.value.contains(track.id);
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);
if (isSelectionMode) { if (isSelectionMode) {
return ListTile( return ListTile(
selected: isSelected, selected: isSelected,
selectedTileColor: Colors.white10, selectedTileColor: Colors.white10,
leading: Checkbox( leading: Checkbox(
value: isSelected, value: isSelected,
onChanged: (_) => toggleSelection(track.id), 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),
), ),
confirmDismiss: (direction) async { title: Text(
return await showDialog( track.title,
context: context, maxLines: 1,
builder: (context) { overflow: TextOverflow.ellipsis,
return AlertDialog( ),
title: const Text('Delete Track?'), subtitle: Text(
content: Text( '${track.artist ?? 'Unknown Artist'}${_formatDuration(track.duration)}',
'Are you sure you want to delete "${track.title}"? This cannot be undone.', maxLines: 1,
), overflow: TextOverflow.ellipsis,
actions: [ ),
TextButton( onTap: () => toggleSelection(track.id),
onPressed: () => );
Navigator.of(context).pop(false), }
child: const Text('Cancel'),
), return Dismissible(
TextButton( key: Key('track_${track.id}'),
onPressed: () => direction: DismissDirection.endToStart,
Navigator.of(context).pop(true), background: Container(
style: TextButton.styleFrom( color: Colors.red,
foregroundColor: Colors.red, alignment: Alignment.centerRight,
), padding: const EdgeInsets.only(right: 20),
child: const Text('Delete'), child: const Icon(Icons.delete, color: Colors.white),
), ),
], confirmDismiss: (direction) async {
); return await showDialog(
}, context: context,
); builder: (context) {
}, return AlertDialog(
onDismissed: (direction) { title: const Text('Delete Track?'),
ref content: Text(
.read(trackRepositoryProvider.notifier) 'Are you sure you want to delete "${track.title}"? This cannot be undone.',
.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 actions: [
? const Icon( TextButton(
Icons.music_note, onPressed: () => Navigator.of(context).pop(false),
color: Colors.white54, 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, : 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),
),
),
), ),
), ),
], ],