diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index e1746222..d9e0d808 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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 >[AppTokens.standard], fontFamily: 'Google Sans Flex', ); } @@ -90,6 +96,7 @@ class AppTheme { switchTheme: _switchTheme(scheme), chipTheme: _chipTheme(scheme), dividerTheme: _dividerTheme(scheme), + extensions: const >[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, ); diff --git a/lib/theme/app_tokens.dart b/lib/theme/app_tokens.dart new file mode 100644 index 00000000..3153bf04 --- /dev/null +++ b/lib/theme/app_tokens.dart @@ -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()`, so a widget rendered outside a +/// themed subtree (tests, isolated previews) still gets [AppTokens.standard]. +@immutable +class AppTokens extends ThemeExtension { + 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.standard; +}