diff --git a/.ui-design/reviews/melo_vollwertigkeit_20260823.md b/.ui-design/reviews/melo_vollwertigkeit_20260823.md index a01c14d..4809213 100644 --- a/.ui-design/reviews/melo_vollwertigkeit_20260823.md +++ b/.ui-design/reviews/melo_vollwertigkeit_20260823.md @@ -36,7 +36,11 @@ erscheint auf Android 13+ keine Wiedergabe-Benachrichtigung. > Aus **G3** bleibt der schwere Teil offen: Wiedergabezähler, Historie und > Kategorien für reine Server-Titel bräuchten eine Zeile in der Bibliothek. > -> Offen sind damit noch die P2- und P3-Listen unten sowie **K4** in seiner +> **Nachtrag 2026-08-24:** Die **P2-Liste** ist ebenfalls abgearbeitet +> (Hero-Übergang, Wischgesten, Künstlerseite mit Alben, Reduced Motion +> app-weit, Querformat/Tablet, Alphabet-Sprung, Mehrfachauswahl). +> +> Offen sind damit noch die P3-Liste unten sowie **K4** in seiner > korrigierten Fassung (Album-Detailansicht **lokal**, mit Tracknummer und > Schema-Erweiterung — die Server-Seite davon ist mit G2 erledigt). diff --git a/BACKLOG.md b/BACKLOG.md index 81b74b6..da4d655 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -41,6 +41,24 @@ 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. +**Neu geprüft (2026-08-24):** `addTearDown(db.close)` **entfernt** und den +Test entsperrt — er hängt unverändert. `db.close()` ist hier also +ausgeschlossen, nicht nur „hilft nicht". + +**Neue Spur aus einem anderen Hänger (2026-08-24).** `auswahl_modus_test.dart` +zeigte dieselbe Signatur: `pumpAndSettle` kam nie zurück, auch nicht mit +gesetzter Zeitgrenze. Ursache dort war, dass **Datenbank und Dienste in +`setUp` entstanden**. `setUp` läuft außerhalb der Testzone: die Ströme von +drift liefen in echter Zeit, während `pumpAndSettle` in Testzeit auf sie +wartete. Aufbau in den Testkörper verschoben → alle fünf Tests laufen in +einer Sekunde durch. + +Auf `song_detail_sheet_test.dart` trifft das nicht unmittelbar zu (dort steht +schon alles im Testkörper) — aber `http.runWithClient` legt eine eigene Zone +um den Aufbau. Ob das dieselbe Trennung zwischen echter und Testzeit erzeugt, +ist der nächste lohnende Versuch: den Widget-Baum **vor** `runWithClient` +aufbauen und nur den Netz-Aufruf darin laufen lassen. + **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 86e20b5..626cc2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,94 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ✨ Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl (2026-08-24) + +Die P2-Liste aus dem Review — lauter Kleinigkeiten, die man einzeln nicht +vermisst und zusammen sofort merkt. + +- 🎞️ **Das Coverbild wandert.** Beim Öffnen der Vollbild-Wiedergabe springt + kein neuer Bildschirm auf; das Bild aus der Leiste wächst an seinen Platz. +- 👆 **Wischen am Mini-Player.** Nach oben öffnet die Wiedergabe, seitwärts + wechselt den Titel. Mit Schwelle: der Mini-Player ist zugleich ein Knopf, + und beim Antippen wandert der Finger fast immer ein paar Pixel — ohne + Schwelle spränge bei jedem zweiten Tipp der Titel. Nach unten ist bewusst + unbelegt, damit niemand die Wiedergabe aus Versehen wegschiebt. +- 🎨 **Die Künstlerliste hat Bilder** und nennt die Zahl der Alben. Dahinter + jetzt eine Künstlerseite mit seinen Alben, darunter alle Titel — vorher + stand bei einem Künstler mit acht Alben alles ungeordnet untereinander. + Bei nur einem Album entfällt die Zwischenstufe. +- 🔤 **Alphabet-Sprung am rechten Rand.** Erscheint nur, wo er stimmt: bei + Sortierung nach Namen und ab 30 Titeln. Bei „Zuletzt hinzugefügt" stünden + die Buchstaben in willkürlicher Reihenfolge. Umlaute zählen zu ihrem + Grundbuchstaben, Ziffern und Zeichen sammeln sich unter „#" — dieselbe + Regel wie die Sortierung selbst. +- ☑️ **Mehrfachauswahl.** Langes Drücken wählt aus, danach wählt jeder Tipp + weitere Titel. Oben stehen „Zur Warteschlange" und „Zu Wiedergabeliste". + Wer zwanzig Lieder in eine Liste wollte, tippte vorher sechzig Mal. +- ♿ **Abgeschaltete Systemanimationen gelten jetzt überall.** Bisher hielt + sich nur die Balken-Animation daran; Reiterwechsel, Songtext und + Mini-Player zogen weiter durch. +- 📱 **Querformat und Tablet.** Der Player legt Cover und Bedienung + nebeneinander, statt vom Cover einen Streifen übrig zu lassen oder die + Knöpfe aus dem Bild zu schieben. Auf breiten Schirmen bekommt der Text eine + Obergrenze, statt über die ganze Breite zu zerren. + +- 🐛 **Beim Bauen gefunden:** Der Alphabet-Sprung landete bei absteigender + Sortierung am **Ende** eines Buchstaben-Blocks statt an seinem Anfang. Der + Index wird jetzt direkt auf der angezeigten Reihenfolge gebaut, mit Test + für beide Richtungen. + +- 🔍 **Und ein hängender Test aufgeklärt.** Der neue Auswahl-Test kam nie + zurück — dieselbe Signatur wie der seit dem 21.8. übersprungene Test im + BACKLOG. Ursache: **Aufbau in `setUp`.** Das läuft außerhalb der Testzone, + also liefen Datenbank und Dienste in echter Zeit, während der Test in + Testzeit auf sie wartete. Aufbau in den Testkörper verschoben → fünf Tests + in einer Sekunde. Der alte Fall im BACKLOG hat eine andere Ursache + (nachgeprüft: `db.close()` ist es dort nicht), aber die Spur ist dort + notiert. + +- 🔍 **Aus dem Code-Review nachgebessert — sieben Punkte, einer davon schwer:** + **(1)** Die Alphabet-Leiste wurde **breiter, je weniger Buchstaben** sie + hatte. Jeder Buchstabe skalierte auf die volle Höhe seines Platzes. Bei + einem Album mit 40 Titeln „Track 01…40" gäbe es genau einen Buchstaben — + die Leiste wäre 528 dp breit geworden und die Songliste vollständig + verschwunden. Feste Breite jetzt, und ab drei verschiedenen Buchstaben + überhaupt erst sichtbar. + **(2)** Die neue Künstlerseite baute **alle** Titel sofort. Jede Zeile hört + auf den Player und öffnet für ihr Herz eine eigene Abfrage — bei einem + Künstler mit 300 Titeln wären das 300 Abos beim Öffnen. Vorher war die + Liste faul; das war ein Rückschritt von mir. + **(3)** Wer Systemanimationen abgeschaltet hat, hätte beim mitlaufenden + Songtext einen Absturz bekommen: die Bildlauf-Animation verträgt keine + Dauer null. Jetzt wird gesprungen — das ist ohnehin, was die Einstellung + meint. + **(4)** Die feste Zeilenhöhe war mit 64 zu klein (ein `ListTile` mit + Untertitel ist 72). Sobald der Schnellzugriff aktiv wurde, schrumpften alle + Zeilen sichtbar gegenüber jeder anderen Liste. Außerdem wird die + Schriftskalierung jetzt an einer echten Schriftgröße gemessen statt an 1.0 + — Android skaliert seit Version 14 nichtlinear. + **(5)** Titel, die schon in der Zielliste waren, wurden **verschoben** statt + übersprungen, und die Meldung zählte sie trotzdem mit. Jetzt: „7 hinzugefügt + · 3 waren schon drin". + **(6)** Ein Fehler mitten im Stapel brach still ab. Jetzt wird gesagt, wie + weit es kam. + **(7)** Die Zurück-Geste verließ den Bildschirm, statt die Auswahl zu + beenden — zwanzig markierte Titel mit einer Geste weg, die überall sonst + „einen Schritt zurück" heißt. + +- 🧹 **Selbst gefunden:** In der Künstlerliste hing der Datenbank-Strom an + `CategoryService` und wurde bei **jeder** Kategorie-Meldung neu aufgesetzt + — während eines Scans also laufend. Derselbe Fehler, den der Review mir + eine Runde zuvor bei der Suche gezeigt hatte. + +- ✅ **Getestet:** 500 Tests grün (vorher 457), `flutter analyze` ohne Befund, + Release-APK gebaut. Neu: 8 Tests für die Wisch-Schwellen, 10 für den + Alphabet-Index, 5 für die Breite der Leiste (inklusive des Falls mit einem + einzigen Buchstaben), 11 für die Auswahl-Regeln und Meldungen, 5 + Widget-Tests für den Auswahl-Modus, 3 für die Album-Gruppierung und 3 für + die Künstler-Zeile. + + ### 🧩 Die letzten vier Punkte aus dem Review (2026-08-23) **G2 — Server-Bereich.** Ein Tipp auf ein Album spielte sofort das ganze diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index 5cd0a55..8fa91df 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -1,25 +1,39 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../settings/app_settings.dart'; +import '../shared/cover.dart'; +import '../shared/theme.dart'; +import '../shared/titel_listen_screen.dart'; +import 'category_service.dart'; import 'database.dart'; import 'song_grouping.dart'; -import '../shared/titel_listen_screen.dart'; -import '../shared/theme.dart'; +import 'song_list.dart'; /// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs]. -class ArtistListScreen extends StatelessWidget { +class ArtistListScreen extends StatefulWidget { const ArtistListScreen({super.key}); + @override + State createState() => _ArtistListScreenState(); +} + +class _ArtistListScreenState extends State { + // Einmal anlegen, nicht in jedem build: die Zeilen hängen an + // CategoryService, und ein neuer Strom je Meldung würde die Abfrage + // während eines Scans laufend neu ausführen. + late final Stream> _songs = context.read().watchSongs(); + @override Widget build(BuildContext context) { - final db = context.read(); return StreamBuilder>( - stream: db.watchSongs(), + stream: _songs, builder: (context, snapshot) { final songs = snapshot.data ?? const []; if (songs.isEmpty) { return const Center( - child: Text('Keine Künstler', style: TextStyle(color: MeloTheme.text2)), + child: Text('Keine Künstler', + style: TextStyle(color: MeloTheme.text2)), ); } final grouped = groupByArtist(songs); @@ -28,18 +42,7 @@ class ArtistListScreen extends StatelessWidget { itemCount: artists.length, itemBuilder: (context, i) { final artist = artists[i]; - final artistSongs = grouped[artist]!; - return ListTile( - title: Text(artist, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text('${artistSongs.length} Songs'), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - TitelListenScreen(titel: artist, songs: artistSongs), - ), - ), - ); + return _KuenstlerZeile(name: artist, songs: grouped[artist]!); }, ); }, @@ -47,3 +50,130 @@ class ArtistListScreen extends StatelessWidget { } } +/// Eine Künstlerzeile. Eigenes Widget, damit nur sie an [CategoryService] +/// hängt und nicht der Bildschirm mit seinem Datenbank-Strom. +class _KuenstlerZeile extends StatelessWidget { + const _KuenstlerZeile({required this.name, required this.songs}); + + final String name; + final List songs; + + @override + Widget build(BuildContext context) { + final kategorien = context.watch(); + final einstellungen = context.watch(); + final cover = kategorien.coverFor(songs.first, + groupByCategory: einstellungen.groupCoversByCategory); + return ListTile( + // Ein Bild statt einer leeren Fläche: die Liste war reiner Text und + // dadurch schwer zu überfliegen. + leading: CoverImage( + artUri: cover != null ? Uri.file(cover) : null, + size: 48, + radius: 6, + ), + title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: + Text(kuenstlerUntertitel(songs.length, groupByAlbum(songs).length)), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => KuenstlerScreen(name: name, songs: songs), + )), + ); + } +} + +/// "12 Titel · 3 Alben" — die Albenzahl nur, wenn es mehr als eines ist. +/// „Titel" hat im Deutschen keine eigene Mehrzahl, deshalb ohne Fallunterscheidung. +String kuenstlerUntertitel(int titel, int alben) => + alben > 1 ? '$titel Titel · $alben Alben' : '$titel Titel'; + +/// Die Seite eines Künstlers: seine Alben, darunter alle Titel. +/// +/// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem +/// Künstler mit acht Alben stand alles ungeordnet untereinander. +class KuenstlerScreen extends StatelessWidget { + const KuenstlerScreen({super.key, required this.name, required this.songs}); + + final String name; + final List songs; + + @override + Widget build(BuildContext context) { + final alben = groupByAlbum(songs); + // Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr. + final zeigeAlben = alben.length > 1; + final albumListe = zeigeAlben ? alben.entries.toList() : const []; + // Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel" + final vorspann = zeigeAlben ? albumListe.length + 3 : 0; + + // Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile + // hört auf den Player und öffnet für ihr Herz eine eigene Abfrage. Bei + // einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen der Seite. + return Scaffold( + appBar: AppBar(title: Text(name)), + body: ListView.builder( + itemCount: vorspann + songs.length, + itemBuilder: (context, i) { + if (zeigeAlben) { + if (i == 0) return const _Kopf('Alben'); + if (i <= albumListe.length) { + final eintrag = albumListe[i - 1]; + return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + } + if (i == albumListe.length + 1) return const Divider(height: 1); + if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); + } + final index = i - vorspann; + return SongZeile( + song: songs[index], + warteschlange: songs, + index: index, + ); + }, + ), + ); + } +} + +class _AlbumZeile extends StatelessWidget { + const _AlbumZeile({required this.name, required this.songs}); + + final String name; + final List songs; + + @override + Widget build(BuildContext context) { + final kategorien = context.watch(); + final einstellungen = context.watch(); + final cover = kategorien.coverFor(songs.first, + groupByCategory: einstellungen.groupCoversByCategory); + return ListTile( + leading: CoverImage( + artUri: cover != null ? Uri.file(cover) : null, + size: 48, + radius: 6, + ), + title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${songs.length} Titel'), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => TitelListenScreen(titel: name, songs: songs), + )), + ); + } +} + +class _Kopf extends StatelessWidget { + const _Kopf(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs), + child: Text(text.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge), + ); + } +} diff --git a/lib/library/song_grouping.dart b/lib/library/song_grouping.dart index 6c61811..29235a7 100644 --- a/lib/library/song_grouping.dart +++ b/lib/library/song_grouping.dart @@ -8,6 +8,14 @@ Map> groupByArtist(List songs) { return _groupBy(songs, (s) => s.artist, unbekannterKuenstler); } +const ohneAlbum = 'Ohne Album'; + +/// Gruppiert [songs] nach Album (fehlendes Album → [ohneAlbum]), +/// alphabetisch nach Albumnamen sortiert. +Map> groupByAlbum(List songs) { + return _groupBy(songs, (s) => s.album, ohneAlbum); +} + Map> _groupBy( List songs, String? Function(Song) keyOf, diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index 943f9cc..d970a0b 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -15,19 +15,93 @@ import 'song_detail_sheet.dart'; import '../shared/theme.dart'; import 'song_media.dart'; -/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab. -class SongList extends StatelessWidget { - const SongList(this.songs, {super.key}); - final List songs; +/// Häkchen-Kreis anstelle des Covers, solange ausgewählt wird. +class _AuswahlZeichen extends StatelessWidget { + const _AuswahlZeichen({required this.ausgewaehlt}); + final bool ausgewaehlt; @override Widget build(BuildContext context) { + return SizedBox( + width: 48, + height: 48, + child: Center( + child: Icon( + ausgewaehlt ? Icons.check_circle : Icons.circle_outlined, + color: ausgewaehlt ? MeloTheme.red : MeloTheme.text3, + size: 26, + ), + ), + ); + } +} + +/// Höhe einer Songzeile bei normaler Schriftgröße. +/// +/// 72 und nicht 64: so hoch ist ein `ListTile` mit Untertitel in Material. +/// Mit 64 schrumpften die Zeilen sichtbar, sobald der Schnellzugriff eine +/// feste Höhe erzwang — gegenüber jeder anderen Liste der App. +const songZeilenHoehe = 72.0; + +/// Schriftgröße, an der die Skalierung gemessen wird: die des Titels +/// ([MeloTheme] `bodyLarge`). +const _messSchrift = 15.0; + +/// Die Zeilenhöhe bei der eingestellten Schriftgröße. +/// +/// Der Schnellzugriff springt über `Index × Höhe` an die richtige Stelle und +/// braucht dafür gleich hohe Zeilen. Eine fest verdrahtete Zahl würde bei +/// großer Systemschrift den Text abschneiden — deshalb mitskaliert. +/// +/// Gemessen an einer **echten** Schriftgröße und nicht an 1.0: Android +/// skaliert seit Version 14 nichtlinear, kleine Größen wachsen dort stärker +/// als große. Der Faktor für 1.0 wäre viel zu groß und ließe zwischen den +/// Zeilen Löcher. +double songZeilenHoeheFuer(BuildContext context) => + songZeilenHoehe * + (MediaQuery.textScalerOf(context).scale(_messSchrift) / _messSchrift); + +/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab. +class SongList extends StatelessWidget { + const SongList( + this.songs, { + super.key, + this.controller, + this.festeHoehe = false, + this.auswahl, + this.onAuswahlWechsel, + }); + + final List songs; + final ScrollController? controller; + + /// Alle Zeilen gleich hoch — nötig, wenn ein Schnellzugriff exakt an eine + /// Position springen soll. + final bool festeHoehe; + + /// Die gerade ausgewählten Song-IDs. `null` heißt: kein Auswahl-Modus. + /// Alle Listen, die ihn nicht anbieten, lassen beides weg und verhalten + /// sich unverändert. + final Set? auswahl; + + final void Function(String songId)? onAuswahlWechsel; + + @override + Widget build(BuildContext context) { + final gewaehlt = auswahl; return ListView.builder( + controller: controller, + itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null, itemCount: songs.length, itemBuilder: (context, i) => SongZeile( song: songs[i], warteschlange: songs, index: i, + auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty, + ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false, + onAuswahlWechsel: onAuswahlWechsel == null + ? null + : () => onAuswahlWechsel!(songs[i].id), ), ); } @@ -46,12 +120,22 @@ class SongZeile extends StatelessWidget { required this.song, required this.warteschlange, required this.index, + this.auswahlModus = false, + this.ausgewaehlt = false, + this.onAuswahlWechsel, }); final Song song; final List warteschlange; final int index; + /// Im Auswahl-Modus wählt ein Tipp aus, statt abzuspielen. + final bool auswahlModus; + final bool ausgewaehlt; + + /// Fehlt sie, bietet die Zeile keine Auswahl an — langes Drücken tut nichts. + final VoidCallback? onAuswahlWechsel; + /// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf. static String _subtitle(Song song, List categories) { final artist = song.artist ?? 'Unbekannt'; @@ -232,16 +316,23 @@ class SongZeile extends StatelessWidget { required bool spielt, }) { return ListTile( - leading: LaufMarkierung( - markiert: markiert, - spielt: spielt, - radius: 6, - child: CoverImage( - artUri: cover != null ? Uri.file(cover) : null, - size: 48, - radius: 6, - ), - ), + selected: ausgewaehlt, + selectedTileColor: MeloTheme.surfaceHigh, + // Im Auswahl-Modus tritt das Häkchen an die Stelle des Covers: eine + // zusätzliche Spalte hätte die Zeile schmaler gemacht und den Titel + // beim Umschalten springen lassen. + leading: auswahlModus + ? _AuswahlZeichen(ausgewaehlt: ausgewaehlt) + : LaufMarkierung( + markiert: markiert, + spielt: spielt, + radius: 6, + child: CoverImage( + artUri: cover != null ? Uri.file(cover) : null, + size: 48, + radius: 6, + ), + ), title: Text( song.title, maxLines: 1, @@ -255,18 +346,27 @@ class SongZeile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FavoriteButton(songId: song.id), - IconButton( - tooltip: 'Mehr', - icon: const Icon(Icons.more_vert), - onPressed: () => _showMenu(context), - ), - ], - ), + // Herz und Menü gelten je einem Titel — im Auswahl-Modus wäre unklar, + // auf welchen sie sich beziehen. + trailing: auswahlModus + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + FavoriteButton(songId: song.id), + IconButton( + tooltip: 'Mehr', + icon: const Icon(Icons.more_vert), + onPressed: () => _showMenu(context), + ), + ], + ), + onLongPress: onAuswahlWechsel, onTap: () async { + if (auswahlModus) { + onAuswahlWechsel?.call(); + return; + } final handler = context.read(); final messenger = ScaffoldMessenger.of(context); try { diff --git a/lib/library/song_sort.dart b/lib/library/song_sort.dart index 0963177..0214092 100644 --- a/lib/library/song_sort.dart +++ b/lib/library/song_sort.dart @@ -33,6 +33,26 @@ List sortSongs(List songs, SortMode mode, {required bool ascending}) return sorted; } +/// Der Buchstabe, unter dem [titel] im Schnellzugriff steht. +/// +/// Benutzt denselben Schlüssel wie die Sortierung — sonst zeigten die +/// Buchstaben der Leiste an Stellen, an denen gar nichts Passendes steht. +String indexBuchstabe(String titel) { + final key = _sortKey(titel); + if (key.isEmpty || !_startsWithLetter(key)) return '#'; + return key[0].toUpperCase(); +} + +/// Erste Position je Buchstabe in einer **nach Namen sortierten** Liste. +/// Die Reihenfolge der Schlüssel entspricht der Reihenfolge in der Liste. +Map buchstabenIndex(List sortiert) { + final index = {}; + for (var i = 0; i < sortiert.length; i++) { + index.putIfAbsent(indexBuchstabe(sortiert[i].title), () => i); + } + return index; +} + /// A–Z zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern, /// Sonderzeichen) landet unter "#" am Ende. int _compareNames(String a, String b) { diff --git a/lib/player/mini_player.dart b/lib/player/mini_player.dart index f009d36..01eb061 100644 --- a/lib/player/mini_player.dart +++ b/lib/player/mini_player.dart @@ -6,6 +6,7 @@ import '../shared/cover.dart'; import '../shared/theme.dart'; import 'audio_handler.dart'; import 'now_playing_screen.dart'; +import 'wischen.dart'; /// Kompakte Wiedergabe-Leiste über der Bottom-Nav. /// Blendet sich aus, wenn nichts läuft. @@ -25,7 +26,7 @@ class MiniPlayer extends StatelessWidget { // Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten // dazu, wenn etwas startet. return AnimatedSwitcher( - duration: MeloMotion.normal, + duration: MeloMotion.ruhig(context, MeloMotion.normal), switchInCurve: MeloMotion.curve, transitionBuilder: (kind, animation) => SizeTransition( sizeFactor: animation, @@ -47,6 +48,10 @@ class _Leiste extends StatelessWidget { final MeloAudioHandler handler; final MediaItem item; + void _oeffne(BuildContext context) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); + @override Widget build(BuildContext context) { final texte = Theme.of(context).textTheme; @@ -61,42 +66,64 @@ class _Leiste extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - InkWell( - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const NowPlayingScreen(), - )), - child: SizedBox( - height: MiniPlayer.hoehe, - child: Row( - children: [ - const SizedBox(width: MeloSpace.md), - CoverImage(artUri: item.artUri, size: 52), - const SizedBox(width: MeloSpace.md), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: texte.bodyLarge - ?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - item.artist ?? 'Unbekannt', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: texte.bodySmall, - ), - ], + GestureDetector( + // Wischen wie in jeder anderen Musik-App: nach oben öffnet die + // Vollbild-Wiedergabe, seitwärts wechselt den Titel. + onVerticalDragEnd: (details) { + if (oeffnetVollbild(details.velocity.pixelsPerSecond.dy)) { + _oeffne(context); + } + }, + onHorizontalDragEnd: (details) { + switch (titelWechsel(details.velocity.pixelsPerSecond.dx)) { + case Wechsel.naechster: + handler.skipToNext(); + case Wechsel.vorheriger: + handler.skipToPrevious(); + case null: + break; + } + }, + child: InkWell( + onTap: () => _oeffne(context), + child: SizedBox( + height: MiniPlayer.hoehe, + child: Row( + children: [ + const SizedBox(width: MeloSpace.md), + // Dasselbe Bild wie im Vollbild: es wandert beim Öffnen + // dorthin, statt dass ein neuer Bildschirm aufspringt. + Hero( + tag: coverHeldenName, + child: CoverImage(artUri: item.artUri, size: 52), ), - ), - const SizedBox(width: MeloSpace.sm), - _Bedienung(handler: handler), - const SizedBox(width: MeloSpace.sm), - ], + const SizedBox(width: MeloSpace.md), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: texte.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + item.artist ?? 'Unbekannt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: texte.bodySmall, + ), + ], + ), + ), + const SizedBox(width: MeloSpace.sm), + _Bedienung(handler: handler), + const SizedBox(width: MeloSpace.sm), + ], + ), ), ), ), diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index e19c2bb..52eebc9 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -75,30 +75,54 @@ class NowPlayingScreen extends StatelessWidget { if (item == null) { return const Center(child: Text('Nichts in Wiedergabe')); } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - Expanded(child: Center(child: _Cover(item: item))), - const SizedBox(height: 24), - Text( - item.title, - style: Theme.of(context).textTheme.headlineSmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - item.artist ?? 'Unbekannt', - style: const TextStyle(color: MeloTheme.text2), - ), - const SizedBox(height: 24), - _ProgressBar(handler: handler), - const _Controls(), - const SizedBox(height: 16), - ], - ), + return LayoutBuilder( + builder: (context, raum) { + // Im Querformat ist die Höhe knapp: Cover und Bedienung + // untereinander ließen vom Cover einen Streifen übrig oder + // schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides + // benutzbar. Dieselbe Aufteilung hilft auf einem Tablet. + final nebeneinander = raum.maxWidth > raum.maxHeight; + final inhalt = _Angaben(item: item, handler: handler); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: nebeneinander + ? Row( + children: [ + Expanded( + child: Center(child: _Cover(item: item)), + ), + const SizedBox(width: MeloSpace.lg), + // Der Inhalt bekommt eine feste Obergrenze: auf + // einem breiten Bildschirm zerrte er sonst über + // die ganze Breite und wäre schlecht lesbar. + Expanded( + child: Center( + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 420), + child: inhalt, + ), + ), + ), + ], + ) + : Column( + children: [ + Expanded( + child: Center(child: _Cover(item: item)), + ), + const SizedBox(height: MeloSpace.lg), + Center( + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 520), + child: inhalt, + ), + ), + ], + ), + ); + }, ); }, ), @@ -107,6 +131,42 @@ class NowPlayingScreen extends StatelessWidget { } } +/// Titel, Künstler, Fortschritt und Bedienung — der Teil, der im Querformat +/// neben das Cover rückt. +class _Angaben extends StatelessWidget { + const _Angaben({required this.item, required this.handler}); + + final MediaItem item; + final MeloAudioHandler handler; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: MeloSpace.sm), + Text( + item.artist ?? 'Unbekannt', + style: const TextStyle(color: MeloTheme.text2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: MeloSpace.lg), + _ProgressBar(handler: handler), + const _Controls(), + const SizedBox(height: MeloSpace.md), + ], + ); + } +} + /// Das passende Herz für den laufenden Titel. class _Herz extends StatelessWidget { const _Herz({required this.item}); @@ -131,6 +191,11 @@ class _Herz extends StatelessWidget { } } +/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — daran +/// erkennt Flutter, dass es dasselbe Bild ist, und lässt es zwischen beiden +/// wandern statt einen neuen Bildschirm aufspringen zu lassen. +const coverHeldenName = 'melo-cover'; + class _Cover extends StatelessWidget { const _Cover({required this.item}); final MediaItem item; @@ -139,7 +204,10 @@ class _Cover extends StatelessWidget { Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, - child: CoverImage(artUri: item.artUri, radius: 16), + child: Hero( + tag: coverHeldenName, + child: CoverImage(artUri: item.artUri, radius: 16), + ), ); } } @@ -403,11 +471,16 @@ class _LyricsSheetState extends State<_LyricsSheet> { final ziel = (index * _zeilenHoehe) - (_scroll.position.viewportDimension / 2) + (_zeilenHoehe / 2); - _scroll.animateTo( - ziel.clamp(0, _scroll.position.maxScrollExtent), - duration: MeloMotion.normal, - curve: MeloMotion.curve, - ); + final wohin = ziel.clamp(0.0, _scroll.position.maxScrollExtent); + final dauer = MeloMotion.ruhig(context, MeloMotion.normal); + // Bei abgeschalteten Systemanimationen wird die Dauer null — `animateTo` + // wirft dann eine Zusicherung. Ohne Bewegung ist Springen ohnehin das, + // was die Einstellung meint. + if (dauer == Duration.zero) { + _scroll.jumpTo(wohin); + return; + } + _scroll.animateTo(wohin, duration: dauer, curve: MeloMotion.curve); } static const _zeilenHoehe = 40.0; @@ -509,7 +582,7 @@ class _Mitlaufend extends StatelessWidget { child: Align( alignment: Alignment.centerLeft, child: AnimatedDefaultTextStyle( - duration: MeloMotion.fast, + duration: MeloMotion.ruhig(context, MeloMotion.fast), style: TextStyle( fontSize: istAktiv ? 17 : 15, height: 1.3, diff --git a/lib/player/wischen.dart b/lib/player/wischen.dart new file mode 100644 index 0000000..3696b7f --- /dev/null +++ b/lib/player/wischen.dart @@ -0,0 +1,25 @@ +/// Was eine waagerechte Wischbewegung am Mini-Player bewirkt. +enum Wechsel { vorheriger, naechster } + +/// Ab welcher Wisch-Geschwindigkeit (Pixel pro Sekunde) eine Geste zählt. +/// +/// Bewusst nicht null: der Mini-Player ist zugleich ein Knopf, und beim +/// Antippen wandert der Finger fast immer ein paar Pixel. Ohne Schwelle +/// spränge bei jedem zweiten Tipp der Titel. +const wischSchwelle = 300.0; + +/// Zu welchem Titel eine waagerechte Bewegung führt, oder `null`, wenn sie +/// zu zaghaft war. +/// +/// Nach links wischen holt den nächsten Titel herein — so, wie man ein Blatt +/// zur Seite schiebt und das darunter sichtbar wird. +Wechsel? titelWechsel(double geschwindigkeit) { + if (geschwindigkeit.abs() <= wischSchwelle) return null; + return geschwindigkeit < 0 ? Wechsel.naechster : Wechsel.vorheriger; +} + +/// Ob eine senkrechte Bewegung die Vollbild-Wiedergabe öffnet. +/// Nur nach oben — nach unten ist bewusst unbelegt, damit niemand die +/// Wiedergabe aus Versehen wegschiebt. +bool oeffnetVollbild(double geschwindigkeit) => + geschwindigkeit < -wischSchwelle; diff --git a/lib/shared/alphabet_leiste.dart b/lib/shared/alphabet_leiste.dart new file mode 100644 index 0000000..c490590 --- /dev/null +++ b/lib/shared/alphabet_leiste.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Ab welcher Länge sich der Schnellzugriff lohnt. +/// +/// Darunter scrollt man in zwei Wischern durch — die Leiste wäre dann nur +/// eine schmale Fläche, die Platz nimmt und Fehltreffer produziert. +const alphabetAb = 30; + +/// Wie viele **verschiedene** Anfangsbuchstaben es mindestens braucht. +/// +/// Ein Album mit 40 Titeln „Track 01…40" hat genau einen — eine Leiste mit +/// einem Buchstaben ist kein Schnellzugriff, sondern nur eine Fläche. +const alphabetMindestBuchstaben = 3; + +/// Feste Breite der Leiste. +/// +/// Bewusst fest und nicht mitwachsend: vorher skalierte jeder Buchstabe auf +/// die volle Höhe seines Platzes, die Leiste wurde dadurch **breiter**, je +/// weniger Buchstaben es gab — bei einem einzigen schob sie die Songliste +/// vollständig aus dem Bild. +const alphabetBreite = 24.0; + +/// Schmale A–Z–#-Leiste am rechten Rand. Tippen und Ziehen springt zur +/// ersten Stelle des Buchstabens. +/// +/// Zeigt **nur** die Buchstaben, unter denen wirklich etwas steht: eine volle +/// A–Z-Leiste in einer Bibliothek mit vier Künstlern wäre zum größten Teil +/// tote Fläche. +class AlphabetLeiste extends StatefulWidget { + const AlphabetLeiste({ + super.key, + required this.buchstaben, + required this.onGewaehlt, + }); + + final List buchstaben; + final ValueChanged onGewaehlt; + + @override + State createState() => _AlphabetLeisteState(); +} + +class _AlphabetLeisteState extends State { + String? _beruehrt; + + void _waehleBei(double y, double hoehe) { + if (widget.buchstaben.isEmpty) return; + final proBuchstabe = hoehe / widget.buchstaben.length; + final index = + (y / proBuchstabe).floor().clamp(0, widget.buchstaben.length - 1); + final buchstabe = widget.buchstaben[index]; + if (buchstabe == _beruehrt) return; + setState(() => _beruehrt = buchstabe); + widget.onGewaehlt(buchstabe); + } + + @override + Widget build(BuildContext context) { + if (widget.buchstaben.isEmpty) return const SizedBox.shrink(); + return SizedBox( + width: alphabetBreite, + child: LayoutBuilder( + builder: (context, raum) => GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (d) => + _waehleBei(d.localPosition.dy, raum.maxHeight), + onVerticalDragUpdate: (d) => + _waehleBei(d.localPosition.dy, raum.maxHeight), + onVerticalDragEnd: (_) => setState(() => _beruehrt = null), + onVerticalDragCancel: () => setState(() => _beruehrt = null), + onTapDown: (d) => _waehleBei(d.localPosition.dy, raum.maxHeight), + onTapUp: (_) => setState(() => _beruehrt = null), + child: Semantics( + label: 'Schnellzugriff nach Anfangsbuchstabe', + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: MeloSpace.xs), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final buchstabe in widget.buchstaben) + Expanded( + child: Center( + child: Text( + buchstabe, + style: TextStyle( + fontSize: 11, + fontWeight: buchstabe == _beruehrt + ? FontWeight.w700 + : FontWeight.w500, + color: buchstabe == _beruehrt + ? MeloTheme.red + : MeloTheme.text3, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/auswahl.dart b/lib/shared/auswahl.dart new file mode 100644 index 0000000..1103fca --- /dev/null +++ b/lib/shared/auswahl.dart @@ -0,0 +1,33 @@ +/// Die Auswahl, nachdem [songId] angetippt wurde — ein Titel kommt dazu oder +/// fällt heraus. +/// +/// Gibt eine neue Menge zurück statt die alte zu ändern: die Oberfläche +/// vergleicht beide, um zu erkennen, ob sich etwas getan hat. +Set auswahlNach(Set auswahl, String songId) { + final neu = {...auswahl}; + if (!neu.remove(songId)) neu.add(songId); + return neu; +} + +/// Beschriftung der Auswahl-Leiste. +String auswahlText(int anzahl) => switch (anzahl) { + 0 => 'Titel auswählen', + 1 => '1 Titel ausgewählt', + _ => '$anzahl ausgewählt', + }; + +/// Rückmeldung nach dem Hinzufügen mehrerer Titel zu einer Wiedergabeliste. +/// +/// Nennt beides: was dazukam und was schon drin war. Vorher meldete die App +/// die volle Auswahl als „hinzugefügt", obwohl bereits enthaltene Titel wegen +/// des Primärschlüssels nur verschoben worden wären. +String playlistMeldung({ + required int neu, + required int schonDa, + String? fehler, +}) { + if (fehler != null) return 'Abgebrochen nach $neu Titeln: $fehler'; + if (neu == 0) return 'Alle schon in der Liste'; + if (schonDa == 0) return '$neu Titel hinzugefügt'; + return '$neu hinzugefügt · $schonDa waren schon drin'; +} diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart index 25317d2..fb67828 100644 --- a/lib/shared/sortable_song_list.dart +++ b/lib/shared/sortable_song_list.dart @@ -5,9 +5,13 @@ import '../library/database.dart'; import '../library/song_list.dart'; import '../library/song_media.dart'; import '../library/category_service.dart'; +import '../library/playlist_service.dart'; import '../library/song_sort.dart'; import '../player/audio_handler.dart'; import '../settings/app_settings.dart'; +import '../playlists/create_playlist_dialog.dart'; +import 'alphabet_leiste.dart'; +import 'auswahl.dart'; import 'sort_store.dart'; import 'theme.dart'; @@ -38,6 +42,126 @@ class _SortableSongListState extends State { ascending: false, ); + final _scroll = ScrollController(); + + /// Song-IDs der gerade ausgewählten Titel. Leer heißt: kein Auswahl-Modus. + Set _auswahl = const {}; + + bool get _waehltAus => _auswahl.isNotEmpty; + + void _wechsleAuswahl(String songId) => + setState(() => _auswahl = auswahlNach(_auswahl, songId)); + + void _beendeAuswahl() => setState(() => _auswahl = const {}); + + /// Die ausgewählten Titel in der Reihenfolge der angezeigten Liste. + List _gewaehlte(List sorted) => + [for (final s in sorted) if (_auswahl.contains(s.id)) s]; + + Future _inWiedergabeliste(List gewaehlte) async { + final db = context.read(); + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ziel = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: StreamBuilder>( + stream: db.watchPlaylists(), + builder: (context, snapshot) { + final listen = snapshot.data ?? const []; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.add), + title: const Text('Neue Wiedergabeliste'), + onTap: () async { + final neu = await showDialog( + context: sheetContext, + builder: (_) => const CreatePlaylistDialog(), + ); + if (neu != null && sheetContext.mounted) { + Navigator.pop(sheetContext, neu); + } + }, + ), + for (final liste in listen) + ListTile( + leading: const Icon(Icons.playlist_play), + title: Text(liste.name), + onTap: () => Navigator.pop(sheetContext, liste.id), + ), + ], + ); + }, + ), + ), + ); + if (ziel == null) return; + final vorhanden = await db.watchPlaylistSongs(ziel).first; + // Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn + // wegen des Primärschlüssels nur verschieben und Lücken hinterlassen. + final bekannt = {for (final s in vorhanden) s.id}; + var position = vorhanden.length; + var neu = 0; + String? fehler; + for (final song in gewaehlte) { + if (bekannt.contains(song.id)) continue; + try { + await dienst.addSongToPlaylist(ziel, song.id, position++); + neu++; + } catch (e) { + fehler = '$e'; + break; + } + } + if (!mounted) return; + _beendeAuswahl(); + messenger.showSnackBar(SnackBar( + content: Text(playlistMeldung( + neu: neu, + schonDa: gewaehlte.length - neu, + fehler: fehler, + )), + )); + } + + + Future _inWarteschlange(List gewaehlte) async { + final handler = context.read(); + final categories = context.read(); + final settings = context.read(); + final messenger = ScaffoldMessenger.of(context); + var angehaengt = 0; + for (final song in gewaehlte) { + final ok = await handler.addToQueue(songToMediaItem( + song, + cover: categories.coverFor(song, + groupByCategory: settings.groupCoversByCategory), + )); + if (ok) angehaengt++; + } + if (!mounted) return; + _beendeAuswahl(); + messenger.showSnackBar(SnackBar( + content: Text(angehaengt == gewaehlte.length + ? '$angehaengt Titel in der Warteschlange' + : '$angehaengt von ${gewaehlte.length} Titeln angehängt'), + )); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + void _springeZu(int index) { + if (!_scroll.hasClients) return; + final ziel = index * songZeilenHoeheFuer(context); + _scroll.jumpTo(ziel.clamp(0.0, _scroll.position.maxScrollExtent)); + } + @override void initState() { super.initState(); @@ -87,6 +211,51 @@ class _SortableSongListState extends State { await SortStore.save(widget.storeKey, chosen); } + /// Die Liste, bei Namenssortierung mit Schnellzugriff daneben. + /// + /// Nur dort: bei „Zuletzt hinzugefügt" oder „Wie oft abgespielt" stünden + /// die Buchstaben in willkürlicher Reihenfolge und führten ins Leere. + Widget _liste(List sorted) { + final index = _setting.mode == SortMode.name && sorted.length >= alphabetAb + ? buchstabenIndex(sorted) + : const {}; + // Auch genug **verschiedene** Buchstaben: bei einem Album mit 40 Titeln + // „Track 01…40" gäbe es nur einen, und die Leiste wäre kein Sprungziel. + final mitLeiste = index.length >= alphabetMindestBuchstaben; + if (!mitLeiste) { + return SongList( + sorted, + controller: _scroll, + auswahl: _auswahl, + onAuswahlWechsel: _wechsleAuswahl, + ); + } + // Der Index steht oben — er wird auf der **angezeigten** Liste gebaut, + // nicht auf einer gedachten aufsteigenden: bei Z–A stehen die Buchstaben + // dann von unten nach oben in der Leiste, und ein Sprung landet am Anfang + // des Blocks statt an seinem Ende. + return Row( + children: [ + Expanded( + child: SongList( + sorted, + controller: _scroll, + festeHoehe: true, + auswahl: _auswahl, + onAuswahlWechsel: _wechsleAuswahl, + ), + ), + AlphabetLeiste( + buchstaben: index.keys.toList(), + onGewaehlt: (b) { + final pos = index[b]; + if (pos != null) _springeZu(pos); + }, + ), + ], + ); + } + @override Widget build(BuildContext context) { final sorted = sortSongs( @@ -94,8 +263,24 @@ class _SortableSongListState extends State { _setting.mode, ascending: _setting.ascending, ); - return Column( + return PopScope( + // Zurück beendet zuerst die Auswahl. Sonst verlöre man zwanzig + // markierte Titel mit einer Geste, die überall sonst „einen Schritt + // zurück" bedeutet. + canPop: !_waehltAus, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _waehltAus) _beendeAuswahl(); + }, + child: Column( children: [ + if (_waehltAus) + _AuswahlLeiste( + anzahl: _auswahl.length, + onAbbrechen: _beendeAuswahl, + onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)), + onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)), + ) + else Padding( padding: const EdgeInsets.fromLTRB(16, 4, 8, 4), child: Row( @@ -145,9 +330,63 @@ class _SortableSongListState extends State { Expanded( child: sorted.isEmpty ? (widget.empty ?? const SizedBox.shrink()) - : SongList(sorted), + : _liste(sorted), ), ], + ), + ); + } +} + +/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen. +/// +/// Sie ersetzt die Shuffle-Zeile, statt darunter zu erscheinen — sonst +/// rutschte die Liste beim Auswählen nach unten weg. +class _AuswahlLeiste extends StatelessWidget { + const _AuswahlLeiste({ + required this.anzahl, + required this.onAbbrechen, + required this.onWiedergabeliste, + required this.onWarteschlange, + }); + + final int anzahl; + final VoidCallback onAbbrechen; + final VoidCallback onWiedergabeliste; + final VoidCallback onWarteschlange; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs), + child: Row( + children: [ + IconButton( + tooltip: 'Auswahl beenden', + icon: const Icon(Icons.close), + onPressed: onAbbrechen, + ), + Expanded( + child: Text( + auswahlText(anzahl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + IconButton( + tooltip: 'Zur Warteschlange hinzufügen', + icon: const Icon(Icons.queue), + onPressed: onWarteschlange, + ), + IconButton( + tooltip: 'Zu Wiedergabeliste hinzufügen', + icon: const Icon(Icons.playlist_add), + onPressed: onWiedergabeliste, + ), + ], + ), ); } } diff --git a/lib/shared/sub_tabs.dart b/lib/shared/sub_tabs.dart index 0160f37..1e047fd 100644 --- a/lib/shared/sub_tabs.dart +++ b/lib/shared/sub_tabs.dart @@ -65,7 +65,7 @@ class _Reiter extends StatelessWidget { child: InkWell( onTap: onTap, child: AnimatedContainer( - duration: MeloMotion.fast, + duration: MeloMotion.ruhig(context, MeloMotion.fast), curve: MeloMotion.curve, // Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht // zuverlässig (Material 3, Mindestgröße für Bedienelemente). @@ -83,7 +83,7 @@ class _Reiter extends StatelessWidget { border: aktiv ? null : MeloTheme.hairline, ), child: AnimatedDefaultTextStyle( - duration: MeloMotion.fast, + duration: MeloMotion.ruhig(context, MeloMotion.fast), curve: MeloMotion.curve, style: TextStyle( color: aktiv ? MeloTheme.black : MeloTheme.text2, diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart index da15a32..d333e04 100644 --- a/lib/shared/theme.dart +++ b/lib/shared/theme.dart @@ -46,6 +46,17 @@ abstract final class MeloMotion { static const Duration normal = Duration(milliseconds: 220); static const Curve curve = Curves.easeOutCubic; + + /// [dauer], oder null, wenn der Nutzer Systemanimationen abgeschaltet hat. + /// + /// Diese Einstellung nutzen Menschen, denen Bewegung auf dem Bildschirm + /// Unwohlsein bereitet. Die Übergänge sollen dann nicht schneller sein, + /// sondern gar nicht stattfinden — der Zustand wechselt schlicht. + /// + /// Bewusst kein globaler Schalter im Theme: `MaterialApp` kennt keinen, und + /// jede animierte Stelle muss die Entscheidung ohnehin selbst treffen. + static Duration ruhig(BuildContext context, Duration dauer) => + MediaQuery.disableAnimationsOf(context) ? Duration.zero : dauer; } /// Melo-Theme: Schwarz + Rot. diff --git a/test/library/kuenstler_untertitel_test.dart b/test/library/kuenstler_untertitel_test.dart new file mode 100644 index 0000000..1f8362e --- /dev/null +++ b/test/library/kuenstler_untertitel_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/artist_list.dart'; + +/// Die Künstlerzeile war vorher nackter Text ohne Angabe zu den Alben. +void main() { + test('nennt Titel und Alben', () { + expect(kuenstlerUntertitel(12, 3), '12 Titel · 3 Alben'); + }); + + test('ein einzelnes Album wird nicht erwähnt — es wäre keine Information', () { + expect(kuenstlerUntertitel(12, 1), '12 Titel'); + }); + + test('ohne Album bleibt es bei den Titeln', () { + expect(kuenstlerUntertitel(3, 0), '3 Titel'); + }); +} diff --git a/test/library/song_grouping_test.dart b/test/library/song_grouping_test.dart index d8ee700..169c706 100644 --- a/test/library/song_grouping_test.dart +++ b/test/library/song_grouping_test.dart @@ -67,4 +67,32 @@ void main() { expect(groupByArtist(const []), isEmpty); }); }); -} \ No newline at end of file + + group('groupByAlbum', () { + test('bündelt Titel desselben Albums', () { + final grouped = groupByAlbum([ + _song(id: 'a', title: 'A', album: 'Trailerpark'), + _song(id: 'b', title: 'B', album: 'Trailerpark'), + _song(id: 'c', title: 'C', album: 'Anderes'), + ]); + + expect(grouped.keys, ['Anderes', 'Trailerpark']); + expect(grouped['Trailerpark'], hasLength(2)); + }); + + test('Titel ohne Album sammeln sich unter "Ohne Album"', () { + final grouped = groupByAlbum([_song(id: 'a', title: 'A')]); + + expect(grouped.keys, [ohneAlbum]); + }); + + test('sortiert ohne Rücksicht auf Groß- und Kleinschreibung', () { + final grouped = groupByAlbum([ + _song(id: 'a', title: 'A', album: 'zeta'), + _song(id: 'b', title: 'B', album: 'Alpha'), + ]); + + expect(grouped.keys, ['Alpha', 'zeta']); + }); + }); +} diff --git a/test/library/song_sort_test.dart b/test/library/song_sort_test.dart index 566d062..a7d3421 100644 --- a/test/library/song_sort_test.dart +++ b/test/library/song_sort_test.dart @@ -136,4 +136,75 @@ void main() { expect(SortMode.playCount.label, 'Wie oft abgespielt'); }); }); + + group('indexBuchstabe', () { + test('nimmt den ersten Buchstaben, groß geschrieben', () { + expect(indexBuchstabe('anfang'), 'A'); + expect(indexBuchstabe('Zenit'), 'Z'); + }); + + test('Umlaute stehen unter ihrem Grundbuchstaben', () { + // Genau wie in der Sortierung: "Ärger" gehört zu A, nicht ans Ende. + expect(indexBuchstabe('Ärger'), 'A'); + expect(indexBuchstabe('Über'), 'U'); + }); + + test('Ziffern und Zeichen sammeln sich unter #', () { + expect(indexBuchstabe('9 Lives'), '#'); + expect(indexBuchstabe('#Hashtag'), '#'); + expect(indexBuchstabe(' '), '#'); + }); + + test('führender Leerraum zählt nicht', () { + expect(indexBuchstabe(' Berg'), 'B'); + }); + }); + + group('buchstabenIndex', () { + test('nennt je Buchstabe die erste Position', () { + final sortiert = sortSongs( + [_song('Anfang'), _song('Berg'), _song('Ärger'), _song('9 Lives')], + SortMode.name, + ascending: true, + ); + + final index = buchstabenIndex(sortiert); + + // Sortiert: Anfang, Ärger, Berg, 9 Lives + expect(index['A'], 0); + expect(index['B'], 2); + expect(index['#'], 3); + }); + + test('ein Buchstabe ohne Titel kommt nicht vor', () { + final index = buchstabenIndex([_song('Berg')]); + + expect(index.containsKey('A'), isFalse); + expect(index['B'], 0); + }); + + test('eine leere Liste ergibt keinen Index', () { + expect(buchstabenIndex(const []), isEmpty); + }); + }); + + group('buchstabenIndex bei Z–A', () { + test('nennt auch absteigend die erste Position des Buchstabens', () { + // Der Sprung soll oben im Block landen, nicht unten — deshalb wird der + // Index immer auf der angezeigten Reihenfolge gebaut. + final sortiert = sortSongs( + [_song('Anfang'), _song('Berg'), _song('Bogen'), _song('Zenit')], + SortMode.name, + ascending: false, + ); + + // Angezeigt: Zenit, Bogen, Berg, Anfang + final index = buchstabenIndex(sortiert); + + expect(index['Z'], 0); + expect(index['B'], 1); + expect(index['A'], 3); + expect(index.keys.toList(), ['Z', 'B', 'A']); + }); + }); } diff --git a/test/player/wischen_test.dart b/test/player/wischen_test.dart new file mode 100644 index 0000000..4878588 --- /dev/null +++ b/test/player/wischen_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/wischen.dart'; + +/// Der Mini-Player ließ sich nur antippen. Wischen ist die Geste, die jede +/// andere Musik-App dort anbietet — aber sie darf nicht bei jedem Wackeln +/// während des Tippens losgehen. +void main() { + group('titelWechsel', () { + test('kräftiges Wischen nach links geht zum nächsten Titel', () { + // Nach links wischen heißt: der nächste kommt von rechts herein. + expect(titelWechsel(-1200), Wechsel.naechster); + }); + + test('kräftiges Wischen nach rechts geht zum vorherigen', () { + expect(titelWechsel(1200), Wechsel.vorheriger); + }); + + test('ein Wackeln beim Tippen wechselt nichts', () { + expect(titelWechsel(80), isNull); + expect(titelWechsel(-80), isNull); + expect(titelWechsel(0), isNull); + }); + + test('genau auf der Schwelle wechselt es noch nicht', () { + expect(titelWechsel(wischSchwelle), isNull); + expect(titelWechsel(-wischSchwelle), isNull); + }); + + test('knapp über der Schwelle wechselt es', () { + expect(titelWechsel(wischSchwelle + 1), Wechsel.vorheriger); + expect(titelWechsel(-wischSchwelle - 1), Wechsel.naechster); + }); + }); + + group('oeffnetVollbild', () { + test('nach oben wischen öffnet', () { + // Nach oben ist in Flutter negativ. + expect(oeffnetVollbild(-1200), isTrue); + }); + + test('nach unten wischen öffnet nicht', () { + // Unten ist für „wegschieben" reserviert und nicht belegt — es soll + // jedenfalls nicht dasselbe tun wie oben. + expect(oeffnetVollbild(1200), isFalse); + }); + + test('ein Wackeln öffnet nicht', () { + expect(oeffnetVollbild(-80), isFalse); + expect(oeffnetVollbild(0), isFalse); + }); + }); +} diff --git a/test/shared/alphabet_leiste_test.dart b/test/shared/alphabet_leiste_test.dart new file mode 100644 index 0000000..275e887 --- /dev/null +++ b/test/shared/alphabet_leiste_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/shared/alphabet_leiste.dart'; + +/// Die Leiste stand zuerst in einer `FittedBox`: jeder Buchstabe skalierte +/// auf die volle Höhe seines Platzes, und die Leiste wurde dadurch **breiter**, +/// je weniger Buchstaben es gab. Bei einem einzigen schob sie die Songliste +/// vollständig aus dem Bild. +void main() { + Future breiteBei(WidgetTester tester, List buchstaben) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + const Expanded(child: SizedBox.expand()), + AlphabetLeiste(buchstaben: buchstaben, onGewaehlt: (_) {}), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + return tester.getSize(find.byType(AlphabetLeiste)); + } + + testWidgets('bleibt bei 26 Buchstaben schmal', (tester) async { + final groesse = await breiteBei(tester, [ + for (var c = 'A'.codeUnitAt(0); c <= 'Z'.codeUnitAt(0); c++) + String.fromCharCode(c), + ]); + + expect(groesse.width, alphabetBreite); + }); + + testWidgets('bleibt bei wenigen Buchstaben genauso schmal', (tester) async { + final groesse = await breiteBei(tester, ['A', 'M', 'Z']); + + expect(groesse.width, alphabetBreite); + }); + + testWidgets('wird auch bei einem einzigen Buchstaben nicht breit', + (tester) async { + final groesse = await breiteBei(tester, ['T']); + + expect(groesse.width, alphabetBreite); + }); + + testWidgets('ohne Buchstaben nimmt sie keinen Platz', (tester) async { + final groesse = await breiteBei(tester, const []); + + expect(groesse.width, 0); + }); + + testWidgets('Antippen meldet den Buchstaben an dieser Stelle', + (tester) async { + final gemeldet = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + const Expanded(child: SizedBox.expand()), + AlphabetLeiste( + buchstaben: const ['A', 'B', 'C', 'D'], + onGewaehlt: gemeldet.add, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final kasten = tester.getRect(find.byType(AlphabetLeiste)); + // Knapp unter der Mitte: der dritte von vier Buchstaben. + await tester.tapAt(Offset(kasten.center.dx, kasten.top + kasten.height * 0.6)); + await tester.pumpAndSettle(); + + expect(gemeldet, ['C']); + }); +} diff --git a/test/shared/auswahl_modus_test.dart b/test/shared/auswahl_modus_test.dart new file mode 100644 index 0000000..2c8303e --- /dev/null +++ b/test/shared/auswahl_modus_test.dart @@ -0,0 +1,154 @@ +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: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/settings/app_settings.dart'; +import 'package:melo/shared/sort_store.dart'; +import 'package:melo/shared/sortable_song_list.dart'; + +/// Langes Drücken schaltet in die Mehrfachauswahl. Geprüft wird, dass dabei +/// nichts abspielt und die Kopfzeile wechselt. +/// +/// **Aufbau bewusst im Testkörper, nicht in `setUp`.** `setUp` läuft außerhalb +/// der Testzone: Datenbank und Dienste entstünden dort in echter Zeit, +/// während `pumpAndSettle` in Testzeit auf ihre Ströme wartet — der Test hängt +/// dann endlos. Dieselbe Klasse Hänger beschreibt BACKLOG.md. +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, + ), + ]; + + /// Begrenzt statt unbegrenzt: hängt doch einmal etwas, scheitert der Test + /// in Sekunden, statt den ganzen Lauf zu blockieren. + Future beruhige(WidgetTester tester) => tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + Future pumpe(WidgetTester tester) async { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + SharedPreferences.setMockInitialValues({}); + final db = MeloDb(NativeDatabase.memory()); + 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), + ], + child: MaterialApp( + home: Scaffold( + body: SortableSongList( + songs: lieder, + storeKey: SortStore.meineMusik, + ), + ), + ), + ), + ); + await beruhige(tester); + } + + Future aufraeumen(WidgetTester tester) async { + // Kein db.close(): drift plant dabei eine Aufräum-Aufgabe ein, die der + // Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md). + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + } + + testWidgets('ohne Auswahl steht die Shuffle-Zeile da', (tester) async { + await pumpe(tester); + + expect(find.text('Shuffle-Wiedergabe'), findsOneWidget); + expect(find.text('1 Titel ausgewählt'), findsNothing); + + await aufraeumen(tester); + }); + + testWidgets('langes Drücken beginnt die Auswahl', (tester) async { + await pumpe(tester); + + await tester.longPress(find.text('Titel 0')); + await beruhige(tester); + + expect(find.text('1 Titel ausgewählt'), findsOneWidget); + // Die Shuffle-Zeile weicht, statt darüber zu wachsen — sonst rutschte + // die Liste beim Auswählen nach unten weg. + expect(find.text('Shuffle-Wiedergabe'), findsNothing); + + await aufraeumen(tester); + }); + + testWidgets('im Auswahl-Modus wählt ein Tipp aus, statt abzuspielen', + (tester) async { + await pumpe(tester); + await tester.longPress(find.text('Titel 0')); + await beruhige(tester); + + await tester.tap(find.text('Titel 1')); + await beruhige(tester); + + expect(find.text('2 ausgewählt'), findsOneWidget); + + await aufraeumen(tester); + }); + + testWidgets('den letzten abwählen beendet die Auswahl', (tester) async { + await pumpe(tester); + await tester.longPress(find.text('Titel 0')); + await beruhige(tester); + + await tester.tap(find.text('Titel 0')); + await beruhige(tester); + + expect(find.text('Shuffle-Wiedergabe'), findsOneWidget); + + await aufraeumen(tester); + }); + + testWidgets('das Kreuz beendet die Auswahl', (tester) async { + await pumpe(tester); + await tester.longPress(find.text('Titel 0')); + await beruhige(tester); + + await tester.tap(find.byTooltip('Auswahl beenden')); + await beruhige(tester); + + expect(find.text('Shuffle-Wiedergabe'), findsOneWidget); + + await aufraeumen(tester); + }); +} diff --git a/test/shared/auswahl_test.dart b/test/shared/auswahl_test.dart new file mode 100644 index 0000000..88d1b64 --- /dev/null +++ b/test/shared/auswahl_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/shared/auswahl.dart'; + +/// Lange Listen ließen sich nur Titel für Titel bearbeiten: wer zwanzig +/// Lieder in eine Wiedergabeliste wollte, tippte sechzig Mal. +void main() { + group('auswahlNach', () { + test('ein neuer Titel kommt dazu', () { + expect(auswahlNach({'a'}, 'b'), {'a', 'b'}); + }); + + test('ein bereits gewählter fällt wieder heraus', () { + expect(auswahlNach({'a', 'b'}, 'b'), {'a'}); + }); + + test('der letzte herausgenommene beendet die Auswahl', () { + expect(auswahlNach({'a'}, 'a'), isEmpty); + }); + + test('lässt die Ausgangsmenge unverändert', () { + final vorher = {'a'}; + + auswahlNach(vorher, 'b'); + + expect(vorher, {'a'}); + }); + }); + + group('auswahlText', () { + test('nennt die Anzahl', () { + expect(auswahlText(3), '3 ausgewählt'); + }); + + test('spricht bei einem Titel in der Einzahl', () { + expect(auswahlText(1), '1 Titel ausgewählt'); + }); + + test('ohne Auswahl steht die Aufforderung', () { + expect(auswahlText(0), 'Titel auswählen'); + }); + }); + + group('Meldung beim Hinzufügen zur Wiedergabeliste', () { + test('nennt nur die neuen, wenn keiner schon drin war', () { + expect(playlistMeldung(neu: 5, schonDa: 0), '5 Titel hinzugefügt'); + }); + + test('nennt beides, wenn einige schon drin waren', () { + // Vorher meldete die App „10 Titel hinzugefügt", obwohl drei davon nur + // verschoben worden wären. + expect(playlistMeldung(neu: 7, schonDa: 3), + '7 hinzugefügt · 3 waren schon drin'); + }); + + test('sagt es, wenn gar nichts dazukam', () { + expect(playlistMeldung(neu: 0, schonDa: 4), 'Alle schon in der Liste'); + }); + + test('ein Fehler unterbricht und wird benannt', () { + expect(playlistMeldung(neu: 2, schonDa: 0, fehler: 'Datenbank weg'), + 'Abgebrochen nach 2 Titeln: Datenbank weg'); + }); + }); +}