Author SHA1 Message Date
Hermes (Server)andClaude Opus 5 239aae3ea3 Datenschutz: Fehlerberichte lassen sich abschalten
Uebertragung eines Befunds aus dem Security-Audit der alten Melo-App
(21.08.2026) in die neue Codebasis.

Ausgangslage hier war eine ANDERE als dort: LoggerService lud jeden Fehler
unbedingt zu logs.baka-net.de hoch, es gab ueberhaupt keinen Schalter. In
der alten App existierte er zwar, wirkte aber nicht ueber einen Neustart
hinaus.

Neu:
- Schalter "Fehlerberichte senden" in den Einstellungen unter Datenschutz.
- Sperre an der einzigen Stelle, durch die jeder Upload laeuft. Lokales
  debugPrint bleibt, aber nichts verlaesst das Geraet; der Puffer wird
  geleert, damit er nicht unbegrenzt waechst.
- Die Entscheidung ueberlebt den Neustart: AppSettings speichert sie und
  reicht sie an den Logger durch, UND LoggerService liest sie beim Start
  zusaetzlich selbst - main() startet den Logger vor den Einstellungen, und
  schon dieses Zeitfenster darf bei Widerspruch nichts senden.
- LoggerService bekam einen injizierbaren http.Client (Muster wie BakaAuth),
  damit sich belegen laesst, dass bei Opt-out KEIN Request rausgeht.

Nicht uebernommen: der zweite Befund (Logout liess Zugangsdaten zurueck)
trifft hier nicht zu. BakaAuth.abmelden() loescht genau die Schluessel, die
anmelden() schreibt, und Navidrome hat mit loescheZugangsdaten() ein
eigenes, vollstaendiges Trennen. Der alte Fehler - Logout raeumt tote
Schluessel ab, waehrend die echten liegen bleiben - existiert in dieser
Codebasis nicht.

Standard bleibt bewusst "an", damit sich fuer bestehende Nutzer nichts
ungefragt aendert. Opt-in waere datenschutzfreundlicher, ist aber eine
Produktentscheidung.

Tests: 304 gruen (vorher 295), 9 neue. Alle drei tragenden Stellen per
Mutationsprobe geprueft. flutter analyze: keine Befunde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbuWT267W8rFDsaavANuEn
2026-08-21 12:24:11 +02:00
31 changed files with 218 additions and 1153 deletions
-11
View File
@@ -28,19 +28,8 @@ den nächsten Bibliotheks-Scan damit überlebt.
| `http.runWithClient` erreicht den State im Modal-Sheet nicht | ❌ widerlegt — eigene Sonde zeigt `MockClient` auch im Sheet |
| drift unter `testWidgets` blockiert (fehlendes `runAsync`) | ❌ widerlegt — `upsertSongs` + `watchSongs().first` laufen durch |
| Einer der Provider blockiert `pumpWidget` | ❌ widerlegt — `MeloDb`, `CategoryService`, `AppSettings` einzeln geprüft |
| `db.close()` im Teardown (der Hänger aus `hauptmenue_test.dart`) | ❌ widerlegt — Entfernen ändert nichts, siehe unten |
| Zusammenspiel `showModalBottomSheet` + Providerbaum | ⏳ offen — der Hänger tritt zwischen `pumpWidget` und dem ersten `tap` auf |
**Nicht die Ursache (2026-08-21 geprüft):** In `hauptmenue_test.dart` trat ein
Hänger mit derselben Signatur auf. Dort war die Ursache `await db.close()`
nach einem `tester.tap` — drift plant beim Schließen eine Aufräum-Aufgabe
ein, die der Test-Rahmen nicht mehr abarbeitet. Das Rezept dagegen:
Dienste per `.value` bereitstellen (der Baum darf sie nicht schließen), am
Testende `pumpWidget(SizedBox())` + `pump(Duration(milliseconds: 1))`, und
auf `db.close()` verzichten. **Bei `song_detail_sheet_test.dart` hilft genau
das nicht** — dort wurde es angewandt und der Test hängt weiter. Die beiden
Hänger haben also unterschiedliche Ursachen.
**Nächster Schritt:** Den Testkörper schrittweise abspecken (Providerbaum
minimieren, Sheet durch eine normale Route ersetzen) bis der Hänger
verschwindet — die letzte entfernte Zutat ist die Ursache. Ein
+10 -120
View File
@@ -5,113 +5,7 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
### 🏷 Der zweite Tab heißt wieder „Download" (2026-08-21)
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
unverändert — YouTube-Downloader und Server-Bibliothek als Unterreiter.
Nur die Beschriftung im Hauptmenü ändert sich.
Die vier Bereiche heißen jetzt: **Meine Musik · Download · Suchen ·
Favoriten**.
- ⬇️ **Passendes Symbol dazu.** Statt der Note steht wieder ein Download-Pfeil
im Menü — genau das Symbol, das der Tab schon einmal hatte, bevor er in
„Online" umbenannt wurde. Also nichts Neues erfunden, sondern der frühere
Stand wiederhergestellt.
- 📄 **Auch die Überschrift im Bereich selbst zieht nach.** Sie stand noch auf
„Online" — ein Tab namens „Download", der auf einen Bildschirm namens
„Online" führt, wäre verwirrend gewesen.
-**Getestet:** 328 Tests grün, `flutter analyze` ohne Befund.
### 🧭 Hauptmenü wieder deutlich sichtbar (2026-08-21)
**Zuerst die Entwarnung:** Es fehlt nichts und nichts ist verschoben. Alle
vier Bereiche standen und stehen unten im Hauptmenü — `lib/main.dart` wurde
von der UI-Politur überhaupt nicht angefasst.
- 🐛 **Behoben: die Politur hatte das Menü abgeschwächt.** Zwei Fehler von
mir aus der letzten Runde:
- Ich hatte für **aktiv und inaktiv dieselbe Schriftgröße** (12) gesetzt.
Damit verlor der aktive Bereich den Größenunterschied (sonst 14 zu 12),
an dem man auf einen Blick erkennt, wo man ist.
- Die inaktiven Einträge liefen auf der **blassen Beiwerk-Stufe**. Ein
Hauptmenü ist kein Beiwerk — sie sind jetzt wieder kräftig.
-**Zusätzlich deutlicher gemacht:**
- Eine **Haarlinie über dem Menü** — auf schwarzem Grund ging die Leiste
sonst optisch im Inhalt auf.
- Der aktive Bereich hat jetzt ein **gefülltes Symbol** (ausgefülltes Herz,
volle Note). Wer Rot schlecht sieht, erkennt trotzdem, wo er steht —
vorher unterschied nur die Farbe.
- Der aktive Eintrag ist fetter gesetzt.
- 🔒 **Damit das keine Politur mehr abschwächen kann:** ein neuer Test prüft,
dass alle vier Bereiche vorhanden **und auf dem Bildschirm** sind, dass das
Menü am unteren Rand klebt, dass jeder Tipp dorthin wechselt, und dass der
aktive Bereich sich nicht nur durch Farbe abhebt.
- 🔧 **Details für Technikinteressierte:**
- Neu: `test/hauptmenue_test.dart` (10 Prüfungen).
- `bottomNavigationBarTheme`: keine feste `fontSize` mehr (damit Flutters
14/12 gelten), `unselectedItemColor` von `text3` auf `text2`,
`showUnselectedLabels` ausdrücklich gesetzt.
- Beim Schreiben des Tests ist eine **Test-Falle** aufgefallen und gelöst:
`await db.close()` nach einem `tester.tap` lässt den Testlauf
**unbegrenzt hängen**. Rezept dagegen steht im Test und in `BACKLOG.md`.
Beim schon bekannten Hänger in `song_detail_sheet_test.dart` hilft es
allerdings nicht — dort ist die Ursache eine andere, jetzt ebenfalls im
Backlog vermerkt.
-**Getestet:** 328 Tests grün (10 neue), `flutter analyze` ohne Befund.
### ✨ UI-Politur: Kontrast, Abstände, Typografie, Bewegung (2026-08-21)
Kein neues Design — dasselbe Melo in sauber. Schwarz/Rot, die vier Tabs und
Lieder/Kategorie bleiben unverändert.
- 🔍 **Behoben: blasse Schrift war nicht mehr lesbar.** An 17 Stellen stand
Text in `white38`. Nachgerechnet sind das **3,4:1** Kontrast — die
Barrierefreiheits-Grenze liegt bei **4,5:1**. Betroffen waren unter anderem
Metadaten, Equalizer und Wiedergabelisten. Alle Textfarben laufen jetzt über
drei benannte Stufen, und ein Test **rechnet die Kontraste bei jedem Lauf
nach** (WCAG 2.1) — behauptet wird nichts mehr.
| Stufe | Wofür | Kontrast |
|---|---|---|
| `text1` | Titel | 15,9:1 |
| `text2` | Künstler, Untertitel | 8,8:1 |
| `text3` | Anzahl, Zeit, Hinweis | 4,9:1 |
- 👆 **Behoben: die Reiter waren zu klein zum Treffen.** *Lieder · Kategorie ·
Künstler* waren nur ~38 dp hoch — Android verlangt **48 dp**, sonst tippt
man im Gehen daneben. Jetzt volle 48 dp, mit spürbarer Rückmeldung beim
Antippen (vorher gab es keine) und weichem Übergang statt hartem Sprung.
Vorlesehilfen bekommen die Auswahl jetzt als Zustand gemeldet, nicht nur
als weiße Farbe.
- 📏 **Abstände nach festem Raster.** Vorher standen überall Einzelwerte
(12, 9, 10, 18 …). Jetzt fünf benannte Stufen auf einem 8er-Raster —
deshalb wirken Listen und Karten ruhiger, ohne dass etwas verschoben wurde.
- 🔤 **Eine Schriftgrößen-Skala statt 52 Einzelangaben.** Sieben Stufen von
der Bildschirm-Überschrift bis zum Beiwerk, mit passender Zeilenhöhe. Kein
Text ist kleiner als 12.
- 🎛️ **Mini-Player überarbeitet.** Der Abspiel-Knopf ist jetzt rot gefüllt
und volle 48 dp groß — der wichtigste Knopf der Leiste lässt sich ohne
Hinsehen treffen. Die Leiste blendet sich ein und aus, statt aufzupoppen,
und ist durch eine Haarlinie vom Inhalt getrennt.
🐛 Nebenbei ein Layout-Fehler entschärft: die Zeile lag in einem `Expanded`
**um** eine feste Höhe herum — zwei widersprüchliche Angaben.
- 🖼️ **Karten und Cover bekommen einen Umriss** statt eines Schattens. Auf
Schwarz ist ein Schatten unsichtbar; eine Haarlinie trennt sauber.
- 🚫 **Emoji raus aus Bedienelementen.** In den Einstellungen stand
„🌐 Navidrome", „🌟 Favoriten-Sync" — jeweils direkt neben einem echten
Symbol. Emoji werden von Vorlesehilfen vorgelesen („Globus mit Meridianen")
und sehen auf jedem Gerät anders aus. Die Symbole bleiben, die Emoji sind weg.
- 🔧 **Details für Technikinteressierte:**
- Neu in `shared/theme.dart`: `MeloSpace`, `MeloRadius`, `MeloMotion`,
Textstufen `text1/2/3`, `border`, `hairline`, `minTouchTarget` sowie ein
vollständiges `textTheme` und Themes für Listen, Sheets, Snackbars,
Fortschritt und Trenner.
- Neu: `test/shared/theme_kontrast_test.dart` (rechnet WCAG-Kontraste,
Raster und Typo-Skala nach) und `test/shared/sub_tabs_test.dart`
(48 dp, Rückmeldung, Semantik, Übergang).
- Grundlage: Regeln aus **ui-ux-pro-max** (Priorität 1 Barrierefreiheit,
2 Touch, 6 Typografie/Farbe, 7 Bewegung), **ui-design/mobile-android**
(Material 3: 48 dp, Theme über `context`) und die Token-Disziplin aus
**Hue** (Primitives → benannte Rollen).
-**Getestet:** 318 Tests grün (23 neue), `flutter analyze` ohne Befund.
### 🛡️ Fehlerberichte nur noch mit Zustimmung (Opt-in) (2026-08-21)
### 🛡 Fehlerberichte lassen sich jetzt abschalten (2026-08-21)
- **Neu: Schalter „Fehlerberichte senden"** in den Einstellungen unter
*Datenschutz*. Bisher lud `LoggerService` jeden Fehler unbedingt zu
`logs.baka-net.de` hoch — es gab **keine** Möglichkeit, das abzustellen.
@@ -127,22 +21,18 @@ Lieder/Kategorie bleiben unverändert.
Melo-App. Dort gab es den Schalter zwar, er hatte aber keine Wirkung über
einen Neustart hinaus — die Anzeige stand auf „aus", gesendet wurde weiter.
Hier ist das strukturell ausgeschlossen und durch Tests festgehalten.
- **Der Standard ist AUS.** Ohne ausdrückliche Zustimmung verlässt kein
Fehlerbericht das Gerät — auch nicht beim allerersten Start, bevor die
Einstellungen jemals geöffnet wurden. Wer Berichte senden möchte, schaltet
den Regler bewusst an.
- Tests: 12 neue in `test/settings/diagnose_opt_out_test.dart` (**340 gesamt**,
vorher 328). Fünf tragende Stellen per Mutationsprobe geprüft — ohne Sperre,
ohne Durchreichen an den Logger, ohne Lesen beim Start und mit wieder auf
„an" gedrehtem Standard (je einmal im Logger und in den Einstellungen) fallen
- Tests: 9 neue in `test/settings/diagnose_opt_out_test.dart` (**304 gesamt**,
vorher 295). Alle drei tragenden Stellen per Mutationsprobe geprüft — ohne
Sperre, ohne Durchreichen an den Logger und ohne Lesen beim Start fallen
jeweils genau die zuständigen Tests um.
- Für die Testbarkeit bekam `LoggerService` einen injizierbaren HTTP-Client
(gleiches Muster wie `BakaAuth`), damit sich belegen lässt, dass ohne
Zustimmung wirklich **kein** Request rausgeht.
(gleiches Muster wie `BakaAuth`), damit sich belegen lässt, dass bei
Widerspruch wirklich **kein** Request rausgeht.
> Auswirkung auf bestehende Installationen: Wer bisher (unfreiwillig) Berichte
> gesendet hat, sendet ab diesem Update keine mehr, bis er zustimmt. Das ist so
> gewollt — die Zustimmung wurde nie eingeholt.
> Hinweis: Der Standard bleibt **an**, damit sich am bisherigen Verhalten für
> bestehende Nutzer nichts ungefragt ändert. Ein Wechsel auf Opt-in (Standard
> aus) wäre die datenschutzfreundlichere Variante — das ist eine
> Produktentscheidung und bewusst nicht mit erledigt.
### 📋 Backlog-Datei angelegt (2026-08-21)
- Neu: `BACKLOG.md` — bewusst zurückgestellte Punkte an einer Stelle, damit
+9 -10
View File
@@ -18,8 +18,7 @@ import '../shared/sub_tabs.dart';
import 'download_einordnung.dart';
import '../shared/theme.dart';
/// Download-Tab: YouTube-Downloader und der verbundene Navidrome-Server
/// (Unterreiter). Abgespielte
/// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
/// und stehen danach offline zur Verfügung.
class DownloadsScreen extends StatefulWidget {
@@ -43,7 +42,7 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Download',
child: Text('Online',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
@@ -154,7 +153,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
const Icon(Icons.cloud_off, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Musikserver nicht verbunden',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 12),
const Text('Gehe zu Einstellungen → Navidrome zum Verbinden',
style: TextStyle(color: Colors.white30, fontSize: 12)),
@@ -184,7 +183,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
Text(
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
textAlign: TextAlign.center,
style: const TextStyle(color: MeloTheme.text2),
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 16),
FilledButton.icon(
@@ -490,7 +489,7 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
const Text(
'Der Downloader läuft über den Baka-Server. Dafür brauchst du '
'deine Baka-Anmeldung.',
style: TextStyle(color: MeloTheme.text2, fontSize: 13),
style: TextStyle(color: Colors.white54, fontSize: 13),
),
const SizedBox(height: 12),
FilledButton.icon(
@@ -501,11 +500,11 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
] else ...[
Row(
children: [
const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
const Icon(Icons.verified_user, size: 18, color: Colors.white38),
const SizedBox(width: 8),
Expanded(
child: Text('Angemeldet als ${auth.benutzer}',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
style: const TextStyle(color: Colors.white54, fontSize: 13)),
),
TextButton(
onPressed: auth.abmelden,
@@ -565,12 +564,12 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
const LinearProgressIndicator(),
const SizedBox(height: 8),
Text(dienst.status ?? 'Läuft …',
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
style: const TextStyle(color: Colors.white54, fontSize: 12)),
],
if (_meldung != null) ...[
const SizedBox(height: 16),
Text(_meldung!,
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
],
);
+1 -2
View File
@@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
import 'database.dart';
import 'song_grouping.dart';
import 'song_list.dart';
import '../shared/theme.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
class ArtistListScreen extends StatelessWidget {
@@ -19,7 +18,7 @@ class ArtistListScreen extends StatelessWidget {
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: Colors.white54)),
);
}
final grouped = groupByArtist(songs);
+1 -2
View File
@@ -7,7 +7,6 @@ import 'categories.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/theme.dart';
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
///
@@ -30,7 +29,7 @@ class CategoryListScreen extends StatelessWidget {
if (songs.isEmpty) {
return const Center(
child: Text('Keine Kategorien',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
);
}
final grouped = groupByCategory(
+1 -2
View File
@@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import 'database.dart';
import '../shared/theme.dart';
/// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik",
/// nur auf den favorisierten Songs.
@@ -56,7 +55,7 @@ class _Empty extends StatelessWidget {
children: [
Icon(Icons.favorite_border, size: 64, color: Colors.white24),
SizedBox(height: 12),
Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)),
SizedBox(height: 6),
Text('Tippe in einer Liederliste auf das Herz.',
style: TextStyle(color: Colors.white30, fontSize: 12)),
+6 -6
View File
@@ -86,7 +86,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
mainAxisSize: MainAxisSize.min,
children: [
const Text('ACRCloud-Zugang (steht in deinen Notizen)',
style: TextStyle(color: MeloTheme.text2, fontSize: 12)),
style: TextStyle(color: Colors.white70, fontSize: 12)),
const SizedBox(height: 12),
TextField(
controller: accessCtrl,
@@ -226,7 +226,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
const Icon(Icons.mic, color: MeloTheme.red, size: 64),
const SizedBox(height: 16),
const Text('Ich höre zu …',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
Text('$_restSekunden',
style: const TextStyle(
@@ -240,7 +240,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
CircularProgressIndicator(color: MeloTheme.red),
SizedBox(height: 16),
Text('Ich suche den Titel …',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white70)),
],
),
_Phase.ergebnis => _treffer == null
@@ -258,13 +258,13 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
Text(_treffer!.kuenstler,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16, color: MeloTheme.text2)),
fontSize: 16, color: Colors.white70)),
if (_treffer!.album.isNotEmpty) ...[
const SizedBox(height: 4),
Text(_treffer!.album,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13, color: MeloTheme.text3)),
fontSize: 13, color: Colors.white38)),
],
const SizedBox(height: 20),
_nochmalKnopf(),
@@ -280,7 +280,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
const SizedBox(height: 16),
Text(text,
textAlign: TextAlign.center,
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 20),
_nochmalKnopf(),
],
+10 -16
View File
@@ -78,7 +78,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
Text(
'Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
style: const TextStyle(color: MeloTheme.text2, fontSize: 12),
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
@@ -99,7 +99,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const <Song>[];
@@ -150,11 +150,11 @@ class _Header extends StatelessWidget {
child: const Row(
children: [
SizedBox(width: 14),
Icon(Icons.search, color: MeloTheme.text2, size: 20),
Icon(Icons.search, color: Colors.white54, size: 20),
SizedBox(width: 10),
Text(
'Titel, Künstler und Alben suchen',
style: TextStyle(color: MeloTheme.text2, fontSize: 14),
style: TextStyle(color: Colors.white54, fontSize: 14),
),
],
),
@@ -252,19 +252,13 @@ class _QuickCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: MeloSpace.xs, vertical: MeloSpace.sm),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: SizedBox(
width: 150,
child: Material(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
// Umriss statt Schatten — siehe MeloTheme.hairline.
// shape statt borderRadius: Material verträgt nur eines von beiden.
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeloRadius.card),
side: const BorderSide(color: MeloTheme.border),
),
child: InkWell(
onTap: onTap,
child: Stack(
@@ -280,7 +274,7 @@ class _QuickCard extends StatelessWidget {
),
),
Padding(
padding: const EdgeInsets.all(MeloSpace.sm + MeloSpace.xs),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -300,7 +294,7 @@ class _QuickCard extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: MeloTheme.text2, fontSize: 11)),
color: Colors.white54, fontSize: 11)),
],
),
],
@@ -329,7 +323,7 @@ class _Empty extends StatelessWidget {
const Icon(Icons.library_music, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Noch keine Musik',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined),
@@ -358,7 +352,7 @@ class RecentlyAddedScreen extends StatelessWidget {
if (songs.isEmpty) {
return const Center(
child: Text('Noch nichts hinzugefügt',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
);
}
return SongList(songs);
+3 -4
View File
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/theme.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -33,7 +32,7 @@ class _SearchScreenState extends State<SearchScreen> {
body: _query.isEmpty
? const Center(
child: Text('Suchbegriff eingeben',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
)
: StreamBuilder<List<Song>>(
stream: db.searchSongs(_query),
@@ -41,14 +40,14 @@ class _SearchScreenState extends State<SearchScreen> {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Nichts gefunden',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
);
}
return SongList(songs);
+11 -11
View File
@@ -62,12 +62,12 @@ class SongDetailSheet extends StatelessWidget {
fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(song.artist ?? 'Unbekannter Künstler',
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white70)),
if (song.album != null) ...[
const SizedBox(height: 2),
Text(song.album!,
style: const TextStyle(
color: MeloTheme.text3, fontSize: 12)),
color: Colors.white38, fontSize: 12)),
],
],
),
@@ -89,7 +89,7 @@ class SongDetailSheet extends StatelessWidget {
),
if (names.isEmpty)
const Text('Keine Kategorie',
style: TextStyle(color: MeloTheme.text3, fontSize: 13))
style: TextStyle(color: Colors.white38, fontSize: 13))
else
Wrap(
spacing: 8,
@@ -143,7 +143,7 @@ class _ExpertSection extends StatelessWidget {
title: const Text('Expertenmodus',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Weitere Metadaten',
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
style: TextStyle(color: Colors.white38, fontSize: 12)),
children: [
_Row('Titel', song.title),
_Row('Künstler', song.artist ?? ''),
@@ -246,10 +246,10 @@ class _OnlineLookupState extends State<_OnlineLookup> {
),
if (vorschlaege != null && vorschlaege.isEmpty)
const Text('Nichts gefunden',
style: TextStyle(color: MeloTheme.text3, fontSize: 13)),
style: TextStyle(color: Colors.white38, fontSize: 13)),
if (vorschlaege != null && vorschlaege.isNotEmpty) ...[
const Text('Tippen übernimmt Titel, Künstler und Album',
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
style: TextStyle(color: Colors.white38, fontSize: 12)),
for (final vorschlag in vorschlaege)
ListTile(
contentPadding: EdgeInsets.zero,
@@ -260,7 +260,7 @@ class _OnlineLookupState extends State<_OnlineLookup> {
[vorschlag.kuenstler, vorschlag.album]
.where((t) => t.isNotEmpty)
.join(''),
style: const TextStyle(color: MeloTheme.text3, fontSize: 12),
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
onTap: () => _uebernehmen(vorschlag),
),
@@ -285,7 +285,7 @@ class _Row extends StatelessWidget {
SizedBox(
width: 110,
child: Text(label,
style: const TextStyle(color: MeloTheme.text3, fontSize: 13)),
style: const TextStyle(color: Colors.white38, fontSize: 13)),
),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13)),
@@ -368,12 +368,12 @@ class _CategoryEditorState extends State<_CategoryEditor> {
children: [
const Text(
'Die erste Kategorie liefert das Coverbild.',
style: TextStyle(color: MeloTheme.text3, fontSize: 12),
style: TextStyle(color: Colors.white38, fontSize: 12),
),
const SizedBox(height: 12),
if (_names.isEmpty)
const Text('Noch keine Kategorie',
style: TextStyle(color: MeloTheme.text3, fontSize: 13))
style: TextStyle(color: Colors.white38, fontSize: 13))
else
Wrap(
spacing: 8,
@@ -399,7 +399,7 @@ class _CategoryEditorState extends State<_CategoryEditor> {
if (offen.isNotEmpty) ...[
const SizedBox(height: 12),
const Text('Bereits vergeben',
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
style: TextStyle(color: Colors.white38, fontSize: 12)),
const SizedBox(height: 4),
Wrap(
spacing: 8,
+3 -14
View File
@@ -176,29 +176,18 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const MiniPlayer(),
],
),
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
bottomNavigationBar: DecoratedBox(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.border)),
),
child: BottomNavigationBar(
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: _goTo,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.headphones), label: 'Meine Musik'),
BottomNavigationBarItem(
icon: Icon(Icons.download_outlined),
activeIcon: Icon(Icons.download),
label: 'Download'),
icon: Icon(Icons.music_note_outlined), label: 'Online'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border),
activeIcon: Icon(Icons.favorite),
label: 'Favoriten'),
icon: Icon(Icons.favorite_border), label: 'Favoriten'),
],
),
),
);
}
+84 -154
View File
@@ -12,9 +12,6 @@ import 'now_playing_screen.dart';
class MiniPlayer extends StatelessWidget {
const MiniPlayer({super.key});
/// Höhe der Zeile ohne den Fortschrittsbalken.
static const double hoehe = 72;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
@@ -22,161 +19,94 @@ class MiniPlayer extends StatelessWidget {
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
// Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
// dazu, wenn etwas startet.
return AnimatedSwitcher(
duration: MeloMotion.normal,
switchInCurve: MeloMotion.curve,
transitionBuilder: (kind, animation) => SizeTransition(
sizeFactor: animation,
alignment: Alignment.topCenter,
child: FadeTransition(opacity: animation, child: kind),
),
child: item == null
? const SizedBox.shrink()
: _Leiste(handler: handler, item: item),
);
},
);
}
}
class _Leiste extends StatelessWidget {
const _Leiste({required this.handler, required this.item});
final MeloAudioHandler handler;
final MediaItem item;
@override
Widget build(BuildContext context) {
final texte = Theme.of(context).textTheme;
return DecoratedBox(
decoration: const BoxDecoration(
color: MeloTheme.surface,
// Trennt die Leiste vom Inhalt darüber, ohne Schatten.
border: Border(top: BorderSide(color: MeloTheme.border)),
),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen(),
)),
child: SizedBox(
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
CoverImage(artUri: item.artUri, size: 52),
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
if (item == null) return const SizedBox.shrink();
return Material(
color: MeloTheme.surface,
child: InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen(),
)),
child: Column(
children: [
Expanded(
child: SizedBox(
height: 80,
child: Row(
children: [
const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.all(8),
child: CoverImage(artUri: item.artUri, size: 64, radius: 8),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54, fontSize: 12)),
],
),
Text(
item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodySmall,
),
],
),
),
StreamBuilder<PlaybackState>(
stream: handler.playbackState,
builder: (context, snap) {
final playing = snap.data?.playing ?? false;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: handler.skipToPrevious,
),
IconButton(
tooltip: playing ? 'Pause' : 'Abspielen',
icon: Icon(playing ? Icons.pause : Icons.play_arrow, size: 28),
onPressed: playing ? handler.pause : handler.play,
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: handler.skipToNext,
),
],
);
},
),
const SizedBox(width: 8),
],
),
const SizedBox(width: MeloSpace.sm),
_Bedienung(handler: handler),
const SizedBox(width: MeloSpace.sm),
],
),
),
),
StreamBuilder<Duration?>(
stream: handler.durationStream,
builder: (context, dSnap) {
final duration = dSnap.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: handler.positionStream,
builder: (context, pSnap) {
final position = pSnap.data ?? Duration.zero;
return LinearProgressIndicator(
value: duration.inMilliseconds > 0
? (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0)
: 0,
minHeight: 3,
backgroundColor: Colors.white12,
valueColor: const AlwaysStoppedAnimation(MeloTheme.red),
);
},
);
},
),
],
),
_Fortschritt(handler: handler),
],
),
),
);
}
}
class _Bedienung extends StatelessWidget {
const _Bedienung({required this.handler});
final MeloAudioHandler handler;
@override
Widget build(BuildContext context) {
return StreamBuilder<PlaybackState>(
stream: handler.playbackState,
builder: (context, snap) {
final laeuft = snap.data?.playing ?? false;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Vorheriger Titel',
icon: const Icon(Icons.skip_previous),
onPressed: handler.skipToPrevious,
),
// Der wichtigste Knopf der Leiste: eingefärbt und größer, damit
// er sich ohne Hinsehen treffen lässt.
IconButton.filled(
tooltip: laeuft ? 'Pause' : 'Abspielen',
style: IconButton.styleFrom(
backgroundColor: MeloTheme.red,
foregroundColor: Colors.white,
minimumSize:
const Size(MeloTheme.minTouchTarget, MeloTheme.minTouchTarget),
),
icon: Icon(laeuft ? Icons.pause : Icons.play_arrow, size: 26),
onPressed: laeuft ? handler.pause : handler.play,
),
IconButton(
tooltip: 'Nächster Titel',
icon: const Icon(Icons.skip_next),
onPressed: handler.skipToNext,
),
],
);
},
);
}
}
class _Fortschritt extends StatelessWidget {
const _Fortschritt({required this.handler});
final MeloAudioHandler handler;
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration?>(
stream: handler.durationStream,
builder: (context, dSnap) {
final dauer = dSnap.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: handler.positionStream,
builder: (context, pSnap) {
final position = pSnap.data ?? Duration.zero;
return LinearProgressIndicator(
value: dauer.inMilliseconds > 0
? (position.inMilliseconds / dauer.inMilliseconds)
.clamp(0.0, 1.0)
: 0,
minHeight: 2,
backgroundColor: MeloTheme.border,
valueColor: const AlwaysStoppedAnimation(MeloTheme.red),
);
},
),
);
},
);
+6 -6
View File
@@ -82,7 +82,7 @@ class NowPlayingScreen extends StatelessWidget {
const SizedBox(height: 8),
Text(
item.artist ?? 'Unbekannt',
style: const TextStyle(color: MeloTheme.text2),
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 24),
_ProgressBar(handler: handler),
@@ -147,9 +147,9 @@ class _ProgressBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(position),
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white54)),
Text(_fmt(total),
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white54)),
],
),
),
@@ -187,7 +187,7 @@ class _Controls extends StatelessWidget {
IconButton(
tooltip: 'Zufallswiedergabe',
icon: Icon(Icons.shuffle,
color: shuffle ? MeloTheme.red : MeloTheme.text2),
color: shuffle ? MeloTheme.red : Colors.white54),
onPressed: () => handler.setShuffleMode(shuffle
? AudioServiceShuffleMode.none
: AudioServiceShuffleMode.all),
@@ -218,7 +218,7 @@ class _Controls extends StatelessWidget {
? Icons.repeat_one
: Icons.repeat,
color: repeat == AudioServiceRepeatMode.none
? MeloTheme.text2
? Colors.white54
: MeloTheme.red,
),
onPressed: () => handler.setRepeatMode(switch (repeat) {
@@ -376,7 +376,7 @@ class _LyricsSheetState extends State<_LyricsSheet> {
: _text == null
? const Center(
child: Text('Kein Songtext verfügbar',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
+1 -1
View File
@@ -27,7 +27,7 @@ class QueueScreen extends StatelessWidget {
if (queue.isEmpty) {
return const Center(
child: Text('Warteschlange ist leer',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
);
}
return StreamBuilder<MediaItem?>(
+2 -3
View File
@@ -8,7 +8,6 @@ import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
/// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen.
class PlaylistDetailScreen extends StatelessWidget {
@@ -30,14 +29,14 @@ class PlaylistDetailScreen extends StatelessWidget {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Playlist ist leer — füge Songs aus der Bibliothek hinzu',
style: TextStyle(color: MeloTheme.text2)),
style: TextStyle(color: Colors.white54)),
);
}
return ReorderableListView.builder(
+4 -4
View File
@@ -42,7 +42,7 @@ class PlaylistsScreen extends StatelessWidget {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
style: const TextStyle(color: Colors.white54)),
);
}
final playlists = snapshot.data ?? const [];
@@ -135,10 +135,10 @@ class _Empty extends StatelessWidget {
children: [
Icon(Icons.playlist_play, size: 64, color: Colors.white24),
SizedBox(height: 12),
Text('Noch keine Playlisten', style: TextStyle(color: MeloTheme.text2)),
Text('Noch keine Playlisten', style: TextStyle(color: Colors.white54)),
SizedBox(height: 8),
Text('Tippe auf + um eine Playlist zu erstellen',
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
style: TextStyle(color: Colors.white38, fontSize: 12)),
],
),
);
@@ -160,7 +160,7 @@ class FavoritesListScreen extends StatelessWidget {
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
child: Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)),
);
}
return SongList(songs);
+3 -6
View File
@@ -50,12 +50,9 @@ class LoggerService extends ChangeNotifier {
final List<LogEntry> _logs = [];
late final String _deviceModel;
bool _initialized = false;
// Opt-in: standardmäßig AUS. Ohne ausdrückliche Zustimmung des Nutzers
// verlässt kein Log das Gerät.
bool _sendeDiagnosedaten = false;
bool _sendeDiagnosedaten = true;
/// Steuert, ob Logs den Server erreichen. Opt-in: ohne ausdrückliche
/// Zustimmung bleibt der Versand aus. Lokales `debugPrint` läuft
/// Steuert, ob Logs den Server erreichen. Lokales `debugPrint` läuft
/// unabhängig davon weiter — nur der Versand wird unterbunden.
/// [AppSettings] hält den Wert persistent und schiebt Änderungen hierher.
bool get sendeDiagnosedaten => _sendeDiagnosedaten;
@@ -80,7 +77,7 @@ class LoggerService extends ChangeNotifier {
// widersprochen hat.
try {
final prefs = await SharedPreferences.getInstance();
_sendeDiagnosedaten = prefs.getBool(diagnoseKey) ?? false;
_sendeDiagnosedaten = prefs.getBool(diagnoseKey) ?? true;
} catch (_) {
// Nicht lesbar: beim bisherigen Verhalten bleiben.
}
+3 -4
View File
@@ -15,7 +15,7 @@ class AppSettings extends ChangeNotifier {
bool _groupCoversByCategory = true;
bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true;
bool _sendeDiagnosedaten = false;
bool _sendeDiagnosedaten = true;
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -29,8 +29,7 @@ class AppSettings extends ChangeNotifier {
bool get normalizeVolume => _normalizeVolume;
/// Fehler- und Absturzberichte an logs.baka-net.de senden.
/// Opt-in: standardmäßig AUS. Die App protokolliert weiter lokal, aber
/// ohne Zustimmung verlässt nichts das Gerät.
/// Aus: die App protokolliert weiter lokal, aber nichts verlässt das Gerät.
bool get sendeDiagnosedaten => _sendeDiagnosedaten;
Future<void> init() async {
@@ -38,7 +37,7 @@ class AppSettings extends ChangeNotifier {
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_sendeDiagnosedaten = prefs.getBool(_keyDiagnose) ?? false;
_sendeDiagnosedaten = prefs.getBool(_keyDiagnose) ?? true;
// An den Logger durchreichen, damit Anzeige und tatsächliches
// Sendeverhalten nicht auseinanderlaufen können.
logger.sendeDiagnosedaten = _sendeDiagnosedaten;
+8 -8
View File
@@ -47,14 +47,14 @@ class EqualizerScreen extends StatelessWidget {
padding: EdgeInsets.only(top: 4, bottom: 12),
child: Row(
children: [
Icon(Icons.headphones, size: 14, color: MeloTheme.text3),
Icon(Icons.headphones, size: 14, color: Colors.white38),
SizedBox(width: 6),
Expanded(
child: Text(
'Kopfhörer aufsetzen, bevor du die Soundeffekte '
'einstellst',
style: TextStyle(
color: MeloTheme.text3, fontSize: 12),
color: Colors.white38, fontSize: 12),
),
),
],
@@ -86,7 +86,7 @@ class _NotAvailable extends StatelessWidget {
const Text(
'Der Equalizer ist erst verfügbar, wenn Musik läuft',
textAlign: TextAlign.center,
style: TextStyle(color: MeloTheme.text2),
style: TextStyle(color: Colors.white54),
),
if (error != null) ...[
const SizedBox(height: 8),
@@ -176,10 +176,10 @@ class _Bands extends StatelessWidget {
),
Text(band.label,
style:
const TextStyle(color: MeloTheme.text2, fontSize: 11)),
const TextStyle(color: Colors.white54, fontSize: 11)),
Text('${(band.level / 100).round()}',
style:
const TextStyle(color: MeloTheme.text3, fontSize: 11)),
const TextStyle(color: Colors.white38, fontSize: 11)),
],
),
),
@@ -249,9 +249,9 @@ class Knob extends StatelessWidget {
),
const SizedBox(height: 8),
Text(label,
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
style: const TextStyle(color: Colors.white54, fontSize: 12)),
Text('${(value * 100).round()}',
style: const TextStyle(color: MeloTheme.text3, fontSize: 11)),
style: const TextStyle(color: Colors.white38, fontSize: 11)),
],
),
);
@@ -294,7 +294,7 @@ class _KnobPainter extends CustomPainter {
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = MeloTheme.border,
..color = Colors.white12,
);
final pointer = _start + _sweep * value;
+10 -10
View File
@@ -163,9 +163,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
const _SectionLabel('Musikserver'),
ListTile(
leading: const Icon(Icons.cloud_circle),
title: const Text('Navidrome'),
title: const Text('🌐 Navidrome'),
subtitle: _navidrome.istVerbunden
? const Text('Verbunden')
? const Text('✅ Verbunden')
: const Text('Nicht verbunden'),
trailing: _navidrome.istVerbunden
? IconButton(
@@ -182,7 +182,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'💡 Tipp: Oder nutze Melo lokal ohne Server-Konto (nur deine Musik auf dem Gerät)',
style: TextStyle(fontSize: 12, color: MeloTheme.text2),
style: TextStyle(fontSize: 12, color: Colors.white54),
),
),
ListTile(
@@ -200,7 +200,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.storage),
title: const Text('Cache-Speicher'),
title: const Text('🗄️ Cache-Speicher'),
subtitle: Text(_formatBytes(_cacheSize)),
trailing: _cacheSize > 0
? IconButton(
@@ -212,7 +212,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.favorite),
title: const Text('Favoriten-Sync'),
title: const Text('🌟 Favoriten-Sync'),
subtitle: const Text('Favoriten vom Server importieren'),
trailing: IconButton(
tooltip: 'Vom Server laden',
@@ -222,7 +222,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.playlist_play),
title: const Text('Playlisten-Sync'),
title: const Text('📋 Playlisten-Sync'),
subtitle: const Text('Server-Playlisten importieren'),
trailing: IconButton(
tooltip: 'Vom Server laden',
@@ -250,7 +250,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
secondary: const Icon(Icons.shield_outlined),
title: const Text('Fehlerberichte senden'),
subtitle: const Text(
'Standardmäßig aus. An: Absturzberichte helfen beim Beheben von Fehlern'),
'Hilft beim Finden von Abstürzen. Aus: nichts verlässt das Gerät'),
value: settings.sendeDiagnosedaten,
onChanged: settings.setSendeDiagnosedaten,
),
@@ -280,7 +280,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text('Navidrome-Anmeldung',
title: const Text('🌐 Navidrome Login',
style: TextStyle(color: Colors.white, fontSize: 16)),
content: SingleChildScrollView(
child: Column(
@@ -296,7 +296,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 12),
const Text('Benutzer:', style: TextStyle(fontSize: 12, color: MeloTheme.text2)),
const Text('Benutzer:', style: TextStyle(fontSize: 12, color: Colors.white70)),
const SizedBox(height: 8),
Row(
children: [
@@ -501,7 +501,7 @@ class _SectionLabel extends StatelessWidget {
child: Text(
label,
style: const TextStyle(
color: MeloTheme.text2,
color: Colors.white54,
fontSize: 12,
fontWeight: FontWeight.bold,
),
+2 -13
View File
@@ -6,12 +6,7 @@ import 'theme.dart';
/// Cover-Bild aus einer file-URI, mit Noten-Platzhalter als Fallback.
class CoverImage extends StatelessWidget {
const CoverImage({
super.key,
required this.artUri,
this.size,
this.radius = MeloRadius.element,
});
const CoverImage({super.key, required this.artUri, this.size, this.radius = 8});
final Uri? artUri;
final double? size;
final double radius;
@@ -21,13 +16,7 @@ class CoverImage extends StatelessWidget {
final placeholder = Container(
width: size,
height: size,
decoration: BoxDecoration(
color: MeloTheme.surface,
// Haarlinie statt Schatten: auf Schwarz trennt ein Umriss die
// Fläche sauber, ein Schatten wäre unsichtbar.
border: radius > 0 ? MeloTheme.hairline : null,
borderRadius: BorderRadius.circular(radius),
),
color: MeloTheme.surface,
child: LayoutBuilder(
builder: (_, c) {
final s = size ?? (c.hasBoundedWidth ? c.maxWidth : 48.0);
+1 -1
View File
@@ -21,7 +21,7 @@ class FavoriteButton extends StatelessWidget {
tooltip: isFavorite ? 'Favorit entfernen' : 'Zu Favoriten hinzufügen',
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? MeloTheme.red : MeloTheme.text2,
color: isFavorite ? MeloTheme.red : Colors.white54,
),
onPressed: () => service.toggleFavorite(songId),
);
+20 -69
View File
@@ -1,12 +1,7 @@
import 'package:flutter/material.dart';
import 'theme.dart';
/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle
/// Pille, die übrigen sind nur Text.
///
/// Die Pille wechselt weich statt zu springen — so ist sichtbar, dass es
/// derselbe Reiter ist, der sich bewegt, und nicht ein neuer Bildschirm.
class SubTabs extends StatelessWidget {
const SubTabs({
super.key,
@@ -23,17 +18,31 @@ class SubTabs extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.xs),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Row(
children: [
for (var i = 0; i < labels.length; i++)
Padding(
padding: const EdgeInsets.only(right: MeloSpace.sm),
child: _Reiter(
label: labels[i],
aktiv: i == index,
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => onChanged(i),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
decoration: BoxDecoration(
color: i == index ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Text(
labels[i],
style: TextStyle(
color: i == index ? Colors.black : Colors.white70,
fontSize: 15,
fontWeight:
i == index ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
],
@@ -41,61 +50,3 @@ class SubTabs extends StatelessWidget {
);
}
}
class _Reiter extends StatelessWidget {
const _Reiter({
required this.label,
required this.aktiv,
required this.onTap,
});
final String label;
final bool aktiv;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: aktiv,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(MeloRadius.pill),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: MeloMotion.fast,
curve: MeloMotion.curve,
// Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht
// zuverlässig (Material 3, Mindestgröße für Bedienelemente).
constraints: const BoxConstraints(
minHeight: MeloTheme.minTouchTarget,
minWidth: MeloTheme.minTouchTarget,
),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.md),
decoration: BoxDecoration(
color: aktiv ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(MeloRadius.pill),
// Der inaktive Reiter bleibt als Fläche erkennbar, ohne sich
// vorzudrängen — sonst schweben die Wörter im Nichts.
border: aktiv ? null : MeloTheme.hairline,
),
child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast,
curve: MeloMotion.curve,
style: TextStyle(
color: aktiv ? MeloTheme.black : MeloTheme.text2,
fontSize: 15,
height: 1.2,
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
),
child: Text(label),
),
),
),
),
);
}
}
+1 -151
View File
@@ -1,53 +1,5 @@
import 'package:flutter/material.dart';
/// Abstände. Alles Sichtbare rastet auf 8 ein (4 nur für Feinheiten
/// innerhalb eines Elements) — daher wirken Listen und Karten ruhig,
/// obwohl nirgends eine Zahl von Hand gewählt wurde.
abstract final class MeloSpace {
/// 4 — innerhalb eines Elements (Icon zu Text).
static const double xs = 4;
/// 8 — zwischen eng zusammengehörenden Elementen.
static const double sm = 8;
/// 16 — Standard: Seitenrand, Innenabstand einer Karte.
static const double md = 16;
/// 24 — zwischen Abschnitten.
static const double lg = 24;
/// 32 — vor einer neuen Überschrift.
static const double xl = 32;
}
/// Eckenradien. Je größer die Fläche, desto größer die Rundung — sonst
/// wirken große Karten kantig und kleine Chips aufgeblasen.
abstract final class MeloRadius {
/// 8 — Coverbilder, kleine Flächen.
static const double element = 8;
/// 12 — Karten.
static const double card = 12;
/// 20 — Sheets von unten.
static const double sheet = 20;
/// Pille — Reiter und Chips.
static const double pill = 999;
}
/// Bewegung. Ruhig und kurz: Übergänge sollen die Verbindung zwischen zwei
/// Zuständen zeigen, nicht auf sich aufmerksam machen.
abstract final class MeloMotion {
/// 120 ms — Reaktion auf eine Berührung (Farbe, Skalierung).
static const Duration fast = Duration(milliseconds: 120);
/// 220 ms — Wechsel eines Inhalts.
static const Duration normal = Duration(milliseconds: 220);
static const Curve curve = Curves.easeOutCubic;
}
/// Melo-Theme: Schwarz + Rot.
class MeloTheme {
static const Color red = Color(0xFFC0392B);
@@ -57,56 +9,6 @@ class MeloTheme {
/// Etwas hellere Fläche für Karten und Chips auf [surface].
static const Color surfaceHigh = Color(0xFF1F1F29);
// === Textfarben ===
// Die Werte sind nicht geschätzt: jede erfüllt auf der hellsten Fläche
// ([surfaceHigh]) mindestens 4,5:1 nach WCAG. Siehe theme_kontrast_test.dart,
// der die Verhältnisse nachrechnet.
/// Titel und alles, was zuerst gelesen wird.
static const Color text1 = Colors.white;
/// Zweite Ebene: Künstler, Untertitel.
static const Color text2 = Color(0xB8FFFFFF); // 72 %
/// Dritte Ebene: Anzahl, Zeitangabe, Hinweis. Untere Grenze für Text —
/// darunter ist der Kontrast nicht mehr ausreichend.
static const Color text3 = Color(0x80FFFFFF); // 50 %
/// Trennlinien und Umrisse. Kein Text — hier gilt 4,5:1 nicht.
static const Color border = Color(0x14FFFFFF); // 8 %
/// Die kleinste Fläche, die sich noch sicher treffen lässt (Material 3).
static const double minTouchTarget = 48;
/// Umriss statt Schlagschatten: auf Schwarz trennt eine Haarlinie
/// Flächen sauberer als ein Schatten, den man ohnehin nicht sieht.
static Border get hairline => Border.all(color: border, width: 1);
static TextTheme get _textTheme => const TextTheme(
// Bildschirm-Überschrift ("Meine Musik", "Online").
displaySmall: TextStyle(
fontSize: 28, height: 1.15, fontWeight: FontWeight.w700, color: text1),
// Titelleiste, Kopf eines Sheets.
headlineSmall: TextStyle(
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
// Hervorgehobene Zeile ("Shuffle-Wiedergabe").
titleMedium: TextStyle(
fontSize: 17, height: 1.3, fontWeight: FontWeight.w600, color: text1),
// Titel in einer Liste.
bodyLarge: TextStyle(fontSize: 15, height: 1.4, color: text1),
// Untertitel in einer Liste.
bodyMedium: TextStyle(fontSize: 13, height: 1.35, color: text2),
// Beiwerk: Anzahl, Dauer.
bodySmall: TextStyle(fontSize: 12, height: 1.3, color: text3),
// Abschnitts-Beschriftung, Knopftext.
labelLarge: TextStyle(
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
color: text3),
);
static ThemeData get dark {
final scheme = ColorScheme.fromSeed(
seedColor: red,
@@ -114,74 +16,22 @@ class MeloTheme {
).copyWith(
primary: red,
surface: black,
outline: border,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: black,
cardColor: surface,
textTheme: _textTheme,
splashFactory: InkSparkle.splashFactory,
appBarTheme: const AppBarTheme(
backgroundColor: black,
surfaceTintColor: Colors.transparent,
centerTitle: false,
titleTextStyle: TextStyle(
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
),
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: MeloSpace.md),
minVerticalPadding: MeloSpace.sm,
iconColor: text2,
),
dividerTheme: const DividerThemeData(
color: border,
space: 1,
thickness: 1,
),
iconTheme: const IconThemeData(color: text2),
sliderTheme: const SliderThemeData(
activeTrackColor: red,
thumbColor: red,
inactiveTrackColor: border,
trackHeight: 3,
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: red,
linearTrackColor: border,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: surfaceHigh,
contentTextStyle: const TextStyle(color: text1, fontSize: 14),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeloRadius.element),
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(MeloRadius.sheet)),
),
),
// Das Hauptmenü. Es muss auf den ersten Blick als solches erkennbar
// sein — deshalb bewusst kräftiger als der übrige Beiwerk-Text:
// - text2 (72 %) statt der blassen dritten Stufe für die inaktiven
// Einträge; sie sollen lesbar sein, nicht nur erahnbar.
// - KEINE fontSize hier: damit gelten Flutters Vorgaben von 14 (aktiv)
// und 12 (inaktiv). Eine feste Größe für beide nahm dem aktiven
// Reiter genau den Größenunterschied, an dem man ihn erkennt.
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surface,
selectedItemColor: red,
unselectedItemColor: text2,
unselectedItemColor: Colors.white54,
type: BottomNavigationBarType.fixed,
showUnselectedLabels: true,
selectedLabelStyle: TextStyle(fontWeight: FontWeight.w700),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w500),
),
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ void main() {
await db.close();
});
testWidgets('Download-Tab hat die Unterreiter YouTube und Server',
testWidgets('Online-Tab hat die Unterreiter YouTube und Server',
(tester) async {
final auth =
BakaAuth(client: MockClient((_) async => http.Response('', 500)),
-208
View File
@@ -1,208 +0,0 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/theme.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _MemorySpeicher implements TokenSpeicher {
final Map<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
/// Die vier Bereiche des Hauptmenüs, in dieser Reihenfolge.
const hauptmenue = ['Meine Musik', 'Download', 'Suchen', 'Favoriten'];
void main() {
late MeloDb db;
late CategoryService kategorien;
late LibraryService bibliothek;
late PlaylistService listen;
late BakaAuth auth;
late AppSettings einstellungen;
late OfflineMode offline;
late YtDownloadService ytDownload;
late SyncService sync;
MeloAudioHandler? handler;
setUp(() {
SharedPreferences.setMockInitialValues({});
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
bibliothek = LibraryService(db);
listen = PlaylistService(db);
auth = BakaAuth(speicher: _MemorySpeicher());
einstellungen = AppSettings();
offline = OfflineMode();
ytDownload = YtDownloadService(auth: auth);
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
});
tearDown(() {
kategorien.dispose();
handler?.dispose();
// Bewusst kein db.close(): drift plant beim Schließen eine Aufräum-
// Aufgabe ein, die im Test-Rahmen nach einem tester.tap nie mehr an die
// Reihe kommt — der Lauf hinge dann unbegrenzt. Die Datenbank liegt nur
// im Arbeitsspeicher und verschwindet mit dem Testprozess.
});
// Alle Dienste gehören dem Test und werden per .value gereicht. Würde der
// Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer
// von drift im Test-Rahmen hängen — und ein anschließendes db.close()
// käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt).
Widget bauen() {
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
handler ??= MeloAudioHandler(db: db);
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: bibliothek),
ChangeNotifierProvider<PlaylistService>.value(value: listen),
Provider<MeloAudioHandler>.value(value: handler!),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<OfflineMode>.value(value: offline),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
ChangeNotifierProvider<SyncService>.value(value: sync),
],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
);
}
/// Baut den Baum ab und beendet die laufenden Timer.
///
/// Muss im Testkörper passieren, nicht im tearDown: der Positions-Timer
/// des Audio-Handlers läuft periodisch, und der Test-Rahmen prüft am Ende
/// des Körpers, ob noch Timer offen sind.
Future<void> abbauen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
handler?.dispose();
handler = null;
// Die Zeit ein Stück vorstellen: beim Abbauen melden sich die
// Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
// einen offenen Timer.
await tester.pump(const Duration(milliseconds: 1));
}
Finder eintrag(String name) => find.descendant(
of: find.byType(BottomNavigationBar),
matching: find.text(name),
);
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
await tester.pumpWidget(bauen());
await tester.pump();
final leiste =
tester.widget<BottomNavigationBar>(find.byType(BottomNavigationBar));
expect(leiste.items.map((i) => i.label).toList(), hauptmenue);
await abbauen(tester);
});
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pump();
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
for (final name in hauptmenue) {
expect(eintrag(name), findsOneWidget, reason: '"$name" fehlt im Menü');
// Vorhanden genügt nicht — ein Eintrag außerhalb des Bildschirms oder
// ohne Ausdehnung wäre für den Nutzer nicht da.
final kasten = tester.getRect(eintrag(name));
expect(kasten.width, greaterThan(0), reason: '"$name" ohne Breite');
expect(kasten.height, greaterThan(0), reason: '"$name" ohne Höhe');
expect(kasten.bottom, lessThanOrEqualTo(bildschirm.height),
reason: '"$name" liegt unterhalb des Bildschirms');
expect(kasten.top, greaterThanOrEqualTo(0),
reason: '"$name" liegt oberhalb des Bildschirms');
}
await abbauen(tester);
});
for (var i = 1; i < hauptmenue.length; i++) {
final ziel = i;
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pump();
await tester.tap(eintrag(hauptmenue[ziel]));
await tester.pump();
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
ziel,
);
await abbauen(tester);
});
}
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
// Läge es woanders, wäre es kein Hauptmenü mehr.
await tester.pumpWidget(bauen());
await tester.pump();
final menue = tester.getRect(find.byType(BottomNavigationBar));
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
expect(menue.bottom, closeTo(bildschirm.height, 1));
await abbauen(tester);
});
group('Darstellung des Hauptmenüs', () {
// Reine Theme-Prüfungen — kein Widget-Baum nötig.
final menue = MeloTheme.dark.bottomNavigationBarTheme;
test('der aktive Bereich hebt sich ab — nicht nur durch Farbe', () {
// Wer Rot schlecht sieht, muss trotzdem erkennen, wo er ist.
expect(menue.selectedItemColor, MeloTheme.red);
expect(menue.selectedLabelStyle!.fontWeight, FontWeight.w700);
expect(menue.unselectedLabelStyle!.fontWeight, FontWeight.w500);
});
test('keine feste Schriftgröße — sonst verschwindet der Unterschied', () {
// Ohne Angabe gelten Flutters 14 (aktiv) und 12 (inaktiv). Beide auf
// 12 zu setzen nahm dem aktiven Reiter genau dieses Merkmal.
expect(menue.selectedLabelStyle!.fontSize, isNull);
expect(menue.unselectedLabelStyle!.fontSize, isNull);
});
test('die inaktiven Einträge sind lesbar, nicht nur erahnbar', () {
// text3 ist die Beiwerk-Stufe. Ein Hauptmenü ist kein Beiwerk.
expect(menue.unselectedItemColor, MeloTheme.text2);
});
test('die Beschriftungen bleiben auch bei inaktiven Einträgen stehen', () {
// Ohne sie müsste man die Symbole raten.
expect(menue.showUnselectedLabels, isTrue);
});
});
}
+2 -2
View File
@@ -30,7 +30,7 @@ void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Untere Navigation folgt der Referenz-UI: '
'Meine Musik, Download, Suchen, Favoriten', (tester) async {
'Meine Musik, Online, Suchen, Favoriten', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
@@ -60,7 +60,7 @@ void main() {
final bar = tester.widget<BottomNavigationBar>(
find.byType(BottomNavigationBar));
expect(bar.items.map((i) => i.label).toList(),
['Meine Musik', 'Download', 'Suchen', 'Favoriten']);
['Meine Musik', 'Online', 'Suchen', 'Favoriten']);
handler.dispose();
await db.close();
+12 -60
View File
@@ -15,8 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Opt-in ist der Ruhezustand: nach jedem Test wieder aus.
tearDown(() => logger.sendeDiagnosedaten = false);
tearDown(() => logger.sendeDiagnosedaten = true);
group('AppSettings.init', () {
test('übernimmt ein gespeichertes Opt-out und reicht es an den Logger', () async {
@@ -31,20 +30,8 @@ void main() {
reason: 'Anzeige und Sendeverhalten dürfen nicht auseinanderlaufen');
});
test('ohne gespeicherte Entscheidung bleibt der Versand AUS (Opt-in)', () async {
test('ohne gespeicherte Entscheidung bleibt es beim Standard (an)', () async {
SharedPreferences.setMockInitialValues({});
logger.sendeDiagnosedaten = true; // absichtlich "falsch" vorbelegt
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isFalse,
reason: 'Ohne ausdrueckliche Zustimmung darf nichts gesendet werden');
expect(logger.sendeDiagnosedaten, isFalse);
});
test('eine gespeicherte Zustimmung wird respektiert', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
final settings = AppSettings();
await settings.init();
@@ -53,27 +40,13 @@ void main() {
expect(logger.sendeDiagnosedaten, isTrue);
});
test('eine erteilte Zustimmung überlebt den Neustart', () async {
test('ein Opt-out überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(true);
// Neustart: neue Instanzen, gleiche Ablage.
logger.sendeDiagnosedaten = false;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
expect(zweiteSitzung.sendeDiagnosedaten, isTrue);
expect(logger.sendeDiagnosedaten, isTrue);
});
test('ein Widerruf überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(false);
// Neustart: neue Instanzen, gleiche Ablage.
logger.sendeDiagnosedaten = true;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
@@ -88,18 +61,16 @@ void main() {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
// Ausgangspunkt ist jetzt AUS (Opt-in), also zuerst zustimmen.
expect(settings.sendeDiagnosedaten, isFalse);
await settings.setSendeDiagnosedaten(true);
expect(logger.sendeDiagnosedaten, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool(LoggerService.diagnoseKey), isTrue);
await settings.setSendeDiagnosedaten(false);
expect(logger.sendeDiagnosedaten, isFalse);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool(LoggerService.diagnoseKey), isFalse);
await settings.setSendeDiagnosedaten(true);
expect(logger.sendeDiagnosedaten, isTrue);
expect(prefs.getBool(LoggerService.diagnoseKey), isTrue);
});
test('benachrichtigt Zuhörer', () async {
@@ -109,8 +80,8 @@ void main() {
var rufe = 0;
settings.addListener(() => rufe++);
await settings.setSendeDiagnosedaten(true);
await settings.setSendeDiagnosedaten(true); // unverändert → kein Ruf
await settings.setSendeDiagnosedaten(false);
await settings.setSendeDiagnosedaten(false); // unverändert → kein Ruf
expect(rufe, 1);
});
@@ -183,24 +154,5 @@ void main() {
expect(eigener.sendeDiagnosedaten, isFalse);
});
test('ein frischer Logger sendet ohne gespeicherte Zustimmung nicht', () async {
SharedPreferences.setMockInitialValues({});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz beim allerersten Start');
await dienst.flush();
expect(dienst.sendeDiagnosedaten, isFalse);
expect(requests, 0,
reason: 'Erststart ohne Zustimmung darf nichts hochladen');
});
});
}
+3 -3
View File
@@ -91,11 +91,11 @@ void main() {
// Die Sync-Einträge liegen weiter unten in der Liste und müssen erst
// in den sichtbaren Bereich gescrollt werden.
await tester.scrollUntilVisible(find.text('Favoriten-Sync'), 200);
await tester.scrollUntilVisible(find.text('🌟 Favoriten-Sync'), 200);
await tester.pumpAndSettle();
expect(find.text('Favoriten-Sync'), findsOneWidget);
expect(find.text('Playlisten-Sync'), findsOneWidget);
expect(find.text('🌟 Favoriten-Sync'), findsOneWidget);
expect(find.text('📋 Playlisten-Sync'), findsOneWidget);
await db.close();
});
-99
View File
@@ -1,99 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/shared/sub_tabs.dart';
import 'package:melo/shared/theme.dart';
Widget _wrap(Widget child) => MaterialApp(
theme: MeloTheme.dark,
home: Scaffold(body: child),
);
void main() {
testWidgets('jeder Reiter ist mindestens 48 dp hoch — sonst trifft man ihn '
'im Gehen nicht', (tester) async {
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie', 'Künstler'],
index: 0,
onChanged: (_) {},
)));
await tester.pumpAndSettle();
for (final label in ['Lieder', 'Kategorie', 'Künstler']) {
final flaeche = tester.getSize(find.ancestor(
of: find.text(label),
matching: find.byType(AnimatedContainer),
));
expect(flaeche.height, greaterThanOrEqualTo(MeloTheme.minTouchTarget),
reason: 'Reiter "$label" ist nur ${flaeche.height} dp hoch');
expect(flaeche.width, greaterThanOrEqualTo(MeloTheme.minTouchTarget));
}
});
testWidgets('ein Tipp meldet den angetippten Reiter', (tester) async {
int? gewaehlt;
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie'],
index: 0,
onChanged: (i) => gewaehlt = i,
)));
await tester.tap(find.text('Kategorie'));
await tester.pumpAndSettle();
expect(gewaehlt, 1);
});
testWidgets('der aktive Reiter ist auch ohne Farbe erkennbar', (tester) async {
final semantik = tester.ensureSemantics();
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie'],
index: 1,
onChanged: (_) {},
)));
await tester.pumpAndSettle();
// Vorlesehilfen brauchen die Auswahl als Zustand, nicht als Farbe —
// wer die weiße Pille nicht sieht, erfährt sonst nie, wo er ist.
expect(
tester.getSemantics(find.text('Kategorie')),
isSemantics(label: 'Kategorie', isSelected: true, isButton: true),
);
expect(
tester.getSemantics(find.text('Lieder')),
isSemantics(label: 'Lieder', isSelected: false, isButton: true),
);
// Muss vor Testende passieren — addTearDown wäre zu spät.
semantik.dispose();
});
testWidgets('jeder Reiter gibt beim Antippen Rückmeldung (Ripple)',
(tester) async {
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie'],
index: 0,
onChanged: (_) {},
)));
// Ohne InkWell bliebe der Tipp unbestätigt — der Nutzer weiß nicht,
// ob er getroffen hat.
expect(find.byType(InkWell), findsNWidgets(2));
});
testWidgets('der Wechsel wird animiert, nicht gesprungen', (tester) async {
await tester.pumpWidget(_wrap(SubTabs(
labels: const ['Lieder', 'Kategorie'],
index: 0,
onChanged: (_) {},
)));
await tester.pumpAndSettle();
final container = tester.widget<AnimatedContainer>(find.ancestor(
of: find.text('Lieder'),
matching: find.byType(AnimatedContainer),
));
expect(container.duration, MeloMotion.fast);
expect(container.curve, MeloMotion.curve);
});
}
-152
View File
@@ -1,152 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/shared/theme.dart';
/// Relative Helligkeit nach WCAG 2.1, 1.4.3.
double _luminanz(Color farbe) {
double kanal(double wert) =>
wert <= 0.03928 ? wert / 12.92 : math.pow((wert + 0.055) / 1.055, 2.4) as double;
return 0.2126 * kanal(farbe.r) +
0.7152 * kanal(farbe.g) +
0.0722 * kanal(farbe.b);
}
/// Legt [vordergrund] (ggf. halbdurchsichtig) auf [hintergrund].
Color _ueber(Color vordergrund, Color hintergrund) {
final a = vordergrund.a;
return Color.from(
alpha: 1,
red: vordergrund.r * a + hintergrund.r * (1 - a),
green: vordergrund.g * a + hintergrund.g * (1 - a),
blue: vordergrund.b * a + hintergrund.b * (1 - a),
);
}
/// Kontrastverhältnis nach WCAG 2.1 — 1 (unlesbar) bis 21 (Schwarz auf Weiß).
double kontrast(Color vordergrund, Color hintergrund) {
final a = _luminanz(_ueber(vordergrund, hintergrund));
final b = _luminanz(hintergrund);
final hell = math.max(a, b);
final dunkel = math.min(a, b);
return (hell + 0.05) / (dunkel + 0.05);
}
void main() {
// Die hellste Fläche ist der ungünstigste Fall: helle Schrift hat dort
// den geringsten Abstand zum Untergrund. Wer hier besteht, besteht überall.
const flaechen = {
'Hintergrund': MeloTheme.black,
'Fläche': MeloTheme.surface,
'Karte': MeloTheme.surfaceHigh,
};
group('Textfarben erfüllen WCAG 4,5:1 auf jeder Fläche', () {
const textfarben = {
'text1': MeloTheme.text1,
'text2': MeloTheme.text2,
'text3': MeloTheme.text3,
};
for (final flaeche in flaechen.entries) {
for (final text in textfarben.entries) {
test('${text.key} auf ${flaeche.key}', () {
final wert = kontrast(text.value, flaeche.value);
expect(wert, greaterThanOrEqualTo(4.5),
reason: '${text.key} auf ${flaeche.key} nur '
'${wert.toStringAsFixed(2)}:1');
});
}
}
});
test('die Rot-Markierung ist auf dem Hintergrund erkennbar', () {
// 3:1 genügt: Rot trägt keinen Fließtext, es markiert (aktiver Reiter,
// Fortschritt). Für Text wäre 4,5:1 nötig — deshalb steht auf Rot Weiß.
expect(kontrast(MeloTheme.red, MeloTheme.black),
greaterThanOrEqualTo(3.0));
});
test('Colors.white38 wäre zu blass — genau deshalb gibt es text3', () {
// Festhalten, warum die alte Schreibweise ersetzt wurde: sie verfehlt
// die Grenze. Fällt dieser Test, ist die Rechnung kaputt, nicht das Theme.
expect(kontrast(Colors.white38, MeloTheme.surfaceHigh), lessThan(4.5));
expect(kontrast(MeloTheme.text3, MeloTheme.surfaceHigh),
greaterThanOrEqualTo(4.5));
});
test('die Rechnung selbst stimmt: Schwarz auf Weiß ergibt 21:1', () {
expect(kontrast(const Color(0xFF000000), const Color(0xFFFFFFFF)),
closeTo(21.0, 0.01));
});
group('Abstände rasten auf 8 (4 nur für Feinheiten)', () {
test('alle Stufen sind Vielfache von 4', () {
for (final wert in [
MeloSpace.xs,
MeloSpace.sm,
MeloSpace.md,
MeloSpace.lg,
MeloSpace.xl,
]) {
expect(wert % 4, 0, reason: '$wert passt nicht ins Raster');
}
});
test('ab der Standardstufe sind es Vielfache von 8', () {
for (final wert in [MeloSpace.sm, MeloSpace.md, MeloSpace.lg, MeloSpace.xl]) {
expect(wert % 8, 0, reason: '$wert passt nicht ins 8er-Raster');
}
});
test('die Stufen steigen streng', () {
const stufen = [
MeloSpace.xs,
MeloSpace.sm,
MeloSpace.md,
MeloSpace.lg,
MeloSpace.xl,
];
for (var i = 1; i < stufen.length; i++) {
expect(stufen[i], greaterThan(stufen[i - 1]));
}
});
});
group('Typo-Skala', () {
final skala = MeloTheme.dark.textTheme;
test('kein Text ist kleiner als 12 — darunter wird es unlesbar', () {
for (final stil in [
skala.displaySmall,
skala.headlineSmall,
skala.titleMedium,
skala.bodyLarge,
skala.bodyMedium,
skala.bodySmall,
skala.labelLarge,
]) {
expect(stil!.fontSize, greaterThanOrEqualTo(12));
}
});
test('Fließtext hat mindestens 1,35 Zeilenhöhe', () {
expect(skala.bodyLarge!.height, greaterThanOrEqualTo(1.35));
expect(skala.bodyMedium!.height, greaterThanOrEqualTo(1.35));
});
test('die Größen nehmen von Überschrift zu Beiwerk ab', () {
final groessen = [
skala.displaySmall!.fontSize!,
skala.headlineSmall!.fontSize!,
skala.titleMedium!.fontSize!,
skala.bodyLarge!.fontSize!,
skala.bodyMedium!.fontSize!,
];
for (var i = 1; i < groessen.length; i++) {
expect(groessen[i], lessThan(groessen[i - 1]));
}
});
});
}