Files
Melo/lib/shared/theme.dart
T
Hermes (Server)andClaude Opus 5 5b6fa949a9 Hauptmenue wieder deutlich sichtbar
Entwarnung vorweg: es fehlt nichts. Alle vier Bereiche (Meine Musik,
Online, Suchen, Favoriten) standen durchgehend in der Bottom-Nav;
lib/main.dart wurde von der UI-Politur gar nicht angefasst.

Aber die Politur hatte das Menue abgeschwaecht — zwei eigene Fehler:
- selectedLabelStyle/unselectedLabelStyle bekamen beide fontSize 12. Damit
  verlor der aktive Eintrag den Groessenunterschied (Flutter-Vorgabe 14 zu
  12), an dem man erkennt, wo man ist. Jetzt keine fontSize mehr.
- unselectedItemColor stand auf text3 (Beiwerk-Stufe). Ein Hauptmenue ist
  kein Beiwerk -> text2.

Zusaetzlich deutlicher: Haarlinie ueber der Leiste (auf Schwarz ging sie
sonst im Inhalt auf), gefuellte Symbole fuer den aktiven Bereich (zweites
Merkmal neben der Farbe) und fetterer aktiver Eintrag.

Neu: test/hauptmenue_test.dart — prueft Vorhandensein, Sichtbarkeit auf dem
Bildschirm, Position am unteren Rand, Wechsel per Tipp und die Abhebung des
aktiven Bereichs. Damit kann keine weitere Politur das Menue unbemerkt
abschwaechen.

Nebenbei eine Test-Falle geloest: await db.close() nach einem tester.tap
laesst den Lauf unbegrenzt haengen (drift plant eine Aufraeum-Aufgabe ein,
die der Test-Rahmen nicht mehr abarbeitet). Rezept im Test dokumentiert.
Beim bekannten Haenger in song_detail_sheet_test.dart hilft es NICHT —
gegengeprueft und als eigene Ursache im BACKLOG vermerkt.

328 Tests gruen (10 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 14:13:11 +02:00

189 lines
6.8 KiB
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.
class MeloTheme {
static const Color red = Color(0xFFC0392B);
static const Color black = Color(0xFF0B0B10);
static const Color surface = Color(0xFF15151C);
/// 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,
brightness: Brightness.dark,
).copyWith(
primary: red,
surface: black,
outline: border,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: black,
cardColor: surface,
textTheme: _textTheme,
splashFactory: InkSparkle.splashFactory,
appBarTheme: const AppBarTheme(
backgroundColor: black,
surfaceTintColor: Colors.transparent,
centerTitle: false,
titleTextStyle: TextStyle(
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
),
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: MeloSpace.md),
minVerticalPadding: MeloSpace.sm,
iconColor: text2,
),
dividerTheme: const DividerThemeData(
color: border,
space: 1,
thickness: 1,
),
iconTheme: const IconThemeData(color: text2),
sliderTheme: const SliderThemeData(
activeTrackColor: red,
thumbColor: red,
inactiveTrackColor: border,
trackHeight: 3,
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: red,
linearTrackColor: border,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: surfaceHigh,
contentTextStyle: const TextStyle(color: text1, fontSize: 14),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeloRadius.element),
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(MeloRadius.sheet)),
),
),
// Das Hauptmenü. Es muss auf den ersten Blick als solches erkennbar
// sein — deshalb bewusst kräftiger als der übrige Beiwerk-Text:
// - text2 (72 %) statt der blassen dritten Stufe für die inaktiven
// Einträge; sie sollen lesbar sein, nicht nur erahnbar.
// - KEINE fontSize hier: damit gelten Flutters Vorgaben von 14 (aktiv)
// und 12 (inaktiv). Eine feste Größe für beide nahm dem aktiven
// Reiter genau den Größenunterschied, an dem man ihn erkennt.
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surface,
selectedItemColor: red,
unselectedItemColor: text2,
type: BottomNavigationBarType.fixed,
showUnselectedLabels: true,
selectedLabelStyle: TextStyle(fontWeight: FontWeight.w700),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w500),
),
);
}
}