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:
co-authored by
Claude Opus 5
parent
0d76d8cc58
commit
490e73ce41
+148
-18
@@ -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<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
|
||||
Widget build(BuildContext context) {
|
||||
final db = context.read<MeloDb>();
|
||||
return StreamBuilder<List<Song>>(
|
||||
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<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,6 +8,14 @@ Map<String, List<Song>> groupByArtist(List<Song> songs) {
|
||||
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(
|
||||
List<Song> songs,
|
||||
String? Function(Song) keyOf,
|
||||
|
||||
+125
-25
@@ -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<Song> 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<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(
|
||||
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<Song> 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<String> 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<MeloAudioHandler>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,26 @@ List<Song> sortSongs(List<Song> 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<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;
|
||||
}
|
||||
|
||||
/// A–Z zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
|
||||
/// Sonderzeichen) landet unter "#" am Ende.
|
||||
int _compareNames(String a, String b) {
|
||||
|
||||
Reference in New Issue
Block a user