feat(theme): add shared design tokens

This commit is contained in:
zarzet
2026-07-29 17:10:07 +07:00
parent 834a8dde89
commit c6f7b22e43
2 changed files with 284 additions and 17 deletions
+38 -17
View File
@@ -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,
);
+246
View File
@@ -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;
}