feat: establish unified MotorSignal design system
This commit is contained in:
parent
79f6f3e526
commit
bcded2a818
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue