Auswahl-Modus: Auf den Server laden (nur in Meine Musik)

This commit is contained in:
Hermes (Server)
2026-08-27 12:14:07 +02:00
parent 78968fd6b8
commit fcf53dcb6c
4 changed files with 214 additions and 0 deletions
+1
View File
@@ -120,6 +120,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
return SortableSongList(
songs: songs,
storeKey: SortStore.meineMusik,
serverUpload: true,
empty:
lib.scanning ? const SizedBox.shrink() : const _Empty(),
);
+27
View File
@@ -7,6 +7,7 @@ import '../library/playlist_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../playlists/create_playlist_dialog.dart';
import '../services/sync_service.dart';
import '../settings/app_settings.dart';
import 'auswahl.dart';
import 'theme.dart';
@@ -22,6 +23,7 @@ class AuswahlLeiste extends StatelessWidget {
required this.onAbbrechen,
required this.onWiedergabeliste,
required this.onWarteschlange,
this.onServerLaden,
});
final int anzahl;
@@ -29,6 +31,10 @@ class AuswahlLeiste extends StatelessWidget {
final VoidCallback onWiedergabeliste;
final VoidCallback onWarteschlange;
/// Nur gesetzt, wo der Upload hingehört („Meine Musik"). Sonst erschiene
/// die Aktion in allen fünf Ansichten, die diese Leiste benutzen.
final VoidCallback? onServerLaden;
@override
Widget build(BuildContext context) {
return Padding(
@@ -49,6 +55,12 @@ class AuswahlLeiste extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
if (onServerLaden != null)
IconButton(
tooltip: 'Auf den Server laden',
icon: const Icon(Icons.cloud_upload),
onPressed: onServerLaden,
),
IconButton(
tooltip: 'Zur Warteschlange hinzufügen',
icon: const Icon(Icons.queue),
@@ -159,3 +171,18 @@ Future<void> fuegeZuWarteschlangeHinzu(
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
));
}
/// Lädt [gewaehlte] zum Melo-Server und meldet das Ergebnis.
///
/// Wohnt neben [fuegeZuWiedergabelisteHinzu]: dieselbe Bauart, dieselbe Art
/// Rückmeldung.
Future<void> ladeAufServer(
BuildContext context, List<Song> gewaehlte) async {
final sync = context.read<SyncService>();
final messenger = ScaffoldMessenger.of(context);
final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)),
);
}
+13
View File
@@ -23,6 +23,7 @@ class SortableSongList extends StatefulWidget {
required this.songs,
required this.storeKey,
this.empty,
this.serverUpload = false,
});
final List<Song> songs;
@@ -31,6 +32,10 @@ class SortableSongList extends StatefulWidget {
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
final Widget? empty;
/// Ob der Auswahl-Modus „Auf den Server laden" anbietet. Standard `false`:
/// dieses Widget steckt in fünf Ansichten, gemeint ist nur „Meine Musik".
final bool serverUpload;
@override
State<SortableSongList> createState() => _SortableSongListState();
}
@@ -67,6 +72,11 @@ class _SortableSongListState extends State<SortableSongList> {
if (mounted) _beendeAuswahl();
}
Future<void> _aufServer(List<Song> gewaehlte) async {
await ladeAufServer(context, gewaehlte);
if (mounted) _beendeAuswahl();
}
@override
void dispose() {
_scroll.dispose();
@@ -196,6 +206,9 @@ class _SortableSongListState extends State<SortableSongList> {
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
onServerLaden: widget.serverUpload
? () => _aufServer(_gewaehlte(sorted))
: null,
)
else
Padding(
+173
View File
@@ -0,0 +1,173 @@
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/sort_store.dart';
import 'package:melo/shared/sortable_song_list.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
/// Merkt sich nur, was hochgeladen werden sollte. Der echte Upload braucht
/// Dateien und einen Server — hier geht es um den Weg vom Knopf zum Dienst.
class _FakeSync extends SyncService {
_FakeSync(MeloDb db)
: super(
db: db,
cloud: MeloCloudService(
auth: BakaAuth(speicher: _MemorySpeicher({})),
client: MockClient(
(_) async => http.Response('{"status":"ok"}', 200)),
),
);
final hochgeladen = <String>[];
@override
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
hochgeladen.addAll([for (final s in songs) s.id]);
return const UploadErgebnis(hochgeladen: 1);
}
}
/// „Auf den Server laden" gehört in „Meine Musik" — und **nur** dorthin.
/// [SortableSongList] wird in fünf Ansichten benutzt; ohne Scoping erschiene
/// die Aktion auch bei Favoriten, Wiedergabelisten und Titellisten.
///
/// Aufbau bewusst im Testkörper, nicht in `setUp` (siehe auswahl_modus_test).
void main() {
final lieder = [
for (var i = 0; i < 3; i++)
Song(
id: 'song-$i',
path: '/music/$i.mp3',
title: 'Titel $i',
artist: 'Neoni',
dateAddedMs: i,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
),
];
Future<void> beruhige(WidgetTester tester) => tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
/// Räumt den Widget-Baum kontrolliert ab, statt es dem Testrahmen am
/// Testende zu überlassen: sonst bleibt ein von drift/SnackBar beim
/// Aufräumen geplanter Timer offen (siehe auswahl_modus_test.dart /
/// BACKLOG.md — derselbe Hänger, dieselbe Ursache).
Future<void> aufraeumen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
Future<_FakeSync> pumpe(WidgetTester tester,
{required bool serverUpload}) async {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
final db = MeloDb(NativeDatabase.memory());
// Kein db.close(): drift plant dabei eine Aufräum-Aufgabe (Timer) ein,
// die der Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md und
// auswahl_modus_test.dart — derselbe Hänger, dieselbe Ursache).
final sync = _FakeSync(db);
final einstellungen = AppSettings();
await einstellungen.init();
for (final song in lieder) {
await db.into(db.songs).insert(SongsCompanion.insert(
id: song.id,
path: song.path,
title: song.title,
artist: Value(song.artist),
dateAddedMs: song.dateAddedMs,
updatedAtMs: 0,
));
}
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<CategoryService>.value(
value: CategoryService(db)),
ChangeNotifierProvider<PlaylistService>.value(
value: PlaylistService(db)),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
// Ohne diesen Provider stürbe schon das erste Antippen der neuen
// Aktion in ladeAufServer mit ProviderNotFoundException.
ChangeNotifierProvider<SyncService>.value(value: sync),
],
child: MaterialApp(
home: Scaffold(
body: SortableSongList(
songs: lieder,
storeKey: SortStore.meineMusik,
serverUpload: serverUpload,
),
),
),
),
);
await beruhige(tester);
await tester.longPress(find.text('Titel 0'));
await beruhige(tester);
return sync;
}
testWidgets('in „Meine Musik" erscheint die Server-Aktion', (tester) async {
await pumpe(tester, serverUpload: true);
expect(find.byTooltip('Auf den Server laden'), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('in den übrigen Ansichten erscheint sie nicht', (tester) async {
await pumpe(tester, serverUpload: false);
// Der Auswahl-Modus läuft, die beiden Bestands-Aktionen sind da …
expect(find.byTooltip('Zur Warteschlange hinzufügen'), findsOneWidget);
// … die neue nicht.
expect(find.byTooltip('Auf den Server laden'), findsNothing);
await aufraeumen(tester);
});
testWidgets('das Antippen reicht die Auswahl an den Upload weiter',
(tester) async {
final sync = await pumpe(tester, serverUpload: true);
await tester.tap(find.byTooltip('Auf den Server laden'));
await beruhige(tester);
// Ein sichtbarer Knopf ist noch keine Funktion: geprüft wird, dass genau
// der lang gedrückte Titel bei ladeAusgewaehlteHoch ankommt.
expect(sync.hochgeladen, ['song-0']);
expect(find.text('1 hochgeladen'), findsOneWidget);
await aufraeumen(tester);
});
}