P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
231 lines
8.6 KiB
Dart
231 lines
8.6 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;
|
|
|
|
/// [dauer], oder null, wenn der Nutzer Systemanimationen abgeschaltet hat.
|
|
///
|
|
/// Diese Einstellung nutzen Menschen, denen Bewegung auf dem Bildschirm
|
|
/// Unwohlsein bereitet. Die Übergänge sollen dann nicht schneller sein,
|
|
/// sondern gar nicht stattfinden — der Zustand wechselt schlicht.
|
|
///
|
|
/// Bewusst kein globaler Schalter im Theme: `MaterialApp` kennt keinen, und
|
|
/// jede animierte Stelle muss die Entscheidung ohnehin selbst treffen.
|
|
static Duration ruhig(BuildContext context, Duration dauer) =>
|
|
MediaQuery.disableAnimationsOf(context) ? Duration.zero : dauer;
|
|
}
|
|
|
|
/// 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);
|
|
|
|
/// Die Stufenleiter der Schrift.
|
|
///
|
|
/// Melo bleibt bei der System-Schrift (auf Android Roboto): sie ist immer
|
|
/// da, trägt jede Sprache und kostet nichts an App-Größe. Geschärft wird
|
|
/// stattdessen, was ohne eigene Schrift geht — Größenabstände, Gewichte
|
|
/// und vor allem die **Laufweite**: große Grade wirken mit den Standard-
|
|
/// abständen auseinandergezogen, Großbuchstaben dagegen gedrängt.
|
|
static TextTheme get _textTheme => const TextTheme(
|
|
// Bildschirm-Überschrift ("Favoriten", "Download", "Suchen").
|
|
// Etwas enger gesetzt, damit die Zeile als ein Wortbild steht.
|
|
displaySmall: TextStyle(
|
|
fontSize: 26,
|
|
height: 1.15,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.4,
|
|
color: text1),
|
|
// Titelleiste, Kopf eines Sheets.
|
|
headlineSmall: TextStyle(
|
|
fontSize: 22,
|
|
height: 1.2,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.3,
|
|
color: text1),
|
|
// Hervorgehobene Zeile ("Shuffle-Wiedergabe").
|
|
titleMedium: TextStyle(
|
|
fontSize: 17,
|
|
height: 1.3,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.1,
|
|
color: text1),
|
|
// Titel in einer Liste. Zeilenhöhe bleibt bei 1,4 — dass Fließtext
|
|
// nicht enger gesetzt wird, hält `theme_kontrast_test.dart` als
|
|
// Lesbarkeitsregel fest, und die ist älter als diese Politur.
|
|
bodyLarge: TextStyle(
|
|
fontSize: 15, height: 1.4, letterSpacing: 0, color: text1),
|
|
// Untertitel in einer Liste.
|
|
bodyMedium: TextStyle(
|
|
fontSize: 13, height: 1.35, letterSpacing: 0, color: text2),
|
|
// Beiwerk: Anzahl, Dauer.
|
|
bodySmall: TextStyle(fontSize: 12, height: 1.3, color: text3),
|
|
// Abschnitts-Beschriftung, Knopftext. Wird in Großbuchstaben
|
|
// gesetzt — und die brauchen mehr Luft, nicht weniger.
|
|
labelLarge: TextStyle(
|
|
fontSize: 12,
|
|
height: 1.2,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.6,
|
|
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,
|
|
// Dieselbe Stufe wie eine Überschrift — sonst hätte derselbe Rang
|
|
// zwei verschiedene Schriftbilder.
|
|
titleTextStyle: TextStyle(
|
|
fontSize: 22,
|
|
height: 1.2,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.3,
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|