diff --git a/assets/images/motorcycles/yamaha_yzf_r6_2001.jpg b/assets/images/motorcycles/yamaha_yzf_r6_2001.jpg new file mode 100644 index 0000000..d1c9920 Binary files /dev/null and b/assets/images/motorcycles/yamaha_yzf_r6_2001.jpg differ diff --git a/assets/images/motorcycles/yamaha_yzf_r6_2001.png b/assets/images/motorcycles/yamaha_yzf_r6_2001.png deleted file mode 100644 index cd97e54..0000000 Binary files a/assets/images/motorcycles/yamaha_yzf_r6_2001.png and /dev/null differ diff --git a/lib/features/dashboard/widgets/g_force_indicator.dart b/lib/features/dashboard/widgets/g_force_indicator.dart index dd403e5..fffbef6 100644 --- a/lib/features/dashboard/widgets/g_force_indicator.dart +++ b/lib/features/dashboard/widgets/g_force_indicator.dart @@ -4,19 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; import '../../../services/telemetry_service.dart'; -import '../../rides/ride_state_machine.dart'; /// Isolated leaf visualizer for longitudinal and lateral G-force loads. /// /// Subscribes strictly to G-force changes via [telemetryStreamProvider] -/// and max session values via [rideStateMachineProvider]. +/// and displays the current acceleration/braking load. class GForceIndicator extends ConsumerWidget { final bool compact; - const GForceIndicator({ - super.key, - this.compact = false, - }); + const GForceIndicator({super.key, this.compact = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -29,24 +25,27 @@ class GForceIndicator extends ConsumerWidget { ), ), ); - final maxValues = ref.watch( - rideStateMachineProvider.select( - (s) => (maxAccel: s.maxAccelG, maxBraking: s.maxBrakingG), - ), - ); + final currentAccel = gForce.accel > 0 ? gForce.accel : 0.0; + final currentBraking = gForce.accel < 0 ? gForce.accel.abs() : 0.0; final isDark = Theme.of(context).brightness == Brightness.dark; final dialRadius = compact ? 34.0 : 44.0; - - final accelTextColor = gForce.speed < 3.0 - ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) + + final accelTextColor = gForce.speed < 3.0 + ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) : (isDark ? AppColors.telemetryGreen : AppColors.successGreen); - final brkTextColor = gForce.speed < 3.0 - ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) + final brkTextColor = gForce.speed < 3.0 + ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) : (isDark ? AppColors.raceRed : AppColors.lightSecondary); - double targetX = (gForce.lateral * dialRadius).clamp(-dialRadius, dialRadius); - double targetY = (-gForce.accel * dialRadius).clamp(-dialRadius, dialRadius); + double targetX = (gForce.lateral * dialRadius).clamp( + -dialRadius, + dialRadius, + ); + double targetY = (-gForce.accel * dialRadius).clamp( + -dialRadius, + dialRadius, + ); final dist = math.sqrt(targetX * targetX + targetY * targetY); if (dist > dialRadius) { @@ -90,10 +89,7 @@ class GForceIndicator extends ConsumerWidget { ), color: isDark ? const Color(0xFF111111) : Colors.white, boxShadow: [ - BoxShadow( - color: dotColor.withValues(alpha: 0.15), - blurRadius: 8, - ), + BoxShadow(color: dotColor.withValues(alpha: 0.15), blurRadius: 8), ], ), child: Stack( @@ -124,8 +120,16 @@ class GForceIndicator extends ConsumerWidget { ), ), // Crosshairs - Container(width: 1, height: dialDiameter, color: isDark ? Colors.white12 : Colors.black12), - Container(width: dialDiameter, height: 1, color: isDark ? Colors.white12 : Colors.black12), + Container( + width: 1, + height: dialDiameter, + color: isDark ? Colors.white12 : Colors.black12, + ), + Container( + width: dialDiameter, + height: 1, + color: isDark ? Colors.white12 : Colors.black12, + ), // Live tracking dot Transform.translate( offset: Offset(targetX, targetY), @@ -154,7 +158,7 @@ class GForceIndicator extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'ACC: ${maxValues.maxAccel.toStringAsFixed(2)}', + 'ACC: ${currentAccel.toStringAsFixed(2)}', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: compact ? 9 : 11, @@ -164,7 +168,7 @@ class GForceIndicator extends ConsumerWidget { ), const SizedBox(width: 8), Text( - 'BRK: ${maxValues.maxBraking.toStringAsFixed(2)}', + 'BRK: ${currentBraking.toStringAsFixed(2)}', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: compact ? 9 : 11, diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index 5f70d99..dec27e9 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -4,19 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; import '../../../services/telemetry_service.dart'; -import '../../rides/ride_state_machine.dart'; /// Isolated leaf visualizer for motorcycle lean angle. /// /// Subscribes strictly to lean angle changes via [telemetryStreamProvider] -/// and max session values via [rideStateMachineProvider]. +/// and displays the current directional lean. class LeanAngleIndicator extends ConsumerWidget { final bool compact; - const LeanAngleIndicator({ - super.key, - this.compact = false, - }); + const LeanAngleIndicator({super.key, this.compact = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -26,16 +22,17 @@ class LeanAngleIndicator extends ConsumerWidget { final speed = ref.watch( telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0), ); - final maxValues = ref.watch( - rideStateMachineProvider.select( - (s) => (maxLeft: s.maxLeanLeft, maxRight: s.maxLeanRight), - ), - ); + final currentLeft = leanAngle < 0 ? leanAngle.abs() : 0.0; + final currentRight = leanAngle > 0 ? leanAngle : 0.0; final isDark = Theme.of(context).brightness == Brightness.dark; final primaryAccent = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; - final secondaryAccent = isDark ? AppColors.warningAmber : AppColors.darkAmber; - final numericTextColor = speed < 3.0 ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) : secondaryAccent; + final secondaryAccent = isDark + ? AppColors.warningAmber + : AppColors.darkAmber; + final numericTextColor = speed < 3.0 + ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) + : secondaryAccent; final String direction; if (leanAngle > 1.0) { @@ -80,7 +77,7 @@ class LeanAngleIndicator extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - '°', + '${leanAngle.abs().toStringAsFixed(0)}°', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: compact ? 20 : 26, @@ -94,7 +91,11 @@ class LeanAngleIndicator extends ConsumerWidget { fontFamily: AppTypography.fontBody, fontSize: compact ? 8 : 10, fontWeight: FontWeight.w800, - color: leanAngle.abs() > 1.0 ? primaryAccent : (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant), + color: leanAngle.abs() > 1.0 + ? primaryAccent + : (isDark + ? Colors.white38 + : AppColors.lightOnSurfaceVariant), ), ), ], @@ -109,7 +110,7 @@ class LeanAngleIndicator extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'L: ${maxValues.maxLeft.toStringAsFixed(0)}°', + 'L: ${currentLeft.toStringAsFixed(0)}°', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: compact ? 9 : 11, @@ -119,7 +120,7 @@ class LeanAngleIndicator extends ConsumerWidget { ), const SizedBox(width: 10), Text( - 'R: ${maxValues.maxRight.toStringAsFixed(0)}°', + 'R: ${currentRight.toStringAsFixed(0)}°', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: compact ? 9 : 11, diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 9f6cd32..1f123bb 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -62,12 +62,12 @@ class _SettingsScreenState extends ConsumerState { final isWide = snapshot.isExpanded && !snapshot.isShortViewport; return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - ), + appBar: AppBar(title: const Text('Settings')), body: SafeArea( top: false, - child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context), + child: isWide + ? _buildWideLayout(context) + : _buildCompactLayout(context), ), ); } @@ -236,7 +236,9 @@ class _SettingsScreenState extends ConsumerState { child: Row( children: [ Icon( - isAuthed ? Icons.account_circle : Icons.account_circle_outlined, + isAuthed + ? Icons.account_circle + : Icons.account_circle_outlined, color: isAuthed ? colors.cyberCyan : colors.outline, size: 28, ), @@ -257,11 +259,14 @@ class _SettingsScreenState extends ConsumerState { const SizedBox(height: 2), Text( isAuthed - ? (user?.email ?? 'Cloud Identity Active • Offline rides protected') + ? (user?.email ?? + 'Cloud Identity Active • Offline rides protected') : 'Optional cloud identity • Local riding always active', style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -271,14 +276,19 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( color: isAuthed ? colors.cyberCyan.withValues(alpha: 0.15) : colors.outlineVariant.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), border: Border.all( - color: isAuthed ? colors.cyberCyan : colors.outlineVariant, + color: isAuthed + ? colors.cyberCyan + : colors.outlineVariant, ), ), child: Text( @@ -293,11 +303,7 @@ class _SettingsScreenState extends ConsumerState { ), ), const SizedBox(width: 6), - Icon( - Icons.chevron_right, - color: colors.outline, - size: 20, - ), + Icon(Icons.chevron_right, color: colors.outline, size: 20), ], ), ), @@ -368,17 +374,35 @@ class _SettingsScreenState extends ConsumerState { segments: const [ ButtonSegment( value: ThemeMode.system, - label: Text('System', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + label: Text( + 'System', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), icon: Icon(Icons.settings_brightness, size: 16), ), ButtonSegment( value: ThemeMode.light, - label: Text('Light', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + label: Text( + 'Light', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), icon: Icon(Icons.light_mode, size: 16), ), ButtonSegment( value: ThemeMode.dark, - label: Text('Dark', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + label: Text( + 'Dark', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), icon: Icon(Icons.dark_mode, size: 16), ), ], @@ -414,11 +438,7 @@ class _SettingsScreenState extends ConsumerState { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.screen_rotation, - color: colors.cyberCyan, - size: 20, - ), + Icon(Icons.screen_rotation, color: colors.cyberCyan, size: 20), const SizedBox(width: 10), Expanded( child: Column( @@ -549,7 +569,13 @@ class _SettingsScreenState extends ConsumerState { value: AppMapType.openStreetMap, label: FittedBox( fit: BoxFit.scaleDown, - child: Text('OpenStreetMap', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + child: Text( + 'OpenStreetMap', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), ), icon: Icon(Icons.public, size: 16), ), @@ -557,7 +583,13 @@ class _SettingsScreenState extends ConsumerState { value: AppMapType.googleMaps, label: FittedBox( fit: BoxFit.scaleDown, - child: Text('Google Maps', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + child: Text( + 'Google Maps', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), ), icon: Icon(Icons.map_outlined, size: 16), ), @@ -602,7 +634,9 @@ class _SettingsScreenState extends ConsumerState { style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, - color: isAuthed ? colors.cyberCyan : colors.outline, + color: isAuthed + ? colors.cyberCyan + : colors.outline, ), ), backgroundColor: isAuthed @@ -773,7 +807,9 @@ class _SettingsScreenState extends ConsumerState { 'Distance to detect upcoming incidents', style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), ), ], @@ -783,26 +819,11 @@ class _SettingsScreenState extends ConsumerState { DropdownButton( value: alertRadiusMeters, items: const [ - DropdownMenuItem( - value: 152.4, - child: Text('500 ft'), - ), - DropdownMenuItem( - value: 304.8, - child: Text('1,000 ft'), - ), - DropdownMenuItem( - value: 402.3, - child: Text('1/4 Mile'), - ), - DropdownMenuItem( - value: 804.7, - child: Text('1/2 Mile'), - ), - DropdownMenuItem( - value: 1609.3, - child: Text('1 Mile'), - ), + DropdownMenuItem(value: 152.4, child: Text('500 ft')), + DropdownMenuItem(value: 304.8, child: Text('1,000 ft')), + DropdownMenuItem(value: 402.3, child: Text('1/4 Mile')), + DropdownMenuItem(value: 804.7, child: Text('1/2 Mile')), + DropdownMenuItem(value: 1609.3, child: Text('1 Mile')), ], onChanged: roadAlertsEnabled ? (val) { @@ -822,10 +843,7 @@ class _SettingsScreenState extends ConsumerState { padding: EdgeInsets.symmetric(vertical: 6.0), child: Text( 'Alert Categories', - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 14, - ), + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14), ), ), ...RoadAlertCategory.values.map((cat) { @@ -888,7 +906,8 @@ class _SettingsScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Community / RapidAPI Key', hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key', - helperText: 'Supplies Police, Speed Cameras, and Crowd Hazards.', + helperText: + 'Supplies Police, Speed Cameras, and Crowd Hazards.', helperMaxLines: 2, border: OutlineInputBorder(), isDense: true, @@ -903,15 +922,19 @@ class _SettingsScreenState extends ConsumerState { controller: _communityEndpointController, decoration: const InputDecoration( labelText: 'Custom Endpoint URL (Optional)', - hintText: 'e.g. https://google-map-places.p.rapidapi.com or custom URL', - helperText: 'Leave blank for automatic OpenWebNinja / RapidAPI detection.', + hintText: + 'e.g. https://google-map-places.p.rapidapi.com or custom URL', + helperText: + 'Leave blank for automatic OpenWebNinja / RapidAPI detection.', helperMaxLines: 2, border: OutlineInputBorder(), isDense: true, ), style: const TextStyle(fontSize: 13), onChanged: (val) { - ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val); + ref + .read(communityEndpointUrlProvider.notifier) + .setEndpoint(val); }, ), const SizedBox(height: 12), @@ -933,20 +956,28 @@ class _SettingsScreenState extends ConsumerState { final status = await provider.testConnection( _communityKeyController.text, ); - ref.read(communityStatusProvider.notifier).state = status; + ref.read(communityStatusProvider.notifier).state = + status; setState(() => _isTestingCommunity = false); if (mounted) { String msg = ''; if (status == RoadAlertProviderStatus.connected) { - msg = 'Community / OpenWebNinja API Connected successfully!'; - } else if (status == RoadAlertProviderStatus.authError) { - msg = 'Authentication Error: Invalid or unauthorized key.'; - } else if (status == RoadAlertProviderStatus.notConfigured) { + msg = + 'Community / OpenWebNinja API Connected successfully!'; + } else if (status == + RoadAlertProviderStatus.authError) { + msg = + 'Authentication Error: Invalid or unauthorized key.'; + } else if (status == + RoadAlertProviderStatus.notConfigured) { msg = 'API Key is empty.'; - } else if (status == RoadAlertProviderStatus.networkError) { - msg = 'Network Error: Check internet connection or endpoint.'; - } else if (status == RoadAlertProviderStatus.rateLimited) { + } else if (status == + RoadAlertProviderStatus.networkError) { + msg = + 'Network Error: Check internet connection or endpoint.'; + } else if (status == + RoadAlertProviderStatus.rateLimited) { msg = 'Rate Limited: Plan limit reached.'; } else { msg = 'Status: ${status.name}'; @@ -989,7 +1020,8 @@ class _SettingsScreenState extends ConsumerState { icon: Icons.security, iconColor: colors.cyberCyan, title: 'TomTom Traffic Provider', - subtitle: 'Supplies Accidents, Hazards, Closures & Construction data.', + subtitle: + 'Supplies Accidents, Hazards, Closures & Construction data.', status: tomtomStatus, ), const SizedBox(height: 16), @@ -998,7 +1030,8 @@ class _SettingsScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'TomTom API Key', hintText: 'Enter your free TomTom API Key', - helperText: 'Supplies Accidents, Hazards, Closures & Construction data.', + helperText: + 'Supplies Accidents, Hazards, Closures & Construction data.', helperMaxLines: 2, border: OutlineInputBorder(), isDense: true, @@ -1034,13 +1067,17 @@ class _SettingsScreenState extends ConsumerState { String msg = ''; if (status == RoadAlertProviderStatus.connected) { msg = 'TomTom API Connected successfully!'; - } else if (status == RoadAlertProviderStatus.authError) { + } else if (status == + RoadAlertProviderStatus.authError) { msg = 'Authentication Error: Invalid Key.'; - } else if (status == RoadAlertProviderStatus.notConfigured) { + } else if (status == + RoadAlertProviderStatus.notConfigured) { msg = 'Key is empty.'; - } else if (status == RoadAlertProviderStatus.networkError) { + } else if (status == + RoadAlertProviderStatus.networkError) { msg = 'Network Error: Failed to connect to API.'; - } else if (status == RoadAlertProviderStatus.rateLimited) { + } else if (status == + RoadAlertProviderStatus.rateLimited) { msg = 'Rate Limited: Too many requests.'; } else { msg = 'Status: ${status.name}'; @@ -1078,7 +1115,11 @@ class _SettingsScreenState extends ConsumerState { children: [ Row( children: [ - const Icon(Icons.bug_report, color: Colors.orangeAccent, size: 20), + const Icon( + Icons.bug_report, + color: Colors.orangeAccent, + size: 20, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -1106,48 +1147,44 @@ class _SettingsScreenState extends ConsumerState { runSpacing: 8, children: [ ElevatedButton.icon( - style: ElevatedButton.styleFrom(minimumSize: const Size(0, 44)), - icon: const Icon(Icons.local_police, color: Colors.blueAccent, size: 18), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 44), + ), + icon: const Icon( + Icons.local_police, + color: Colors.blueAccent, + size: 18, + ), label: const Text('Test Police (100m)'), onPressed: () { - final alert = RoadAlert( - id: 'sim_police', - providerId: 'mock', - category: RoadAlertCategory.police, - description: 'Simulated police alert', - latitude: 0, - longitude: 0, - distanceFromUser: 100.0, - reportedAt: DateTime.now(), - lastUpdated: DateTime.now(), - source: 'Test', - ); - ref.read(roadAlertServiceProvider).simulateAlert(alert); + ref + .read(roadAlertServiceProvider) + .triggerTestAlert(category: RoadAlertCategory.police); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Simulated Police Alert sent.')), + const SnackBar( + content: Text('Simulated Police Alert sent.'), + ), ); }, ), ElevatedButton.icon( - style: ElevatedButton.styleFrom(minimumSize: const Size(0, 44)), - icon: const Icon(Icons.warning, color: Colors.orangeAccent, size: 18), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 44), + ), + icon: const Icon( + Icons.warning, + color: Colors.orangeAccent, + size: 18, + ), label: const Text('Test Hazard (250m)'), onPressed: () { - final alert = RoadAlert( - id: 'sim_hazard', - providerId: 'mock', - category: RoadAlertCategory.hazard, - description: 'Simulated hazard alert', - latitude: 0, - longitude: 0, - distanceFromUser: 250.0, - reportedAt: DateTime.now(), - lastUpdated: DateTime.now(), - source: 'Test', - ); - ref.read(roadAlertServiceProvider).simulateAlert(alert); + ref + .read(roadAlertServiceProvider) + .triggerTestAlert(category: RoadAlertCategory.hazard); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Simulated Hazard Alert sent.')), + const SnackBar( + content: Text('Simulated Hazard Alert sent.'), + ), ); }, ), @@ -1255,7 +1292,9 @@ class _SettingsScreenState extends ConsumerState { style: ElevatedButton.styleFrom( backgroundColor: colors.raceRed.withValues(alpha: 0.15), foregroundColor: colors.raceRed, - side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)), + side: BorderSide( + color: colors.raceRed.withValues(alpha: 0.6), + ), minimumSize: const Size(120, 48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -1369,7 +1408,9 @@ class _SettingsScreenState extends ConsumerState { 'Silent black-box diagnostic recorder', style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), overflow: TextOverflow.ellipsis, ), @@ -1417,7 +1458,9 @@ class _SettingsScreenState extends ConsumerState { border: Border.all( color: activeSession != null ? colors.launchGreen - : (isEnabled ? colors.cyberCyan : colors.outlineVariant), + : (isEnabled + ? colors.cyberCyan + : colors.outlineVariant), ), ), child: Text( @@ -1456,7 +1499,8 @@ class _SettingsScreenState extends ConsumerState { 'Pre-Ride Check', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), - onPressed: () => context.go('/settings/field-test-pre-ride-check'), + onPressed: () => + context.go('/settings/field-test-pre-ride-check'), ), ElevatedButton.icon( style: ElevatedButton.styleFrom( @@ -1497,9 +1541,7 @@ class _SettingsScreenState extends ConsumerState { Align( alignment: Alignment.centerRight, child: TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: colors.raceRed, - ), + style: TextButton.styleFrom(foregroundColor: colors.raceRed), icon: const Icon(Icons.delete_sweep, size: 16), label: const Text( 'Clear Field Test Logs', @@ -1534,7 +1576,10 @@ class _SettingsScreenState extends ConsumerState { onPressed: () => Navigator.pop(ctx, true), child: Text( 'CLEAR', - style: TextStyle(color: colors.raceRed, fontWeight: FontWeight.bold), + style: TextStyle( + color: colors.raceRed, + fontWeight: FontWeight.bold, + ), ), ), ], @@ -1714,7 +1759,10 @@ class _AppVersionFooterState extends State<_AppVersionFooter> { onPressed: () => Navigator.of(ctx).pop(), child: Text( 'CLOSE', - style: TextStyle(color: colors.cyberCyan, fontWeight: FontWeight.bold), + style: TextStyle( + color: colors.cyberCyan, + fontWeight: FontWeight.bold, + ), ), ), ], @@ -1776,7 +1824,9 @@ class _AppVersionFooterState extends State<_AppVersionFooter> { Text( 'Tap for forensic build identity', style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), fontSize: 9, ), ), diff --git a/lib/services/clubs/club_models.dart b/lib/services/clubs/club_models.dart index d5ff6c7..b73c830 100644 --- a/lib/services/clubs/club_models.dart +++ b/lib/services/clubs/club_models.dart @@ -213,7 +213,9 @@ class Club { 'memberCount': memberCount, 'createdAt': _dateTimeToFirestore(createdAt), 'updatedAt': _dateTimeToFirestore(updatedAt), - if (archivedAt != null) 'archivedAt': _dateTimeToFirestore(archivedAt), + // Keep the field present so Firestore rules can distinguish active + // clubs from legacy documents without a missing-field rule error. + 'archivedAt': _dateTimeToFirestore(archivedAt), if (logoUrl != null) 'logoUrl': logoUrl, if (bannerUrl != null) 'bannerUrl': bannerUrl, if (homeRegion != null) 'homeRegion': homeRegion, diff --git a/lib/services/motorcycle_image_service.dart b/lib/services/motorcycle_image_service.dart index 662613f..198c203 100644 --- a/lib/services/motorcycle_image_service.dart +++ b/lib/services/motorcycle_image_service.dart @@ -61,7 +61,8 @@ class MotorcycleImageDescriptor { if (normalize(make) != normalize(targetMake)) return false; final normalizedTargetModel = normalize(targetModel); - final modelMatches = normalize(model) == normalizedTargetModel || + final modelMatches = + normalize(model) == normalizedTargetModel || modelAliases.any((alias) => normalize(alias) == normalizedTargetModel); if (!modelMatches) return false; @@ -75,11 +76,11 @@ class MotorcycleImageDescriptor { } MotorcycleImage toImage() => MotorcycleImage( - assetPath: assetPath, - caption: caption, - attribution: attribution, - isPhoto: isPhoto, - ); + assetPath: assetPath, + caption: caption, + attribution: attribution, + isPhoto: isPhoto, + ); } /// Single authoritative resolver for motorcycle imagery throughout MotorSignal. @@ -96,7 +97,7 @@ class MotorcycleImageService { static final MotorcycleImageService instance = MotorcycleImageService._(); static const List bundledPhotographicAssets = [ - 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + 'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg', ]; static const List _defaultDescriptors = [ @@ -106,9 +107,9 @@ class MotorcycleImageService { minYear: 1999, maxYear: 2002, modelAliases: ['R6', 'YZFR6', 'YZF R6'], - assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg', caption: '2001 Yamaha YZF-R6 (5EB)', - attribution: 'Factory Supersport Reference', + attribution: 'James McCauley / Wikimedia Commons, CC BY 2.0', isPhoto: true, ), ]; @@ -127,9 +128,9 @@ class MotorcycleImageService { } List get allDescriptors => [ - ..._customDescriptors, - ..._defaultDescriptors, - ]; + ..._customDescriptors, + ..._defaultDescriptors, + ]; MotorcycleImage? getCachedOrCuratedImage({ required int year, @@ -159,10 +160,7 @@ class MotorcycleImageService { // Cascading query strategy: // 1. Exact make + model + year // 2. Broad make + model - final queries = [ - '$make $model $year', - '$make $model', - ]; + final queries = ['$make $model $year', '$make $model']; for (final query in queries) { final image = await _searchWikimedia(query, year, make, model); @@ -202,10 +200,15 @@ class MotorcycleImageService { 'iiextmetadatafilter': 'Artist|Credit|LicenseShortName|LicenseUrl', }); - final response = await http.get(uri, headers: const { - 'Accept': 'application/json', - 'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)', - }).timeout(const Duration(seconds: 6)); + final response = await http + .get( + uri, + headers: const { + 'Accept': 'application/json', + 'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)', + }, + ) + .timeout(const Duration(seconds: 6)); if (response.statusCode != 200) return null; @@ -235,7 +238,8 @@ class MotorcycleImageService { ); final license = _metadataValue(metadata, 'LicenseShortName'); final licenseLower = (license ?? '').toLowerCase(); - final allowed = licenseLower.contains('cc by') || + final allowed = + licenseLower.contains('cc by') || licenseLower.contains('public domain') || licenseLower.contains('cc0'); if (!allowed) continue; @@ -244,14 +248,17 @@ class MotorcycleImageService { if (normalizedTitle.contains(normalizedModel)) score += 5; if (normalizedTitle.contains(normalizedMake)) score += 3; if (title.contains(normalizedYear)) score += 4; - if (title.contains('motorcycle') || title.contains('motorbike')) score += 1; + if (title.contains('motorcycle') || title.contains('motorbike')) { + score += 1; + } if (score >= 4 && score > bestScore) { bestScore = score; best = MotorcycleImage( imageUrl: thumb, sourceUrl: source, - credit: _metadataValue(metadata, 'Artist') ?? + credit: + _metadataValue(metadata, 'Artist') ?? _metadataValue(metadata, 'Credit'), license: license, caption: '$year $make $model', @@ -273,7 +280,10 @@ class MotorcycleImageService { required String? model, int? year, }) { - if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) { + if (make == null || + model == null || + make.trim().isEmpty || + model.trim().isEmpty) { return const MotorcycleImage(); } @@ -301,7 +311,8 @@ class MotorcycleImageService { }) { if (year != null) { for (final descriptor in allDescriptors) { - if (descriptor.exactYear != null && descriptor.matches(make, model, year)) { + if (descriptor.exactYear != null && + descriptor.matches(make, model, year)) { return descriptor.toImage(); } } diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart index 30f64cc..935729e 100644 --- a/test/canonical_dashboard_responsive_test.dart +++ b/test/canonical_dashboard_responsive_test.dart @@ -50,8 +50,12 @@ void main() { return ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), - telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), - activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream), + telemetryStreamProvider.overrideWith( + (ref) => telemetryController.stream, + ), + activeAlertsStreamProvider.overrideWith( + (ref) => alertsController.stream, + ), locationPermissionGrantedProvider.overrideWith( (ref) => hasLocationPermission, ), @@ -98,7 +102,11 @@ void main() { await tester.pumpWidget(buildTestDashboard()); await tester.pump(); - expect(tester.takeException(), isNull, reason: 'Zero RenderFlex overflow on ${entry.key}'); + expect( + tester.takeException(), + isNull, + reason: 'Zero RenderFlex overflow on ${entry.key}', + ); expect(find.byType(SpeedReadout), findsOneWidget); expect(find.byType(HeadingIndicator), findsOneWidget); expect(find.byType(GPSStatusIndicator), findsOneWidget); @@ -135,7 +143,9 @@ void main() { }); group('Dashboard Layout Modes (Sport, Touring, Minimal)', () { - testWidgets('Sport Mode displays Lean and G-Force indicators', (tester) async { + testWidgets('Sport Mode displays Lean and G-Force indicators', ( + tester, + ) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); @@ -146,7 +156,9 @@ void main() { expect(find.byType(LeanAngleIndicator), findsOneWidget); }); - testWidgets('Minimal Mode displays Speedometer and minimal controls', (tester) async { + testWidgets('Minimal Mode displays Speedometer and minimal controls', ( + tester, + ) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); @@ -160,59 +172,98 @@ void main() { expect(find.byType(RideActionButton), findsOneWidget); }); - testWidgets('Touring Mode is mode-driven: displays map in both portrait and landscape', (tester) async { - await prefs.setString('dashboardLayout', 'touring'); + testWidgets( + 'Touring Mode is mode-driven: displays map in both portrait and landscape', + (tester) async { + await prefs.setString('dashboardLayout', 'touring'); - // Portrait - configureViewport(tester, 400, 800); - await tester.pumpWidget(buildTestDashboard()); - await tester.pump(); + // Portrait + configureViewport(tester, 400, 800); + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); - expect(tester.takeException(), isNull); - expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in portrait Touring'); - expect(find.text('MOVING'), findsOneWidget); + expect(tester.takeException(), isNull); + expect( + find.byType(DashboardMapPanel), + findsOneWidget, + reason: 'Map present in portrait Touring', + ); + expect(find.text('MOVING'), findsOneWidget); - // Landscape - configureViewport(tester, 800, 360); - await tester.pump(); + // Landscape + configureViewport(tester, 800, 360); + await tester.pump(); - expect(tester.takeException(), isNull); - expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in landscape Touring'); - }); + expect(tester.takeException(), isNull); + expect( + find.byType(DashboardMapPanel), + findsOneWidget, + reason: 'Map present in landscape Touring', + ); + }, + ); }); group('Performance & Rebuild Isolation Tests', () { - testWidgets('Telemetry updates speed readout without destroying ride state', (tester) async { - configureViewport(tester, 400, 800); - addTearDown(() => tester.view.resetPhysicalSize()); + testWidgets( + 'Telemetry updates speed readout without destroying ride state', + (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); - await tester.pumpWidget(buildTestDashboard()); - await tester.pump(); + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); - expect(find.text('0'), findsWidgets); + expect(find.text('0'), findsWidgets); - // Emit high-frequency sample - telemetryController.add( - TelemetrySample( - timestamp: DateTime.now(), - speed: 68.4, - heading: 270.0, - leanAngle: 24.5, - longitudinalG: 0.42, - lateralG: 0.35, - gpsAccuracy: 9.0, - ), - ); + // Emit high-frequency sample + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 68.4, + heading: 270.0, + leanAngle: 24.5, + longitudinalG: 0.42, + lateralG: 0.35, + gpsAccuracy: 9.0, + ), + ); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 50)); - expect(find.text('68'), findsOneWidget); - expect(find.text('W'), findsOneWidget); - expect(find.text('270°'), findsOneWidget); - expect(find.text('GPS 3D FIX'), findsOneWidget); - }); + expect(find.text('68'), findsOneWidget); + expect(find.text('W'), findsOneWidget); + expect(find.text('270°'), findsOneWidget); + expect(find.text('GPS 3D FIX'), findsOneWidget); + expect(find.text('25°'), findsOneWidget); + expect(find.text('L: 0°'), findsOneWidget); + expect(find.text('R: 25°'), findsOneWidget); + expect(find.text('ACC: 0.42'), findsOneWidget); + expect(find.text('BRK: 0.00'), findsOneWidget); - testWidgets('Resizing viewport preserves active ride session', (tester) async { + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 42.0, + leanAngle: -18.2, + longitudinalG: -0.31, + lateralG: -0.12, + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('18°'), findsOneWidget); + expect(find.text('L: 18°'), findsOneWidget); + expect(find.text('R: 0°'), findsOneWidget); + expect(find.text('ACC: 0.00'), findsOneWidget); + expect(find.text('BRK: 0.31'), findsOneWidget); + }, + ); + + testWidgets('Resizing viewport preserves active ride session', ( + tester, + ) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); @@ -235,7 +286,9 @@ void main() { expect(find.text('RECORDING'), findsOneWidget); }); - testWidgets('Alert region displays alert banner and handles dismissal', (tester) async { + testWidgets('Alert region displays alert banner and handles dismissal', ( + tester, + ) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); @@ -265,14 +318,19 @@ void main() { expect(find.byIcon(Icons.close), findsOneWidget); }); - testWidgets('Displays location permission banner when permission denied', (tester) async { + testWidgets('Displays location permission banner when permission denied', ( + tester, + ) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard(hasLocationPermission: false)); await tester.pump(); - expect(find.text('Location permission needed for GPS speedometer.'), findsOneWidget); + expect( + find.text('Location permission needed for GPS speedometer.'), + findsOneWidget, + ); expect(find.text('Enable'), findsOneWidget); }); }); @@ -281,13 +339,10 @@ void main() { class _TestRideStateMachine extends StateNotifier implements RideStateMachine { _TestRideStateMachine([RideSession? initial]) - : super( - initial ?? - RideSession( - state: RideState.idle, - startTime: DateTime.now(), - ), - ); + : super( + initial ?? + RideSession(state: RideState.idle, startTime: DateTime.now()), + ); @override Future startRide() async { @@ -302,9 +357,7 @@ class _TestRideStateMachine extends StateNotifier @override Future finishRide() async { - state = state.copyWith( - state: RideState.finished, - ); + state = state.copyWith(state: RideState.finished); } @override diff --git a/test/club_models_test.dart b/test/club_models_test.dart index 978e5ff..ffe90ef 100644 --- a/test/club_models_test.dart +++ b/test/club_models_test.dart @@ -122,6 +122,7 @@ void main() { final fsMap = club.toFirestore(); expect(fsMap['createdAt'], isA()); expect(fsMap['updatedAt'], isA()); + expect(fsMap['archivedAt'], isNull); // Deserializing from map with Timestamps final fromFs = Club.fromJson(fsMap); diff --git a/test/maintenance_v102_test.dart b/test/maintenance_v102_test.dart index 72410df..4a8867a 100644 --- a/test/maintenance_v102_test.dart +++ b/test/maintenance_v102_test.dart @@ -91,7 +91,7 @@ void main() { ); expect(r6Ref.hasImage, isTrue); expect(r6Ref.isPhoto, isTrue); - expect(r6Ref.assetPath, contains('yamaha_yzf_r6_2001.png')); + expect(r6Ref.assetPath, contains('yamaha_yzf_r6_2001.jpg')); expect(r6Ref.caption, contains('2001 Yamaha YZF-R6')); // Fallback for uncataloged motorcycle diff --git a/test/motorcycle_image_selection_audit_test.dart b/test/motorcycle_image_selection_audit_test.dart index 16ece33..ea44394 100644 --- a/test/motorcycle_image_selection_audit_test.dart +++ b/test/motorcycle_image_selection_audit_test.dart @@ -18,131 +18,228 @@ void main() { }); test('1. 2001 Yamaha YZF-R6 returns verified authentic photograph', () { - final ref = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2001); + final ref = service.getReferenceImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 2001, + ); expect(ref.isPhoto, isTrue); expect(ref.hasImage, isTrue); - expect(ref.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + expect( + ref.assetPath, + equals('assets/images/motorcycles/yamaha_yzf_r6_2001.jpg'), + ); expect(ref.caption, contains('2001 Yamaha YZF-R6')); - expect(ref.attribution, equals('Factory Supersport Reference')); - expect(service.hasPhotographicAsset(make: 'Yamaha', model: 'YZF-R6', year: 2001), isTrue); + expect(ref.attribution, contains('James McCauley')); + expect(ref.attribution, contains('CC BY 2.0')); + expect( + service.hasPhotographicAsset( + make: 'Yamaha', + model: 'YZF-R6', + year: 2001, + ), + isTrue, + ); }); test('2. 1st Generation Yamaha YZF-R6 aliases resolve correctly', () { - final r6Alias = service.getReferenceImage(make: 'Yamaha', model: 'R6', year: 2001); + final r6Alias = service.getReferenceImage( + make: 'Yamaha', + model: 'R6', + year: 2001, + ); expect(r6Alias.isPhoto, isTrue); - expect(r6Alias.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + expect( + r6Alias.assetPath, + equals('assets/images/motorcycles/yamaha_yzf_r6_2001.jpg'), + ); - final spaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZF R6', year: 2001); + final spaceAlias = service.getReferenceImage( + make: 'Yamaha', + model: 'YZF R6', + year: 2001, + ); expect(spaceAlias.isPhoto, isTrue); - final noSpaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZFR6', year: 2000); + final noSpaceAlias = service.getReferenceImage( + make: 'Yamaha', + model: 'YZFR6', + year: 2000, + ); expect(noSpaceAlias.isPhoto, isTrue); }); test('3. Model-generation year matching works for 1999-2002 5EB', () { - final gen1999 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 1999); + final gen1999 = service.getReferenceImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 1999, + ); expect(gen1999.isPhoto, isTrue); - final gen2002 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2002); + final gen2002 = service.getReferenceImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 2002, + ); expect(gen2002.isPhoto, isTrue); - final gen2008 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2008); + final gen2008 = service.getReferenceImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 2008, + ); expect(gen2008.isPhoto, isFalse); expect(gen2008.assetPath, isNull); expect(gen2008.caption, equals('2008 Yamaha YZF-R6')); expect(gen2008.attribution, equals('Technical Schematic')); }); - test('4. Motorcycle with no bundled photograph returns graceful technical schematic', () { - final ref = service.getReferenceImage(make: 'Honda', model: 'CBR600RR', year: 2018); - expect(ref.isPhoto, isFalse); - expect(ref.hasImage, isFalse); - expect(ref.assetPath, isNull); - expect(ref.caption, equals('2018 Honda CBR600RR')); - expect(ref.attribution, equals('Technical Schematic')); - expect(service.hasPhotographicAsset(make: 'Honda', model: 'CBR600RR', year: 2018), isFalse); - }); + test( + '4. Motorcycle with no bundled photograph returns graceful technical schematic', + () { + final ref = service.getReferenceImage( + make: 'Honda', + model: 'CBR600RR', + year: 2018, + ); + expect(ref.isPhoto, isFalse); + expect(ref.hasImage, isFalse); + expect(ref.assetPath, isNull); + expect(ref.caption, equals('2018 Honda CBR600RR')); + expect(ref.attribution, equals('Technical Schematic')); + expect( + service.hasPhotographicAsset( + make: 'Honda', + model: 'CBR600RR', + year: 2018, + ), + isFalse, + ); + }, + ); test('5. Unknown make and model returns generic technical fallback', () { - final nullRef = service.getReferenceImage(make: null, model: null, year: null); + final nullRef = service.getReferenceImage( + make: null, + model: null, + year: null, + ); expect(nullRef.isPhoto, isFalse); expect(nullRef.assetPath, isNull); expect(nullRef.caption, equals('Motorcycle Technical Reference')); - final emptyRef = service.getReferenceImage(make: '', model: '', year: 2020); + final emptyRef = service.getReferenceImage( + make: '', + model: '', + year: 2020, + ); expect(emptyRef.isPhoto, isFalse); expect(emptyRef.assetPath, isNull); - final customRef = service.getReferenceImage(make: 'Custom', model: 'Cafe Racer', year: 1982); + final customRef = service.getReferenceImage( + make: 'Custom', + model: 'Cafe Racer', + year: 1982, + ); expect(customRef.isPhoto, isFalse); expect(customRef.assetPath, isNull); expect(customRef.caption, equals('1982 Custom Cafe Racer')); expect(customRef.attribution, equals('Technical Schematic')); }); - test('6. Yamaha R6 photograph is NEVER returned for an unrelated motorcycle', () { - final testCases = [ - {'make': 'Honda', 'model': 'CBR600RR', 'year': 2001}, - {'make': 'Honda', 'model': 'CBR1000RR-R', 'year': 2024}, - {'make': 'Suzuki', 'model': 'GSX-R600', 'year': 2001}, - {'make': 'Suzuki', 'model': 'Hayabusa GSX1300R', 'year': 2022}, - {'make': 'Kawasaki', 'model': 'Ninja ZX-6R', 'year': 2001}, - {'make': 'Kawasaki', 'model': 'Ninja H2', 'year': 2023}, - {'make': 'Ducati', 'model': 'Panigale V4 S', 'year': 2023}, - {'make': 'Ducati', 'model': 'Monster 821', 'year': 2019}, - {'make': 'BMW', 'model': 'S1000RR', 'year': 2022}, - {'make': 'BMW', 'model': 'R1250GS Adventure', 'year': 2021}, - {'make': 'KTM', 'model': '390 Duke', 'year': 2020}, - {'make': 'KTM', 'model': '1290 Super Duke R', 'year': 2022}, - {'make': 'Triumph', 'model': 'Daytona 675', 'year': 2012}, - {'make': 'Triumph', 'model': 'Street Triple 765 RS', 'year': 2023}, - {'make': 'Harley-Davidson', 'model': 'Sportster S', 'year': 2022}, - {'make': 'Aprilia', 'model': 'RSV4 1100 Factory', 'year': 2023}, - ]; + test( + '6. Yamaha R6 photograph is NEVER returned for an unrelated motorcycle', + () { + final testCases = [ + {'make': 'Honda', 'model': 'CBR600RR', 'year': 2001}, + {'make': 'Honda', 'model': 'CBR1000RR-R', 'year': 2024}, + {'make': 'Suzuki', 'model': 'GSX-R600', 'year': 2001}, + {'make': 'Suzuki', 'model': 'Hayabusa GSX1300R', 'year': 2022}, + {'make': 'Kawasaki', 'model': 'Ninja ZX-6R', 'year': 2001}, + {'make': 'Kawasaki', 'model': 'Ninja H2', 'year': 2023}, + {'make': 'Ducati', 'model': 'Panigale V4 S', 'year': 2023}, + {'make': 'Ducati', 'model': 'Monster 821', 'year': 2019}, + {'make': 'BMW', 'model': 'S1000RR', 'year': 2022}, + {'make': 'BMW', 'model': 'R1250GS Adventure', 'year': 2021}, + {'make': 'KTM', 'model': '390 Duke', 'year': 2020}, + {'make': 'KTM', 'model': '1290 Super Duke R', 'year': 2022}, + {'make': 'Triumph', 'model': 'Daytona 675', 'year': 2012}, + {'make': 'Triumph', 'model': 'Street Triple 765 RS', 'year': 2023}, + {'make': 'Harley-Davidson', 'model': 'Sportster S', 'year': 2022}, + {'make': 'Aprilia', 'model': 'RSV4 1100 Factory', 'year': 2023}, + ]; - for (final tc in testCases) { - final make = tc['make'] as String; - final model = tc['model'] as String; - final year = tc['year'] as int; - final ref = service.getReferenceImage(make: make, model: model, year: year); + for (final tc in testCases) { + final make = tc['make'] as String; + final model = tc['model'] as String; + final year = tc['year'] as int; + final ref = service.getReferenceImage( + make: make, + model: model, + year: year, + ); - expect( - ref.assetPath, - isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')), - reason: 'Failed isolation: $make $model returned R6 asset!', - ); - expect(ref.isPhoto, isFalse, reason: 'Expected schematic fallback for unbundled $make $model'); - expect(ref.caption, equals('$year $make $model')); - expect(ref.attribution, equals('Technical Schematic')); - } - }); + expect( + ref.assetPath, + isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.jpg')), + reason: 'Failed isolation: $make $model returned R6 asset!', + ); + expect( + ref.isPhoto, + isFalse, + reason: 'Expected schematic fallback for unbundled $make $model', + ); + expect(ref.caption, equals('$year $make $model')); + expect(ref.attribution, equals('Technical Schematic')); + } + }, + ); - test('7. Architecture is genuinely extensible via dynamic descriptor registration', () { - final beforeReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024); - expect(beforeReg.isPhoto, isFalse); - - MotorcycleImageService.registerDescriptor( - const MotorcycleImageDescriptor( + test( + '7. Architecture is genuinely extensible via dynamic descriptor registration', + () { + final beforeReg = service.getReferenceImage( make: 'Ducati', model: 'Panigale V4', - minYear: 2022, - maxYear: 2025, - modelAliases: ['PanigaleV4', 'Panigale V4 S'], - assetPath: 'assets/images/motorcycles/ducati_panigale_v4.png', - caption: 'Ducati Panigale V4 (Desmosedici Stradale)', - attribution: 'Factory Superbike Reference', - isPhoto: true, - ), - ); + year: 2024, + ); + expect(beforeReg.isPhoto, isFalse); - final afterReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024); - expect(afterReg.isPhoto, isTrue); - expect(afterReg.assetPath, equals('assets/images/motorcycles/ducati_panigale_v4.png')); - expect(afterReg.caption, contains('Ducati Panigale V4')); + MotorcycleImageService.registerDescriptor( + const MotorcycleImageDescriptor( + make: 'Ducati', + model: 'Panigale V4', + minYear: 2022, + maxYear: 2025, + modelAliases: ['PanigaleV4', 'Panigale V4 S'], + assetPath: 'assets/images/motorcycles/ducati_panigale_v4.png', + caption: 'Ducati Panigale V4 (Desmosedici Stradale)', + attribution: 'Factory Superbike Reference', + isPhoto: true, + ), + ); - final aliasReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4 S', year: 2023); - expect(aliasReg.isPhoto, isTrue); - }); + final afterReg = service.getReferenceImage( + make: 'Ducati', + model: 'Panigale V4', + year: 2024, + ); + expect(afterReg.isPhoto, isTrue); + expect( + afterReg.assetPath, + equals('assets/images/motorcycles/ducati_panigale_v4.png'), + ); + expect(afterReg.caption, contains('Ducati Panigale V4')); + + final aliasReg = service.getReferenceImage( + make: 'Ducati', + model: 'Panigale V4 S', + year: 2023, + ); + expect(aliasReg.isPhoto, isTrue); + }, + ); test('8. Bundled photographic assets physically exist on disk', () { final bundledAssets = MotorcycleImageService.bundledPhotographicAssets; @@ -150,23 +247,49 @@ void main() { for (final assetPath in bundledAssets) { final file = File(assetPath); - expect(file.existsSync(), isTrue, reason: 'Bundled asset $assetPath does not exist on disk!'); - expect(file.lengthSync(), greaterThan(1000), reason: 'Asset file $assetPath is empty or corrupt'); + expect( + file.existsSync(), + isTrue, + reason: 'Bundled asset $assetPath does not exist on disk!', + ); + expect( + file.lengthSync(), + greaterThan(100000), + reason: + 'Bundled photographic asset $assetPath is an undersized render or corrupt', + ); } }); test('9. getCachedOrCuratedImage synchronously provides curated photo', () { - final img = service.getCachedOrCuratedImage(make: 'Yamaha', model: 'YZF-R6', year: 2001); + final img = service.getCachedOrCuratedImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 2001, + ); expect(img, isNotNull); expect(img!.isPhoto, isTrue); - expect(img.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + expect( + img.assetPath, + equals('assets/images/motorcycles/yamaha_yzf_r6_2001.jpg'), + ); }); - test('10. resolve returns curated photo directly without network', () async { - final img = await service.resolve(make: 'Yamaha', model: 'YZF-R6', year: 2001); - expect(img, isNotNull); - expect(img!.isPhoto, isTrue); - expect(img.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); - }); + test( + '10. resolve returns curated photo directly without network', + () async { + final img = await service.resolve( + make: 'Yamaha', + model: 'YZF-R6', + year: 2001, + ); + expect(img, isNotNull); + expect(img!.isPhoto, isTrue); + expect( + img.assetPath, + equals('assets/images/motorcycles/yamaha_yzf_r6_2001.jpg'), + ); + }, + ); }); } diff --git a/test/road_alert_test.dart b/test/road_alert_test.dart index 4283e39..060120f 100644 --- a/test/road_alert_test.dart +++ b/test/road_alert_test.dart @@ -40,17 +40,17 @@ void main() { setUpAll(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(flutterTtsChannel, (call) async { - switch (call.method) { - case 'setLanguage': - case 'setSpeechRate': - case 'setVolume': - case 'speak': - case 'stop': - return 1; - default: - return null; - } - }); + switch (call.method) { + case 'setLanguage': + case 'setSpeechRate': + case 'setVolume': + case 'speak': + case 'stop': + return 1; + default: + return null; + } + }); }); tearDownAll(() { @@ -74,14 +74,29 @@ void main() { expect(provider.name, equals('TomTom Traffic')); // Verify TomTom explicitly advertises its supported categories (No police/speed-traps) - expect(provider.supportedCategories, contains(RoadAlertCategory.accident)); + expect( + provider.supportedCategories, + contains(RoadAlertCategory.accident), + ); expect(provider.supportedCategories, contains(RoadAlertCategory.hazard)); - expect(provider.supportedCategories, contains(RoadAlertCategory.construction)); - expect(provider.supportedCategories, contains(RoadAlertCategory.roadClosure)); + expect( + provider.supportedCategories, + contains(RoadAlertCategory.construction), + ); + expect( + provider.supportedCategories, + contains(RoadAlertCategory.roadClosure), + ); expect(provider.supportedCategories, contains(RoadAlertCategory.traffic)); - expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.police))); - expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.speedCamera))); + expect( + provider.supportedCategories, + isNot(contains(RoadAlertCategory.police)), + ); + expect( + provider.supportedCategories, + isNot(contains(RoadAlertCategory.speedCamera)), + ); }); test('LocalRoadAlertProvider returns demo alerts', () async { @@ -94,7 +109,10 @@ void main() { expect(alerts.isNotEmpty, isTrue); expect(alerts.any((a) => a.category == RoadAlertCategory.hazard), isTrue); - expect(alerts.any((a) => a.category == RoadAlertCategory.construction), isTrue); + expect( + alerts.any((a) => a.category == RoadAlertCategory.construction), + isTrue, + ); }); test('Road alert providers & setting defaults', () { @@ -106,11 +124,50 @@ void main() { expect(container.read(audioAlertsEnabledProvider), isTrue); expect(container.read(alertRadiusMetersProvider), equals(804.7)); expect(container.read(tomtomApiKeyProvider), isEmpty); - expect(container.read(tomtomStatusProvider), equals(RoadAlertProviderStatus.notConfigured)); + expect( + container.read(tomtomStatusProvider), + equals(RoadAlertProviderStatus.notConfigured), + ); }); - test('RoadAlertService dismissAlert and clearAllAlerts function correctly', () async { - SharedPreferences.setMockInitialValues({}); + test( + 'RoadAlertService dismissAlert and clearAllAlerts function correctly', + () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer( + overrides: [ + fieldTestLoggerProvider.overrideWithValue(_NoOpFieldTestLogger()), + ], + ); + addTearDown(container.dispose); + + final service = container.read(roadAlertServiceProvider); + + final future1 = service.alertsStream.skip(1).first; + service.triggerTestAlert(category: RoadAlertCategory.police); + final alerts1 = await future1; + expect(alerts1.isNotEmpty, isTrue); + final alertId = alerts1.first.id; + + final future2 = service.alertsStream.skip(1).first; + service.dismissAlert(alertId); + final alerts2 = await future2; + expect(alerts2.any((a) => a.id == alertId), isFalse); + + final future3 = service.alertsStream.skip(1).first; + service.triggerTestAlert(category: RoadAlertCategory.hazard); + final alerts3 = await future3; + expect(alerts3.isNotEmpty, isTrue); + + final future4 = service.alertsStream.skip(1).first; + service.clearAllAlerts(); + final alerts4 = await future4; + expect(alerts4.isEmpty, isTrue); + }, + ); + + test('diagnostic alert bypasses disabled production alerts', () async { + SharedPreferences.setMockInitialValues({'road_alerts_enabled': false}); final container = ProviderContainer( overrides: [ fieldTestLoggerProvider.overrideWithValue(_NoOpFieldTestLogger()), @@ -118,28 +175,29 @@ void main() { ); addTearDown(container.dispose); + await container + .read(roadAlertsEnabledProvider.notifier) + .setEnabled(false); + expect(container.read(roadAlertsEnabledProvider), isFalse); + final service = container.read(roadAlertServiceProvider); - - final future1 = service.alertsStream.skip(1).first; + service.simulateAlert( + RoadAlert( + id: 'production_simulation', + providerId: 'mock', + category: RoadAlertCategory.police, + latitude: 0, + longitude: 0, + description: 'Production simulation', + reportedAt: DateTime.now(), + lastUpdated: DateTime.now(), + source: 'test', + ), + ); + expect(await service.alertsStream.first, isEmpty); + service.triggerTestAlert(category: RoadAlertCategory.police); - final alerts1 = await future1; - expect(alerts1.isNotEmpty, isTrue); - final alertId = alerts1.first.id; - - final future2 = service.alertsStream.skip(1).first; - service.dismissAlert(alertId); - final alerts2 = await future2; - expect(alerts2.any((a) => a.id == alertId), isFalse); - - final future3 = service.alertsStream.skip(1).first; - service.triggerTestAlert(category: RoadAlertCategory.hazard); - final alerts3 = await future3; - expect(alerts3.isNotEmpty, isTrue); - - final future4 = service.alertsStream.skip(1).first; - service.clearAllAlerts(); - final alerts4 = await future4; - expect(alerts4.isEmpty, isTrue); + expect(await service.alertsStream.first, isNotEmpty); }); }); @@ -201,7 +259,10 @@ void main() { test('expired alert does not reappear', () async { final service = createContainer().read(roadAlertServiceProvider); service.simulateAlert( - alert('expired', expiresAt: DateTime.now().subtract(const Duration(seconds: 1))), + alert( + 'expired', + expiresAt: DateTime.now().subtract(const Duration(seconds: 1)), + ), ); expect(await service.alertsStream.first, isEmpty); @@ -218,5 +279,4 @@ void main() { expect(secondMount.map((item) => item.id), ['stable']); }); }); - }