Files
SpotiFLAC-Mobile/lib/widgets/playlist_picker_sheet.dart
T

585 lines
19 KiB
Dart

import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
final _log = AppLogger('PlaylistPicker');
Future<void> showAddTrackToPlaylistSheet(
BuildContext context,
WidgetRef ref,
Track track,
) async {
return showAddTracksToPlaylistSheet(context, ref, [track]);
}
Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
List<Track> tracks, {
String? playlistNamePrefill,
}) async {
if (tracks.isEmpty) return;
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
showDragHandle: !context.isMornye,
backgroundColor: context.isMornye ? Colors.transparent : null,
isScrollControlled: true,
builder: (sheetContext) {
final content = _PlaylistPickerSheetContent(
tracks: tracks,
playlistNamePrefill: playlistNamePrefill,
);
return sheetContext.isMornye
? MornyeGlassPanel(tintOpacity: 0.78, child: content)
: content;
},
);
}
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
final List<Track> tracks;
final String? playlistNamePrefill;
const _PlaylistPickerSheetContent({
required this.tracks,
this.playlistNamePrefill,
});
@override
ConsumerState<_PlaylistPickerSheetContent> createState() =>
_PlaylistPickerSheetContentState();
}
class _PlaylistPickerSheetContentState
extends ConsumerState<_PlaylistPickerSheetContent> {
late final PlaylistPickerSummaryRequest _summaryRequest;
final Set<String> _selectedPlaylistIds = {};
final Set<String> _committedPlaylistIds = {};
bool _isSubmitting = false;
@override
void initState() {
super.initState();
_summaryRequest = PlaylistPickerSummaryRequest.fromTracks(widget.tracks);
}
Future<void> _handleDone(List<PlaylistPickerSummary> playlists) async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
final notifier = ref.read(libraryCollectionsProvider.notifier);
final effectiveDisabledIds = <String>{
..._committedPlaylistIds,
for (final playlist in playlists)
if (playlist.containsAllRequestedTracks) playlist.id,
};
final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds);
final playlistNamesById = {
for (final playlist in playlists) playlist.id: playlist.name,
};
final addedNames = <String>[];
try {
for (final playlistId in idsToAdd) {
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
_committedPlaylistIds.add(playlistId);
final playlistName = playlistNamesById[playlistId];
if (playlistName != null && playlistName.isNotEmpty) {
addedNames.add(playlistName);
}
}
} catch (error, stack) {
_log.e('Failed to add tracks to selected playlists', error, stack);
if (!mounted) return;
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.friendlyError(error))));
return;
}
if (!mounted) return;
Navigator.of(context).pop();
if (addedNames.isNotEmpty) {
final name = addedNames.length == 1
? addedNames.first
: addedNames.join(', ');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.collectionAddedToPlaylist(name))),
);
}
}
@override
Widget build(BuildContext context) {
final playlistSummariesValue = ref.watch(
libraryPlaylistPickerSummariesProvider(_summaryRequest),
);
final notifier = ref.read(libraryCollectionsProvider.notifier);
final String subtitle;
if (widget.tracks.length == 1) {
final track = widget.tracks.first;
subtitle = '${track.name} • ${track.artistName}';
} else {
subtitle = context.l10n.tracksCount(widget.tracks.length);
}
final resolvedPlaylists = playlistSummariesValue.asData?.value ?? const [];
final effectiveDisabledIds = <String>{
..._committedPlaylistIds,
for (final playlist in resolvedPlaylists)
if (playlist.containsAllRequestedTracks) playlist.id,
};
final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds);
final hasNewSelections = idsToAdd.isNotEmpty;
void onDone() {
if (_isSubmitting) return;
if (hasNewSelections) {
_handleDone(resolvedPlaylists);
} else {
setState(() => _isSubmitting = true);
Navigator.of(context).pop();
}
}
return SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (context.isMornye) ...[
const AppSheetHandle(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.collectionAddToPlaylist,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
] else
ListTile(
leading: const Icon(Icons.playlist_add),
title: Text(context.l10n.collectionAddToPlaylist),
subtitle: Text(subtitle),
),
const Divider(height: 1),
_PlaylistPickerRow(
leading: Icon(
context.isMornye
? CupertinoIcons.add_circled
: Icons.add_circle_outline,
color: Theme.of(context).colorScheme.primary,
),
title: Text(context.l10n.collectionCreatePlaylist),
enabled: !_isSubmitting,
onTap: () async {
if (_isSubmitting) return;
final name = await _promptPlaylistName(
context,
widget.playlistNamePrefill,
);
if (name == null || name.trim().isEmpty || !context.mounted) {
return;
}
final playlistId = await notifier.createPlaylist(name.trim());
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
if (!mounted) return;
setState(() {
_committedPlaylistIds.add(playlistId);
_selectedPlaylistIds.remove(playlistId);
});
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.collectionAddedToPlaylist(name.trim()),
),
),
);
},
),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: playlistSummariesValue.when(
data: (playlists) {
if (playlists.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Text(
context.l10n.collectionNoPlaylistsYet,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
return ListView.builder(
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
final isAlreadyIn = effectiveDisabledIds.contains(
playlist.id,
);
final isSelected =
_selectedPlaylistIds.contains(playlist.id) ||
isAlreadyIn;
return _PlaylistPickerRow(
leading: _PlaylistPickerThumbnail(
playlist: playlist,
isSelected: !context.isMornye && isSelected,
),
title: Text(playlist.name),
subtitle: Text(
context.l10n.collectionPlaylistTracks(
playlist.trackCount,
),
),
enabled: !isAlreadyIn && !_isSubmitting,
selected: isSelected,
onTap: !isAlreadyIn && !_isSubmitting
? () {
if (_isSubmitting) return;
setState(() {
if (_selectedPlaylistIds.contains(
playlist.id,
)) {
_selectedPlaylistIds.remove(playlist.id);
} else {
_selectedPlaylistIds.add(playlist.id);
}
});
}
: null,
);
},
);
},
loading: () => const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (_, _) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Text(
context.l10n.collectionNoPlaylistsYet,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: context.isMornye
? AppActionButton(
onPressed: _isSubmitting ? null : onDone,
icon: const Icon(CupertinoIcons.checkmark),
label: Text(context.l10n.dialogDone),
)
: FilledButton(
onPressed: _isSubmitting ? null : onDone,
child: Text(context.l10n.dialogDone),
),
),
),
],
),
);
}
}
Future<String?> _promptPlaylistName(
BuildContext context,
String? playlistNamePrefill,
) async {
final controller = TextEditingController(text: playlistNamePrefill);
final formKey = GlobalKey<FormState>();
final result = await showAppDialog<String>(
context: context,
builder: (dialogContext) {
return AppAlertDialog(
title: Text(dialogContext.l10n.collectionCreatePlaylist),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: dialogContext.l10n.collectionPlaylistNameHint,
),
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return dialogContext.l10n.collectionPlaylistNameRequired;
}
return null;
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
),
),
actions: [
AppDialogAction(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(dialogContext.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
child: Text(dialogContext.l10n.actionCreate),
),
],
);
},
);
return result;
}
class _PlaylistPickerRow extends StatelessWidget {
const _PlaylistPickerRow({
required this.leading,
required this.title,
this.subtitle,
this.enabled = true,
this.selected,
this.onTap,
});
final Widget leading;
final Widget title;
final Widget? subtitle;
final bool enabled;
final bool? selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (!context.isMornye) {
return ListTile(
leading: leading,
title: title,
subtitle: subtitle,
enabled: enabled,
onTap: onTap,
);
}
final theme = Theme.of(context);
return Semantics(
selected: selected,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
onPressed: enabled ? onTap : null,
child: Opacity(
opacity: enabled ? 1 : 0.5,
child: Row(
children: [
leading,
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DefaultTextStyle(
style: theme.textTheme.bodyLarge!,
child: title,
),
if (subtitle != null) ...[
const SizedBox(height: 3),
DefaultTextStyle(
style: theme.textTheme.bodyMedium!.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
child: subtitle!,
),
],
],
),
),
if (selected != null) ...[
const SizedBox(width: 14),
Icon(
selected!
? CupertinoIcons.checkmark_circle_fill
: CupertinoIcons.circle,
size: 24,
color: selected!
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
),
const Divider(height: 0.5, thickness: 0.5, indent: 20, endIndent: 20),
],
),
);
}
}
class _PlaylistPickerThumbnail extends StatelessWidget {
final PlaylistPickerSummary playlist;
final bool isSelected;
const _PlaylistPickerThumbnail({
required this.playlist,
required this.isSelected,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
const double size = 48;
final borderRadius = BorderRadius.circular(8);
return SizedBox(
width: size,
height: size,
child: Stack(
children: [
ClipRRect(
borderRadius: borderRadius,
child: _buildCoverImage(context, colorScheme, size),
),
if (isSelected) ...[
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.3),
borderRadius: borderRadius,
),
),
),
Positioned(
right: 2,
top: 2,
child: Container(
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.primary, width: 1.5),
),
child: Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 14,
),
),
),
],
],
),
);
}
Widget _buildCoverImage(
BuildContext context,
ColorScheme colorScheme,
double size,
) {
final customCoverPath = playlist.coverImagePath;
if (customCoverPath != null && customCoverPath.isNotEmpty) {
return Image.file(
File(customCoverPath),
width: size,
height: size,
fit: BoxFit.cover,
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(),
cacheHeight: (size * MediaQuery.devicePixelRatioOf(context)).round(),
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) => _iconFallback(context, colorScheme, size),
);
}
final firstCoverUrl = playlist.previewCover;
if (firstCoverUrl != null) {
return LocalOrNetworkCoverImage(
url: firstCoverUrl,
width: size,
height: size,
networkCacheWidth: (size * 2).toInt(),
placeholder: (_) => _iconFallback(context, colorScheme, size),
);
}
return _iconFallback(context, colorScheme, size);
}
Widget _iconFallback(
BuildContext context,
ColorScheme colorScheme,
double size,
) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
context.isMornye ? CupertinoIcons.music_note_list : Icons.queue_music,
color: colorScheme.onSurfaceVariant,
),
);
}
}