fix: close mobile regression findings

This commit is contained in:
Developer 2026-09-07 18:26:31 -04:00
parent 2676c90e01
commit 9574b1b4dc
12 changed files with 675 additions and 370 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -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,

View File

@ -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,

View File

@ -62,12 +62,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
),
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
'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<SettingsScreen> {
DropdownButton<double>(
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
'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<SettingsScreen> {
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<SettingsScreen> {
'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<SettingsScreen> {
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<SettingsScreen> {
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,
),
),

View File

@ -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,

View File

@ -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<String> bundledPhotographicAssets = [
'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg',
];
static const List<MotorcycleImageDescriptor> _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<MotorcycleImageDescriptor> 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();
}
}

View File

@ -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<RideSession>
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<void> startRide() async {
@ -302,9 +357,7 @@ class _TestRideStateMachine extends StateNotifier<RideSession>
@override
Future<void> finishRide() async {
state = state.copyWith(
state: RideState.finished,
);
state = state.copyWith(state: RideState.finished);
}
@override

View File

@ -122,6 +122,7 @@ void main() {
final fsMap = club.toFirestore();
expect(fsMap['createdAt'], isA<Timestamp>());
expect(fsMap['updatedAt'], isA<Timestamp>());
expect(fsMap['archivedAt'], isNull);
// Deserializing from map with Timestamps
final fromFs = Club.fromJson(fsMap);

View File

@ -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

View File

@ -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'),
);
},
);
});
}

View File

@ -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']);
});
});
}