mobile-music/lib/screens/add_to_playlist_sheet.dart
2026-07-29 16:22:00 -04:00

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)),
);
}