updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
151
lib/screens/add_tag_sheet.dart
Normal file
151
lib/screens/add_tag_sheet.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
|
||||
|
||||
/// Bottom sheet to apply a tag to [songs] — pick an existing tag or create one.
|
||||
/// Tags are playlists under the hood, so this mirrors the "add to playlist"
|
||||
/// sheet; the difference is membership is idempotent (a song already carrying
|
||||
/// the tag is skipped, not duplicated). No-op when offline.
|
||||
Future<void> showAddTagSheet(
|
||||
BuildContext context, {
|
||||
required List<Song> songs,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _AddTagSheet(songs: songs),
|
||||
);
|
||||
}
|
||||
|
||||
class _AddTagSheet extends ConsumerWidget {
|
||||
const _AddTagSheet({required this.songs});
|
||||
|
||||
final List<Song> songs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final tags = ref.watch(tagsProvider);
|
||||
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 tag',
|
||||
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 tags.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else ...[
|
||||
_Tile(
|
||||
icon: Icons.add,
|
||||
label: 'New tag…',
|
||||
accent: accent,
|
||||
onTap: () => _createAndAdd(context, ref),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final t in tags)
|
||||
_Tile(
|
||||
icon: Icons.label_outline,
|
||||
label: t.name,
|
||||
trailing:
|
||||
t.songCount != null ? '${t.songCount}' : null,
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playlistsProvider.notifier)
|
||||
.addToTag(t.id, songs);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showToast(context, 'Tagged "${t.name}"');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
|
||||
final name = await promptPlaylistName(context, title: 'New tag');
|
||||
if (name == null || name.isEmpty) return;
|
||||
final id = await ref.read(playlistsProvider.notifier).createTag(name);
|
||||
if (id != null) {
|
||||
await ref.read(playlistsProvider.notifier).addToTag(id, songs);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showToast(context,
|
||||
id != null ? 'Tagged "$name"' : 'Could not create tag');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/toast.dart';
|
||||
|
||||
/// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
|
||||
/// offline (playlist mutations require the server).
|
||||
|
|
@ -27,7 +28,7 @@ class _AddToPlaylistSheet extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final playlists = ref.watch(playlistsProvider).playlists;
|
||||
final playlists = ref.watch(realPlaylistsProvider);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return SafeArea(
|
||||
|
|
@ -184,8 +185,5 @@ Future<String?> promptPlaylistName(
|
|||
);
|
||||
}
|
||||
|
||||
void _toast(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
void _toast(BuildContext context, String message) =>
|
||||
showToast(context, message);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import '../state/providers.dart';
|
|||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_tag_sheet.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
import 'downloads_screen.dart';
|
||||
import 'favorites_screen.dart';
|
||||
import 'playlists_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'tags_screen.dart';
|
||||
|
||||
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
|
||||
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
|
||||
|
|
@ -58,6 +61,13 @@ class BrowserScreen extends ConsumerWidget {
|
|||
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.label_outline,
|
||||
label: 'Tags',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const TagsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.download,
|
||||
label: 'Downloads',
|
||||
|
|
@ -344,6 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onAddToTag: () => showAddTagSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
|
|
@ -472,11 +483,7 @@ class AlbumScreen extends ConsumerWidget {
|
|||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading album…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
showToast(context, 'Downloading album…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
|
|
@ -499,6 +506,7 @@ class AlbumScreen extends ConsumerWidget {
|
|||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onAddToTag: () => showAddTagSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
|
|
@ -523,6 +531,7 @@ class BrowseRow extends StatelessWidget {
|
|||
this.onPlayNext,
|
||||
this.onAddToQueue,
|
||||
this.onAddToPlaylist,
|
||||
this.onAddToTag,
|
||||
this.onDownload,
|
||||
this.onRemoveDownload,
|
||||
this.downloadStatus,
|
||||
|
|
@ -540,6 +549,7 @@ class BrowseRow extends StatelessWidget {
|
|||
/// Secondary song actions, folded into a trailing overflow menu so the row
|
||||
/// stays uncluttered.
|
||||
final VoidCallback? onAddToPlaylist;
|
||||
final VoidCallback? onAddToTag;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onRemoveDownload;
|
||||
|
||||
|
|
@ -548,7 +558,10 @@ class BrowseRow extends StatelessWidget {
|
|||
final DownloadStatus? downloadStatus;
|
||||
|
||||
bool get _hasMenu =>
|
||||
onAddToPlaylist != null || onDownload != null || onRemoveDownload != null;
|
||||
onAddToPlaylist != null ||
|
||||
onAddToTag != null ||
|
||||
onDownload != null ||
|
||||
onRemoveDownload != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -600,14 +613,27 @@ class BrowseRow extends StatelessWidget {
|
|||
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||
],
|
||||
if (onPlayNext != null)
|
||||
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
|
||||
_RowIcon(
|
||||
icon: Icons.playlist_play,
|
||||
onTap: () {
|
||||
onPlayNext!();
|
||||
showToast(context, 'Playing next', icon: Icons.check);
|
||||
},
|
||||
),
|
||||
if (onAddToQueue != null)
|
||||
_RowIcon(icon: Icons.add, onTap: onAddToQueue!),
|
||||
_RowIcon(
|
||||
icon: Icons.add,
|
||||
onTap: () {
|
||||
onAddToQueue!();
|
||||
showToast(context, 'Added to queue', icon: Icons.check);
|
||||
},
|
||||
),
|
||||
if (_hasMenu)
|
||||
_RowMenu(
|
||||
isDownloaded: isDone,
|
||||
isDownloading: isActive,
|
||||
onAddToPlaylist: onAddToPlaylist,
|
||||
onAddToTag: onAddToTag,
|
||||
onDownload: onDownload,
|
||||
onRemoveDownload: onRemoveDownload,
|
||||
),
|
||||
|
|
@ -626,6 +652,7 @@ class _RowMenu extends StatelessWidget {
|
|||
required this.isDownloaded,
|
||||
required this.isDownloading,
|
||||
this.onAddToPlaylist,
|
||||
this.onAddToTag,
|
||||
this.onDownload,
|
||||
this.onRemoveDownload,
|
||||
});
|
||||
|
|
@ -633,6 +660,7 @@ class _RowMenu extends StatelessWidget {
|
|||
final bool isDownloaded;
|
||||
final bool isDownloading;
|
||||
final VoidCallback? onAddToPlaylist;
|
||||
final VoidCallback? onAddToTag;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onRemoveDownload;
|
||||
|
||||
|
|
@ -648,6 +676,8 @@ class _RowMenu extends StatelessWidget {
|
|||
switch (v) {
|
||||
case 'playlist':
|
||||
onAddToPlaylist?.call();
|
||||
case 'tag':
|
||||
onAddToTag?.call();
|
||||
case 'download':
|
||||
onDownload?.call();
|
||||
case 'remove_download':
|
||||
|
|
@ -658,6 +688,8 @@ class _RowMenu extends StatelessWidget {
|
|||
if (onAddToPlaylist != null)
|
||||
const PopupMenuItem(
|
||||
value: 'playlist', child: Text('Add to playlist')),
|
||||
if (onAddToTag != null)
|
||||
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
|
||||
if (isDownloaded && onRemoveDownload != null)
|
||||
const PopupMenuItem(
|
||||
value: 'remove_download', child: Text('Remove download'))
|
||||
|
|
|
|||
|
|
@ -5,18 +5,145 @@ import '../state/providers.dart';
|
|||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Open the connect-server sheet.
|
||||
Future<void> showConnectSheet(BuildContext context) {
|
||||
/// Open the add/edit-server sheet. Pass [initial] to edit an existing saved
|
||||
/// server (its password is reused if the field is left blank).
|
||||
Future<void> showConnectSheet(BuildContext context,
|
||||
{SubsonicCredentials? initial}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _ConnectSheet(),
|
||||
builder: (_) => _ConnectSheet(initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the server switcher: pick an active server, or add a new one.
|
||||
Future<void> showServerSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _ServerSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lists saved servers (tap to switch) with an "Add server" action.
|
||||
class _ServerSheet extends ConsumerWidget {
|
||||
const _ServerSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
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('Servers',
|
||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
if (conn.servers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
|
||||
child: Text('No servers saved yet.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else
|
||||
for (final s in conn.servers)
|
||||
_ServerRow(
|
||||
creds: s,
|
||||
active: s.id == conn.activeId,
|
||||
onTap: () {
|
||||
// Grab the notifier before popping — this widget's `ref` is
|
||||
// disposed with the sheet.
|
||||
final notifier = ref.read(connectionProvider.notifier);
|
||||
Navigator.of(context).pop();
|
||||
notifier.switchTo(s.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Capture the navigator's (still-mounted) context before popping
|
||||
// this sheet, so the next sheet has a valid overlay to show in.
|
||||
final nav = Navigator.of(context);
|
||||
nav.pop();
|
||||
showConnectSheet(nav.context);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add, size: 16, color: TimbreColors.foreground),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Add server',
|
||||
style: TextStyle(color: TimbreColors.foreground)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerRow extends StatelessWidget {
|
||||
const _ServerRow({
|
||||
required this.creds,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SubsonicCredentials creds;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(active ? '● ' : '○ ',
|
||||
style: TextStyle(
|
||||
color: active ? accent : TimbreColors.dimmed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
creds.display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.foreground,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectSheet extends ConsumerStatefulWidget {
|
||||
const _ConnectSheet();
|
||||
const _ConnectSheet({this.initial});
|
||||
|
||||
final SubsonicCredentials? initial;
|
||||
|
||||
@override
|
||||
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
|
||||
|
|
@ -26,15 +153,20 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
final _url = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _alias = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _localError;
|
||||
|
||||
bool get _isEdit => widget.initial != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final creds = ref.read(connectionProvider).credentials;
|
||||
if (creds != null) {
|
||||
_url.text = creds.url;
|
||||
_user.text = creds.username;
|
||||
final initial = widget.initial;
|
||||
if (initial != null) {
|
||||
_url.text = initial.url;
|
||||
_user.text = initial.username;
|
||||
_alias.text = initial.alias ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
_url.dispose();
|
||||
_user.dispose();
|
||||
_pass.dispose();
|
||||
_alias.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Build credentials from the form. Returns null (and sets [_localError]) if
|
||||
/// required fields are missing. On edit, a blank password reuses the saved one.
|
||||
SubsonicCredentials? _readForm() {
|
||||
final url = _url.text.trim();
|
||||
final user = _user.text.trim();
|
||||
if (url.isEmpty || user.isEmpty) {
|
||||
setState(() => _localError = 'Server URL and username are required.');
|
||||
return null;
|
||||
}
|
||||
final password =
|
||||
_pass.text.isEmpty && _isEdit ? widget.initial!.password : _pass.text;
|
||||
if (password.isEmpty) {
|
||||
setState(() => _localError = 'Password is required.');
|
||||
return null;
|
||||
}
|
||||
final alias = _alias.text.trim();
|
||||
return SubsonicCredentials(
|
||||
url: url,
|
||||
username: user,
|
||||
password: password,
|
||||
alias: alias.isEmpty ? null : alias,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
setState(() => _busy = true);
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(
|
||||
SubsonicCredentials(
|
||||
url: _url.text.trim(),
|
||||
username: _user.text.trim(),
|
||||
password: _pass.text,
|
||||
),
|
||||
);
|
||||
final creds = _readForm();
|
||||
if (creds == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_localError = null;
|
||||
});
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(creds);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (ok) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final creds = _readForm();
|
||||
if (creds == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_localError = null;
|
||||
});
|
||||
await ref.read(connectionProvider.notifier).saveServer(creds);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final error = _localError ??
|
||||
(conn.status == ConnStatus.error ? conn.error : null);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: TimbreSpacing.xl,
|
||||
|
|
@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Connect to server',
|
||||
Text(_isEdit ? 'Edit server' : 'Add server',
|
||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
_field(_url, 'Server URL', 'https://navidrome.example.com',
|
||||
keyboard: TextInputType.url),
|
||||
_field(_user, 'Username', 'you'),
|
||||
_field(_pass, 'Password', '••••••••', obscure: true),
|
||||
if (conn.status == ConnStatus.error && conn.error != null) ...[
|
||||
_field(_pass, 'Password',
|
||||
_isEdit ? '•••••••• (unchanged)' : '••••••••',
|
||||
obscure: true),
|
||||
_field(_alias, 'Label (optional)', 'Home server'),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
Text(conn.error!,
|
||||
style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _connect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: TimbreColors.background,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : _save,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: TimbreColors.foreground,
|
||||
side: const BorderSide(color: TimbreColors.border),
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy ? null : _connect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: TimbreColors.background,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import 'package:just_audio/just_audio.dart' show LoopMode;
|
|||
|
||||
import '../downloads/download_manager.dart';
|
||||
import '../playback/playback_engine.dart';
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/block_progress_bar.dart';
|
||||
import '../widgets/cassette_view.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
|
|
@ -210,28 +212,32 @@ class _AlbumArtPanel extends ConsumerWidget {
|
|||
final coverArt =
|
||||
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final cassette =
|
||||
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
|
||||
final artUri = (client != null && coverArt != null)
|
||||
? client.coverArtUri(coverArt, size: 512).toString()
|
||||
: null;
|
||||
|
||||
return HairlinePanel(
|
||||
title: 'Album Art',
|
||||
title: cassette ? 'Cassette' : 'Album Art',
|
||||
padding: const EdgeInsets.all(TimbreSpacing.md),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
child: cassette
|
||||
? Center(child: CassetteView(artUri: artUri))
|
||||
: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../state/providers.dart';
|
|||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
/// Playlists list — server-backed with an offline mirror. Create from the app
|
||||
|
|
@ -16,7 +17,7 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(playlistsProvider);
|
||||
final playlists = ref.watch(realPlaylistsProvider);
|
||||
final controller = ref.read(playlistsProvider.notifier);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
|
|
@ -38,10 +39,9 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
child: HairlinePanel(
|
||||
title: 'Playlists',
|
||||
active: true,
|
||||
trailing:
|
||||
state.playlists.isEmpty ? null : '(${state.playlists.length})',
|
||||
trailing: playlists.isEmpty ? null : '(${playlists.length})',
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: state.playlists.isEmpty
|
||||
child: playlists.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
connected
|
||||
|
|
@ -53,9 +53,9 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: state.playlists.length,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, i) {
|
||||
final p = state.playlists[i];
|
||||
final p = playlists[i];
|
||||
return _PlaylistRow(
|
||||
name: p.name,
|
||||
subtitle: p.songCount != null
|
||||
|
|
@ -233,11 +233,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
|
|||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading playlist…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
showToast(context, 'Downloading playlist…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
|
|
@ -337,8 +333,10 @@ class _TrackRow extends StatelessWidget {
|
|||
switch (v) {
|
||||
case 'next':
|
||||
onPlayNext();
|
||||
showToast(context, 'Playing next', icon: Icons.check);
|
||||
case 'queue':
|
||||
onAddToQueue();
|
||||
showToast(context, 'Added to queue', icon: Icons.check);
|
||||
case 'remove':
|
||||
onRemove?.call();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'connect_sheet.dart';
|
||||
|
||||
/// Audio-quality settings: independent streaming and download knobs, both
|
||||
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
|
||||
|
|
@ -14,6 +17,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final controller = ref.read(settingsProvider.notifier);
|
||||
final conn = ref.watch(connectionProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -72,6 +76,143 @@ class SettingsScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Appearance',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'By default the accent color is drawn from the playing '
|
||||
'album art. Turn on a static accent to pin one color.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<bool>(
|
||||
label: 'Static accent',
|
||||
values: const [false, true],
|
||||
selected: settings.useStaticAccent,
|
||||
labelFor: (v) => v ? 'On' : 'Off',
|
||||
onSelect: controller.setUseStaticAccent,
|
||||
),
|
||||
if (settings.useStaticAccent) ...[
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
_ColorChips(
|
||||
label: 'Accent color',
|
||||
values: AppSettings.accentChoices,
|
||||
selected: settings.staticAccentColor,
|
||||
onSelect: controller.setStaticAccentColor,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
const _Caption(
|
||||
'Show Now Playing as an animated cassette — cover art on '
|
||||
'the label, reels that spin and wind with the track.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<bool>(
|
||||
label: 'Now Playing art',
|
||||
values: const [false, true],
|
||||
selected: settings.nowPlayingCassette,
|
||||
labelFor: (v) => v ? 'Cassette' : 'Album cover',
|
||||
onSelect: controller.setNowPlayingCassette,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Browse',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption('Which view the Browse tab opens on.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<BrowseMode>(
|
||||
label: 'Default view',
|
||||
values: BrowseMode.values,
|
||||
selected: settings.defaultBrowseMode,
|
||||
labelFor: AppSettings.browseModeLabel,
|
||||
onSelect: controller.setDefaultBrowseMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Search',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'"Standard" matches only names/titles that contain your '
|
||||
'query. "Discovery" uses the server\'s broader matching.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<SearchMode>(
|
||||
label: 'Search mode',
|
||||
values: const [SearchMode.standard, SearchMode.discovery],
|
||||
selected: settings.searchMode,
|
||||
labelFor: AppSettings.searchModeLabel,
|
||||
onSelect: controller.setSearchMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Servers',
|
||||
active: true,
|
||||
trailing:
|
||||
conn.servers.isNotEmpty ? '(${conn.servers.length})' : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'Save multiple servers and switch the active one. Only '
|
||||
'one is connected at a time.'),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
if (conn.servers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
|
||||
child: Text('No servers saved yet.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else
|
||||
for (final s in conn.servers)
|
||||
_ServerManageRow(
|
||||
creds: s,
|
||||
active: s.id == conn.activeId,
|
||||
onActivate: () => ref
|
||||
.read(connectionProvider.notifier)
|
||||
.switchTo(s.id),
|
||||
onEdit: () =>
|
||||
showConnectSheet(context, initial: s),
|
||||
onDelete: () => ref
|
||||
.read(connectionProvider.notifier)
|
||||
.removeServer(s.id),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
child: const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add,
|
||||
size: 16, color: TimbreColors.foreground),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Add server',
|
||||
style:
|
||||
TextStyle(color: TimbreColors.foreground)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -79,6 +220,72 @@ class SettingsScreen extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A saved-server row in Settings: activate (tap the label), edit, or delete.
|
||||
class _ServerManageRow extends StatelessWidget {
|
||||
const _ServerManageRow({
|
||||
required this.creds,
|
||||
required this.active,
|
||||
required this.onActivate,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SubsonicCredentials creds;
|
||||
final bool active;
|
||||
final VoidCallback onActivate;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: onActivate,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: TimbreSpacing.minTouchTarget),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(active ? '● ' : '○ ',
|
||||
style: TextStyle(
|
||||
color: active ? accent : TimbreColors.dimmed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
creds.display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.foreground,
|
||||
fontWeight:
|
||||
active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: onDelete,
|
||||
icon:
|
||||
const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Caption extends StatelessWidget {
|
||||
const _Caption(this.text);
|
||||
final String text;
|
||||
|
|
@ -172,3 +379,90 @@ class _Chip extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
|
||||
/// color swatch and the color's own hue drives the active border/fill.
|
||||
class _ColorChips extends StatelessWidget {
|
||||
const _ColorChips({
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<AccentChoice> values;
|
||||
final Color selected;
|
||||
final ValueChanged<Color> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
Wrap(
|
||||
spacing: TimbreSpacing.sm,
|
||||
runSpacing: TimbreSpacing.sm,
|
||||
children: [
|
||||
for (final c in values)
|
||||
_ColorChip(
|
||||
choice: c,
|
||||
active: c.color == selected,
|
||||
onTap: () => onSelect(c.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorChip extends StatelessWidget {
|
||||
const _ColorChip({
|
||||
required this.choice,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AccentChoice choice;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.lg,
|
||||
vertical: TimbreSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: active ? choice.color : TimbreColors.border,
|
||||
),
|
||||
color: active
|
||||
? choice.color.withValues(alpha: 0.12)
|
||||
: TimbreColors.surface,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 12, height: 12, color: choice.color),
|
||||
const SizedBox(width: TimbreSpacing.sm),
|
||||
Text(
|
||||
choice.name,
|
||||
style: TextStyle(
|
||||
color: active ? choice.color : TimbreColors.foreground,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
lib/screens/tags_screen.dart
Normal file
187
lib/screens/tags_screen.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../playlists/playlists.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
|
||||
import 'playlists_screen.dart' show PlaylistDetailScreen;
|
||||
|
||||
/// Tags list — the same server-backed store as Playlists, filtered to the
|
||||
/// playlists carrying the tag marker. Tagging a song adds it to the tag's
|
||||
/// playlist; this screen is where tags are created, browsed, renamed, deleted.
|
||||
/// Tapping a tag reuses [PlaylistDetailScreen] (a tag *is* a playlist).
|
||||
class TagsScreen extends ConsumerWidget {
|
||||
const TagsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tags = ref.watch(tagsProvider);
|
||||
final controller = ref.read(playlistsProvider.notifier);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
const Text('Tags', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'New tag',
|
||||
onPressed: connected ? () => _create(context, ref) : null,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(TimbreSpacing.lg),
|
||||
child: HairlinePanel(
|
||||
title: 'Tags',
|
||||
active: true,
|
||||
trailing: tags.isEmpty ? null : '(${tags.length})',
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: tags.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
connected
|
||||
? 'No tags yet. Tap + to create one, then tag songs\n'
|
||||
'from their ⋮ menu.'
|
||||
: 'Connect to a server to see tags.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: TimbreColors.dimmed),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: tags.length,
|
||||
itemBuilder: (context, i) {
|
||||
final t = tags[i];
|
||||
return _TagRow(
|
||||
name: t.name,
|
||||
subtitle: t.songCount != null
|
||||
? '${t.songCount} tracks'
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PlaylistDetailScreen(id: t.id),
|
||||
),
|
||||
),
|
||||
onRename: connected
|
||||
? () async {
|
||||
final name = await promptPlaylistName(context,
|
||||
title: 'Rename tag', initial: t.name);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
controller.rename(t.id, name);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onDelete: connected
|
||||
? () => _confirmDelete(context, controller, t)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _create(BuildContext context, WidgetRef ref) async {
|
||||
final name = await promptPlaylistName(context, title: 'New tag');
|
||||
if (name != null && name.isNotEmpty) {
|
||||
await ref.read(playlistsProvider.notifier).createTag(name);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, PlaylistsController controller, Playlist t) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: TimbreColors.surface,
|
||||
title: Text('Delete tag "${t.name}"?'),
|
||||
content: const Text(
|
||||
'This removes the tag from every song. The songs themselves are '
|
||||
'not deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) controller.delete(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagRow extends StatelessWidget {
|
||||
const _TagRow({
|
||||
required this.name,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
this.onRename,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.label_outline, size: 18, color: TimbreColors.dimmed),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: TimbreColors.foreground)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(
|
||||
color: TimbreColors.dimmed, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onRename != null || onDelete != null)
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert,
|
||||
size: 20, color: TimbreColors.dimmed),
|
||||
color: TimbreColors.surface,
|
||||
onSelected: (v) {
|
||||
if (v == 'rename') onRename?.call();
|
||||
if (v == 'delete') onDelete?.call();
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (onRename != null)
|
||||
const PopupMenuItem(value: 'rename', child: Text('Rename')),
|
||||
if (onDelete != null)
|
||||
const PopupMenuItem(value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue