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
This commit is contained in:
Hermes (Server)
2026-08-21 12:40:25 +02:00
co-authored by Claude Opus 5
parent 2df85e650e
commit bc861f6087
23 changed files with 773 additions and 184 deletions
+13 -2
View File
@@ -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);
+1 -1
View File
@@ -21,7 +21,7 @@ class FavoriteButton extends StatelessWidget {
tooltip: isFavorite ? 'Favorit entfernen' : 'Zu Favoriten hinzufügen',
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? MeloTheme.red : Colors.white54,
color: isFavorite ? MeloTheme.red : MeloTheme.text2,
),
onPressed: () => service.toggleFavorite(songId),
);
+69 -20
View File
@@ -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),
),
),
),
),
);
}
}
+144 -1
View File
@@ -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),
),
);
}