Die P2-Liste aus dem Review — Kleinigkeiten, die man einzeln nicht vermisst und zusammen sofort merkt. - Das Coverbild wandert beim Oeffnen der Vollbild-Wiedergabe an seinen Platz, statt dass ein neuer Bildschirm aufspringt. - Wischen am Mini-Player: nach oben oeffnen, seitwaerts Titelwechsel. Mit Schwelle, weil der Mini-Player zugleich ein Knopf ist und der Finger beim Tippen fast immer ein paar Pixel wandert. Nach unten bewusst unbelegt, damit niemand die Wiedergabe versehentlich wegschiebt. - Kuenstlerliste mit Bildern und Albenzahl; dahinter eine Kuenstlerseite mit seinen Alben und darunter allen Titeln (dafuer neu: groupByAlbum). Bei nur einem Album entfaellt die Zwischenstufe. - Alphabet-Sprung am rechten Rand, nur bei Namenssortierung und ab 30 Titeln. Umlaute zaehlen zu ihrem Grundbuchstaben, Ziffern und Zeichen unter "#" — dieselbe Regel wie die Sortierung. - Mehrfachauswahl per langem Druck, mit "Zur Warteschlange" und "Zu Wiedergabeliste". - Abgeschaltete Systemanimationen gelten jetzt ueberall, nicht nur bei den Lauf-Balken. - Querformat und Tablet: der Player legt Cover und Bedienung nebeneinander und begrenzt die Textbreite. Beim Bauen gefunden: der Alphabet-Sprung landete bei absteigender Sortierung am Ende eines Buchstaben-Blocks statt an seinem Anfang. Aus dem Code-Review nachgebessert, sieben Punkte: die Alphabet-Leiste wurde breiter, je weniger Buchstaben sie hatte (bei einem einzigen 528 dp — die Songliste waere verschwunden); die neue Kuenstlerseite baute alle Titel sofort und oeffnete damit bei 300 Titeln ebenso viele Abos; die Bildlauf-Animation im Songtext stuerzte bei abgeschalteten Systemanimationen ab; die feste Zeilenhoehe war 64 statt 72 und die Schriftskalierung an 1.0 statt an einer echten Groesse gemessen; bereits enthaltene Titel wurden in Wiedergabelisten verschoben statt uebersprungen und trotzdem mitgezaehlt; ein Fehler im Stapel brach still ab; und die Zurueck-Geste verliess den Bildschirm, statt die Auswahl zu beenden. Selbst gefunden: in der Kuenstlerliste hing der Datenbank-Strom an CategoryService und wurde bei jeder Kategorie-Meldung neu aufgesetzt. Nebenbei aufgeklaert, warum ein neuer Widget-Test endlos hing: Aufbau in setUp laeuft ausserhalb der Testzone, die drift-Stroeme liefen dort in echter Zeit, waehrend pumpAndSettle in Testzeit auf sie wartete. Aufbau in den Testkoerper verschoben. Der aeltere Haenger im BACKLOG hat eine andere Ursache (db.close() dort nachweislich ausgeschlossen); die Spur ist notiert. 500 Tests gruen (vorher 457), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
200 lines
7.4 KiB
Dart
200 lines
7.4 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);
|
|
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|