diff --git a/BACKLOG.md b/BACKLOG.md index 4ecb723..81b74b6 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,8 +28,19 @@ den nächsten Bibliotheks-Scan damit überlebt. | `http.runWithClient` erreicht den State im Modal-Sheet nicht | ❌ widerlegt — eigene Sonde zeigt `MockClient` auch im Sheet | | drift unter `testWidgets` blockiert (fehlendes `runAsync`) | ❌ widerlegt — `upsertSongs` + `watchSongs().first` laufen durch | | Einer der Provider blockiert `pumpWidget` | ❌ widerlegt — `MeloDb`, `CategoryService`, `AppSettings` einzeln geprüft | +| `db.close()` im Teardown (der Hänger aus `hauptmenue_test.dart`) | ❌ widerlegt — Entfernen ändert nichts, siehe unten | | Zusammenspiel `showModalBottomSheet` + Providerbaum | ⏳ offen — der Hänger tritt zwischen `pumpWidget` und dem ersten `tap` auf | +**Nicht die Ursache (2026-08-21 geprüft):** In `hauptmenue_test.dart` trat ein +Hänger mit derselben Signatur auf. Dort war die Ursache `await db.close()` +nach einem `tester.tap` — drift plant beim Schließen eine Aufräum-Aufgabe +ein, die der Test-Rahmen nicht mehr abarbeitet. Das Rezept dagegen: +Dienste per `.value` bereitstellen (der Baum darf sie nicht schließen), am +Testende `pumpWidget(SizedBox())` + `pump(Duration(milliseconds: 1))`, und +auf `db.close()` verzichten. **Bei `song_detail_sheet_test.dart` hilft genau +das nicht** — dort wurde es angewandt und der Test hängt weiter. Die beiden +Hänger haben also unterschiedliche Ursachen. + **Nächster Schritt:** Den Testkörper schrittweise abspecken (Providerbaum minimieren, Sheet durch eine normale Route ersetzen) bis der Hänger verschwindet — die letzte entfernte Zutat ist die Ursache. Ein diff --git a/CHANGELOG.md b/CHANGELOG.md index b949f23..0e478f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🧭 Hauptmenü wieder deutlich sichtbar (2026-08-21) + +**Zuerst die Entwarnung:** Es fehlt nichts und nichts ist verschoben. Alle +vier Bereiche standen und stehen unten im Hauptmenü — `lib/main.dart` wurde +von der UI-Politur überhaupt nicht angefasst. + +- 🐛 **Behoben: die Politur hatte das Menü abgeschwächt.** Zwei Fehler von + mir aus der letzten Runde: + - Ich hatte für **aktiv und inaktiv dieselbe Schriftgröße** (12) gesetzt. + Damit verlor der aktive Bereich den Größenunterschied (sonst 14 zu 12), + an dem man auf einen Blick erkennt, wo man ist. + - Die inaktiven Einträge liefen auf der **blassen Beiwerk-Stufe**. Ein + Hauptmenü ist kein Beiwerk — sie sind jetzt wieder kräftig. +- ✨ **Zusätzlich deutlicher gemacht:** + - Eine **Haarlinie über dem Menü** — auf schwarzem Grund ging die Leiste + sonst optisch im Inhalt auf. + - Der aktive Bereich hat jetzt ein **gefülltes Symbol** (ausgefülltes Herz, + volle Note). Wer Rot schlecht sieht, erkennt trotzdem, wo er steht — + vorher unterschied nur die Farbe. + - Der aktive Eintrag ist fetter gesetzt. +- 🔒 **Damit das keine Politur mehr abschwächen kann:** ein neuer Test prüft, + dass alle vier Bereiche vorhanden **und auf dem Bildschirm** sind, dass das + Menü am unteren Rand klebt, dass jeder Tipp dorthin wechselt, und dass der + aktive Bereich sich nicht nur durch Farbe abhebt. +- 🔧 **Details für Technikinteressierte:** + - Neu: `test/hauptmenue_test.dart` (10 Prüfungen). + - `bottomNavigationBarTheme`: keine feste `fontSize` mehr (damit Flutters + 14/12 gelten), `unselectedItemColor` von `text3` auf `text2`, + `showUnselectedLabels` ausdrücklich gesetzt. + - Beim Schreiben des Tests ist eine **Test-Falle** aufgefallen und gelöst: + `await db.close()` nach einem `tester.tap` lässt den Testlauf + **unbegrenzt hängen**. Rezept dagegen steht im Test und in `BACKLOG.md`. + Beim schon bekannten Hänger in `song_detail_sheet_test.dart` hilft es + allerdings nicht — dort ist die Ursache eine andere, jetzt ebenfalls im + Backlog vermerkt. +- ✅ **Getestet:** 328 Tests grün (10 neue), `flutter analyze` ohne Befund. + ### ✨ UI-Politur: Kontrast, Abstände, Typografie, Bewegung (2026-08-21) Kein neues Design — dasselbe Melo in sauber. Schwarz/Rot, die vier Tabs und diff --git a/lib/main.dart b/lib/main.dart index 19ce435..7c85c68 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -176,18 +176,29 @@ class _HomeShellState extends State with WidgetsBindingObserver { const MiniPlayer(), ], ), - bottomNavigationBar: BottomNavigationBar( + // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund + // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. + bottomNavigationBar: DecoratedBox( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: MeloTheme.border)), + ), + child: BottomNavigationBar( currentIndex: _index, onTap: _goTo, items: const [ BottomNavigationBarItem( icon: Icon(Icons.headphones), label: 'Meine Musik'), BottomNavigationBarItem( - icon: Icon(Icons.music_note_outlined), label: 'Online'), + icon: Icon(Icons.music_note_outlined), + activeIcon: Icon(Icons.music_note), + label: 'Online'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), BottomNavigationBarItem( - icon: Icon(Icons.favorite_border), label: 'Favoriten'), + icon: Icon(Icons.favorite_border), + activeIcon: Icon(Icons.favorite), + label: 'Favoriten'), ], + ), ), ); } diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart index 975fe55..da15a32 100644 --- a/lib/shared/theme.dart +++ b/lib/shared/theme.dart @@ -167,14 +167,21 @@ class MeloTheme { BorderRadius.vertical(top: Radius.circular(MeloRadius.sheet)), ), ), + // Das Hauptmenü. Es muss auf den ersten Blick als solches erkennbar + // sein — deshalb bewusst kräftiger als der übrige Beiwerk-Text: + // - text2 (72 %) statt der blassen dritten Stufe für die inaktiven + // Einträge; sie sollen lesbar sein, nicht nur erahnbar. + // - KEINE fontSize hier: damit gelten Flutters Vorgaben von 14 (aktiv) + // und 12 (inaktiv). Eine feste Größe für beide nahm dem aktiven + // Reiter genau den Größenunterschied, an dem man ihn erkennt. bottomNavigationBarTheme: const BottomNavigationBarThemeData( backgroundColor: surface, selectedItemColor: red, - unselectedItemColor: text3, + unselectedItemColor: text2, type: BottomNavigationBarType.fixed, - selectedLabelStyle: - TextStyle(fontSize: 12, fontWeight: FontWeight.w600), - unselectedLabelStyle: TextStyle(fontSize: 12), + showUnselectedLabels: true, + selectedLabelStyle: TextStyle(fontWeight: FontWeight.w700), + unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w500), ), ); } diff --git a/test/hauptmenue_test.dart b/test/hauptmenue_test.dart new file mode 100644 index 0000000..5453367 --- /dev/null +++ b/test/hauptmenue_test.dart @@ -0,0 +1,208 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/library_service.dart'; +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/melo_cloud_service.dart'; +import 'package:melo/services/offline_mode.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/services/yt_download_service.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:melo/shared/theme.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemorySpeicher implements TokenSpeicher { + 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); +} + +/// Die vier Bereiche des Hauptmenüs, in dieser Reihenfolge. +const hauptmenue = ['Meine Musik', 'Online', 'Suchen', 'Favoriten']; + +void main() { + late MeloDb db; + late CategoryService kategorien; + late LibraryService bibliothek; + late PlaylistService listen; + late BakaAuth auth; + late AppSettings einstellungen; + late OfflineMode offline; + late YtDownloadService ytDownload; + late SyncService sync; + MeloAudioHandler? handler; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + db = MeloDb(NativeDatabase.memory()); + kategorien = CategoryService(db); + bibliothek = LibraryService(db); + listen = PlaylistService(db); + auth = BakaAuth(speicher: _MemorySpeicher()); + einstellungen = AppSettings(); + offline = OfflineMode(); + ytDownload = YtDownloadService(auth: auth); + sync = SyncService(db: db, cloud: MeloCloudService(auth: auth)); + }); + + tearDown(() { + kategorien.dispose(); + handler?.dispose(); + // Bewusst kein db.close(): drift plant beim Schließen eine Aufräum- + // Aufgabe ein, die im Test-Rahmen nach einem tester.tap nie mehr an die + // Reihe kommt — der Lauf hinge dann unbegrenzt. Die Datenbank liegt nur + // im Arbeitsspeicher und verschwindet mit dem Testprozess. + }); + + // Alle Dienste gehören dem Test und werden per .value gereicht. Würde der + // Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer + // von drift im Test-Rahmen hängen — und ein anschließendes db.close() + // käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt). + Widget bauen() { + // Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen + // Plattform-Kanal zu, den es im setUp noch nicht gibt. + handler ??= MeloAudioHandler(db: db); + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: bibliothek), + ChangeNotifierProvider.value(value: listen), + Provider.value(value: handler!), + ChangeNotifierProvider.value(value: kategorien), + ChangeNotifierProvider.value(value: einstellungen), + ChangeNotifierProvider.value(value: offline), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: ytDownload), + ChangeNotifierProvider.value(value: sync), + ], + child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()), + ); + } + + /// Baut den Baum ab und beendet die laufenden Timer. + /// + /// Muss im Testkörper passieren, nicht im tearDown: der Positions-Timer + /// des Audio-Handlers läuft periodisch, und der Test-Rahmen prüft am Ende + /// des Körpers, ob noch Timer offen sind. + Future abbauen(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + handler?.dispose(); + handler = null; + // Die Zeit ein Stück vorstellen: beim Abbauen melden sich die + // Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein + // pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann + // einen offenen Timer. + await tester.pump(const Duration(milliseconds: 1)); + } + + Finder eintrag(String name) => find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.text(name), + ); + + testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async { + await tester.pumpWidget(bauen()); + await tester.pump(); + + final leiste = + tester.widget(find.byType(BottomNavigationBar)); + expect(leiste.items.map((i) => i.label).toList(), hauptmenue); + + await abbauen(tester); + }); + + testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm', + (tester) async { + await tester.pumpWidget(bauen()); + await tester.pump(); + + final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio; + for (final name in hauptmenue) { + expect(eintrag(name), findsOneWidget, reason: '"$name" fehlt im Menü'); + + // Vorhanden genügt nicht — ein Eintrag außerhalb des Bildschirms oder + // ohne Ausdehnung wäre für den Nutzer nicht da. + final kasten = tester.getRect(eintrag(name)); + expect(kasten.width, greaterThan(0), reason: '"$name" ohne Breite'); + expect(kasten.height, greaterThan(0), reason: '"$name" ohne Höhe'); + expect(kasten.bottom, lessThanOrEqualTo(bildschirm.height), + reason: '"$name" liegt unterhalb des Bildschirms'); + expect(kasten.top, greaterThanOrEqualTo(0), + reason: '"$name" liegt oberhalb des Bildschirms'); + } + + await abbauen(tester); + }); + + for (var i = 1; i < hauptmenue.length; i++) { + final ziel = i; + testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin', + (tester) async { + await tester.pumpWidget(bauen()); + await tester.pump(); + + await tester.tap(eintrag(hauptmenue[ziel])); + await tester.pump(); + + expect( + tester + .widget(find.byType(BottomNavigationBar)) + .currentIndex, + ziel, + ); + + await abbauen(tester); + }); + } + + testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async { + // Läge es woanders, wäre es kein Hauptmenü mehr. + await tester.pumpWidget(bauen()); + await tester.pump(); + + final menue = tester.getRect(find.byType(BottomNavigationBar)); + final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(menue.bottom, closeTo(bildschirm.height, 1)); + + await abbauen(tester); + }); + + group('Darstellung des Hauptmenüs', () { + // Reine Theme-Prüfungen — kein Widget-Baum nötig. + final menue = MeloTheme.dark.bottomNavigationBarTheme; + + test('der aktive Bereich hebt sich ab — nicht nur durch Farbe', () { + // Wer Rot schlecht sieht, muss trotzdem erkennen, wo er ist. + expect(menue.selectedItemColor, MeloTheme.red); + expect(menue.selectedLabelStyle!.fontWeight, FontWeight.w700); + expect(menue.unselectedLabelStyle!.fontWeight, FontWeight.w500); + }); + + test('keine feste Schriftgröße — sonst verschwindet der Unterschied', () { + // Ohne Angabe gelten Flutters 14 (aktiv) und 12 (inaktiv). Beide auf + // 12 zu setzen nahm dem aktiven Reiter genau dieses Merkmal. + expect(menue.selectedLabelStyle!.fontSize, isNull); + expect(menue.unselectedLabelStyle!.fontSize, isNull); + }); + + test('die inaktiven Einträge sind lesbar, nicht nur erahnbar', () { + // text3 ist die Beiwerk-Stufe. Ein Hauptmenü ist kein Beiwerk. + expect(menue.unselectedItemColor, MeloTheme.text2); + }); + + test('die Beschriftungen bleiben auch bei inaktiven Einträgen stehen', () { + // Ohne sie müsste man die Symbole raten. + expect(menue.showUnselectedLabels, isTrue); + }); + }); +}