Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl

Die P2-Liste aus dem Review — Kleinigkeiten, die man einzeln nicht
vermisst und zusammen sofort merkt.

- Das Coverbild wandert beim Oeffnen der Vollbild-Wiedergabe an seinen
  Platz, statt dass ein neuer Bildschirm aufspringt.
- Wischen am Mini-Player: nach oben oeffnen, seitwaerts Titelwechsel. Mit
  Schwelle, weil der Mini-Player zugleich ein Knopf ist und der Finger
  beim Tippen fast immer ein paar Pixel wandert. Nach unten bewusst
  unbelegt, damit niemand die Wiedergabe versehentlich wegschiebt.
- Kuenstlerliste mit Bildern und Albenzahl; dahinter eine Kuenstlerseite
  mit seinen Alben und darunter allen Titeln (dafuer neu: groupByAlbum).
  Bei nur einem Album entfaellt die Zwischenstufe.
- Alphabet-Sprung am rechten Rand, nur bei Namenssortierung und ab 30
  Titeln. Umlaute zaehlen zu ihrem Grundbuchstaben, Ziffern und Zeichen
  unter "#" — dieselbe Regel wie die Sortierung.
- Mehrfachauswahl per langem Druck, mit "Zur Warteschlange" und "Zu
  Wiedergabeliste".
- Abgeschaltete Systemanimationen gelten jetzt ueberall, nicht nur bei den
  Lauf-Balken.
- Querformat und Tablet: der Player legt Cover und Bedienung nebeneinander
  und begrenzt die Textbreite.

Beim Bauen gefunden: der Alphabet-Sprung landete bei absteigender
Sortierung am Ende eines Buchstaben-Blocks statt an seinem Anfang.

Aus dem Code-Review nachgebessert, sieben Punkte: die Alphabet-Leiste
wurde breiter, je weniger Buchstaben sie hatte (bei einem einzigen 528 dp
— die Songliste waere verschwunden); die neue Kuenstlerseite baute alle
Titel sofort und oeffnete damit bei 300 Titeln ebenso viele Abos; die
Bildlauf-Animation im Songtext stuerzte bei abgeschalteten
Systemanimationen ab; die feste Zeilenhoehe war 64 statt 72 und die
Schriftskalierung an 1.0 statt an einer echten Groesse gemessen; bereits
enthaltene Titel wurden in Wiedergabelisten verschoben statt uebersprungen
und trotzdem mitgezaehlt; ein Fehler im Stapel brach still ab; und die
Zurueck-Geste verliess den Bildschirm, statt die Auswahl zu beenden.

Selbst gefunden: in der Kuenstlerliste hing der Datenbank-Strom an
CategoryService und wurde bei jeder Kategorie-Meldung neu aufgesetzt.

Nebenbei aufgeklaert, warum ein neuer Widget-Test endlos hing: Aufbau in
setUp laeuft ausserhalb der Testzone, die drift-Stroeme liefen dort in
echter Zeit, waehrend pumpAndSettle in Testzeit auf sie wartete. Aufbau in
den Testkoerper verschoben. Der aeltere Haenger im BACKLOG hat eine andere
Ursache (db.close() dort nachweislich ausgeschlossen); die Spur ist
notiert.

500 Tests gruen (vorher 457), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
Hermes (Server)
2026-08-24 15:09:46 +02:00
co-authored by Claude Opus 5
parent 0d76d8cc58
commit 490e73ce41
22 changed files with 1467 additions and 116 deletions
@@ -36,7 +36,11 @@ erscheint auf Android 13+ keine Wiedergabe-Benachrichtigung.
> Aus **G3** bleibt der schwere Teil offen: Wiedergabezähler, Historie und > Aus **G3** bleibt der schwere Teil offen: Wiedergabezähler, Historie und
> Kategorien für reine Server-Titel bräuchten eine Zeile in der Bibliothek. > 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 > korrigierten Fassung (Album-Detailansicht **lokal**, mit Tracknummer und
> Schema-Erweiterung — die Server-Seite davon ist mit G2 erledigt). > Schema-Erweiterung — die Server-Seite davon ist mit G2 erledigt).
+18
View File
@@ -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 das nicht** — dort wurde es angewandt und der Test hängt weiter. Die beiden
Hänger haben also unterschiedliche Ursachen. 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 **Nächster Schritt:** Den Testkörper schrittweise abspecken (Providerbaum
minimieren, Sheet durch eine normale Route ersetzen) bis der Hänger minimieren, Sheet durch eine normale Route ersetzen) bis der Hänger
verschwindet — die letzte entfernte Zutat ist die Ursache. Ein verschwindet — die letzte entfernte Zutat ist die Ursache. Ein
+88
View File
@@ -5,6 +5,94 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [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) ### 🧩 Die letzten vier Punkte aus dem Review (2026-08-23)
**G2 — Server-Bereich.** Ein Tipp auf ein Album spielte sofort das ganze **G2 — Server-Bereich.** Ein Tipp auf ein Album spielte sofort das ganze
+148 -18
View File
@@ -1,25 +1,39 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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 'database.dart';
import 'song_grouping.dart'; import 'song_grouping.dart';
import '../shared/titel_listen_screen.dart'; import 'song_list.dart';
import '../shared/theme.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs]. /// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
class ArtistListScreen extends StatelessWidget { class ArtistListScreen extends StatefulWidget {
const ArtistListScreen({super.key}); const ArtistListScreen({super.key});
@override
State<ArtistListScreen> createState() => _ArtistListScreenState();
}
class _ArtistListScreenState extends State<ArtistListScreen> {
// 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<List<Song>> _songs = context.read<MeloDb>().watchSongs();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return StreamBuilder<List<Song>>( return StreamBuilder<List<Song>>(
stream: db.watchSongs(), stream: _songs,
builder: (context, snapshot) { builder: (context, snapshot) {
final songs = snapshot.data ?? const []; final songs = snapshot.data ?? const [];
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( 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); final grouped = groupByArtist(songs);
@@ -28,18 +42,7 @@ class ArtistListScreen extends StatelessWidget {
itemCount: artists.length, itemCount: artists.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final artist = artists[i]; final artist = artists[i];
final artistSongs = grouped[artist]!; return _KuenstlerZeile(name: artist, songs: 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),
),
),
);
}, },
); );
}, },
@@ -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<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
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<Song> 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<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
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),
);
}
}
+8
View File
@@ -8,6 +8,14 @@ Map<String, List<Song>> groupByArtist(List<Song> songs) {
return _groupBy(songs, (s) => s.artist, unbekannterKuenstler); return _groupBy(songs, (s) => s.artist, unbekannterKuenstler);
} }
const ohneAlbum = 'Ohne Album';
/// Gruppiert [songs] nach Album (fehlendes Album → [ohneAlbum]),
/// alphabetisch nach Albumnamen sortiert.
Map<String, List<Song>> groupByAlbum(List<Song> songs) {
return _groupBy(songs, (s) => s.album, ohneAlbum);
}
Map<String, List<Song>> _groupBy( Map<String, List<Song>> _groupBy(
List<Song> songs, List<Song> songs,
String? Function(Song) keyOf, String? Function(Song) keyOf,
+125 -25
View File
@@ -15,19 +15,93 @@ import 'song_detail_sheet.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
import 'song_media.dart'; import 'song_media.dart';
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab. /// Häkchen-Kreis anstelle des Covers, solange ausgewählt wird.
class SongList extends StatelessWidget { class _AuswahlZeichen extends StatelessWidget {
const SongList(this.songs, {super.key}); const _AuswahlZeichen({required this.ausgewaehlt});
final List<Song> songs; final bool ausgewaehlt;
@override @override
Widget build(BuildContext context) { 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<Song> 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<String>? auswahl;
final void Function(String songId)? onAuswahlWechsel;
@override
Widget build(BuildContext context) {
final gewaehlt = auswahl;
return ListView.builder( return ListView.builder(
controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length, itemCount: songs.length,
itemBuilder: (context, i) => SongZeile( itemBuilder: (context, i) => SongZeile(
song: songs[i], song: songs[i],
warteschlange: songs, warteschlange: songs,
index: i, 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.song,
required this.warteschlange, required this.warteschlange,
required this.index, required this.index,
this.auswahlModus = false,
this.ausgewaehlt = false,
this.onAuswahlWechsel,
}); });
final Song song; final Song song;
final List<Song> warteschlange; final List<Song> warteschlange;
final int index; 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. /// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) { static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt'; final artist = song.artist ?? 'Unbekannt';
@@ -232,16 +316,23 @@ class SongZeile extends StatelessWidget {
required bool spielt, required bool spielt,
}) { }) {
return ListTile( return ListTile(
leading: LaufMarkierung( selected: ausgewaehlt,
markiert: markiert, selectedTileColor: MeloTheme.surfaceHigh,
spielt: spielt, // Im Auswahl-Modus tritt das Häkchen an die Stelle des Covers: eine
radius: 6, // zusätzliche Spalte hätte die Zeile schmaler gemacht und den Titel
child: CoverImage( // beim Umschalten springen lassen.
artUri: cover != null ? Uri.file(cover) : null, leading: auswahlModus
size: 48, ? _AuswahlZeichen(ausgewaehlt: ausgewaehlt)
radius: 6, : LaufMarkierung(
), markiert: markiert,
), spielt: spielt,
radius: 6,
child: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
),
title: Text( title: Text(
song.title, song.title,
maxLines: 1, maxLines: 1,
@@ -255,18 +346,27 @@ class SongZeile extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: Row( // Herz und Menü gelten je einem Titel — im Auswahl-Modus wäre unklar,
mainAxisSize: MainAxisSize.min, // auf welchen sie sich beziehen.
children: [ trailing: auswahlModus
FavoriteButton(songId: song.id), ? null
IconButton( : Row(
tooltip: 'Mehr', mainAxisSize: MainAxisSize.min,
icon: const Icon(Icons.more_vert), children: [
onPressed: () => _showMenu(context), FavoriteButton(songId: song.id),
), IconButton(
], tooltip: 'Mehr',
), icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
],
),
onLongPress: onAuswahlWechsel,
onTap: () async { onTap: () async {
if (auswahlModus) {
onAuswahlWechsel?.call();
return;
}
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
+20
View File
@@ -33,6 +33,26 @@ List<Song> sortSongs(List<Song> songs, SortMode mode, {required bool ascending})
return sorted; 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<String, int> buchstabenIndex(List<Song> sortiert) {
final index = <String, int>{};
for (var i = 0; i < sortiert.length; i++) {
index.putIfAbsent(indexBuchstabe(sortiert[i].title), () => i);
}
return index;
}
/// AZ zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern, /// AZ zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
/// Sonderzeichen) landet unter "#" am Ende. /// Sonderzeichen) landet unter "#" am Ende.
int _compareNames(String a, String b) { int _compareNames(String a, String b) {
+63 -36
View File
@@ -6,6 +6,7 @@ import '../shared/cover.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
import 'audio_handler.dart'; import 'audio_handler.dart';
import 'now_playing_screen.dart'; import 'now_playing_screen.dart';
import 'wischen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav. /// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
/// Blendet sich aus, wenn nichts läuft. /// 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 // Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
// dazu, wenn etwas startet. // dazu, wenn etwas startet.
return AnimatedSwitcher( return AnimatedSwitcher(
duration: MeloMotion.normal, duration: MeloMotion.ruhig(context, MeloMotion.normal),
switchInCurve: MeloMotion.curve, switchInCurve: MeloMotion.curve,
transitionBuilder: (kind, animation) => SizeTransition( transitionBuilder: (kind, animation) => SizeTransition(
sizeFactor: animation, sizeFactor: animation,
@@ -47,6 +48,10 @@ class _Leiste extends StatelessWidget {
final MeloAudioHandler handler; final MeloAudioHandler handler;
final MediaItem item; final MediaItem item;
void _oeffne(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final texte = Theme.of(context).textTheme; final texte = Theme.of(context).textTheme;
@@ -61,42 +66,64 @@ class _Leiste extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
InkWell( GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute( // Wischen wie in jeder anderen Musik-App: nach oben öffnet die
builder: (_) => const NowPlayingScreen(), // Vollbild-Wiedergabe, seitwärts wechselt den Titel.
)), onVerticalDragEnd: (details) {
child: SizedBox( if (oeffnetVollbild(details.velocity.pixelsPerSecond.dy)) {
height: MiniPlayer.hoehe, _oeffne(context);
child: Row( }
children: [ },
const SizedBox(width: MeloSpace.md), onHorizontalDragEnd: (details) {
CoverImage(artUri: item.artUri, size: 52), switch (titelWechsel(details.velocity.pixelsPerSecond.dx)) {
const SizedBox(width: MeloSpace.md), case Wechsel.naechster:
Expanded( handler.skipToNext();
child: Column( case Wechsel.vorheriger:
mainAxisAlignment: MainAxisAlignment.center, handler.skipToPrevious();
crossAxisAlignment: CrossAxisAlignment.start, case null:
children: [ break;
Text( }
item.title, },
maxLines: 1, child: InkWell(
overflow: TextOverflow.ellipsis, onTap: () => _oeffne(context),
style: texte.bodyLarge child: SizedBox(
?.copyWith(fontWeight: FontWeight.w600), height: MiniPlayer.hoehe,
), child: Row(
Text( children: [
item.artist ?? 'Unbekannt', const SizedBox(width: MeloSpace.md),
maxLines: 1, // Dasselbe Bild wie im Vollbild: es wandert beim Öffnen
overflow: TextOverflow.ellipsis, // dorthin, statt dass ein neuer Bildschirm aufspringt.
style: texte.bodySmall, Hero(
), tag: coverHeldenName,
], child: CoverImage(artUri: item.artUri, size: 52),
), ),
), const SizedBox(width: MeloSpace.md),
const SizedBox(width: MeloSpace.sm), Expanded(
_Bedienung(handler: handler), child: Column(
const SizedBox(width: MeloSpace.sm), 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),
],
),
), ),
), ),
), ),
+104 -31
View File
@@ -75,30 +75,54 @@ class NowPlayingScreen extends StatelessWidget {
if (item == null) { if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe')); return const Center(child: Text('Nichts in Wiedergabe'));
} }
return Padding( return LayoutBuilder(
padding: const EdgeInsets.symmetric(horizontal: 24), builder: (context, raum) {
child: Column( // Im Querformat ist die Höhe knapp: Cover und Bedienung
children: [ // untereinander ließen vom Cover einen Streifen übrig oder
Expanded(child: Center(child: _Cover(item: item))), // schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides
const SizedBox(height: 24), // benutzbar. Dieselbe Aufteilung hilft auf einem Tablet.
Text( final nebeneinander = raum.maxWidth > raum.maxHeight;
item.title, final inhalt = _Angaben(item: item, handler: handler);
style: Theme.of(context).textTheme.headlineSmall, return Padding(
maxLines: 2, padding: const EdgeInsets.symmetric(horizontal: 24),
overflow: TextOverflow.ellipsis, child: nebeneinander
textAlign: TextAlign.center, ? Row(
), children: [
const SizedBox(height: 8), Expanded(
Text( child: Center(child: _Cover(item: item)),
item.artist ?? 'Unbekannt', ),
style: const TextStyle(color: MeloTheme.text2), const SizedBox(width: MeloSpace.lg),
), // Der Inhalt bekommt eine feste Obergrenze: auf
const SizedBox(height: 24), // einem breiten Bildschirm zerrte er sonst über
_ProgressBar(handler: handler), // die ganze Breite und wäre schlecht lesbar.
const _Controls(), Expanded(
const SizedBox(height: 16), 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. /// Das passende Herz für den laufenden Titel.
class _Herz extends StatelessWidget { class _Herz extends StatelessWidget {
const _Herz({required this.item}); 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 { class _Cover extends StatelessWidget {
const _Cover({required this.item}); const _Cover({required this.item});
final MediaItem item; final MediaItem item;
@@ -139,7 +204,10 @@ class _Cover extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AspectRatio( return AspectRatio(
aspectRatio: 1, 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) - final ziel = (index * _zeilenHoehe) -
(_scroll.position.viewportDimension / 2) + (_scroll.position.viewportDimension / 2) +
(_zeilenHoehe / 2); (_zeilenHoehe / 2);
_scroll.animateTo( final wohin = ziel.clamp(0.0, _scroll.position.maxScrollExtent);
ziel.clamp(0, _scroll.position.maxScrollExtent), final dauer = MeloMotion.ruhig(context, MeloMotion.normal);
duration: MeloMotion.normal, // Bei abgeschalteten Systemanimationen wird die Dauer null — `animateTo`
curve: MeloMotion.curve, // 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; static const _zeilenHoehe = 40.0;
@@ -509,7 +582,7 @@ class _Mitlaufend extends StatelessWidget {
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AnimatedDefaultTextStyle( child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast, duration: MeloMotion.ruhig(context, MeloMotion.fast),
style: TextStyle( style: TextStyle(
fontSize: istAktiv ? 17 : 15, fontSize: istAktiv ? 17 : 15,
height: 1.3, height: 1.3,
+25
View File
@@ -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;
+107
View File
@@ -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 AZ#-Leiste am rechten Rand. Tippen und Ziehen springt zur
/// ersten Stelle des Buchstabens.
///
/// Zeigt **nur** die Buchstaben, unter denen wirklich etwas steht: eine volle
/// AZ-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<String> buchstaben;
final ValueChanged<String> onGewaehlt;
@override
State<AlphabetLeiste> createState() => _AlphabetLeisteState();
}
class _AlphabetLeisteState extends State<AlphabetLeiste> {
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,
),
),
),
),
],
),
),
),
),
),
);
}
}
+33
View File
@@ -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<String> auswahlNach(Set<String> 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';
}
+241 -2
View File
@@ -5,9 +5,13 @@ import '../library/database.dart';
import '../library/song_list.dart'; import '../library/song_list.dart';
import '../library/song_media.dart'; import '../library/song_media.dart';
import '../library/category_service.dart'; import '../library/category_service.dart';
import '../library/playlist_service.dart';
import '../library/song_sort.dart'; import '../library/song_sort.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../settings/app_settings.dart'; import '../settings/app_settings.dart';
import '../playlists/create_playlist_dialog.dart';
import 'alphabet_leiste.dart';
import 'auswahl.dart';
import 'sort_store.dart'; import 'sort_store.dart';
import 'theme.dart'; import 'theme.dart';
@@ -38,6 +42,126 @@ class _SortableSongListState extends State<SortableSongList> {
ascending: false, ascending: false,
); );
final _scroll = ScrollController();
/// Song-IDs der gerade ausgewählten Titel. Leer heißt: kein Auswahl-Modus.
Set<String> _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<Song> _gewaehlte(List<Song> sorted) =>
[for (final s in sorted) if (_auswahl.contains(s.id)) s];
Future<void> _inWiedergabeliste(List<Song> gewaehlte) async {
final db = context.read<MeloDb>();
final dienst = context.read<PlaylistService>();
final messenger = ScaffoldMessenger.of(context);
final ziel = await showModalBottomSheet<String>(
context: context,
builder: (sheetContext) => SafeArea(
child: StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final listen = snapshot.data ?? const <Playlist>[];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add),
title: const Text('Neue Wiedergabeliste'),
onTap: () async {
final neu = await showDialog<String?>(
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<void> _inWarteschlange(List<Song> gewaehlte) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -87,6 +211,51 @@ class _SortableSongListState extends State<SortableSongList> {
await SortStore.save(widget.storeKey, chosen); 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<Song> sorted) {
final index = _setting.mode == SortMode.name && sorted.length >= alphabetAb
? buchstabenIndex(sorted)
: const <String, int>{};
// 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 ZA 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sorted = sortSongs( final sorted = sortSongs(
@@ -94,8 +263,24 @@ class _SortableSongListState extends State<SortableSongList> {
_setting.mode, _setting.mode,
ascending: _setting.ascending, 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: [ children: [
if (_waehltAus)
_AuswahlLeiste(
anzahl: _auswahl.length,
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
)
else
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4), padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row( child: Row(
@@ -145,9 +330,63 @@ class _SortableSongListState extends State<SortableSongList> {
Expanded( Expanded(
child: sorted.isEmpty child: sorted.isEmpty
? (widget.empty ?? const SizedBox.shrink()) ? (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,
),
],
),
); );
} }
} }
+2 -2
View File
@@ -65,7 +65,7 @@ class _Reiter extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: MeloMotion.fast, duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve, curve: MeloMotion.curve,
// Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht // Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht
// zuverlässig (Material 3, Mindestgröße für Bedienelemente). // zuverlässig (Material 3, Mindestgröße für Bedienelemente).
@@ -83,7 +83,7 @@ class _Reiter extends StatelessWidget {
border: aktiv ? null : MeloTheme.hairline, border: aktiv ? null : MeloTheme.hairline,
), ),
child: AnimatedDefaultTextStyle( child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast, duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve, curve: MeloMotion.curve,
style: TextStyle( style: TextStyle(
color: aktiv ? MeloTheme.black : MeloTheme.text2, color: aktiv ? MeloTheme.black : MeloTheme.text2,
+11
View File
@@ -46,6 +46,17 @@ abstract final class MeloMotion {
static const Duration normal = Duration(milliseconds: 220); static const Duration normal = Duration(milliseconds: 220);
static const Curve curve = Curves.easeOutCubic; 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. /// Melo-Theme: Schwarz + Rot.
@@ -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');
});
}
+28
View File
@@ -67,4 +67,32 @@ void main() {
expect(groupByArtist(const []), isEmpty); expect(groupByArtist(const []), isEmpty);
}); });
}); });
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']);
});
});
} }
+71
View File
@@ -136,4 +136,75 @@ void main() {
expect(SortMode.playCount.label, 'Wie oft abgespielt'); 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 ZA', () {
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']);
});
});
} }
+52
View File
@@ -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);
});
});
}
+82
View File
@@ -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<Size> breiteBei(WidgetTester tester, List<String> 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 = <String>[];
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']);
});
}
+154
View File
@@ -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<void> beruhige(WidgetTester tester) => tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
Future<void> 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<MeloDb>.value(value: db),
ChangeNotifierProvider<CategoryService>.value(
value: CategoryService(db)),
ChangeNotifierProvider<PlaylistService>.value(
value: PlaylistService(db)),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
],
child: MaterialApp(
home: Scaffold(
body: SortableSongList(
songs: lieder,
storeKey: SortStore.meineMusik,
),
),
),
),
);
await beruhige(tester);
}
Future<void> 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);
});
}
+64
View File
@@ -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');
});
});
}