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
@@ -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');
});
}
+29 -1
View File
@@ -67,4 +67,32 @@ void main() {
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');
});
});
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');
});
});
}