UX-Simulation, Teil 3: Abbrechen, Badge, Versionsnummer, Wisch-Strecke

Die vier zuletzt zurückgestellten Punkte aus der UX-Simulation:

- Abbrechen-Knopf für hängende YouTube-Downloads (Future.any gegen ein
  Abbruch-Signal, Anfrage läuft im Hintergrund aus statt die App zu
  blockieren)
- Punkt am inaktiven Download-Unterreiter, wenn dort etwas im Hintergrund
  läuft (SubTabs bekommt einen optionalen badges-Parameter)
- Versionsnummer unter "Über Melo" (neue Abhängigkeit: package_info_plus)
- Mini-Player führt beim Wischen jetzt sichtbar mit und wertet zusätzlich
  zum Schwung auch die zurückgelegte Strecke aus — ein langsames, aber
  weites Wischen wurde vorher komplett ignoriert

Dabei drei bestehende Widget-Tests korrigiert, die den Download-Tab ohne
Provider<DownloadService> aufbauten (hauptmenue_test.dart,
home_shell_test.dart, online_screen_test.dart) — lief bisher nur, weil
niemand danach fragte; das neue Badge tut das jetzt app-weit.

550 Tests grün (vorher 539), flutter analyze ohne Befund.
This commit is contained in:
Hermes (Server)
2026-08-25 10:25:43 +02:00
parent 8f80362f08
commit c2f31dee01
16 changed files with 392 additions and 33 deletions
+7
View File
@@ -10,6 +10,8 @@ import 'package:melo/downloads/downloads_screen.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -40,6 +42,11 @@ Widget _wrap(BakaAuth auth,
client: MockClient((_) async => http.Response('', 500)),
),
),
// Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der
// Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
);
+5
View File
@@ -8,7 +8,9 @@ import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
@@ -39,6 +41,7 @@ void main() {
late AppSettings einstellungen;
late OfflineMode offline;
late YtDownloadService ytDownload;
late DownloadService downloads;
late SyncService sync;
MeloAudioHandler? handler;
@@ -52,6 +55,7 @@ void main() {
einstellungen = AppSettings();
offline = OfflineMode();
ytDownload = YtDownloadService(auth: auth);
downloads = DownloadService(db: db, navidrome: NavidromeService());
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
});
@@ -83,6 +87,7 @@ void main() {
ChangeNotifierProvider<OfflineMode>.value(value: offline),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync),
],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
+4
View File
@@ -8,7 +8,9 @@ import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
@@ -49,6 +51,8 @@ void main() {
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
+43
View File
@@ -49,4 +49,47 @@ void main() {
expect(oeffnetVollbild(0), isFalse);
});
});
group('titelWechselMitStrecke', () {
test('kräftiger Schwung reicht wie bisher, egal wie kurz die Strecke',
() {
expect(titelWechselMitStrecke(-5, -1200), Wechsel.naechster);
expect(titelWechselMitStrecke(5, 1200), Wechsel.vorheriger);
});
test('eine weite, aber langsame Bewegung wechselt trotzdem', () {
expect(titelWechselMitStrecke(-wischStreckeSchwelle - 1, 0),
Wechsel.naechster);
expect(titelWechselMitStrecke(wischStreckeSchwelle + 1, 0),
Wechsel.vorheriger);
});
test('kurz und langsam wechselt nichts', () {
expect(titelWechselMitStrecke(20, 80), isNull);
});
test('genau auf der Streckenschwelle wechselt es noch nicht', () {
expect(titelWechselMitStrecke(wischStreckeSchwelle, 0), isNull);
});
});
group('oeffnetVollbildMitStrecke', () {
test('kräftiger Schwung reicht wie bisher, egal wie kurz die Strecke',
() {
expect(oeffnetVollbildMitStrecke(-5, -1200), isTrue);
});
test('eine weite, aber langsame Bewegung nach oben öffnet trotzdem', () {
expect(oeffnetVollbildMitStrecke(-wischStreckeSchwelle - 1, 0), isTrue);
});
test('dieselbe Strecke nach unten öffnet nicht — nur oben ist belegt',
() {
expect(oeffnetVollbildMitStrecke(wischStreckeSchwelle + 1, 0), isFalse);
});
test('kurz und langsam öffnet nichts', () {
expect(oeffnetVollbildMitStrecke(-20, -80), isFalse);
});
});
}
+22
View File
@@ -74,6 +74,28 @@ void main() {
'Der Server braucht zu lange — bitte später erneut versuchen');
});
test('abbrechen() beendet einen hängenden Download sofort', () async {
// Antwortet nie — genau der Fall, in dem der Nutzer heute nicht mehr
// herankam.
final haengt = Completer<http.Response>();
final dienst = YtDownloadService(
auth: await _angemeldeteAuth(),
client: MockClient((_) => haengt.future),
);
final ergebnisFuture = dienst.herunterladen('https://youtu.be/dQw4w9WgXcQ',
zielOrdner: ziel.path);
// Der Service braucht einen Mikrotask, um _abbruch zu setzen, bevor
// abbrechen() etwas zu vervollständigen hat.
await Future<void>.delayed(Duration.zero);
dienst.abbrechen();
final ergebnis = await ergebnisFuture;
expect(ergebnis, isNull);
expect(dienst.fehler, 'Abgebrochen');
expect(dienst.laeuft, isFalse);
});
test('eine tote Verbindung zum Proxy meldet sich anders als ein Timeout',
() async {
final dienst = YtDownloadService(
+51
View File
@@ -81,6 +81,57 @@ void main() {
expect(find.byType(InkWell), findsNWidgets(2));
});
bool istPunkt(Widget w) =>
w is Container && (w.decoration as BoxDecoration?)?.shape == BoxShape.circle;
testWidgets(
'ein Punkt zeigt an, dass im anderen Reiter etwas im Hintergrund läuft',
(tester) async {
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['YouTube', 'Server'],
index: 0,
badges: const [false, true],
onChanged: (_) {},
)));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.ancestor(
of: find.text('Server'), matching: find.byType(AnimatedContainer)),
matching: find.byWidgetPredicate(istPunkt),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.ancestor(
of: find.text('YouTube'),
matching: find.byType(AnimatedContainer)),
matching: find.byWidgetPredicate(istPunkt),
),
findsNothing,
);
});
testWidgets('ohne badges-Angabe bleibt ein Reiter ohne Punkt', (tester) async {
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie'],
index: 0,
onChanged: (_) {},
)));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.ancestor(
of: find.text('Lieder'), matching: find.byType(AnimatedContainer)),
matching: find.byWidgetPredicate(istPunkt),
),
findsNothing,
);
});
testWidgets('der Wechsel wird animiert, nicht gesprungen', (tester) async {
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie'],