refactor(screens): share album-screen scaffolding and track flows

- SelectionModeMixin + CollapsingHeaderScrollMixin extracted;
  local/downloaded album screens now share AlbumTrackTile,
  AlbumScaffoldBody, DestructiveSelectionButton, HeaderMetaRow,
  and confirmAndDeleteTracks
- track_detail_actions.dart: shared downloadSingleTrack,
  queueTracksSkippingDownloaded, download-all confirm, queued
  snackbar, release-date formatter, list footer, love-all
- TtlCache<T> replaces the copied 10-minute static caches;
  album fetch branches share _applyAlbumMetadata
- playlist error card now uses ErrorCard; formatMegabytes shared
  by queue tab and update dialog
This commit is contained in:
zarzet
2026-07-12 19:19:22 +07:00
parent 4b9853eef7
commit 44de61a06a
19 changed files with 1152 additions and 1478 deletions
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Shows a delete-confirmation dialog, deletes the given [ids] one by one via
/// [deleteItem], then shows a "deleted N tracks" snackbar.
/// [onExitSelectionMode] runs right after the delete loop, matching the
/// screens' original ordering.
///
/// Returns the number of items [deleteItem] reported deleted, or null if the
/// user cancelled the dialog.
Future<int?> confirmAndDeleteTracks({
required BuildContext context,
required List<String> ids,
required Future<bool> Function(String id) deleteItem,
required VoidCallback onExitSelectionMode,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed != true || !context.mounted) return null;
var deletedCount = 0;
for (final id in ids) {
if (await deleteItem(id)) deletedCount++;
}
onExitSelectionMode();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
}
return deletedCount;
}
+5
View File
@@ -55,6 +55,11 @@ String? normalizeRemoteHttpUrl(String? value) {
return null;
}
/// Byte count expressed as a plain megabyte number with 1 decimal, e.g. "3.4".
String formatMegabytes(num bytes) {
return (bytes / (1024 * 1024)).toStringAsFixed(1);
}
/// Human-readable byte size: "512 B", "3.4 KB", "12.0 MB", "1.25 GB".
String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
+27
View File
@@ -0,0 +1,27 @@
/// Simple in-memory cache where each entry expires after [ttl].
class TtlCache<T> {
final Duration ttl;
final Map<String, _TtlEntry<T>> _entries = {};
TtlCache(this.ttl);
T? get(String key) {
final entry = _entries[key];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_entries.remove(key);
return null;
}
return entry.value;
}
void set(String key, T value) {
_entries[key] = _TtlEntry(value, DateTime.now().add(ttl));
}
}
class _TtlEntry<T> {
final T value;
final DateTime expiresAt;
_TtlEntry(this.value, this.expiresAt);
}