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 '../../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<DashboardThemeMode> {
|
||||
final SharedPreferences _prefs;
|
||||
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 prefs = ref.watch(sharedPreferencesProvider);
|
||||
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 {
|
||||
/// 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<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
|
||||
},
|
||||
),
|
||||
),
|
||||
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<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 '../../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<SettingsScreen> {
|
|||
),
|
||||
),
|
||||
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<SettingsScreen> {
|
|||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.palette_outlined,
|
||||
Icons.brightness_medium_outlined,
|
||||
color: Colors.cyanAccent,
|
||||
size: 20,
|
||||
),
|
||||
|
|
@ -193,7 +194,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
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<SettingsScreen> {
|
|||
),
|
||||
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<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(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
|
|
|
|||
|
|
@ -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<EvolutionSettingsView> {
|
|||
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<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(
|
||||
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
|
||||
padding: EdgeInsets.symmetric(
|
||||
|
|
@ -116,14 +189,14 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
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<EvolutionSettingsView> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
appearanceToggleBar,
|
||||
themeToggleBar,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -244,6 +318,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
appearanceToggleBar,
|
||||
themeToggleBar,
|
||||
currentPlanCard,
|
||||
const SizedBox(height: 16),
|
||||
|
|
|
|||
|
|
@ -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<MotorcycleDashApp> {
|
|||
|
||||
@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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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