diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..c94214c --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -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); +} diff --git a/lib/core/theme/app_spacing.dart b/lib/core/theme/app_spacing.dart new file mode 100644 index 0000000..d1fb91f --- /dev/null +++ b/lib/core/theme/app_spacing.dart @@ -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)); +} diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart new file mode 100644 index 0000000..3df19e2 --- /dev/null +++ b/lib/core/theme/app_typography.dart @@ -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, + ); +} diff --git a/lib/core/theme/evolution_theme_config.dart b/lib/core/theme/evolution_theme_config.dart index af1dae6..de88638 100644 --- a/lib/core/theme/evolution_theme_config.dart +++ b/lib/core/theme/evolution_theme_config.dart @@ -2,8 +2,14 @@ import 'package:shared_preferences/shared_preferences.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 } +/// StateNotifier for legacy [DashboardThemeMode]. +@Deprecated('Use canonical MotorSignal responsive layout with ThemeMode instead') class DashboardThemeModeNotifier extends StateNotifier { final SharedPreferences _prefs; static const String _key = 'app_dashboard_theme_mode'; @@ -31,6 +37,8 @@ class DashboardThemeModeNotifier extends StateNotifier { } } +/// Provider for legacy [DashboardThemeMode]. +@Deprecated('Use canonical MotorSignal responsive layout with ThemeMode instead') final dashboardThemeModeProvider = StateNotifierProvider((ref) { final prefs = ref.watch(sharedPreferencesProvider); return DashboardThemeModeNotifier(prefs); diff --git a/lib/core/theme/theme.dart b/lib/core/theme/theme.dart index 27256f8..2268a8b 100644 --- a/lib/core/theme/theme.dart +++ b/lib/core/theme/theme.dart @@ -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 { + /// Dark Theme (Cockpit OLED). static ThemeData get darkTheme { - return ThemeData.dark(useMaterial3: true).copyWith( - scaffoldBackgroundColor: const Color(0xFF0D0D0D), + const colorScheme = ColorScheme.dark( + 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( backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, - ), - colorScheme: const ColorScheme.dark( - primary: Color(0xFF00E5FF), - secondary: Color(0xFFFF2A2A), - surface: Color(0xFF1A1A1A), - ), - - segmentedButtonTheme: SegmentedButtonThemeData( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith( - (Set 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( - (Set states) { - if (states.contains(WidgetState.selected)) { - return const Color(0xFF00E5FF); // Cyan text for selected - } - return Colors.white70; // White text for unselected - }, - ), - ), + scrolledUnderElevation: 0, + titleTextStyle: AppTypography.headlineSm, + iconTheme: IconThemeData(color: AppColors.darkOnSurface), ), cardTheme: CardThemeData( - color: const Color(0xFF1A1A1A), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - elevation: 4, + color: AppColors.darkSurfaceContainer, + elevation: 0, + 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((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((states) { + if (states.contains(WidgetState.selected)) { + return AppColors.cyberCyan.withValues(alpha: 0.15); + } + return AppColors.darkSurfaceContainer; + }), + foregroundColor: WidgetStateProperty.resolveWith((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((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((states) { + if (states.contains(WidgetState.selected)) { + return AppColors.lightPrimary.withValues(alpha: 0.12); + } + return AppColors.lightSurfaceContainer; + }), + foregroundColor: WidgetStateProperty.resolveWith((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, ), ); } diff --git a/lib/core/theme/theme_extensions.dart b/lib/core/theme/theme_extensions.dart new file mode 100644 index 0000000..ef82cef --- /dev/null +++ b/lib/core/theme/theme_extensions.dart @@ -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 { + 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? 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() ?? + (Theme.of(this).brightness == Brightness.light + ? MotorSignalThemeColors.light + : MotorSignalThemeColors.dark); +} diff --git a/lib/core/theme/theme_mode_provider.dart b/lib/core/theme/theme_mode_provider.dart new file mode 100644 index 0000000..e7762c4 --- /dev/null +++ b/lib/core/theme/theme_mode_provider.dart @@ -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 { + 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 setThemeMode(ThemeMode mode) async { + state = mode; + await _prefs.setString(_key, mode.name); + } + + Future 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((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AppThemeModeNotifier(prefs); +}); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index be1b6c4..3d36034 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -13,6 +13,7 @@ import '../../database/database.dart'; import '../../core/app_info.dart'; import '../../services/field_test/field_test_logger.dart'; import '../../core/theme/evolution_theme_config.dart'; +import '../../core/theme/theme_mode_provider.dart'; import '../../services/osm_oauth_service.dart'; import '../../services/auth/auth_providers.dart'; import 'widgets/evolution_settings_view.dart'; @@ -171,7 +172,7 @@ class _SettingsScreenState extends ConsumerState { ), ), const SizedBox(height: 24), - _buildSectionHeader('Dashboard UI Theme & Design'), + _buildSectionHeader('Appearance & Display Theme'), const SizedBox(height: 12), Card( clipBehavior: Clip.antiAlias, @@ -183,7 +184,7 @@ class _SettingsScreenState extends ConsumerState { const Row( children: [ Icon( - Icons.palette_outlined, + Icons.brightness_medium_outlined, color: Colors.cyanAccent, size: 20, ), @@ -193,7 +194,7 @@ class _SettingsScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Visual Theme Mode', + 'Display Appearance', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -201,7 +202,75 @@ class _SettingsScreenState extends ConsumerState { ), SizedBox(height: 2), 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( + 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 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( fontSize: 12, color: Colors.white70, diff --git a/lib/features/settings/widgets/evolution_settings_view.dart b/lib/features/settings/widgets/evolution_settings_view.dart index 558724e..7f9b734 100644 --- a/lib/features/settings/widgets/evolution_settings_view.dart +++ b/lib/features/settings/widgets/evolution_settings_view.dart @@ -14,6 +14,7 @@ import '../../../database/database.dart'; import '../../rides/ride_state_machine.dart'; import '../../../services/auth/auth_providers.dart'; import '../../../core/theme/evolution_theme_config.dart'; +import '../../../core/theme/theme_mode_provider.dart'; import '../../../core/app_info.dart'; import 'current_plan_card.dart'; @@ -98,6 +99,78 @@ class _EvolutionSettingsViewState extends ConsumerState { builder: (context, constraints) { 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( + 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 selection) { + ref + .read(appThemeModeProvider.notifier) + .setThemeMode(selection.first); + }, + ), + ], + ), + ); + final themeToggleBar = Container( margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12), padding: EdgeInsets.symmetric( @@ -116,14 +189,14 @@ class _EvolutionSettingsViewState extends ConsumerState { child: Row( children: [ Icon( - Icons.palette_outlined, + Icons.dashboard_customize_outlined, color: Color(0xFF00E5FF), size: 18, ), SizedBox(width: 8), Flexible( child: Text( - 'ACTIVE THEME:', + 'COCKPIT LAYOUT:', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: 'Inter', @@ -193,6 +266,7 @@ class _EvolutionSettingsViewState extends ConsumerState { padding: const EdgeInsets.all(16), child: Column( children: [ + appearanceToggleBar, themeToggleBar, Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -244,6 +318,7 @@ class _EvolutionSettingsViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + appearanceToggleBar, themeToggleBar, currentPlanCard, const SizedBox(height: 16), diff --git a/lib/main.dart b/lib/main.dart index 493c2c9..d9829a9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:app_links/app_links.dart'; import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; import 'core/theme/theme.dart'; +import 'core/theme/theme_mode_provider.dart'; import 'core/router.dart'; import 'features/records/records_service.dart'; import 'services/sync/sync_observer.dart'; @@ -94,9 +95,13 @@ class _MotorcycleDashAppState extends ConsumerState { @override Widget build(BuildContext context) { + final themeMode = ref.watch(appThemeModeProvider); + return MaterialApp.router( - title: 'Motorcycle Dash', - theme: AppTheme.darkTheme, + title: 'MotorSignal', + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, routerConfig: appRouter, debugShowCheckedModeBanner: false, ); diff --git a/test/theme_test.dart b/test/theme_test.dart new file mode 100644 index 0000000..93337a7 --- /dev/null +++ b/test/theme_test.dart @@ -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()); + 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(); + 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(); + 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); + }); + }); +}