diff --git a/CHANGELOG.md b/CHANGELOG.md index 4287128..b949f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ✨ 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. + ### 📋 Backlog-Datei angelegt (2026-08-21) - Neu: `BACKLOG.md` — bewusst zurückgestellte Punkte an einer Stelle, damit nichts stillschweigend liegen bleibt. Drin stehen aktuell der übersprungene diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 8eddd5d..77ff44e 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -153,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: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), const SizedBox(height: 12), const Text('Gehe zu Einstellungen → Navidrome zum Verbinden', style: TextStyle(color: Colors.white30, fontSize: 12)), @@ -183,7 +183,7 @@ class _ServerBrowserState extends State<_ServerBrowser> { Text( hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen', textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white54), + style: const TextStyle(color: MeloTheme.text2), ), const SizedBox(height: 16), FilledButton.icon( @@ -489,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: Colors.white54, fontSize: 13), + style: TextStyle(color: MeloTheme.text2, fontSize: 13), ), const SizedBox(height: 12), FilledButton.icon( @@ -500,11 +500,11 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { ] else ...[ Row( 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), Expanded( child: Text('Angemeldet als ${auth.benutzer}', - style: const TextStyle(color: Colors.white54, fontSize: 13)), + style: const TextStyle(color: MeloTheme.text2, fontSize: 13)), ), TextButton( onPressed: auth.abmelden, @@ -564,12 +564,12 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { const LinearProgressIndicator(), const SizedBox(height: 8), Text(dienst.status ?? 'Läuft …', - style: const TextStyle(color: Colors.white54, fontSize: 12)), + style: const TextStyle(color: MeloTheme.text2, fontSize: 12)), ], if (_meldung != null) ...[ const SizedBox(height: 16), Text(_meldung!, - style: const TextStyle(color: Colors.white70, fontSize: 13)), + style: const TextStyle(color: MeloTheme.text2, fontSize: 13)), ], ], ); diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index acdd271..6463f51 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -4,6 +4,7 @@ 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 { @@ -18,7 +19,7 @@ class ArtistListScreen extends StatelessWidget { final songs = snapshot.data ?? const []; if (songs.isEmpty) { 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); diff --git a/lib/library/category_list.dart b/lib/library/category_list.dart index 77ff703..f223412 100644 --- a/lib/library/category_list.dart +++ b/lib/library/category_list.dart @@ -7,6 +7,7 @@ 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. /// @@ -29,7 +30,7 @@ class CategoryListScreen extends StatelessWidget { if (songs.isEmpty) { return const Center( child: Text('Keine Kategorien', - style: TextStyle(color: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ); } final grouped = groupByCategory( diff --git a/lib/library/favorites_screen.dart b/lib/library/favorites_screen.dart index 3d80078..f9bdffc 100644 --- a/lib/library/favorites_screen.dart +++ b/lib/library/favorites_screen.dart @@ -4,6 +4,7 @@ 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. @@ -55,7 +56,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: Colors.white54)), + Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)), SizedBox(height: 6), Text('Tippe in einer Liederliste auf das Herz.', style: TextStyle(color: Colors.white30, fontSize: 12)), diff --git a/lib/library/music_recognition_sheet.dart b/lib/library/music_recognition_sheet.dart index 6203b5f..5100567 100644 --- a/lib/library/music_recognition_sheet.dart +++ b/lib/library/music_recognition_sheet.dart @@ -86,7 +86,7 @@ class _MusicRecognitionSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ 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), TextField( controller: accessCtrl, @@ -226,7 +226,7 @@ class _MusicRecognitionSheetState extends State { const Icon(Icons.mic, color: MeloTheme.red, size: 64), const SizedBox(height: 16), const Text('Ich höre zu …', - style: TextStyle(color: Colors.white70)), + style: TextStyle(color: MeloTheme.text2)), const SizedBox(height: 8), Text('$_restSekunden', style: const TextStyle( @@ -240,7 +240,7 @@ class _MusicRecognitionSheetState extends State { CircularProgressIndicator(color: MeloTheme.red), SizedBox(height: 16), Text('Ich suche den Titel …', - style: TextStyle(color: Colors.white70)), + style: TextStyle(color: MeloTheme.text2)), ], ), _Phase.ergebnis => _treffer == null @@ -258,13 +258,13 @@ class _MusicRecognitionSheetState extends State { Text(_treffer!.kuenstler, textAlign: TextAlign.center, style: const TextStyle( - fontSize: 16, color: Colors.white70)), + fontSize: 16, color: MeloTheme.text2)), if (_treffer!.album.isNotEmpty) ...[ const SizedBox(height: 4), Text(_treffer!.album, textAlign: TextAlign.center, style: const TextStyle( - fontSize: 13, color: Colors.white38)), + fontSize: 13, color: MeloTheme.text3)), ], const SizedBox(height: 20), _nochmalKnopf(), @@ -280,7 +280,7 @@ class _MusicRecognitionSheetState extends State { const SizedBox(height: 16), Text(text, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70)), + style: const TextStyle(color: MeloTheme.text2)), const SizedBox(height: 20), _nochmalKnopf(), ], diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index b1af67c..411fd8a 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -78,7 +78,7 @@ class _MyMusicScreenState extends State { Text( 'Scanne … ${lib.scanDone}' '${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 { if (snapshot.hasError) { return Center( child: Text('Fehler: ${snapshot.error}', - style: const TextStyle(color: Colors.white54)), + style: const TextStyle(color: MeloTheme.text2)), ); } final songs = snapshot.data ?? const []; @@ -150,11 +150,11 @@ class _Header extends StatelessWidget { child: const Row( children: [ SizedBox(width: 14), - Icon(Icons.search, color: Colors.white54, size: 20), + Icon(Icons.search, color: MeloTheme.text2, size: 20), SizedBox(width: 10), Text( '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 Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: MeloSpace.xs, vertical: MeloSpace.sm), 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( @@ -274,7 +280,7 @@ class _QuickCard extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(MeloSpace.sm + MeloSpace.xs), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -294,7 +300,7 @@ class _QuickCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, 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 SizedBox(height: 12), const Text('Noch keine Musik', - style: TextStyle(color: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), const SizedBox(height: 16), FilledButton.icon( icon: const Icon(Icons.create_new_folder_outlined), @@ -352,7 +358,7 @@ class RecentlyAddedScreen extends StatelessWidget { if (songs.isEmpty) { return const Center( child: Text('Noch nichts hinzugefügt', - style: TextStyle(color: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ); } return SongList(songs); diff --git a/lib/library/search_screen.dart b/lib/library/search_screen.dart index f0ed868..2d151c8 100644 --- a/lib/library/search_screen.dart +++ b/lib/library/search_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'database.dart'; import 'song_list.dart'; +import '../shared/theme.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @@ -32,7 +33,7 @@ class _SearchScreenState extends State { body: _query.isEmpty ? const Center( child: Text('Suchbegriff eingeben', - style: TextStyle(color: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ) : StreamBuilder>( stream: db.searchSongs(_query), @@ -40,14 +41,14 @@ class _SearchScreenState extends State { if (snapshot.hasError) { return Center( child: Text('Fehler: ${snapshot.error}', - style: const TextStyle(color: Colors.white54)), + style: const TextStyle(color: MeloTheme.text2)), ); } final songs = snapshot.data ?? const []; if (songs.isEmpty) { return const Center( child: Text('Nichts gefunden', - style: TextStyle(color: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ); } return SongList(songs); diff --git a/lib/library/song_detail_sheet.dart b/lib/library/song_detail_sheet.dart index 4ce5e71..a94d55a 100644 --- a/lib/library/song_detail_sheet.dart +++ b/lib/library/song_detail_sheet.dart @@ -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: Colors.white70)), + style: const TextStyle(color: MeloTheme.text2)), if (song.album != null) ...[ const SizedBox(height: 2), Text(song.album!, style: const TextStyle( - color: Colors.white38, fontSize: 12)), + color: MeloTheme.text3, fontSize: 12)), ], ], ), @@ -89,7 +89,7 @@ class SongDetailSheet extends StatelessWidget { ), if (names.isEmpty) const Text('Keine Kategorie', - style: TextStyle(color: Colors.white38, fontSize: 13)) + style: TextStyle(color: MeloTheme.text3, 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: Colors.white38, fontSize: 12)), + style: TextStyle(color: MeloTheme.text3, 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: Colors.white38, fontSize: 13)), + style: TextStyle(color: MeloTheme.text3, fontSize: 13)), if (vorschlaege != null && vorschlaege.isNotEmpty) ...[ 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) 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: Colors.white38, fontSize: 12), + style: const TextStyle(color: MeloTheme.text3, fontSize: 12), ), onTap: () => _uebernehmen(vorschlag), ), @@ -285,7 +285,7 @@ class _Row extends StatelessWidget { SizedBox( width: 110, child: Text(label, - style: const TextStyle(color: Colors.white38, fontSize: 13)), + style: const TextStyle(color: MeloTheme.text3, 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: Colors.white38, fontSize: 12), + style: TextStyle(color: MeloTheme.text3, fontSize: 12), ), const SizedBox(height: 12), if (_names.isEmpty) const Text('Noch keine Kategorie', - style: TextStyle(color: Colors.white38, fontSize: 13)) + style: TextStyle(color: MeloTheme.text3, 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: Colors.white38, fontSize: 12)), + style: TextStyle(color: MeloTheme.text3, fontSize: 12)), const SizedBox(height: 4), Wrap( spacing: 8, diff --git a/lib/player/mini_player.dart b/lib/player/mini_player.dart index ad26243..f009d36 100644 --- a/lib/player/mini_player.dart +++ b/lib/player/mini_player.dart @@ -12,6 +12,9 @@ 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(); @@ -19,94 +22,161 @@ class MiniPlayer extends StatelessWidget { stream: handler.mediaItem, builder: (context, snapshot) { final item = snapshot.data; - 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)), - ], - ), - ), - StreamBuilder( - 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), - ], - ), - ), - ), - StreamBuilder( - stream: handler.durationStream, - builder: (context, dSnap) { - final duration = dSnap.data ?? Duration.zero; - return StreamBuilder( - 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), - ); - }, - ); - }, - ), - ], - ), + // 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), + ), + Text( + item.artist ?? 'Unbekannt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: texte.bodySmall, + ), + ], + ), + ), + const SizedBox(width: MeloSpace.sm), + _Bedienung(handler: handler), + const SizedBox(width: MeloSpace.sm), + ], + ), + ), + ), + _Fortschritt(handler: handler), + ], + ), + ), + ); + } +} + +class _Bedienung extends StatelessWidget { + const _Bedienung({required this.handler}); + + final MeloAudioHandler handler; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + 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( + stream: handler.durationStream, + builder: (context, dSnap) { + final dauer = dSnap.data ?? Duration.zero; + return StreamBuilder( + 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), + ); + }, ); }, ); diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 0782dcc..55de957 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -82,7 +82,7 @@ class NowPlayingScreen extends StatelessWidget { const SizedBox(height: 8), Text( item.artist ?? 'Unbekannt', - style: const TextStyle(color: Colors.white54), + style: const TextStyle(color: MeloTheme.text2), ), 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: Colors.white54)), + style: const TextStyle(color: MeloTheme.text2)), Text(_fmt(total), - style: const TextStyle(color: Colors.white54)), + style: const TextStyle(color: MeloTheme.text2)), ], ), ), @@ -187,7 +187,7 @@ class _Controls extends StatelessWidget { IconButton( tooltip: 'Zufallswiedergabe', icon: Icon(Icons.shuffle, - color: shuffle ? MeloTheme.red : Colors.white54), + color: shuffle ? MeloTheme.red : MeloTheme.text2), 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 - ? Colors.white54 + ? MeloTheme.text2 : 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: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ) : SingleChildScrollView( padding: const EdgeInsets.all(16), diff --git a/lib/player/queue_screen.dart b/lib/player/queue_screen.dart index 4d04dea..5c5d0e1 100644 --- a/lib/player/queue_screen.dart +++ b/lib/player/queue_screen.dart @@ -27,7 +27,7 @@ class QueueScreen extends StatelessWidget { if (queue.isEmpty) { return const Center( child: Text('Warteschlange ist leer', - style: TextStyle(color: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ); } return StreamBuilder( diff --git a/lib/playlists/playlist_detail_screen.dart b/lib/playlists/playlist_detail_screen.dart index 19616c5..3fe0bd9 100644 --- a/lib/playlists/playlist_detail_screen.dart +++ b/lib/playlists/playlist_detail_screen.dart @@ -8,6 +8,7 @@ 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 { @@ -29,14 +30,14 @@ class PlaylistDetailScreen extends StatelessWidget { if (snapshot.hasError) { return Center( child: Text('Fehler: ${snapshot.error}', - style: const TextStyle(color: Colors.white54)), + style: const TextStyle(color: MeloTheme.text2)), ); } 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: Colors.white54)), + style: TextStyle(color: MeloTheme.text2)), ); } return ReorderableListView.builder( diff --git a/lib/playlists/playlists_screen.dart b/lib/playlists/playlists_screen.dart index 55de201..4bb5184 100644 --- a/lib/playlists/playlists_screen.dart +++ b/lib/playlists/playlists_screen.dart @@ -42,7 +42,7 @@ class PlaylistsScreen extends StatelessWidget { if (snapshot.hasError) { return Center( child: Text('Fehler: ${snapshot.error}', - style: const TextStyle(color: Colors.white54)), + style: const TextStyle(color: MeloTheme.text2)), ); } 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: Colors.white54)), + Text('Noch keine Playlisten', style: TextStyle(color: MeloTheme.text2)), SizedBox(height: 8), 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 []; if (songs.isEmpty) { 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); diff --git a/lib/settings/equalizer_screen.dart b/lib/settings/equalizer_screen.dart index 393353b..6bae9f8 100644 --- a/lib/settings/equalizer_screen.dart +++ b/lib/settings/equalizer_screen.dart @@ -47,14 +47,14 @@ class EqualizerScreen extends StatelessWidget { padding: EdgeInsets.only(top: 4, bottom: 12), child: Row( children: [ - Icon(Icons.headphones, size: 14, color: Colors.white38), + Icon(Icons.headphones, size: 14, color: MeloTheme.text3), SizedBox(width: 6), Expanded( child: Text( 'Kopfhörer aufsetzen, bevor du die Soundeffekte ' 'einstellst', style: TextStyle( - color: Colors.white38, fontSize: 12), + color: MeloTheme.text3, 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: Colors.white54), + style: TextStyle(color: MeloTheme.text2), ), if (error != null) ...[ const SizedBox(height: 8), @@ -176,10 +176,10 @@ class _Bands extends StatelessWidget { ), Text(band.label, style: - const TextStyle(color: Colors.white54, fontSize: 11)), + const TextStyle(color: MeloTheme.text2, fontSize: 11)), Text('${(band.level / 100).round()}', 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), Text(label, - style: const TextStyle(color: Colors.white54, fontSize: 12)), + style: const TextStyle(color: MeloTheme.text2, fontSize: 12)), 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() ..style = PaintingStyle.stroke ..strokeWidth = 1 - ..color = Colors.white12, + ..color = MeloTheme.border, ); final pointer = _start + _sweep * value; diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 86a8601..68f1498 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -163,9 +163,9 @@ class _SettingsScreenState extends State { 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 { 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: Colors.white54), + style: TextStyle(fontSize: 12, color: MeloTheme.text2), ), ), ListTile( @@ -200,7 +200,7 @@ class _SettingsScreenState extends State { ), 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 { ), 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 { ), 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', @@ -268,7 +268,7 @@ class _SettingsScreenState extends State { builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( backgroundColor: Colors.grey.shade900, - title: const Text('🌐 Navidrome Login', + title: const Text('Navidrome-Anmeldung', style: TextStyle(color: Colors.white, fontSize: 16)), content: SingleChildScrollView( child: Column( @@ -284,7 +284,7 @@ class _SettingsScreenState extends State { ), ), 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), Row( children: [ @@ -489,7 +489,7 @@ class _SectionLabel extends StatelessWidget { child: Text( label, style: const TextStyle( - color: Colors.white54, + color: MeloTheme.text2, fontSize: 12, fontWeight: FontWeight.bold, ), diff --git a/lib/shared/cover.dart b/lib/shared/cover.dart index 4b2dd62..8c23bd3 100644 --- a/lib/shared/cover.dart +++ b/lib/shared/cover.dart @@ -6,7 +6,12 @@ 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 = 8}); + const CoverImage({ + super.key, + required this.artUri, + this.size, + this.radius = MeloRadius.element, + }); final Uri? artUri; final double? size; final double radius; @@ -16,7 +21,13 @@ class CoverImage extends StatelessWidget { final placeholder = Container( width: size, height: size, - color: MeloTheme.surface, + 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), + ), child: LayoutBuilder( builder: (_, c) { final s = size ?? (c.hasBoundedWidth ? c.maxWidth : 48.0); diff --git a/lib/shared/favorite_button.dart b/lib/shared/favorite_button.dart index 88f6b88..39f6061 100644 --- a/lib/shared/favorite_button.dart +++ b/lib/shared/favorite_button.dart @@ -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 : Colors.white54, + color: isFavorite ? MeloTheme.red : MeloTheme.text2, ), onPressed: () => service.toggleFavorite(songId), ); diff --git a/lib/shared/sub_tabs.dart b/lib/shared/sub_tabs.dart index 10b48ff..0160f37 100644 --- a/lib/shared/sub_tabs.dart +++ b/lib/shared/sub_tabs.dart @@ -1,7 +1,12 @@ 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, @@ -18,31 +23,17 @@ class SubTabs extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + padding: const EdgeInsets.fromLTRB( + MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.xs), child: Row( children: [ for (var i = 0; i < labels.length; i++) Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( + padding: const EdgeInsets.only(right: MeloSpace.sm), + child: _Reiter( + label: labels[i], + aktiv: i == index, 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), + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart index 9c98b41..975fe55 100644 --- a/lib/shared/theme.dart +++ b/lib/shared/theme.dart @@ -1,5 +1,53 @@ 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); @@ -9,6 +57,56 @@ 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, @@ -16,22 +114,67 @@ 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)), + ), + ), bottomNavigationBarTheme: const BottomNavigationBarThemeData( backgroundColor: surface, selectedItemColor: red, - unselectedItemColor: Colors.white54, + unselectedItemColor: text3, type: BottomNavigationBarType.fixed, + selectedLabelStyle: + TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + unselectedLabelStyle: TextStyle(fontSize: 12), ), ); } diff --git a/test/settings/settings_screen_test.dart b/test/settings/settings_screen_test.dart index adaedb3..8927ed9 100644 --- a/test/settings/settings_screen_test.dart +++ b/test/settings/settings_screen_test.dart @@ -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(); }); diff --git a/test/shared/sub_tabs_test.dart b/test/shared/sub_tabs_test.dart new file mode 100644 index 0000000..4478048 --- /dev/null +++ b/test/shared/sub_tabs_test.dart @@ -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(find.ancestor( + of: find.text('Lieder'), + matching: find.byType(AnimatedContainer), + )); + expect(container.duration, MeloMotion.fast); + expect(container.curve, MeloMotion.curve); + }); +} diff --git a/test/shared/theme_kontrast_test.dart b/test/shared/theme_kontrast_test.dart new file mode 100644 index 0000000..ea866a5 --- /dev/null +++ b/test/shared/theme_kontrast_test.dart @@ -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])); + } + }); + }); +}