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), ), ); } }