diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index 684b4bb..9864d9b 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -120,6 +120,7 @@ class _MyMusicScreenState extends State { return SortableSongList( songs: songs, storeKey: SortStore.meineMusik, + serverUpload: true, empty: lib.scanning ? const SizedBox.shrink() : const _Empty(), ); diff --git a/lib/shared/auswahl_leiste.dart b/lib/shared/auswahl_leiste.dart index 09b2fd7..4c1ea5b 100644 --- a/lib/shared/auswahl_leiste.dart +++ b/lib/shared/auswahl_leiste.dart @@ -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 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 ladeAufServer( + BuildContext context, List gewaehlte) async { + final sync = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte); + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)), + ); +} diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart index f764173..7ab5be2 100644 --- a/lib/shared/sortable_song_list.dart +++ b/lib/shared/sortable_song_list.dart @@ -23,6 +23,7 @@ class SortableSongList extends StatefulWidget { required this.songs, required this.storeKey, this.empty, + this.serverUpload = false, }); final List 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 createState() => _SortableSongListState(); } @@ -67,6 +72,11 @@ class _SortableSongListState extends State { if (mounted) _beendeAuswahl(); } + Future _aufServer(List gewaehlte) async { + await ladeAufServer(context, gewaehlte); + if (mounted) _beendeAuswahl(); + } + @override void dispose() { _scroll.dispose(); @@ -196,6 +206,9 @@ class _SortableSongListState extends State { onAbbrechen: _beendeAuswahl, onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)), onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)), + onServerLaden: widget.serverUpload + ? () => _aufServer(_gewaehlte(sorted)) + : null, ) else Padding( diff --git a/test/shared/server_upload_aktion_test.dart b/test/shared/server_upload_aktion_test.dart new file mode 100644 index 0000000..3635994 --- /dev/null +++ b/test/shared/server_upload_aktion_test.dart @@ -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 werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future 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 = []; + + @override + Future ladeAusgewaehlteHoch(List 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 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 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.value(value: db), + ChangeNotifierProvider.value( + value: CategoryService(db)), + ChangeNotifierProvider.value( + value: PlaylistService(db)), + ChangeNotifierProvider.value(value: einstellungen), + // Ohne diesen Provider stürbe schon das erste Antippen der neuen + // Aktion in ladeAufServer mit ProviderNotFoundException. + ChangeNotifierProvider.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); + }); +}