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