mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(theme): add shared design tokens
This commit is contained in:
+38
-17
@@ -2,10 +2,15 @@ import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||
|
||||
/// Component radii resolve from the same tokens the widgets read, so the
|
||||
/// scale cannot drift between `ThemeData` and hand-built containers.
|
||||
static const AppTokens _tokens = AppTokens.standard;
|
||||
|
||||
// Override Flutter's default page transitions. Recent Flutter defaults the
|
||||
// Android route transition to PredictiveBackPageTransitionsBuilder, whose
|
||||
// gesture detector mis-routes the predictive-back gesture to a nested
|
||||
@@ -52,6 +57,7 @@ class AppTheme {
|
||||
switchTheme: _switchTheme(scheme),
|
||||
chipTheme: _chipTheme(scheme),
|
||||
dividerTheme: _dividerTheme(scheme),
|
||||
extensions: const <ThemeExtension<dynamic>>[AppTokens.standard],
|
||||
fontFamily: 'Google Sans Flex',
|
||||
);
|
||||
}
|
||||
@@ -90,6 +96,7 @@ class AppTheme {
|
||||
switchTheme: _switchTheme(scheme),
|
||||
chipTheme: _chipTheme(scheme),
|
||||
dividerTheme: _dividerTheme(scheme),
|
||||
extensions: const <ThemeExtension<dynamic>>[AppTokens.standard],
|
||||
fontFamily: 'Google Sans Flex',
|
||||
);
|
||||
}
|
||||
@@ -125,7 +132,9 @@ class AppTheme {
|
||||
|
||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
color: scheme.surfaceContainerLow,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
@@ -135,7 +144,7 @@ class AppTheme {
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
@@ -145,7 +154,7 @@ class AppTheme {
|
||||
FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
@@ -155,7 +164,7 @@ class AppTheme {
|
||||
OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
@@ -165,7 +174,7 @@ class AppTheme {
|
||||
TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
@@ -174,7 +183,9 @@ class AppTheme {
|
||||
static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) =>
|
||||
FloatingActionButtonThemeData(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
foregroundColor: scheme.onPrimaryContainer,
|
||||
);
|
||||
@@ -184,19 +195,19 @@ class AppTheme {
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
@@ -207,20 +218,26 @@ class AppTheme {
|
||||
|
||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||
ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusControl),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
|
||||
// Caps modal sheet width on tablets/landscape (no effect on phones, whose
|
||||
// screens are narrower); Flutter centers the sheet when narrower than the
|
||||
// screen.
|
||||
static const BottomSheetThemeData _bottomSheetTheme = BottomSheetThemeData(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
// screen. The shape is set here so no call site has to repeat it — every
|
||||
// sheet inherits the token radius.
|
||||
static final BottomSheetThemeData _bottomSheetTheme = BottomSheetThemeData(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
shape: _tokens.sheetShape,
|
||||
);
|
||||
|
||||
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
||||
elevation: 6,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusSheet),
|
||||
),
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
@@ -239,7 +256,9 @@ class AppTheme {
|
||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
|
||||
SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusThumb),
|
||||
),
|
||||
backgroundColor: scheme.inverseSurface,
|
||||
contentTextStyle: TextStyle(color: scheme.onInverseSurface),
|
||||
);
|
||||
@@ -274,7 +293,9 @@ class AppTheme {
|
||||
);
|
||||
|
||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_tokens.radiusCard),
|
||||
),
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
selectedColor: scheme.secondaryContainer,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Single source of truth for the app's visual scale.
|
||||
///
|
||||
/// Before this existed, radii, cover sizes, badge metrics and motion durations
|
||||
/// were written literally at every call site (300+ `BorderRadius.circular`
|
||||
/// calls across 70 files, with five different radii for what is visually the
|
||||
/// same thumbnail). Anything reused across more than one screen belongs here so
|
||||
/// a design change is a one-line edit instead of a grep-and-replace campaign.
|
||||
///
|
||||
/// Read it through [AppTokensContext.tokens] rather than
|
||||
/// `Theme.of(context).extension<AppTokens>()`, so a widget rendered outside a
|
||||
/// themed subtree (tests, isolated previews) still gets [AppTokens.standard].
|
||||
@immutable
|
||||
class AppTokens extends ThemeExtension<AppTokens> {
|
||||
const AppTokens({
|
||||
required this.radiusBadge,
|
||||
required this.radiusThumb,
|
||||
required this.radiusCover,
|
||||
required this.radiusControl,
|
||||
required this.radiusCard,
|
||||
required this.radiusSheet,
|
||||
required this.gapXs,
|
||||
required this.gapSm,
|
||||
required this.gapMd,
|
||||
required this.gapLg,
|
||||
required this.gapXl,
|
||||
required this.coverMini,
|
||||
required this.coverCompact,
|
||||
required this.coverList,
|
||||
required this.badgeFontSize,
|
||||
required this.badgePadding,
|
||||
required this.minTouchTarget,
|
||||
required this.headerExpandedHeight,
|
||||
required this.headerCollapsedTitleSize,
|
||||
required this.headerExpandedTitleSize,
|
||||
required this.motionFast,
|
||||
required this.motionMedium,
|
||||
required this.motionSlow,
|
||||
});
|
||||
|
||||
/// The values every theme in the app uses today. Kept as the single
|
||||
/// definition so a future "compact"/"expressive" theme only overrides the
|
||||
/// handful of tokens it actually changes.
|
||||
static const AppTokens standard = AppTokens(
|
||||
radiusBadge: 6,
|
||||
radiusThumb: 8,
|
||||
radiusCover: 12,
|
||||
radiusControl: 16,
|
||||
radiusCard: 20,
|
||||
radiusSheet: 28,
|
||||
gapXs: 4,
|
||||
gapSm: 8,
|
||||
gapMd: 12,
|
||||
gapLg: 16,
|
||||
gapXl: 24,
|
||||
coverMini: 44,
|
||||
coverCompact: 48,
|
||||
coverList: 56,
|
||||
badgeFontSize: 11,
|
||||
badgePadding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
minTouchTarget: 48,
|
||||
headerExpandedHeight: 120,
|
||||
headerCollapsedTitleSize: 20,
|
||||
headerExpandedTitleSize: 28,
|
||||
motionFast: Duration(milliseconds: 150),
|
||||
motionMedium: Duration(milliseconds: 250),
|
||||
motionSlow: Duration(milliseconds: 400),
|
||||
);
|
||||
|
||||
/// Quality/source pills and other inline chips drawn inside dense rows.
|
||||
final double radiusBadge;
|
||||
|
||||
/// Small square artwork in list rows and grid cells.
|
||||
final double radiusThumb;
|
||||
|
||||
/// Large artwork: detail headers, now playing, grid covers.
|
||||
final double radiusCover;
|
||||
|
||||
/// Buttons, text fields and other interactive controls. Matches the radius
|
||||
/// already baked into [ThemeData.filledButtonTheme] and friends.
|
||||
final double radiusControl;
|
||||
|
||||
/// Grouped containers: settings groups, section cards, info cards.
|
||||
final double radiusCard;
|
||||
|
||||
/// Modal bottom sheets and the search fields styled to match them.
|
||||
final double radiusSheet;
|
||||
|
||||
final double gapXs;
|
||||
final double gapSm;
|
||||
final double gapMd;
|
||||
final double gapLg;
|
||||
final double gapXl;
|
||||
|
||||
/// Mini player artwork.
|
||||
final double coverMini;
|
||||
|
||||
/// Dense rows (album track lists, pickers).
|
||||
final double coverCompact;
|
||||
|
||||
/// Standard list row artwork.
|
||||
final double coverList;
|
||||
|
||||
/// Floor for badge text. Anything smaller stops being legible at the default
|
||||
/// system font scale.
|
||||
final double badgeFontSize;
|
||||
|
||||
final EdgeInsets badgePadding;
|
||||
|
||||
/// Material's minimum interactive size. Applied to icon buttons and pills
|
||||
/// that were previously shrunk to 28-32dp.
|
||||
final double minTouchTarget;
|
||||
|
||||
/// Expanded height of the collapsing headers shared by the tab roots and the
|
||||
/// settings-style sub-pages.
|
||||
final double headerExpandedHeight;
|
||||
|
||||
final double headerCollapsedTitleSize;
|
||||
|
||||
/// Expanded title size for every collapsing header in the app. Tab roots used
|
||||
/// to expand to 34 while sub-pages expanded to 28; both now follow this one
|
||||
/// value, which matches the Material 3 large top app bar headline.
|
||||
final double headerExpandedTitleSize;
|
||||
|
||||
final Duration motionFast;
|
||||
final Duration motionMedium;
|
||||
final Duration motionSlow;
|
||||
|
||||
BorderRadius get borderRadiusBadge => BorderRadius.circular(radiusBadge);
|
||||
BorderRadius get borderRadiusThumb => BorderRadius.circular(radiusThumb);
|
||||
BorderRadius get borderRadiusCover => BorderRadius.circular(radiusCover);
|
||||
BorderRadius get borderRadiusCard => BorderRadius.circular(radiusCard);
|
||||
|
||||
/// Top-rounded shape for modal bottom sheets.
|
||||
RoundedRectangleBorder get sheetShape => RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(radiusSheet)),
|
||||
);
|
||||
|
||||
@override
|
||||
AppTokens copyWith({
|
||||
double? radiusBadge,
|
||||
double? radiusThumb,
|
||||
double? radiusCover,
|
||||
double? radiusControl,
|
||||
double? radiusCard,
|
||||
double? radiusSheet,
|
||||
double? gapXs,
|
||||
double? gapSm,
|
||||
double? gapMd,
|
||||
double? gapLg,
|
||||
double? gapXl,
|
||||
double? coverMini,
|
||||
double? coverCompact,
|
||||
double? coverList,
|
||||
double? badgeFontSize,
|
||||
EdgeInsets? badgePadding,
|
||||
double? minTouchTarget,
|
||||
double? headerExpandedHeight,
|
||||
double? headerCollapsedTitleSize,
|
||||
double? headerExpandedTitleSize,
|
||||
Duration? motionFast,
|
||||
Duration? motionMedium,
|
||||
Duration? motionSlow,
|
||||
}) {
|
||||
return AppTokens(
|
||||
radiusBadge: radiusBadge ?? this.radiusBadge,
|
||||
radiusThumb: radiusThumb ?? this.radiusThumb,
|
||||
radiusCover: radiusCover ?? this.radiusCover,
|
||||
radiusControl: radiusControl ?? this.radiusControl,
|
||||
radiusCard: radiusCard ?? this.radiusCard,
|
||||
radiusSheet: radiusSheet ?? this.radiusSheet,
|
||||
gapXs: gapXs ?? this.gapXs,
|
||||
gapSm: gapSm ?? this.gapSm,
|
||||
gapMd: gapMd ?? this.gapMd,
|
||||
gapLg: gapLg ?? this.gapLg,
|
||||
gapXl: gapXl ?? this.gapXl,
|
||||
coverMini: coverMini ?? this.coverMini,
|
||||
coverCompact: coverCompact ?? this.coverCompact,
|
||||
coverList: coverList ?? this.coverList,
|
||||
badgeFontSize: badgeFontSize ?? this.badgeFontSize,
|
||||
badgePadding: badgePadding ?? this.badgePadding,
|
||||
minTouchTarget: minTouchTarget ?? this.minTouchTarget,
|
||||
headerExpandedHeight: headerExpandedHeight ?? this.headerExpandedHeight,
|
||||
headerCollapsedTitleSize:
|
||||
headerCollapsedTitleSize ?? this.headerCollapsedTitleSize,
|
||||
headerExpandedTitleSize:
|
||||
headerExpandedTitleSize ?? this.headerExpandedTitleSize,
|
||||
motionFast: motionFast ?? this.motionFast,
|
||||
motionMedium: motionMedium ?? this.motionMedium,
|
||||
motionSlow: motionSlow ?? this.motionSlow,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppTokens lerp(covariant AppTokens? other, double t) {
|
||||
if (other == null) return this;
|
||||
return AppTokens(
|
||||
radiusBadge: lerpDouble(radiusBadge, other.radiusBadge, t),
|
||||
radiusThumb: lerpDouble(radiusThumb, other.radiusThumb, t),
|
||||
radiusCover: lerpDouble(radiusCover, other.radiusCover, t),
|
||||
radiusControl: lerpDouble(radiusControl, other.radiusControl, t),
|
||||
radiusCard: lerpDouble(radiusCard, other.radiusCard, t),
|
||||
radiusSheet: lerpDouble(radiusSheet, other.radiusSheet, t),
|
||||
gapXs: lerpDouble(gapXs, other.gapXs, t),
|
||||
gapSm: lerpDouble(gapSm, other.gapSm, t),
|
||||
gapMd: lerpDouble(gapMd, other.gapMd, t),
|
||||
gapLg: lerpDouble(gapLg, other.gapLg, t),
|
||||
gapXl: lerpDouble(gapXl, other.gapXl, t),
|
||||
coverMini: lerpDouble(coverMini, other.coverMini, t),
|
||||
coverCompact: lerpDouble(coverCompact, other.coverCompact, t),
|
||||
coverList: lerpDouble(coverList, other.coverList, t),
|
||||
badgeFontSize: lerpDouble(badgeFontSize, other.badgeFontSize, t),
|
||||
badgePadding:
|
||||
EdgeInsets.lerp(badgePadding, other.badgePadding, t) ?? badgePadding,
|
||||
minTouchTarget: lerpDouble(minTouchTarget, other.minTouchTarget, t),
|
||||
headerExpandedHeight: lerpDouble(
|
||||
headerExpandedHeight,
|
||||
other.headerExpandedHeight,
|
||||
t,
|
||||
),
|
||||
headerCollapsedTitleSize: lerpDouble(
|
||||
headerCollapsedTitleSize,
|
||||
other.headerCollapsedTitleSize,
|
||||
t,
|
||||
),
|
||||
headerExpandedTitleSize: lerpDouble(
|
||||
headerExpandedTitleSize,
|
||||
other.headerExpandedTitleSize,
|
||||
t,
|
||||
),
|
||||
motionFast: t < 0.5 ? motionFast : other.motionFast,
|
||||
motionMedium: t < 0.5 ? motionMedium : other.motionMedium,
|
||||
motionSlow: t < 0.5 ? motionSlow : other.motionSlow,
|
||||
);
|
||||
}
|
||||
|
||||
static double lerpDouble(double a, double b, double t) => a + (b - a) * t;
|
||||
}
|
||||
|
||||
extension AppTokensContext on BuildContext {
|
||||
/// The active [AppTokens]. Falls back to [AppTokens.standard] when the widget
|
||||
/// is built outside a theme that registered the extension.
|
||||
AppTokens get tokens =>
|
||||
Theme.of(this).extension<AppTokens>() ?? AppTokens.standard;
|
||||
}
|
||||
Reference in New Issue
Block a user