191 lines
6 KiB
Dart
191 lines
6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../state/providers.dart';
|
|
import '../subsonic/models.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
/// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
|
|
/// offline (playlist mutations require the server).
|
|
Future<void> showAddToPlaylistSheet(
|
|
BuildContext context, {
|
|
required List<Song> songs,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: TimbreColors.background,
|
|
isScrollControlled: true,
|
|
builder: (_) => _AddToPlaylistSheet(songs: songs),
|
|
);
|
|
}
|
|
|
|
class _AddToPlaylistSheet extends ConsumerWidget {
|
|
const _AddToPlaylistSheet({required this.songs});
|
|
|
|
final List<Song> songs;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final accent = Theme.of(context).colorScheme.primary;
|
|
final playlists = ref.watch(playlistsProvider).playlists;
|
|
final connected = ref.watch(subsonicClientProvider) != null;
|
|
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
|
child: Text('Add to playlist',
|
|
style:
|
|
TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
|
),
|
|
const SizedBox(height: TimbreSpacing.md),
|
|
if (!connected)
|
|
const Padding(
|
|
padding: EdgeInsets.all(TimbreSpacing.xl),
|
|
child: Text('Connect to a server to manage playlists.',
|
|
style: TextStyle(color: TimbreColors.dimmed)),
|
|
)
|
|
else ...[
|
|
_Tile(
|
|
icon: Icons.add,
|
|
label: 'New playlist…',
|
|
accent: accent,
|
|
onTap: () => _createAndAdd(context, ref),
|
|
),
|
|
Flexible(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
for (final p in playlists)
|
|
_Tile(
|
|
icon: Icons.queue_music,
|
|
label: p.name,
|
|
trailing:
|
|
p.songCount != null ? '${p.songCount}' : null,
|
|
onTap: () async {
|
|
await ref
|
|
.read(playlistsProvider.notifier)
|
|
.addTracks(p.id, songs);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
_toast(context, 'Added to ${p.name}');
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
|
|
final name = await promptPlaylistName(context, title: 'New playlist');
|
|
if (name == null || name.isEmpty) return;
|
|
final id = await ref.read(playlistsProvider.notifier).create(name);
|
|
if (id != null) {
|
|
await ref.read(playlistsProvider.notifier).addTracks(id, songs);
|
|
}
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
_toast(context, id != null ? 'Added to $name' : 'Could not create playlist');
|
|
}
|
|
}
|
|
}
|
|
|
|
class _Tile extends StatelessWidget {
|
|
const _Tile({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.trailing,
|
|
this.accent,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final String? trailing;
|
|
final Color? accent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Container(
|
|
constraints:
|
|
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
|
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 20, color: accent ?? TimbreColors.dimmed),
|
|
const SizedBox(width: TimbreSpacing.lg),
|
|
Expanded(
|
|
child: Text(label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: accent ?? TimbreColors.foreground)),
|
|
),
|
|
if (trailing != null)
|
|
Text(trailing!,
|
|
style: const TextStyle(color: TimbreColors.dimmed)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shared name prompt used by create / rename flows. Returns the trimmed name
|
|
/// or null if cancelled.
|
|
Future<String?> promptPlaylistName(
|
|
BuildContext context, {
|
|
required String title,
|
|
String initial = '',
|
|
}) {
|
|
final controller = TextEditingController(text: initial);
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
final accent = Theme.of(ctx).colorScheme.primary;
|
|
return AlertDialog(
|
|
backgroundColor: TimbreColors.surface,
|
|
title: Text(title),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
style: const TextStyle(color: TimbreColors.foreground),
|
|
decoration: const InputDecoration(
|
|
hintText: 'Playlist name',
|
|
hintStyle: TextStyle(color: TimbreColors.dimmed),
|
|
),
|
|
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('Cancel')),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
|
child: Text('Save', style: TextStyle(color: accent)),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _toast(BuildContext context, String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
|
);
|
|
}
|