feat: establish unified MotorSignal design system

This commit is contained in:
Developer 2026-09-04 23:10:25 -04:00
parent 79f6f3e526
commit bcded2a818
11 changed files with 1117 additions and 40 deletions

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
/// Authoritative semantic color tokens for MotorSignal.
///
/// Designed for high-contrast motorcycle cockpits:
/// - Dark Mode (Cockpit OLED): deep matte blacks and vibrant saturated accents.
/// - Light Mode (Daylight HUD): high-contrast daylight surfaces for outdoor sunlight readability.
abstract final class AppColors {
// Brand & High-Contrast Accents
static const Color cyberCyan = Color(0xFF00E5FF);
static const Color technicalBlue = Color(0xFF124580);
static const Color velocityBlue = Color(0xFF2E7DFF);
static const Color raceRed = Color(0xFFFF2A2A);
static const Color hazardRed = Color(0xFFFF3B30);
static const Color launchGreen = Color(0xFF34C759);
static const Color telemetryGreen = Color(0xFF69F0AE);
static const Color successGreen = Color(0xFF008547);
static const Color warningAmber = Color(0xFFFFD740);
static const Color darkAmber = Color(0xFFC48100);
// Dark Mode Palette (Cockpit OLED)
static const Color darkBackground = Color(0xFF0A0A0A);
static const Color darkSurface = Color(0xFF0D0D0D);
static const Color darkSurfaceContainer = Color(0xFF141414);
static const Color darkSurfaceContainerHigh = Color(0xFF1E1E1E);
static const Color darkSurfaceContainerHighest = Color(0xFF282828);
static const Color darkOutline = Color(0xFF2A2A2A);
static const Color darkOutlineVariant = Color(0xFF1F1F1F);
static const Color darkOnSurface = Color(0xFFFFFFFF);
static const Color darkOnSurfaceVariant = Color(0xFFB3B3B3);
static const Color darkPrimary = Color(0xFF00E5FF);
static const Color darkSecondary = Color(0xFFFF2A2A);
// Light Mode Palette (Daylight HUD)
static const Color lightBackground = Color(0xFFF0F0F0);
static const Color lightSurface = Color(0xFFF5F5F5);
static const Color lightSurfaceContainer = Color(0xFFFFFFFF);
static const Color lightSurfaceContainerHigh = Color(0xFFEAEAEA);
static const Color lightSurfaceContainerHighest = Color(0xFFDFDFDF);
static const Color lightOutline = Color(0xFFD1D1D1);
static const Color lightOutlineVariant = Color(0xFFE0E0E0);
static const Color lightOnSurface = Color(0xFF1A1C1C);
static const Color lightOnSurfaceVariant = Color(0xFF555555);
static const Color lightPrimary = Color(0xFF124580);
static const Color lightSecondary = Color(0xFFD32F2F);
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
/// Authoritative spatial, radius, and dimension tokens for MotorSignal.
///
/// Built on a 4dp base grid, with motorcycle-glove friendly touch targets.
abstract final class AppSpacing {
static const double xs = 4.0;
static const double sm = 8.0;
static const double md = 12.0;
static const double lg = 16.0;
static const double xl = 24.0;
static const double xxl = 32.0;
static const double xxxl = 48.0;
/// Minimum interactive target size for motorcycle/touch interaction (48dp).
static const double minTouchTarget = 48.0;
}
/// Authoritative corner radius tokens for cards, buttons, badges, and dials.
abstract final class AppRadius {
static const double sm = 4.0;
static const double md = 8.0;
static const double lg = 12.0;
static const double xl = 16.0;
static const double full = 999.0;
static const BorderRadius smBorderRadius = BorderRadius.all(Radius.circular(sm));
static const BorderRadius mdBorderRadius = BorderRadius.all(Radius.circular(md));
static const BorderRadius lgBorderRadius = BorderRadius.all(Radius.circular(lg));
static const BorderRadius xlBorderRadius = BorderRadius.all(Radius.circular(xl));
static const BorderRadius fullBorderRadius = BorderRadius.all(Radius.circular(full));
}

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
/// Authoritative typography tokens for MotorSignal.
///
/// Multi-font architecture:
/// - JetBrains Mono: Dynamic numbers, telemetry readouts, timers (zero numerical jitter).
/// - Sora: Headlines, app headers, compass directions.
/// - Inter: Body, labels, metadata, specifications.
abstract final class AppTypography {
static const String fontTelemetry = 'JetBrains Mono';
static const String fontHeadline = 'Sora';
static const String fontBody = 'Inter';
// Large Speedometer Readouts
static const TextStyle speedometer = TextStyle(
fontFamily: fontTelemetry,
fontSize: 96,
fontWeight: FontWeight.w900,
height: 1.0,
letterSpacing: -0.04 * 96,
);
static const TextStyle speedometerCompact = TextStyle(
fontFamily: fontTelemetry,
fontSize: 72,
fontWeight: FontWeight.w900,
height: 1.0,
letterSpacing: -0.04 * 72,
);
static const TextStyle speedometerUnit = TextStyle(
fontFamily: fontTelemetry,
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: 0.15 * 20,
);
// Headlines
static const TextStyle headingCompass = TextStyle(
fontFamily: fontHeadline,
fontSize: 28,
fontWeight: FontWeight.w800,
letterSpacing: 0.05 * 28,
);
static const TextStyle headlineLg = TextStyle(
fontFamily: fontHeadline,
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
);
static const TextStyle headlineMd = TextStyle(
fontFamily: fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
);
static const TextStyle headlineSm = TextStyle(
fontFamily: fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
);
// Telemetry Labels & Readouts
static const TextStyle telemetryValue = TextStyle(
fontFamily: fontTelemetry,
fontSize: 18,
fontWeight: FontWeight.w700,
);
static const TextStyle telemetryLabel = TextStyle(
fontFamily: fontBody,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
);
static const TextStyle badgeLabel = TextStyle(
fontFamily: fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
);
// Body text
static const TextStyle bodyLg = TextStyle(
fontFamily: fontBody,
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.4,
);
static const TextStyle bodyMd = TextStyle(
fontFamily: fontBody,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.4,
);
static const TextStyle bodySm = TextStyle(
fontFamily: fontBody,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.3,
);
static const TextStyle button = TextStyle(
fontFamily: fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
);
}

View File

@ -2,8 +2,14 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../features/records/records_service.dart'; import '../../features/records/records_service.dart';
/// Legacy dashboard style generation.
///
/// Deprecated in UI-2A in favor of unified responsive layouts with ThemeMode appearance.
@Deprecated('Use canonical MotorSignal responsive layout with ThemeMode instead')
enum DashboardThemeMode { classic, evolution } enum DashboardThemeMode { classic, evolution }
/// StateNotifier for legacy [DashboardThemeMode].
@Deprecated('Use canonical MotorSignal responsive layout with ThemeMode instead')
class DashboardThemeModeNotifier extends StateNotifier<DashboardThemeMode> { class DashboardThemeModeNotifier extends StateNotifier<DashboardThemeMode> {
final SharedPreferences _prefs; final SharedPreferences _prefs;
static const String _key = 'app_dashboard_theme_mode'; static const String _key = 'app_dashboard_theme_mode';
@ -31,6 +37,8 @@ class DashboardThemeModeNotifier extends StateNotifier<DashboardThemeMode> {
} }
} }
/// Provider for legacy [DashboardThemeMode].
@Deprecated('Use canonical MotorSignal responsive layout with ThemeMode instead')
final dashboardThemeModeProvider = StateNotifierProvider<DashboardThemeModeNotifier, DashboardThemeMode>((ref) { final dashboardThemeModeProvider = StateNotifierProvider<DashboardThemeModeNotifier, DashboardThemeMode>((ref) {
final prefs = ref.watch(sharedPreferencesProvider); final prefs = ref.watch(sharedPreferencesProvider);
return DashboardThemeModeNotifier(prefs); return DashboardThemeModeNotifier(prefs);

View File

@ -1,44 +1,401 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_spacing.dart';
import 'app_typography.dart';
import 'theme_extensions.dart';
/// Authoritative Material 3 theme definitions for MotorSignal.
class AppTheme { class AppTheme {
/// Dark Theme (Cockpit OLED).
static ThemeData get darkTheme { static ThemeData get darkTheme {
return ThemeData.dark(useMaterial3: true).copyWith( const colorScheme = ColorScheme.dark(
scaffoldBackgroundColor: const Color(0xFF0D0D0D), primary: AppColors.darkPrimary,
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF004D56),
onPrimaryContainer: AppColors.cyberCyan,
secondary: AppColors.darkSecondary,
onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFF5A0000),
onSecondaryContainer: Color(0xFFFFB4AB),
surface: AppColors.darkSurface,
onSurface: AppColors.darkOnSurface,
onSurfaceVariant: AppColors.darkOnSurfaceVariant,
outline: AppColors.darkOutline,
outlineVariant: AppColors.darkOutlineVariant,
error: AppColors.hazardRed,
onError: Color(0xFFFFFFFF),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.darkBackground,
fontFamily: AppTypography.fontBody,
extensions: const [
MotorSignalThemeColors.dark,
],
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
), scrolledUnderElevation: 0,
colorScheme: const ColorScheme.dark( titleTextStyle: AppTypography.headlineSm,
primary: Color(0xFF00E5FF), iconTheme: IconThemeData(color: AppColors.darkOnSurface),
secondary: Color(0xFFFF2A2A),
surface: Color(0xFF1A1A1A),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return const Color(0xFF00E5FF).withValues(alpha: 0.2); // Cyan with opacity for selected
}
return null; // Use default for unselected
},
),
foregroundColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return const Color(0xFF00E5FF); // Cyan text for selected
}
return Colors.white70; // White text for unselected
},
),
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: const Color(0xFF1A1A1A), color: AppColors.darkSurfaceContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), elevation: 0,
elevation: 4, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.darkSurfaceContainerHigh,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
),
titleTextStyle: AppTypography.headlineMd.copyWith(color: AppColors.darkOnSurface),
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.darkOnSurfaceVariant),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: AppColors.darkSurfaceContainerHigh,
modalBackgroundColor: AppColors.darkSurfaceContainerHigh,
elevation: 16,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
),
side: WidgetStateProperty.resolveWith<BorderSide>((states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(color: AppColors.cyberCyan, width: 1.5);
}
return const BorderSide(color: AppColors.darkOutline, width: 1);
}),
backgroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.cyberCyan.withValues(alpha: 0.15);
}
return AppColors.darkSurfaceContainer;
}),
foregroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.cyberCyan;
}
return AppColors.darkOnSurfaceVariant;
}),
textStyle: WidgetStateProperty.all(AppTypography.button),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.darkPrimary,
foregroundColor: const Color(0xFF000000),
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.darkSurfaceContainerHigh,
foregroundColor: AppColors.darkOnSurface,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
elevation: 2,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.cyberCyan,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
side: const BorderSide(color: AppColors.darkOutline, width: 1),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.cyberCyan,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
textStyle: AppTypography.button,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.darkSurfaceContainer,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
border: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.darkOutline, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.darkOutline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.cyberCyan, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.hazardRed, width: 1.5),
),
labelStyle: AppTypography.bodyMd.copyWith(color: AppColors.darkOnSurfaceVariant),
hintStyle: AppTypography.bodyMd.copyWith(color: AppColors.darkOnSurfaceVariant.withValues(alpha: 0.6)),
),
chipTheme: ChipThemeData(
backgroundColor: AppColors.darkSurfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
),
labelStyle: AppTypography.badgeLabel.copyWith(color: AppColors.darkOnSurface),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
),
dividerTheme: const DividerThemeData(
color: AppColors.darkOutline,
thickness: 1,
space: 1,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.darkSurface,
indicatorColor: AppColors.cyberCyan.withValues(alpha: 0.15),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppTypography.telemetryLabel.copyWith(color: AppColors.cyberCyan);
}
return AppTypography.telemetryLabel.copyWith(color: AppColors.darkOnSurfaceVariant);
}),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: AppColors.darkSurface,
indicatorColor: AppColors.cyberCyan.withValues(alpha: 0.15),
elevation: 0,
selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.cyberCyan),
unselectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.darkOnSurfaceVariant),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.darkSurfaceContainerHighest,
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.darkOnSurface),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
),
behavior: SnackBarBehavior.floating,
),
);
}
/// Light Theme (Daylight HUD).
static ThemeData get lightTheme {
const colorScheme = ColorScheme.light(
primary: AppColors.lightPrimary,
onPrimary: Color(0xFFFFFFFF),
primaryContainer: Color(0xFFD6E4FF),
onPrimaryContainer: Color(0xFF001B3E),
secondary: AppColors.lightSecondary,
onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFFFFDAD6),
onSecondaryContainer: Color(0xFF410002),
surface: AppColors.lightSurface,
onSurface: AppColors.lightOnSurface,
onSurfaceVariant: AppColors.lightOnSurfaceVariant,
outline: AppColors.lightOutline,
outlineVariant: AppColors.lightOutlineVariant,
error: AppColors.hazardRed,
onError: Color(0xFFFFFFFF),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.lightBackground,
fontFamily: AppTypography.fontBody,
extensions: const [
MotorSignalThemeColors.light,
],
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
titleTextStyle: AppTypography.headlineSm,
iconTheme: IconThemeData(color: AppColors.lightOnSurface),
),
cardTheme: CardThemeData(
color: AppColors.lightSurfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.lightOutline, width: 1),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.lightSurfaceContainer,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.lightOutline, width: 1),
),
titleTextStyle: AppTypography.headlineMd.copyWith(color: AppColors.lightOnSurface),
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.lightOnSurfaceVariant),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: AppColors.lightSurfaceContainer,
modalBackgroundColor: AppColors.lightSurfaceContainer,
elevation: 16,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
),
side: WidgetStateProperty.resolveWith<BorderSide>((states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(color: AppColors.lightPrimary, width: 1.5);
}
return const BorderSide(color: AppColors.lightOutline, width: 1);
}),
backgroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.lightPrimary.withValues(alpha: 0.12);
}
return AppColors.lightSurfaceContainer;
}),
foregroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.lightPrimary;
}
return AppColors.lightOnSurfaceVariant;
}),
textStyle: WidgetStateProperty.all(AppTypography.button),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: const Color(0xFFFFFFFF),
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightSurfaceContainer,
foregroundColor: AppColors.lightOnSurface,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
elevation: 2,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.lightPrimary,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
side: const BorderSide(color: AppColors.lightOutline, width: 1),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.lightPrimary,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
textStyle: AppTypography.button,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.lightSurfaceContainer,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
border: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.lightOutline, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.lightOutline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.lightPrimary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: AppRadius.mdBorderRadius,
borderSide: const BorderSide(color: AppColors.hazardRed, width: 1.5),
),
labelStyle: AppTypography.bodyMd.copyWith(color: AppColors.lightOnSurfaceVariant),
hintStyle: AppTypography.bodyMd.copyWith(color: AppColors.lightOnSurfaceVariant.withValues(alpha: 0.6)),
),
chipTheme: ChipThemeData(
backgroundColor: AppColors.lightSurfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorderRadius,
side: const BorderSide(color: AppColors.lightOutline, width: 1),
),
labelStyle: AppTypography.badgeLabel.copyWith(color: AppColors.lightOnSurface),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
),
dividerTheme: const DividerThemeData(
color: AppColors.lightOutlineVariant,
thickness: 1,
space: 1,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.lightSurface,
indicatorColor: AppColors.lightPrimary.withValues(alpha: 0.12),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppTypography.telemetryLabel.copyWith(color: AppColors.lightPrimary);
}
return AppTypography.telemetryLabel.copyWith(color: AppColors.lightOnSurfaceVariant);
}),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: AppColors.lightSurface,
indicatorColor: AppColors.lightPrimary.withValues(alpha: 0.12),
elevation: 0,
selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.lightPrimary),
unselectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.lightOnSurfaceVariant),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.lightSurfaceContainerHighest,
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.lightOnSurface),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorderRadius,
side: const BorderSide(color: AppColors.lightOutline, width: 1),
),
behavior: SnackBarBehavior.floating,
), ),
); );
} }

View File

@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Semantic ThemeExtension for MotorSignal telemetry, indicators, and UI surfaces.
@immutable
class MotorSignalThemeColors extends ThemeExtension<MotorSignalThemeColors> {
final Color cyberCyan;
final Color velocityBlue;
final Color raceRed;
final Color launchGreen;
final Color warningAmber;
final Color telemetryGreen;
final Color leanAngleLeft;
final Color leanAngleRight;
final Color gForcePositive;
final Color gForceNegative;
final Color speedometerDial;
final Color speedometerGlow;
final Color surfaceContainer;
final Color surfaceContainerHigh;
final Color surfaceContainerHighest;
final Color outline;
final Color outlineVariant;
final Color glowShadow;
const MotorSignalThemeColors({
required this.cyberCyan,
required this.velocityBlue,
required this.raceRed,
required this.launchGreen,
required this.warningAmber,
required this.telemetryGreen,
required this.leanAngleLeft,
required this.leanAngleRight,
required this.gForcePositive,
required this.gForceNegative,
required this.speedometerDial,
required this.speedometerGlow,
required this.surfaceContainer,
required this.surfaceContainerHigh,
required this.surfaceContainerHighest,
required this.outline,
required this.outlineVariant,
required this.glowShadow,
});
/// Dark Mode default telemetry tokens (Cockpit OLED).
static const MotorSignalThemeColors dark = MotorSignalThemeColors(
cyberCyan: AppColors.cyberCyan,
velocityBlue: AppColors.velocityBlue,
raceRed: AppColors.raceRed,
launchGreen: AppColors.launchGreen,
warningAmber: AppColors.warningAmber,
telemetryGreen: AppColors.telemetryGreen,
leanAngleLeft: AppColors.cyberCyan,
leanAngleRight: AppColors.velocityBlue,
gForcePositive: AppColors.launchGreen,
gForceNegative: AppColors.raceRed,
speedometerDial: AppColors.cyberCyan,
speedometerGlow: Color(0x3300E5FF),
surfaceContainer: AppColors.darkSurfaceContainer,
surfaceContainerHigh: AppColors.darkSurfaceContainerHigh,
surfaceContainerHighest: AppColors.darkSurfaceContainerHighest,
outline: AppColors.darkOutline,
outlineVariant: AppColors.darkOutlineVariant,
glowShadow: Color(0x6600E5FF),
);
/// Light Mode default telemetry tokens (Daylight HUD).
static const MotorSignalThemeColors light = MotorSignalThemeColors(
cyberCyan: AppColors.technicalBlue,
velocityBlue: AppColors.velocityBlue,
raceRed: AppColors.lightSecondary,
launchGreen: AppColors.successGreen,
warningAmber: AppColors.darkAmber,
telemetryGreen: AppColors.successGreen,
leanAngleLeft: AppColors.technicalBlue,
leanAngleRight: AppColors.velocityBlue,
gForcePositive: AppColors.successGreen,
gForceNegative: AppColors.lightSecondary,
speedometerDial: AppColors.technicalBlue,
speedometerGlow: Color(0x1F124580),
surfaceContainer: AppColors.lightSurfaceContainer,
surfaceContainerHigh: AppColors.lightSurfaceContainerHigh,
surfaceContainerHighest: AppColors.lightSurfaceContainerHighest,
outline: AppColors.lightOutline,
outlineVariant: AppColors.lightOutlineVariant,
glowShadow: Color(0x22000000),
);
@override
MotorSignalThemeColors copyWith({
Color? cyberCyan,
Color? velocityBlue,
Color? raceRed,
Color? launchGreen,
Color? warningAmber,
Color? telemetryGreen,
Color? leanAngleLeft,
Color? leanAngleRight,
Color? gForcePositive,
Color? gForceNegative,
Color? speedometerDial,
Color? speedometerGlow,
Color? surfaceContainer,
Color? surfaceContainerHigh,
Color? surfaceContainerHighest,
Color? outline,
Color? outlineVariant,
Color? glowShadow,
}) {
return MotorSignalThemeColors(
cyberCyan: cyberCyan ?? this.cyberCyan,
velocityBlue: velocityBlue ?? this.velocityBlue,
raceRed: raceRed ?? this.raceRed,
launchGreen: launchGreen ?? this.launchGreen,
warningAmber: warningAmber ?? this.warningAmber,
telemetryGreen: telemetryGreen ?? this.telemetryGreen,
leanAngleLeft: leanAngleLeft ?? this.leanAngleLeft,
leanAngleRight: leanAngleRight ?? this.leanAngleRight,
gForcePositive: gForcePositive ?? this.gForcePositive,
gForceNegative: gForceNegative ?? this.gForceNegative,
speedometerDial: speedometerDial ?? this.speedometerDial,
speedometerGlow: speedometerGlow ?? this.speedometerGlow,
surfaceContainer: surfaceContainer ?? this.surfaceContainer,
surfaceContainerHigh: surfaceContainerHigh ?? this.surfaceContainerHigh,
surfaceContainerHighest: surfaceContainerHighest ?? this.surfaceContainerHighest,
outline: outline ?? this.outline,
outlineVariant: outlineVariant ?? this.outlineVariant,
glowShadow: glowShadow ?? this.glowShadow,
);
}
@override
MotorSignalThemeColors lerp(ThemeExtension<MotorSignalThemeColors>? other, double t) {
if (other is! MotorSignalThemeColors) {
return this;
}
return MotorSignalThemeColors(
cyberCyan: Color.lerp(cyberCyan, other.cyberCyan, t) ?? cyberCyan,
velocityBlue: Color.lerp(velocityBlue, other.velocityBlue, t) ?? velocityBlue,
raceRed: Color.lerp(raceRed, other.raceRed, t) ?? raceRed,
launchGreen: Color.lerp(launchGreen, other.launchGreen, t) ?? launchGreen,
warningAmber: Color.lerp(warningAmber, other.warningAmber, t) ?? warningAmber,
telemetryGreen: Color.lerp(telemetryGreen, other.telemetryGreen, t) ?? telemetryGreen,
leanAngleLeft: Color.lerp(leanAngleLeft, other.leanAngleLeft, t) ?? leanAngleLeft,
leanAngleRight: Color.lerp(leanAngleRight, other.leanAngleRight, t) ?? leanAngleRight,
gForcePositive: Color.lerp(gForcePositive, other.gForcePositive, t) ?? gForcePositive,
gForceNegative: Color.lerp(gForceNegative, other.gForceNegative, t) ?? gForceNegative,
speedometerDial: Color.lerp(speedometerDial, other.speedometerDial, t) ?? speedometerDial,
speedometerGlow: Color.lerp(speedometerGlow, other.speedometerGlow, t) ?? speedometerGlow,
surfaceContainer: Color.lerp(surfaceContainer, other.surfaceContainer, t) ?? surfaceContainer,
surfaceContainerHigh: Color.lerp(surfaceContainerHigh, other.surfaceContainerHigh, t) ?? surfaceContainerHigh,
surfaceContainerHighest: Color.lerp(surfaceContainerHighest, other.surfaceContainerHighest, t) ?? surfaceContainerHighest,
outline: Color.lerp(outline, other.outline, t) ?? outline,
outlineVariant: Color.lerp(outlineVariant, other.outlineVariant, t) ?? outlineVariant,
glowShadow: Color.lerp(glowShadow, other.glowShadow, t) ?? glowShadow,
);
}
}
/// Convenience extension on [BuildContext] for accessing MotorSignal theme colors.
extension MotorColorsExtension on BuildContext {
MotorSignalThemeColors get motorColors =>
Theme.of(this).extension<MotorSignalThemeColors>() ??
(Theme.of(this).brightness == Brightness.light
? MotorSignalThemeColors.light
: MotorSignalThemeColors.dark);
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/records/records_service.dart';
/// State notifier managing application appearance ([ThemeMode.system], [ThemeMode.light], [ThemeMode.dark]).
///
/// Persisted in [SharedPreferences] under key `app_theme_mode`.
class AppThemeModeNotifier extends StateNotifier<ThemeMode> {
final SharedPreferences _prefs;
static const String _key = 'app_theme_mode';
AppThemeModeNotifier(this._prefs) : super(_loadInitial(_prefs));
static ThemeMode _loadInitial(SharedPreferences prefs) {
final val = prefs.getString(_key);
switch (val) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
case 'system':
default:
return ThemeMode.system;
}
}
Future<void> setThemeMode(ThemeMode mode) async {
state = mode;
await _prefs.setString(_key, mode.name);
}
Future<void> toggleTheme() async {
final next = switch (state) {
ThemeMode.system => ThemeMode.dark,
ThemeMode.dark => ThemeMode.light,
ThemeMode.light => ThemeMode.system,
};
await setThemeMode(next);
}
}
/// Provider for application-wide [ThemeMode].
final appThemeModeProvider = StateNotifierProvider<AppThemeModeNotifier, ThemeMode>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return AppThemeModeNotifier(prefs);
});

View File

@ -13,6 +13,7 @@ import '../../database/database.dart';
import '../../core/app_info.dart'; import '../../core/app_info.dart';
import '../../services/field_test/field_test_logger.dart'; import '../../services/field_test/field_test_logger.dart';
import '../../core/theme/evolution_theme_config.dart'; import '../../core/theme/evolution_theme_config.dart';
import '../../core/theme/theme_mode_provider.dart';
import '../../services/osm_oauth_service.dart'; import '../../services/osm_oauth_service.dart';
import '../../services/auth/auth_providers.dart'; import '../../services/auth/auth_providers.dart';
import 'widgets/evolution_settings_view.dart'; import 'widgets/evolution_settings_view.dart';
@ -171,7 +172,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildSectionHeader('Dashboard UI Theme & Design'), _buildSectionHeader('Appearance & Display Theme'),
const SizedBox(height: 12), const SizedBox(height: 12),
Card( Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@ -183,7 +184,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const Row( const Row(
children: [ children: [
Icon( Icon(
Icons.palette_outlined, Icons.brightness_medium_outlined,
color: Colors.cyanAccent, color: Colors.cyanAccent,
size: 20, size: 20,
), ),
@ -193,7 +194,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Visual Theme Mode', 'Display Appearance',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -201,7 +202,75 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
SizedBox(height: 2), SizedBox(height: 2),
Text( Text(
'Choose between Classic Cockpit and the alternate MotorSignal Evolution Stitch theme.', 'System default, Daylight Light mode, or Cockpit OLED Dark mode.',
style: TextStyle(
fontSize: 12,
color: Colors.white70,
height: 1.3,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Consumer(
builder: (context, ref, _) {
final appThemeMode = ref.watch(appThemeModeProvider);
return SizedBox(
width: double.infinity,
child: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('System', style: TextStyle(fontSize: 12)),
icon: Icon(Icons.settings_brightness, size: 18),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light', style: TextStyle(fontSize: 12)),
icon: Icon(Icons.light_mode, size: 18),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark', style: TextStyle(fontSize: 12)),
icon: Icon(Icons.dark_mode, size: 18),
),
],
selected: {appThemeMode},
onSelectionChanged: (Set<ThemeMode> selection) {
ref
.read(appThemeModeProvider.notifier)
.setThemeMode(selection.first);
},
),
);
},
),
const Divider(height: 32),
const Row(
children: [
Icon(
Icons.dashboard_customize_outlined,
color: Colors.cyanAccent,
size: 20,
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dashboard Layout Generation',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 2),
Text(
'Legacy cockpit design layout selector (Classic / Evolution).',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.white70, color: Colors.white70,

View File

@ -14,6 +14,7 @@ import '../../../database/database.dart';
import '../../rides/ride_state_machine.dart'; import '../../rides/ride_state_machine.dart';
import '../../../services/auth/auth_providers.dart'; import '../../../services/auth/auth_providers.dart';
import '../../../core/theme/evolution_theme_config.dart'; import '../../../core/theme/evolution_theme_config.dart';
import '../../../core/theme/theme_mode_provider.dart';
import '../../../core/app_info.dart'; import '../../../core/app_info.dart';
import 'current_plan_card.dart'; import 'current_plan_card.dart';
@ -98,6 +99,78 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
builder: (context, constraints) { builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight; final isLandscape = constraints.maxWidth > constraints.maxHeight;
final appThemeMode = ref.watch(appThemeModeProvider);
final appearanceToggleBar = Container(
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: isLandscape ? 6 : 12,
),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Row(
children: [
Icon(
Icons.brightness_medium_outlined,
color: Color(0xFF00E5FF),
size: 18,
),
SizedBox(width: 8),
Flexible(
child: Text(
'APPEARANCE:',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w800,
color: Colors.white70,
),
),
),
],
),
),
const SizedBox(width: 8),
SegmentedButton<ThemeMode>(
showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('System', style: TextStyle(fontSize: 11)),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light', style: TextStyle(fontSize: 11)),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark', style: TextStyle(fontSize: 11)),
),
],
selected: {appThemeMode},
onSelectionChanged: (Set<ThemeMode> selection) {
ref
.read(appThemeModeProvider.notifier)
.setThemeMode(selection.first);
},
),
],
),
);
final themeToggleBar = Container( final themeToggleBar = Container(
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12), margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@ -116,14 +189,14 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.palette_outlined, Icons.dashboard_customize_outlined,
color: Color(0xFF00E5FF), color: Color(0xFF00E5FF),
size: 18, size: 18,
), ),
SizedBox(width: 8), SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(
'ACTIVE THEME:', 'COCKPIT LAYOUT:',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontFamily: 'Inter', fontFamily: 'Inter',
@ -193,6 +266,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
appearanceToggleBar,
themeToggleBar, themeToggleBar,
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -244,6 +318,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
appearanceToggleBar,
themeToggleBar, themeToggleBar,
currentPlanCard, currentPlanCard,
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -9,6 +9,7 @@ import 'package:app_links/app_links.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'core/theme/theme.dart'; import 'core/theme/theme.dart';
import 'core/theme/theme_mode_provider.dart';
import 'core/router.dart'; import 'core/router.dart';
import 'features/records/records_service.dart'; import 'features/records/records_service.dart';
import 'services/sync/sync_observer.dart'; import 'services/sync/sync_observer.dart';
@ -94,9 +95,13 @@ class _MotorcycleDashAppState extends ConsumerState<MotorcycleDashApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeMode = ref.watch(appThemeModeProvider);
return MaterialApp.router( return MaterialApp.router(
title: 'Motorcycle Dash', title: 'MotorSignal',
theme: AppTheme.darkTheme, theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
routerConfig: appRouter, routerConfig: appRouter,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );

155
test/theme_test.dart Normal file
View File

@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/app_colors.dart';
import 'package:motorcycle_dash/core/theme/app_spacing.dart';
import 'package:motorcycle_dash/core/theme/app_typography.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/core/theme/theme_mode_provider.dart';
import 'package:motorcycle_dash/core/theme/evolution_theme_config.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MotorSignal Design System - Tokens & Hierarchy', () {
test('AppColors verifies dark and light semantic palette definitions', () {
expect(AppColors.cyberCyan, const Color(0xFF00E5FF));
expect(AppColors.velocityBlue, const Color(0xFF2E7DFF));
expect(AppColors.raceRed, const Color(0xFFFF2A2A));
expect(AppColors.darkBackground, const Color(0xFF0A0A0A));
expect(AppColors.darkSurface, const Color(0xFF0D0D0D));
expect(AppColors.lightBackground, const Color(0xFFF0F0F0));
expect(AppColors.lightSurface, const Color(0xFFF5F5F5));
});
test('AppTypography specifies authoritative multi-font system', () {
expect(AppTypography.fontTelemetry, 'JetBrains Mono');
expect(AppTypography.fontHeadline, 'Sora');
expect(AppTypography.fontBody, 'Inter');
expect(AppTypography.speedometer.fontFamily, 'JetBrains Mono');
expect(AppTypography.speedometer.fontSize, 96);
expect(AppTypography.speedometer.fontWeight, FontWeight.w900);
expect(AppTypography.headingCompass.fontFamily, 'Sora');
expect(AppTypography.bodyMd.fontFamily, 'Inter');
expect(AppTypography.button.fontFamily, 'Sora');
});
test('AppSpacing and AppRadius conform to 4dp base grid and 48dp touch targets', () {
expect(AppSpacing.xs, 4.0);
expect(AppSpacing.sm, 8.0);
expect(AppSpacing.md, 12.0);
expect(AppSpacing.lg, 16.0);
expect(AppSpacing.xl, 24.0);
expect(AppSpacing.minTouchTarget, 48.0);
expect(AppRadius.sm, 4.0);
expect(AppRadius.md, 8.0);
expect(AppRadius.lg, 12.0);
expect(AppRadius.xl, 16.0);
expect(AppRadius.full, 999.0);
});
});
group('MotorSignalThemeColors Extension', () {
test('Dark and Light presets have correct brightness semantics', () {
const dark = MotorSignalThemeColors.dark;
expect(dark.cyberCyan, AppColors.cyberCyan);
expect(dark.launchGreen, AppColors.launchGreen);
expect(dark.surfaceContainer, AppColors.darkSurfaceContainer);
const light = MotorSignalThemeColors.light;
expect(light.cyberCyan, AppColors.technicalBlue);
expect(light.launchGreen, AppColors.successGreen);
expect(light.surfaceContainer, AppColors.lightSurfaceContainer);
});
test('copyWith and lerp interpolate colors accurately', () {
const dark = MotorSignalThemeColors.dark;
final modified = dark.copyWith(cyberCyan: Colors.purple);
expect(modified.cyberCyan, Colors.purple);
expect(modified.velocityBlue, dark.velocityBlue);
final lerped = dark.lerp(MotorSignalThemeColors.light, 0.5);
expect(lerped, isA<MotorSignalThemeColors>());
expect(lerped.cyberCyan, Color.lerp(dark.cyberCyan, MotorSignalThemeColors.light.cyberCyan, 0.5));
});
});
group('AppTheme ThemeData', () {
test('darkTheme configures Material 3 dark brightness and extensions', () {
final theme = AppTheme.darkTheme;
expect(theme.useMaterial3, isTrue);
expect(theme.brightness, Brightness.dark);
expect(theme.scaffoldBackgroundColor, AppColors.darkBackground);
expect(theme.colorScheme.primary, AppColors.darkPrimary);
final ext = theme.extension<MotorSignalThemeColors>();
expect(ext, isNotNull);
expect(ext?.cyberCyan, AppColors.cyberCyan);
});
test('lightTheme configures Material 3 light brightness and extensions', () {
final theme = AppTheme.lightTheme;
expect(theme.useMaterial3, isTrue);
expect(theme.brightness, Brightness.light);
expect(theme.scaffoldBackgroundColor, AppColors.lightBackground);
expect(theme.colorScheme.primary, AppColors.lightPrimary);
final ext = theme.extension<MotorSignalThemeColors>();
expect(ext, isNotNull);
expect(ext?.cyberCyan, AppColors.technicalBlue);
});
});
group('ThemeMode & Persistence Provider', () {
test('AppThemeModeNotifier defaults to ThemeMode.system and persists transitions', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final notifier = AppThemeModeNotifier(prefs);
expect(notifier.state, ThemeMode.system);
await notifier.setThemeMode(ThemeMode.dark);
expect(notifier.state, ThemeMode.dark);
expect(prefs.getString('app_theme_mode'), 'dark');
await notifier.setThemeMode(ThemeMode.light);
expect(notifier.state, ThemeMode.light);
expect(prefs.getString('app_theme_mode'), 'light');
await notifier.toggleTheme();
expect(notifier.state, ThemeMode.system);
});
test('AppThemeModeNotifier restores saved theme from SharedPreferences', () async {
SharedPreferences.setMockInitialValues({'app_theme_mode': 'light'});
final prefs = await SharedPreferences.getInstance();
final notifier = AppThemeModeNotifier(prefs);
expect(notifier.state, ThemeMode.light);
});
test('DashboardThemeMode and ThemeMode are distinct independent concepts', () async {
SharedPreferences.setMockInitialValues({
'app_theme_mode': 'light',
'app_dashboard_theme_mode': 'evolution',
});
final prefs = await SharedPreferences.getInstance();
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
);
expect(container.read(appThemeModeProvider), ThemeMode.light);
// Deprecated dashboard theme mode maintains legacy behavior
expect(container.read(dashboardThemeModeProvider), DashboardThemeMode.evolution);
});
});
}