📱 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: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<String>('');
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<int>(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),
),
),
),
),
],