fix: close mobile regression findings
This commit is contained in:
parent
2676c90e01
commit
9574b1b4dc
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.8 KiB |
|
|
@ -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,11 +25,8 @@ 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;
|
||||
|
|
@ -45,8 +38,14 @@ class GForceIndicator extends ConsumerWidget {
|
|||
? (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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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,
|
||||
),
|
||||
];
|
||||
|
|
@ -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 {
|
||||
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));
|
||||
},
|
||||
)
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +172,9 @@ void main() {
|
|||
expect(find.byType(RideActionButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Touring Mode is mode-driven: displays map in both portrait and landscape', (tester) async {
|
||||
testWidgets(
|
||||
'Touring Mode is mode-driven: displays map in both portrait and landscape',
|
||||
(tester) async {
|
||||
await prefs.setString('dashboardLayout', 'touring');
|
||||
|
||||
// Portrait
|
||||
|
|
@ -169,7 +183,11 @@ void main() {
|
|||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in portrait Touring');
|
||||
expect(
|
||||
find.byType(DashboardMapPanel),
|
||||
findsOneWidget,
|
||||
reason: 'Map present in portrait Touring',
|
||||
);
|
||||
expect(find.text('MOVING'), findsOneWidget);
|
||||
|
||||
// Landscape
|
||||
|
|
@ -177,12 +195,19 @@ void main() {
|
|||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in landscape Touring');
|
||||
});
|
||||
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 {
|
||||
testWidgets(
|
||||
'Telemetry updates speed readout without destroying ride state',
|
||||
(tester) async {
|
||||
configureViewport(tester, 400, 800);
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
|
|
@ -210,9 +235,35 @@ void main() {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -283,10 +341,7 @@ class _TestRideStateMachine extends StateNotifier<RideSession>
|
|||
_TestRideStateMachine([RideSession? initial])
|
||||
: super(
|
||||
initial ??
|
||||
RideSession(
|
||||
state: RideState.idle,
|
||||
startTime: DateTime.now(),
|
||||
),
|
||||
RideSession(state: RideState.idle, startTime: DateTime.now()),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,69 +18,139 @@ 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);
|
||||
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);
|
||||
});
|
||||
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', () {
|
||||
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},
|
||||
|
|
@ -104,21 +174,36 @@ void main() {
|
|||
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);
|
||||
final ref = service.getReferenceImage(
|
||||
make: make,
|
||||
model: model,
|
||||
year: year,
|
||||
);
|
||||
|
||||
expect(
|
||||
ref.assetPath,
|
||||
isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')),
|
||||
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.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);
|
||||
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(
|
||||
|
|
@ -135,14 +220,26 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
final afterReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024);
|
||||
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.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);
|
||||
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);
|
||||
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'));
|
||||
});
|
||||
expect(
|
||||
img.assetPath,
|
||||
equals('assets/images/motorcycles/yamaha_yzf_r6_2001.jpg'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,10 +124,15 @@ 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 {
|
||||
test(
|
||||
'RoadAlertService dismissAlert and clearAllAlerts function correctly',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
|
|
@ -140,6 +163,41 @@ void main() {
|
|||
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()),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(roadAlertsEnabledProvider.notifier)
|
||||
.setEnabled(false);
|
||||
expect(container.read(roadAlertsEnabledProvider), isFalse);
|
||||
|
||||
final service = container.read(roadAlertServiceProvider);
|
||||
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);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue