feat(navigation): implement shared turn-by-turn navigation, route preferences, and garage/telemetry enhancements

This commit is contained in:
Developer 2026-10-09 21:03:22 -04:00
parent 28dc54a0c5
commit 618ef8798b
24 changed files with 3013 additions and 134 deletions

View File

@ -9,6 +9,7 @@ import '../../../services/alerts/road_alert_service.dart';
import '../../../services/group_rides/group_ride_models.dart';
import '../../../services/group_rides/group_ride_providers.dart';
import '../../../services/location_service.dart';
import 'navigation_maneuver_card.dart';
/// Top alert and notification banner region for the dashboard.
///
@ -32,6 +33,7 @@ class DashboardAlertRegion extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (!hasLocPermission) _buildPermissionBanner(context, ref),
const NavigationManeuverCard(),
if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref),
if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide),
],

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../rides/ride_state_machine.dart';
import '../../../services/telemetry_service.dart';
import '../../../services/sensor_fusion_service.dart';
/// Leaf visualizer for motorcycle lean angle with authentic motorcycle gauge.
///
@ -51,13 +52,71 @@ class LeanAngleIndicator extends ConsumerWidget {
final String angleText = isAvailable ? '${displayLean.abs().toStringAsFixed(0)}°' : '--°';
void handleTapToCalibrate() {
final currentSpeed = sample?.speed ?? 0.0;
final rideState = ref.read(rideStateMachineProvider).state;
final isMoving = currentSpeed > 3.0 || rideState == RideState.riding;
if (isMoving) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
key: ValueKey('lean_calibrate_moving_warning'),
content: Text('Cannot calibrate while moving (> 3.0 MPH). Come to a complete stop upright.'),
backgroundColor: Color(0xFFEF4444),
),
);
return;
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
key: const ValueKey('lean_calibrate_dialog'),
title: const Text('Calibrate Lean Angle (Upright)'),
content: const Text(
'Ensure your motorcycle is completely upright and centered (off kickstand or on level ground).\n\nThis will calibrate 0° upright roll.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
key: const ValueKey('confirm_lean_calibrate_button'),
style: FilledButton.styleFrom(backgroundColor: colors.brandCyan),
onPressed: () async {
Navigator.pop(ctx);
await ref.read(sensorFusionServiceProvider).calibrateUpright();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
key: ValueKey('lean_calibrate_success_snackbar'),
content: Text('Lean angle calibrated upright (0°).'),
backgroundColor: Color(0xFF10B981),
),
);
}
},
child: const Text('Calibrate Upright', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)),
),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0);
final double gaugeHeight = compact ? 52.0 : 68.0;
return Semantics(
return Tooltip(
message: 'Tap to Calibrate Upright (Stationary)',
child: InkWell(
key: const ValueKey('lean_angle_indicator_tap'),
borderRadius: BorderRadius.circular(12),
onTap: handleTapToCalibrate,
child: Semantics(
label: isAvailable
? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
: 'Lean angle: uncalibrated. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
@ -177,7 +236,9 @@ class LeanAngleIndicator extends ConsumerWidget {
),
],
),
);
),
),
);
},
);
}

View File

@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/navigation/navigation_session_service.dart';
/// High-contrast, glanceable turn-by-turn navigation HUD card mounted on the Dashboard.
///
/// Disappears when navigation is inactive. When active, displays high-visibility
/// maneuver direction, next road, countdown distance in feet/miles, ETA, and
/// quick mute/end controls.
class NavigationManeuverCard extends ConsumerWidget {
const NavigationManeuverCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final navState = ref.watch(navigationSessionProvider);
if (!navState.isNavigating || navState.currentManeuver == null) {
return const SizedBox.shrink();
}
final currentManeuver = navState.currentManeuver!;
final nextManeuver = navState.nextManeuver;
final colors = context.motorColors;
final theme = Theme.of(context);
return Semantics(
label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.',
child: Card(
key: const ValueKey('navigation_maneuver_card'),
margin: const EdgeInsets.only(bottom: 12),
elevation: 6,
color: const Color(0xFF0F172A), // Dark tactical navy/slate for extreme contrast
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: 0.6),
width: 1.5,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
// Tap navigates to live map
context.go('/map');
},
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top Row: Big Turn Icon, Distance, Street & Voice/Exit controls
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// High-contrast Turn Direction Icon Badge
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.cyberCyan,
width: 1.8,
),
),
alignment: Alignment.center,
child: Icon(
currentManeuver.type.icon,
size: 32,
color: colors.cyberCyan,
),
),
const SizedBox(width: 14),
// Maneuver Distance and Street Name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
navState.formattedDistanceToNext,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 22,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: -0.5,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
currentManeuver.type.directionShort,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
letterSpacing: 0.8,
),
),
),
],
),
const SizedBox(height: 2),
Text(
currentManeuver.streetName ?? currentManeuver.instruction,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
),
// Quick Audio Mute & End Navigation Buttons
IconButton(
key: const ValueKey('nav_toggle_tts_button'),
icon: Icon(
navState.ttsEnabled ? Icons.volume_up : Icons.volume_off,
color: navState.ttsEnabled ? colors.cyberCyan : Colors.white54,
size: 22,
),
tooltip: navState.ttsEnabled ? 'Mute Voice Guidance' : 'Unmute Voice Guidance',
onPressed: () {
ref.read(navigationSessionProvider.notifier).toggleTts();
},
),
IconButton(
key: const ValueKey('nav_stop_button'),
icon: const Icon(Icons.close, color: Colors.white70, size: 22),
tooltip: 'Exit Navigation',
onPressed: () {
ref.read(navigationSessionProvider.notifier).stopNavigation();
},
),
],
),
const SizedBox(height: 10),
Divider(
height: 1,
color: colors.outlineVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.spaceBetween,
children: [
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, size: 14, color: colors.cyberCyan),
const SizedBox(width: 4),
Text(
'${navState.formattedDurationRemaining} (${navState.formattedEta})',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.route, size: 14, color: Colors.white70),
const SizedBox(width: 4),
Text(
navState.formattedRemainingDistance,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
),
],
),
],
),
if (nextManeuver != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Then',
style: TextStyle(
fontSize: 10.5,
color: Colors.white54,
fontFamily: 'Inter',
),
),
const SizedBox(width: 4),
Icon(
nextManeuver.type.icon,
size: 14,
color: colors.cyberCyan,
),
],
),
],
),
],
),
),
),
),
);
}
}

View File

@ -205,6 +205,44 @@ class GarageController {
));
}
Future<void> updateServiceRecord(int motorcycleId, ServiceRecord updatedRecord) async {
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
if (bike == null) return;
final currentData = MaintenanceData.fromSpecificationJson(bike.specificationJson);
final updatedRecords = currentData.serviceRecords.map((r) => r.id == updatedRecord.id ? updatedRecord : r).toList();
final updatedJson = MaintenanceData.updateSpecJsonWithMaintenance(
originalSpecJson: bike.specificationJson,
serviceRecords: updatedRecords,
);
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId)))
.write(GarageMotorcyclesCompanion(
specificationJson: Value(updatedJson),
lastUpdated: Value(DateTime.now()),
));
}
Future<void> updateCustomInterval(int motorcycleId, String key, int intervalMiles) async {
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
if (bike == null) return;
final currentData = MaintenanceData.fromSpecificationJson(bike.specificationJson);
final updatedIntervals = Map<String, int>.from(currentData.customIntervals)..[key] = intervalMiles;
final updatedJson = MaintenanceData.updateSpecJsonWithMaintenance(
originalSpecJson: bike.specificationJson,
customIntervals: updatedIntervals,
);
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId)))
.write(GarageMotorcyclesCompanion(
specificationJson: Value(updatedJson),
lastUpdated: Value(DateTime.now()),
));
}
Future<void> updateBaseOdometer(int motorcycleId, double baseOdometer) async {
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
if (bike == null) return;

View File

@ -11,6 +11,7 @@ import 'widgets/motorcycle_photo.dart';
import '../../services/entitlements/entitlement_providers.dart';
import '../../services/entitlements/feature_access_service.dart';
import '../../services/sync/sync_providers.dart';
import '../../services/auth/auth_providers.dart';
import '../entitlements/widgets/pro_lock_badge.dart';
class GarageScreen extends ConsumerWidget {
@ -20,6 +21,8 @@ class GarageScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final colors = context.motorColors;
final isSyncing = ref.watch(isSyncProcessingProvider);
final pendingCount = ref.watch(pendingSyncCountProvider);
return Scaffold(
appBar: AppBar(
@ -28,16 +31,72 @@ class GarageScreen extends ConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
IconButton(
tooltip: 'Sync Garage with Cloud',
icon: const Icon(Icons.cloud_sync_outlined),
onPressed: () async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Syncing garage with cloud...')),
);
await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
},
),
if (isSyncing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
key: ValueKey('garage_sync_spinner'),
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF00E5FF)),
),
),
)
else
IconButton(
key: const ValueKey('garage_sync_button'),
tooltip: pendingCount > 0
? 'Sync Garage ($pendingCount pending)'
: 'Sync Garage with Cloud',
icon: Badge(
isLabelVisible: pendingCount > 0,
label: Text('$pendingCount'),
child: const Icon(Icons.cloud_sync_outlined),
),
onPressed: () async {
final auth = ref.read(authNotifierProvider);
if (!auth.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
key: ValueKey('garage_sync_auth_required_snackbar'),
content: Text('Sign in to sync your garage with MotorCloud.'),
),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
key: ValueKey('garage_sync_started_snackbar'),
content: Text('Syncing garage and telemetry with cloud...'),
),
);
try {
await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
await ref.read(syncManagerProvider).processQueue();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
key: ValueKey('garage_sync_success_snackbar'),
content: Text('Garage and rides synced successfully.'),
backgroundColor: Color(0xFF10B981),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
key: const ValueKey('garage_sync_error_snackbar'),
content: Text('Sync failed: $e. Changes are queued offline.'),
backgroundColor: const Color(0xFFEF4444),
),
);
}
}
},
),
],
),
body: bikesAsync.when(

View File

@ -789,26 +789,55 @@ class MotorcycleDetailScreen extends ConsumerWidget {
const SizedBox(height: 16),
// Countdown Pills Grid
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final itemWidth = width >= 480
? (width - 20) / 3
: (width >= 280 ? (width - 10) / 2 : width);
return Wrap(
spacing: 10,
runSpacing: 10,
children: pills
.map(
(pill) => SizedBox(
width: itemWidth,
child: _buildMaintenancePill(context, pill, colors, theme),
if (pills.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 18, color: colors.cyberCyan),
const SizedBox(width: 10),
Expanded(
child: Text(
'Set your odometer or log your first service above to activate maintenance countdowns.',
style: TextStyle(
fontSize: 11.5,
fontFamily: 'Inter',
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
)
.toList(),
);
},
),
),
),
],
),
)
else
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final itemWidth = width >= 480
? (width - 20) / 3
: (width >= 280 ? (width - 10) / 2 : width);
return Wrap(
spacing: 10,
runSpacing: 10,
children: pills
.map(
(pill) => SizedBox(
width: itemWidth,
child: _buildMaintenancePill(context, ref, bike, pill, currentOdometer, colors, theme),
),
)
.toList(),
);
},
),
const SizedBox(height: 20),
Divider(color: colors.outlineVariant.withValues(alpha: 0.4), height: 1),
@ -959,6 +988,14 @@ class MotorcycleDetailScreen extends ConsumerWidget {
],
),
),
IconButton(
icon: Icon(Icons.edit_outlined, size: 16, color: colors.cyberCyan),
tooltip: 'Edit Service Record',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _showEditServiceRecordDialog(context, ref, bike, record, colors, theme),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(Icons.delete_outline, size: 16, color: colors.outlineVariant),
tooltip: 'Delete Service Record',
@ -979,70 +1016,113 @@ class MotorcycleDetailScreen extends ConsumerWidget {
Widget _buildMaintenancePill(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MaintenanceItemStatus pill,
double currentOdometer,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final badgeColor = pill.getBadgeColor(context);
final isUrgent = pill.urgency == MaintenanceUrgency.overdue || pill.urgency == MaintenanceUrgency.dueSoon;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
width: isUrgent ? 1.5 : 1.0,
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _showLogServiceForPill(context, ref, bike, pill, currentOdometer, colors, theme),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
width: isUrgent ? 1.5 : 1.0,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(pill.icon, size: 15, color: badgeColor),
const SizedBox(width: 5),
Expanded(
child: Text(
pill.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(pill.icon, size: 15, color: badgeColor),
const SizedBox(width: 5),
Expanded(
child: Text(
pill.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
),
),
),
Icon(Icons.add_task, size: 13, color: badgeColor.withValues(alpha: 0.7)),
],
),
const SizedBox(height: 6),
Text(
pill.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
fontFamily: 'JetBrains Mono',
color: badgeColor,
),
],
),
const SizedBox(height: 6),
Text(
pill.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
fontFamily: 'JetBrains Mono',
color: badgeColor,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
minHeight: 3,
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
minHeight: 3,
),
),
),
],
],
),
),
);
}
void _showLogServiceForPill(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MaintenanceItemStatus pill,
double currentOdometer,
MotorSignalThemeColors colors,
ThemeData theme,
) {
String serviceType = 'General Maintenance';
switch (pill.key) {
case 'oil':
serviceType = 'Engine Oil & Filter';
break;
case 'chain':
serviceType = 'Chain Clean & Lube';
break;
case 'tire':
serviceType = 'Tire Replacement (Rear)';
break;
case 'brake':
serviceType = 'Brake Fluid Flush';
break;
}
_showLogServiceDialog(
context,
ref,
bike,
currentOdometer,
colors,
theme,
initialServiceType: serviceType,
);
}
void _showSetOdometerDialog(
BuildContext context,
WidgetRef ref,
@ -1097,8 +1177,9 @@ class MotorcycleDetailScreen extends ConsumerWidget {
GarageMotorcycle bike,
double currentOdometer,
MotorSignalThemeColors colors,
ThemeData theme,
) {
ThemeData theme, {
String? initialServiceType,
}) {
final serviceTypes = [
'Engine Oil & Filter',
'Chain Clean & Lube',
@ -1113,7 +1194,9 @@ class MotorcycleDetailScreen extends ConsumerWidget {
'General Maintenance',
];
String selectedType = serviceTypes[0];
String selectedType = (initialServiceType != null && serviceTypes.contains(initialServiceType))
? initialServiceType
: serviceTypes[0];
final odoController = TextEditingController(text: currentOdometer.round().toString());
final notesController = TextEditingController();
final costController = TextEditingController();
@ -1213,6 +1296,130 @@ class MotorcycleDetailScreen extends ConsumerWidget {
);
}
void _showEditServiceRecordDialog(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
ServiceRecord record,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final serviceTypes = [
'Engine Oil & Filter',
'Chain Clean & Lube',
'Tire Replacement (Rear)',
'Tire Replacement (Front)',
'Tire Replacement (Set)',
'Brake Fluid Flush',
'Brake Pads Replacement',
'Air Filter Replacement',
'Spark Plugs',
'Valve Clearance Check',
'General Maintenance',
];
String selectedType = serviceTypes.contains(record.serviceType)
? record.serviceType
: serviceTypes.last;
final odoController = TextEditingController(text: record.odometer.round().toString());
final notesController = TextEditingController(text: record.notes ?? '');
final costController = TextEditingController(text: record.cost != null ? record.cost!.toStringAsFixed(2) : '');
DateTime serviceDate = record.date;
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Edit Service Record'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Service Type'),
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (val) {
if (val != null) setState(() => selectedType = val);
},
),
const SizedBox(height: 12),
TextField(
controller: odoController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Odometer at Service',
suffixText: 'mi',
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Service Date', style: TextStyle(fontSize: 12)),
subtitle: Text(DateFormat('MMMM d, yyyy').format(serviceDate)),
trailing: const Icon(Icons.calendar_today, size: 18),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: serviceDate,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked != null) {
setState(() => serviceDate = picked);
}
},
),
const SizedBox(height: 8),
TextField(
controller: costController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Cost (Optional)',
prefixText: '\$ ',
),
),
const SizedBox(height: 12),
TextField(
controller: notesController,
decoration: const InputDecoration(
labelText: 'Notes (e.g. Brand, weight, shop)',
hintText: 'e.g. Motul 300V 10W-40, OEM filter',
),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
final odo = double.tryParse(odoController.text.trim()) ?? record.odometer;
final cost = double.tryParse(costController.text.trim());
final updated = ServiceRecord(
id: record.id,
date: serviceDate,
odometer: odo,
serviceType: selectedType,
notes: notesController.text.trim().isNotEmpty ? notesController.text.trim() : null,
cost: cost,
);
ref.read(garageControllerProvider).updateServiceRecord(bike.id, updated);
Navigator.pop(context);
},
child: const Text('Save Changes'),
),
],
);
},
);
},
);
}
void _confirmDeleteServiceRecord(
BuildContext context,
WidgetRef ref,

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
@ -14,6 +15,12 @@ import '../../services/alerts/road_alert_models.dart';
import '../../services/alerts/road_alert_service.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/map/map_layer_settings.dart';
import '../../services/routes/route_models.dart';
import '../../services/routes/route_provider.dart';
import '../../services/routes/route_providers.dart';
import '../../services/routes/navigation_handoff.dart';
import '../../services/navigation/navigation_session_service.dart';
import '../dashboard/widgets/navigation_maneuver_card.dart';
import 'widgets/tactical_layers_bottom_sheet.dart';
import '../replay/replay_screen.dart';
@ -43,6 +50,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
bool _isNorthUp = true;
String _destinationQuery = '';
late final TextEditingController _searchController;
Timer? _searchDebounce;
bool _isSearching = false;
List<PlaceSearchResult> _searchResults = [];
ResolvedPlace? _selectedDestination;
RouteCalculationResult? _previewRouteResult;
bool _isCalculatingRoute = false;
RoutingOptions _routingOptions = const RoutingOptions();
static const double _hardAccelThresholdG = 0.35;
static const double _hardBrakingThresholdG = -0.35;
@ -64,10 +78,139 @@ class _MapScreenState extends ConsumerState<MapScreen> {
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
void _onSearchQueryChanged(String query, AppLatLng currentPos) {
setState(() => _destinationQuery = query);
_searchDebounce?.cancel();
final clean = query.trim();
if (clean.length < 2) {
setState(() {
_searchResults = [];
_isSearching = false;
});
return;
}
setState(() => _isSearching = true);
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
try {
final results = await ref
.read(activeRouteCalculationProvider)
.searchPlaces(clean, nearLocation: currentPos);
if (mounted && _destinationQuery.trim() == clean) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (_) {
if (mounted) setState(() => _isSearching = false);
}
});
}
Future<void> _onSelectPlace(
PlaceSearchResult place,
AppLatLng currentPos,
) async {
_searchDebounce?.cancel();
setState(() {
_searchResults = [];
_searchController.text = place.primaryText;
_destinationQuery = place.primaryText;
});
try {
final resolved = place.hasCoordinates
? place.toResolvedPlace()!
: await ref
.read(activeRouteCalculationProvider)
.resolvePlace(place.providerId);
if (!mounted) return;
setState(() => _selectedDestination = resolved);
await _calculateRouteToDestination(resolved, currentPos);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to resolve place: $e')),
);
}
}
}
Future<void> _calculateRouteToDestination(
ResolvedPlace dest,
AppLatLng currentPos,
) async {
setState(() => _isCalculatingRoute = true);
try {
final origin = RouteStop(
id: 'origin',
order: 0,
displayName: 'Current Location',
latitude: currentPos.latitude,
longitude: currentPos.longitude,
);
final destinationStop = RouteStop(
id: 'dest',
order: 1,
displayName: dest.displayName,
latitude: dest.latitude,
longitude: dest.longitude,
);
final result = await ref
.read(activeRouteCalculationProvider)
.calculateRoute([origin, destinationStop], options: _routingOptions);
if (mounted) {
setState(() {
_previewRouteResult = result;
_isCalculatingRoute = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isCalculatingRoute = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route calculation error: $e')),
);
}
}
}
void _clearDestination() {
_searchDebounce?.cancel();
_searchController.clear();
setState(() {
_destinationQuery = '';
_searchResults = [];
_selectedDestination = null;
_previewRouteResult = null;
});
}
void _startTurnByTurnNavigation() {
if (_previewRouteResult == null || _selectedDestination == null) return;
ref.read(navigationSessionProvider.notifier).startNavigation(
calculationResult: _previewRouteResult!,
destination: _selectedDestination,
options: _routingOptions,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigation started to ${_selectedDestination!.displayName}'),
backgroundColor: const Color(0xFF00E5FF),
),
);
}
Future<void> _initLiveLocation() async {
final locationService = ref.read(locationServiceProvider);
await locationService.requestPermissions();
@ -743,6 +886,50 @@ class _MapScreenState extends ConsumerState<MapScreen> {
);
}
final navSession = ref.watch(navigationSessionProvider);
if (_selectedDestination != null) {
mapMarkers.add(
AppMapMarker(
id: 'destination_marker',
position: _selectedDestination!.latLng,
label: _selectedDestination!.displayName,
iconWidget: Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: Color(0xFFFF0055),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: const Icon(Icons.flag, color: Colors.white, size: 20),
),
),
);
}
final livePolylines = <AppPolyline>[];
if (_previewRouteResult != null &&
_previewRouteResult!.geometry.isNotEmpty) {
livePolylines.add(
AppPolyline(
id: 'route_preview_polyline',
points: _previewRouteResult!.geometry,
color: colors.cyberCyan,
width: 5.5,
),
);
} else if (navSession.isNavigating &&
navSession.calculationResult?.geometry.isNotEmpty == true) {
livePolylines.add(
AppPolyline(
id: 'nav_active_polyline',
points: navSession.calculationResult!.geometry,
color: colors.cyberCyan,
width: 5.5,
),
);
}
return Scaffold(
extendBodyBehindAppBar: isShortLandscape,
appBar: AppBar(
@ -761,17 +948,40 @@ class _MapScreenState extends ConsumerState<MapScreen> {
center: currentPos,
zoom: _currentZoom,
markers: mapMarkers,
polylines: livePolylines,
),
),
// Top Floating Search Card (portrait and large screens)
if (!isShortLandscape)
// Active Navigation Maneuver HUD or Top Search & Preview Cards
if (navSession.isNavigating)
const Positioned(
top: 10,
left: 14,
right: 14,
child: NavigationManeuverCard(),
)
else if (!isShortLandscape) ...[
Positioned(
top: 10,
left: 14,
right: 14,
child: _buildTopSearchCard(colors),
child: _buildTopSearchCard(colors, currentPos),
),
if (_searchResults.isNotEmpty)
Positioned(
top: 106,
left: 14,
right: 14,
child: _buildSearchResultsOverlay(colors, currentPos),
),
if (_previewRouteResult != null)
Positioned(
bottom: 24,
left: 14,
right: 14,
child: _buildRoutePreviewCard(colors, currentPos),
),
],
// Offline Cached Only Warning Pill
if (layerSettings.offlineOnly)
@ -868,24 +1078,25 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
// Bottom-left Quick Swarm Report Button
Positioned(
bottom: 24,
left: 20,
child: FloatingActionButton.extended(
key: const ValueKey('quick_swarm_report_button'),
backgroundColor: const Color(0xFFFF9100),
foregroundColor: Colors.black,
icon: const Icon(Icons.campaign, size: 22),
label: const Text(
'REPORT',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
if (_previewRouteResult == null && !navSession.isNavigating)
Positioned(
bottom: 24,
left: 20,
child: FloatingActionButton.extended(
key: const ValueKey('quick_swarm_report_button'),
backgroundColor: const Color(0xFFFF9100),
foregroundColor: Colors.black,
icon: const Icon(Icons.campaign, size: 22),
label: const Text(
'REPORT',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
),
),
onPressed: () => QuickSwarmReportModal.show(context),
),
onPressed: () => QuickSwarmReportModal.show(context),
),
),
],
),
);
@ -1009,7 +1220,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
);
Widget _buildTopSearchCard(MotorSignalThemeColors colors) {
Widget _buildTopSearchCard(MotorSignalThemeColors colors, AppLatLng currentPos) {
final theme = Theme.of(context);
return Card(
elevation: 6,
@ -1030,7 +1241,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
children: [
Row(
children: [
Icon(Icons.search, size: 20, color: colors.cyberCyan),
if (_isSearching || _isCalculatingRoute)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(Icons.search, size: 20, color: colors.cyberCyan),
const SizedBox(width: 8),
Expanded(
child: TextField(
@ -1051,18 +1269,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
),
onChanged: (val) {
setState(() => _destinationQuery = val);
},
onChanged: (val) => _onSearchQueryChanged(val, currentPos),
onSubmitted: (val) => _onSearchQueryChanged(val, currentPos),
),
),
if (_destinationQuery.isNotEmpty)
GestureDetector(
key: const ValueKey('clear_search_button'),
onTap: () {
_searchController.clear();
setState(() => _destinationQuery = '');
},
onTap: _clearDestination,
child: Icon(
Icons.close,
size: 18,
@ -1080,24 +1294,28 @@ class _MapScreenState extends ConsumerState<MapScreen> {
label: 'Gas & Fuel',
icon: Icons.local_gas_station,
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Twisty Passes',
icon: Icons.alt_route,
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Scenic Rest',
icon: Icons.photo_camera,
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Repair/Moto',
icon: Icons.build,
colors: colors,
currentPos: currentPos,
),
],
),
@ -1112,21 +1330,19 @@ class _MapScreenState extends ConsumerState<MapScreen> {
required String label,
required IconData icon,
required MotorSignalThemeColors colors,
required AppLatLng currentPos,
}) {
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
return InkWell(
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
borderRadius: BorderRadius.circular(20),
onTap: () {
setState(() {
if (isSelected) {
_destinationQuery = '';
_searchController.clear();
} else {
_destinationQuery = label;
_searchController.text = label;
}
});
if (isSelected) {
_clearDestination();
} else {
_searchController.text = label;
_onSearchQueryChanged(label, currentPos);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
@ -1165,6 +1381,301 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
);
}
Widget _buildSearchResultsOverlay(
MotorSignalThemeColors colors,
AppLatLng currentPos,
) {
return Card(
key: const ValueKey('search_results_overlay'),
elevation: 8,
shadowColor: Colors.black87,
color: colors.surfaceContainer.withValues(alpha: 0.98),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: 0.4),
width: 1.0,
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: _searchResults.length,
separatorBuilder: (_, _) => Divider(
height: 1,
color: colors.outlineVariant.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final item = _searchResults[index];
return ListTile(
dense: true,
leading: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
item.hasCoordinates ? Icons.pin_drop : Icons.place_outlined,
size: 18,
color: colors.cyberCyan,
),
),
title: Text(
item.primaryText,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
subtitle: item.secondaryText.isNotEmpty
? Text(
item.secondaryText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
color: Colors.white70,
),
)
: null,
onTap: () => _onSelectPlace(item, currentPos),
);
},
),
),
);
}
Widget _buildRoutePreviewCard(
MotorSignalThemeColors colors,
AppLatLng currentPos,
) {
final preview = _previewRouteResult!;
final dest = _selectedDestination;
return Card(
key: const ValueKey('route_preview_card'),
elevation: 8,
color: colors.surfaceContainer.withValues(alpha: 0.95),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: 0.6),
width: 1.2,
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dest?.displayName ?? 'Destination Route',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 2),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'${preview.distanceMiles.toStringAsFixed(1)} mi • ${preview.formattedDuration}',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFF10B981),
width: 0.8,
),
),
child: Text(
preview.twistinessRating,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: Color(0xFF10B981),
),
),
),
],
),
],
),
),
IconButton(
key: const ValueKey('clear_route_button'),
icon: const Icon(Icons.close, color: Colors.white70),
onPressed: _clearDestination,
tooltip: 'Cancel Destination',
),
],
),
const SizedBox(height: 10),
// Routing Preferences Selector Row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildPreferenceChip(
preference: RoutingPreference.fastest,
label: 'Fastest',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildPreferenceChip(
preference: RoutingPreference.shortest,
label: 'Shortest',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildPreferenceChip(
preference: RoutingPreference.twisty,
label: 'Twisty (Curves)',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildPreferenceChip(
preference: RoutingPreference.scenic,
label: 'Scenic (No Hwy)',
colors: colors,
currentPos: currentPos,
),
],
),
),
const SizedBox(height: 12),
// Navigation Actions: Start Turn-by-Turn & External Handoff
Row(
children: [
Expanded(
child: FilledButton.icon(
key: const ValueKey('start_navigation_button'),
icon: const Icon(Icons.navigation, size: 18),
label: const Text(
'START NAVIGATION',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: _startTurnByTurnNavigation,
),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('handoff_navigation_button'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () {
if (dest != null) {
NavigationHandoffService.showHandoffSheet(
context: context,
latitude: dest.latitude,
longitude: dest.longitude,
displayName: dest.displayName,
);
}
},
child: const Icon(Icons.open_in_new, size: 18),
),
],
),
],
),
),
);
}
Widget _buildPreferenceChip({
required RoutingPreference preference,
required String label,
required MotorSignalThemeColors colors,
required AppLatLng currentPos,
}) {
final isSelected = _routingOptions.preference == preference;
return ChoiceChip(
label: Text(label),
selected: isSelected,
labelStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.black : Colors.white70,
),
selectedColor: colors.cyberCyan,
backgroundColor: colors.surfaceContainerHigh,
onSelected: (selected) {
if (selected) {
setState(() {
_routingOptions = _routingOptions.copyWith(
preference: preference,
avoidHighways: preference == RoutingPreference.twisty ||
preference == RoutingPreference.scenic,
);
});
if (_selectedDestination != null) {
_calculateRouteToDestination(_selectedDestination!, currentPos);
}
}
},
);
}
}
class _HudIconButton extends StatelessWidget {

View File

@ -1636,13 +1636,14 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF101419).withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
),
if (item.isGpsVerified)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF101419).withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [

View File

@ -56,6 +56,8 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
'Social',
style: TextStyle(fontWeight: FontWeight.w700),
),
backgroundColor: colors.surfaceContainer,
elevation: 0,
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),

View File

@ -151,7 +151,13 @@ class MaintenanceData {
}
/// Calculates real-time countdown pills for all standard maintenance categories.
/// Returns empty list if no odometer is set and no service records exist yet,
/// avoiding fake synthetic countdowns.
List<MaintenanceItemStatus> calculatePills(double totalRiddenMiles) {
if (baseOdometer == 0.0 && serviceRecords.isEmpty) {
return const [];
}
final currentOdometer = baseOdometer + totalRiddenMiles;
final oilInterval = customIntervals['oil'] ?? defaultOilInterval;

View File

@ -0,0 +1,350 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:intl/intl.dart';
import '../location_service.dart';
import '../map/map_provider_interface.dart';
import '../routes/route_models.dart';
import '../routes/route_provider.dart';
/// Navigation session status lifecycle.
enum NavigationStatus {
idle,
navigating,
paused,
rerouting,
arrived,
}
/// State of an active turn-by-turn navigation session.
@immutable
class NavigationSessionState {
final NavigationStatus status;
final MotorSignalRoute? route;
final ResolvedPlace? destination;
final RouteCalculationResult? calculationResult;
final List<RouteManeuver> maneuvers;
final int currentManeuverIndex;
final double distanceToNextManeuverMeters;
final double remainingDistanceMeters;
final int remainingDurationSeconds;
final bool ttsEnabled;
final String? lastAnnouncedInstruction;
final RoutingOptions routingOptions;
final DateTime? startedAt;
const NavigationSessionState({
this.status = NavigationStatus.idle,
this.route,
this.destination,
this.calculationResult,
this.maneuvers = const [],
this.currentManeuverIndex = 0,
this.distanceToNextManeuverMeters = 0.0,
this.remainingDistanceMeters = 0.0,
this.remainingDurationSeconds = 0,
this.ttsEnabled = true,
this.lastAnnouncedInstruction,
this.routingOptions = const RoutingOptions(),
this.startedAt,
});
bool get isNavigating => status == NavigationStatus.navigating;
bool get hasActiveRoute => maneuvers.isNotEmpty;
RouteManeuver? get currentManeuver =>
currentManeuverIndex < maneuvers.length ? maneuvers[currentManeuverIndex] : null;
RouteManeuver? get nextManeuver =>
(currentManeuverIndex + 1) < maneuvers.length
? maneuvers[currentManeuverIndex + 1]
: null;
double get distanceToNextMiles =>
distanceToNextManeuverMeters * 0.000621371;
double get remainingDistanceMiles =>
remainingDistanceMeters * 0.000621371;
String get formattedDistanceToNext {
if (distanceToNextMiles < 0.1) {
final feet = (distanceToNextManeuverMeters * 3.28084).round();
return '$feet ft';
}
return '${distanceToNextMiles.toStringAsFixed(1)} mi';
}
String get formattedRemainingDistance {
return '${remainingDistanceMiles.toStringAsFixed(1)} mi';
}
String get formattedEta {
if (remainingDurationSeconds <= 0) return '--:--';
final arrival = DateTime.now().add(Duration(seconds: remainingDurationSeconds));
return DateFormat('h:mm a').format(arrival);
}
String get formattedDurationRemaining {
if (remainingDurationSeconds <= 0) return '0 min';
final totalSec = remainingDurationSeconds;
final hours = totalSec ~/ 3600;
final minutes = (totalSec % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes.clamp(1, 60)} min';
}
NavigationSessionState copyWith({
NavigationStatus? status,
MotorSignalRoute? route,
ResolvedPlace? destination,
RouteCalculationResult? calculationResult,
List<RouteManeuver>? maneuvers,
int? currentManeuverIndex,
double? distanceToNextManeuverMeters,
double? remainingDistanceMeters,
int? remainingDurationSeconds,
bool? ttsEnabled,
String? lastAnnouncedInstruction,
RoutingOptions? routingOptions,
DateTime? startedAt,
}) {
return NavigationSessionState(
status: status ?? this.status,
route: route ?? this.route,
destination: destination ?? this.destination,
calculationResult: calculationResult ?? this.calculationResult,
maneuvers: maneuvers ?? this.maneuvers,
currentManeuverIndex: currentManeuverIndex ?? this.currentManeuverIndex,
distanceToNextManeuverMeters:
distanceToNextManeuverMeters ?? this.distanceToNextManeuverMeters,
remainingDistanceMeters:
remainingDistanceMeters ?? this.remainingDistanceMeters,
remainingDurationSeconds:
remainingDurationSeconds ?? this.remainingDurationSeconds,
ttsEnabled: ttsEnabled ?? this.ttsEnabled,
lastAnnouncedInstruction:
lastAnnouncedInstruction ?? this.lastAnnouncedInstruction,
routingOptions: routingOptions ?? this.routingOptions,
startedAt: startedAt ?? this.startedAt,
);
}
}
/// Controller managing live motorcycle turn-by-turn navigation session.
class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
final FlutterTts? _tts;
bool _ttsInitialized = false;
NavigationSessionNotifier({FlutterTts? tts})
: _tts = tts ?? _createTtsSafe(),
super(const NavigationSessionState()) {
_initTts();
}
static FlutterTts? _createTtsSafe() {
try {
return FlutterTts();
} catch (_) {
return null;
}
}
bool get isNavigating => state.isNavigating;
Future<void> _initTts() async {
if (_tts == null) return;
try {
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(0.5); // Clear motorcycle cadence
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_ttsInitialized = true;
} catch (_) {
_ttsInitialized = false;
}
}
/// Begins turn-by-turn guidance along a calculated route.
Future<void> startNavigation({
required RouteCalculationResult calculationResult,
ResolvedPlace? destination,
MotorSignalRoute? route,
RoutingOptions? options,
}) async {
final maneuvers = calculationResult.maneuvers;
if (maneuvers.isEmpty) {
return;
}
final initialDist = calculationResult.estimatedDistanceMeters ??
calculationResult.straightLineDistanceMeters;
final initialDuration = calculationResult.estimatedDurationSeconds ??
(initialDist / 15.0).round(); // Default ~33 mph assumption if null
final firstManeuverDist = maneuvers.first.distanceMeters > 0
? maneuvers.first.distanceMeters
: (maneuvers.length > 1 ? maneuvers[1].distanceMeters : initialDist);
state = NavigationSessionState(
status: NavigationStatus.navigating,
route: route,
destination: destination,
calculationResult: calculationResult,
maneuvers: maneuvers,
currentManeuverIndex: 0,
distanceToNextManeuverMeters: firstManeuverDist,
remainingDistanceMeters: initialDist,
remainingDurationSeconds: initialDuration,
ttsEnabled: state.ttsEnabled,
routingOptions: options ?? const RoutingOptions(),
startedAt: DateTime.now(),
);
// Speak initial maneuver
final firstInstruction = maneuvers.first.instruction;
_speakInstruction(firstInstruction);
}
/// Halts and clears active navigation.
Future<void> stopNavigation() async {
if (_tts != null && _ttsInitialized) {
try {
await _tts.stop();
} catch (_) {}
}
state = const NavigationSessionState();
}
/// Pauses turn-by-turn tracking (e.g. during a rest stop).
void pauseNavigation() {
if (state.isNavigating) {
state = state.copyWith(status: NavigationStatus.paused);
}
}
/// Resumes paused navigation.
void resumeNavigation() {
if (state.status == NavigationStatus.paused) {
state = state.copyWith(status: NavigationStatus.navigating);
final current = state.currentManeuver;
if (current != null) {
_speakInstruction('Resuming route. ${current.instruction}');
}
}
}
/// Toggles spoken voice guidance.
void toggleTts() {
final newEnabled = !state.ttsEnabled;
state = state.copyWith(ttsEnabled: newEnabled);
if (!newEnabled && _tts != null && _ttsInitialized) {
try {
_tts.stop();
} catch (_) {}
}
}
/// Updates navigation progress against rider's current location and speed.
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
if (!state.isNavigating || state.maneuvers.isEmpty) return;
final currIdx = state.currentManeuverIndex;
if (currIdx >= state.maneuvers.length) {
_completeArrival();
return;
}
final currentManeuver = state.maneuvers[currIdx];
final distToManeuver = _haversineMeters(riderPos, currentManeuver.location);
// If within 25 meters of maneuver point, advance to next maneuver
if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) {
final nextIdx = currIdx + 1;
final nextManeuver = state.maneuvers[nextIdx];
final distToNext = _haversineMeters(riderPos, nextManeuver.location);
state = state.copyWith(
currentManeuverIndex: nextIdx,
distanceToNextManeuverMeters: distToNext,
);
_speakInstruction(nextManeuver.instruction);
return;
}
// Recompute remaining distance along maneuvers
double remainingMeters = distToManeuver;
for (int i = currIdx + 1; i < state.maneuvers.length; i++) {
remainingMeters += state.maneuvers[i].distanceMeters;
}
// Dynamic duration based on speed (or default 30 mph if stationary)
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
state = state.copyWith(
distanceToNextManeuverMeters: distToManeuver,
remainingDistanceMeters: remainingMeters,
remainingDurationSeconds: remainingSec,
);
}
void _completeArrival() {
state = state.copyWith(
status: NavigationStatus.arrived,
remainingDistanceMeters: 0.0,
remainingDurationSeconds: 0,
distanceToNextManeuverMeters: 0.0,
);
_speakInstruction('You have arrived at your destination.');
}
Future<void> _speakInstruction(String instruction) async {
if (!state.ttsEnabled || _tts == null || !_ttsInitialized) return;
if (state.lastAnnouncedInstruction == instruction) return;
state = state.copyWith(lastAnnouncedInstruction: instruction);
try {
await _tts.speak(instruction);
} catch (_) {}
}
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
const r = 6371000.0; // Earth radius in meters
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
final lat1 = p1.latitude * (math.pi / 180.0);
final lat2 = p2.latitude * (math.pi / 180.0);
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.sin(dLon / 2) *
math.sin(dLon / 2) *
math.cos(lat1) *
math.cos(lat2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return r * c;
}
}
/// Global shared navigation session provider across Map and Dashboard.
final navigationSessionProvider =
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
(ref) {
final notifier = NavigationSessionNotifier();
// Connect live location updates when navigating
ref.listen(locationStreamProvider, (_, next) {
final pos = next.value;
if (pos != null && notifier.isNavigating) {
notifier.updateLocation(
AppLatLng(pos.latitude, pos.longitude),
speedMph: pos.speed * 2.23694,
);
}
});
return notifier;
},
);

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import '../map/map_provider_interface.dart';
import 'route_models.dart';
@ -239,6 +240,7 @@ class AwsLocationRouteProvider implements RouteProvider {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) async {
final ordered = List<RouteStop>.from(stops);
ordered.sort((a, b) => a.order.compareTo(b.order));
@ -250,6 +252,16 @@ class AwsLocationRouteProvider implements RouteProvider {
);
}
final effectiveAvoidHighways = options?.avoidHighways ??
(options?.preference == RoutingPreference.twisty ||
options?.preference == RoutingPreference.scenic ||
avoidHighways);
final effectiveAvoidTolls = options?.avoidTolls ?? avoidTolls;
final effectiveAvoidFerries = options?.avoidFerries ?? avoidFerries;
final optimizeFor = options?.preference == RoutingPreference.shortest
? 'ShortestRoute'
: 'FastestRoute';
try {
final uri = Uri.parse(
'https://routes.geo.$region.amazonaws.com/v2/routes?key=$apiKey',
@ -264,11 +276,25 @@ class AwsLocationRouteProvider implements RouteProvider {
.toList()
: null;
final travelOptions = <String, dynamic>{
'Car': {
'Avoid': {
if (effectiveAvoidHighways) 'Highways': true,
if (effectiveAvoidTolls) 'Tolls': true,
if (effectiveAvoidFerries) 'Ferries': true,
},
},
};
final bodyMap = <String, dynamic>{
'Origin': origin,
'Destination': destination,
'TravelMode': 'Car',
'TravelModeOptions': travelOptions,
'OptimizeRoutingFor': optimizeFor,
'LegGeometryFormat': 'Simple',
'LegAdditionalInputs': ['TravelSteps'],
'Instructions': true,
};
if (waypoints != null && waypoints.isNotEmpty) {
bodyMap['Waypoints'] = waypoints;
@ -320,18 +346,30 @@ class AwsLocationRouteProvider implements RouteProvider {
}
}
final effectiveGeometry = geometry.isNotEmpty
? geometry
: ordered.map((s) => s.latLng).toList();
final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
final effectiveDist = distanceMeters > 0 ? distanceMeters : straightLine;
final distMiles = effectiveDist * 0.000621371;
// Extract maneuvers from TravelSteps if available
final maneuvers = _extractManeuvers(legs, effectiveGeometry, ordered);
// Compute geometric curvature metric
final curvature = _calculateCurvatureMetric(effectiveGeometry, distMiles);
return RouteCalculationResult(
straightLineDistanceMeters: straightLine,
estimatedDistanceMeters:
distanceMeters > 0 ? distanceMeters : straightLine,
estimatedDistanceMeters: effectiveDist,
estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null,
geometry: geometry.isNotEmpty
? geometry
: ordered.map((s) => s.latLng).toList(),
geometry: effectiveGeometry,
isStraightLine: geometry.isEmpty,
providerName: name,
maneuvers: maneuvers,
options: options,
curvatureMetric: curvature,
);
} catch (e) {
if (e is RouteProviderException) rethrow;
@ -341,4 +379,195 @@ class AwsLocationRouteProvider implements RouteProvider {
);
}
}
static List<RouteManeuver> _extractManeuvers(
List<dynamic> legs,
List<AppLatLng> geometry,
List<RouteStop> orderedStops,
) {
final maneuvers = <RouteManeuver>[];
for (final leg in legs) {
if (leg is! Map<String, dynamic>) continue;
final legDetails = leg['VehicleLegDetails'] as Map<String, dynamic>?;
final travelSteps = (legDetails?['TravelSteps'] ?? leg['TravelSteps'])
as List<dynamic>? ?? [];
for (final rawStep in travelSteps) {
if (rawStep is! Map<String, dynamic>) continue;
final instruction = rawStep['Instruction']?.toString() ?? 'Continue';
final stepType = rawStep['Type']?.toString();
final turn = rawStep['Turn']?.toString();
final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0;
final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0;
final road = rawStep['NextRoad']?.toString() ??
rawStep['CurrentRoad']?.toString();
final offset = (rawStep['GeometryOffset'] as num?)?.toInt();
AppLatLng loc;
if (offset != null && offset >= 0 && offset < geometry.length) {
loc = geometry[offset];
} else if (geometry.isNotEmpty) {
loc = geometry.first;
} else {
loc = orderedStops.first.latLng;
}
maneuvers.add(
RouteManeuver(
index: maneuvers.length,
instruction: instruction,
type: _mapManeuverType(stepType, turn),
distanceMeters: dist,
durationSeconds: dur,
streetName: road,
location: loc,
geometryOffset: offset,
),
);
}
}
// If no provider steps were supplied, synthesize maneuvers from geometry
if (maneuvers.isEmpty && geometry.length >= 2) {
maneuvers.addAll(_synthesizeManeuvers(geometry, orderedStops));
}
return maneuvers;
}
static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) {
final t = (typeStr ?? '').toLowerCase();
final turn = (turnStr ?? '').toLowerCase();
if (t == 'arrive') return ManeuverType.arrive;
if (t == 'depart') return ManeuverType.depart;
if (t == 'uturn' || turn == 'uturn') return ManeuverType.uTurn;
if (t.contains('roundabout') || turn.contains('roundabout')) {
if (t.contains('exit')) return ManeuverType.exitRoundabout;
return ManeuverType.enterRoundabout;
}
if (t == 'ramp') return ManeuverType.ramp;
if (turn == 'sharpright') return ManeuverType.turnSharpRight;
if (turn == 'sharpleft') return ManeuverType.turnSharpLeft;
if (turn == 'slightright') return ManeuverType.turnSlightRight;
if (turn == 'slightleft') return ManeuverType.turnSlightLeft;
if (turn == 'right' || t == 'turnright') return ManeuverType.turnRight;
if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft;
if (turn == 'keepright') return ManeuverType.keepRight;
if (turn == 'keepleft') return ManeuverType.keepLeft;
if (t == 'continue') return ManeuverType.continueStraight;
return ManeuverType.continueStraight;
}
static List<RouteManeuver> _synthesizeManeuvers(
List<AppLatLng> geometry,
List<RouteStop> orderedStops,
) {
final list = <RouteManeuver>[];
final destinationName = orderedStops.isNotEmpty
? orderedStops.last.displayName
: 'Destination';
list.add(
RouteManeuver(
index: 0,
instruction: 'Head towards $destinationName',
type: ManeuverType.depart,
distanceMeters: 0.0,
durationSeconds: 0,
location: geometry.first,
geometryOffset: 0,
),
);
for (int i = 0; i < geometry.length - 2; i++) {
final b1 = _bearing(geometry[i], geometry[i + 1]);
final b2 = _bearing(geometry[i + 1], geometry[i + 2]);
double diff = (b2 - b1);
while (diff < -180.0) {
diff += 360.0;
}
while (diff > 180.0) {
diff -= 360.0;
}
if (diff.abs() >= 35.0) {
final ManeuverType type;
final String dirText;
if (diff > 120.0) {
type = ManeuverType.turnSharpRight;
dirText = 'Sharp right';
} else if (diff > 60.0) {
type = ManeuverType.turnRight;
dirText = 'Turn right';
} else if (diff > 35.0) {
type = ManeuverType.turnSlightRight;
dirText = 'Slight right';
} else if (diff < -120.0) {
type = ManeuverType.turnSharpLeft;
dirText = 'Sharp left';
} else if (diff < -60.0) {
type = ManeuverType.turnLeft;
dirText = 'Turn left';
} else {
type = ManeuverType.turnSlightLeft;
dirText = 'Slight left';
}
list.add(
RouteManeuver(
index: list.length,
instruction: '$dirText onto road',
type: type,
distanceMeters: 0.0,
durationSeconds: 0,
location: geometry[i + 1],
geometryOffset: i + 1,
),
);
}
}
list.add(
RouteManeuver(
index: list.length,
instruction: 'Arrive at $destinationName',
type: ManeuverType.arrive,
distanceMeters: 0.0,
durationSeconds: 0,
location: geometry.last,
geometryOffset: geometry.length - 1,
),
);
return list;
}
static double _calculateCurvatureMetric(
List<AppLatLng> points,
double distanceMiles,
) {
if (points.length < 3 || distanceMiles <= 0.05) return 0.0;
double totalTurnDeg = 0.0;
for (int i = 0; i < points.length - 2; i++) {
final b1 = _bearing(points[i], points[i + 1]);
final b2 = _bearing(points[i + 1], points[i + 2]);
double diff = (b2 - b1).abs();
if (diff > 180.0) diff = 360.0 - diff;
totalTurnDeg += diff;
}
return totalTurnDeg / distanceMiles;
}
static double _bearing(AppLatLng from, AppLatLng to) {
final lat1 = from.latitude * (math.pi / 180.0);
final lat2 = to.latitude * (math.pi / 180.0);
final dLon = (to.longitude - from.longitude) * (math.pi / 180.0);
final y = math.sin(dLon) * math.cos(lat2);
final x = math.cos(lat1) * math.sin(lat2) -
math.sin(lat1) * math.cos(lat2) * math.cos(dLon);
final brng = math.atan2(y, x) * (180.0 / math.pi);
return (brng + 360.0) % 360.0;
}
}

View File

@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show IconData, Icons;
import '../map/map_provider_interface.dart';
/// Semantic types of route stops and waypoints.
@ -435,3 +436,215 @@ class MotorSignalRoute {
);
}
}
/// Semantic maneuver types for turn-by-turn navigation.
enum ManeuverType {
depart,
turnRight,
turnLeft,
turnSlightRight,
turnSlightLeft,
turnSharpRight,
turnSharpLeft,
keepRight,
keepLeft,
uTurn,
continueStraight,
enterRoundabout,
exitRoundabout,
ramp,
arrive,
unknown;
IconData get icon {
switch (this) {
case ManeuverType.depart:
return Icons.navigation;
case ManeuverType.turnRight:
case ManeuverType.turnSharpRight:
return Icons.turn_right;
case ManeuverType.turnLeft:
case ManeuverType.turnSharpLeft:
return Icons.turn_left;
case ManeuverType.turnSlightRight:
case ManeuverType.keepRight:
return Icons.turn_slight_right;
case ManeuverType.turnSlightLeft:
case ManeuverType.keepLeft:
return Icons.turn_slight_left;
case ManeuverType.uTurn:
return Icons.u_turn_left;
case ManeuverType.continueStraight:
return Icons.straight;
case ManeuverType.enterRoundabout:
case ManeuverType.exitRoundabout:
return Icons.roundabout_right;
case ManeuverType.ramp:
return Icons.ramp_right;
case ManeuverType.arrive:
return Icons.flag;
case ManeuverType.unknown:
return Icons.navigation;
}
}
String get directionShort {
switch (this) {
case ManeuverType.turnRight:
return 'RIGHT';
case ManeuverType.turnLeft:
return 'LEFT';
case ManeuverType.turnSlightRight:
return 'SLIGHT RIGHT';
case ManeuverType.turnSlightLeft:
return 'SLIGHT LEFT';
case ManeuverType.turnSharpRight:
return 'HARD RIGHT';
case ManeuverType.turnSharpLeft:
return 'HARD LEFT';
case ManeuverType.uTurn:
return 'U-TURN';
case ManeuverType.keepRight:
return 'KEEP RIGHT';
case ManeuverType.keepLeft:
return 'KEEP LEFT';
case ManeuverType.continueStraight:
return 'STRAIGHT';
case ManeuverType.depart:
return 'HEAD OUT';
case ManeuverType.arrive:
return 'ARRIVE';
default:
return 'CONTINUE';
}
}
}
/// A single turn-by-turn navigation maneuver step.
@immutable
class RouteManeuver {
final int index;
final String instruction;
final ManeuverType type;
final double distanceMeters;
final int durationSeconds;
final String? streetName;
final AppLatLng location;
final int? geometryOffset;
const RouteManeuver({
required this.index,
required this.instruction,
required this.type,
required this.distanceMeters,
required this.durationSeconds,
this.streetName,
required this.location,
this.geometryOffset,
});
double get distanceMiles => distanceMeters * 0.000621371;
String get formattedDistance {
if (distanceMiles < 0.1) {
final feet = (distanceMeters * 3.28084).round();
return '$feet ft';
}
return '${distanceMiles.toStringAsFixed(1)} mi';
}
Map<String, dynamic> toJson() => {
'index': index,
'instruction': instruction,
'type': type.name,
'distanceMeters': distanceMeters,
'durationSeconds': durationSeconds,
if (streetName != null) 'streetName': streetName,
'latitude': location.latitude,
'longitude': location.longitude,
if (geometryOffset != null) 'geometryOffset': geometryOffset,
};
factory RouteManeuver.fromJson(Map<String, dynamic> json) {
return RouteManeuver(
index: (json['index'] as num?)?.toInt() ?? 0,
instruction: json['instruction'] as String? ?? '',
type: ManeuverType.values.firstWhere(
(t) => t.name == (json['type'] as String?),
orElse: () => ManeuverType.unknown,
),
distanceMeters: (json['distanceMeters'] as num?)?.toDouble() ?? 0.0,
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
streetName: json['streetName'] as String?,
location: AppLatLng(
(json['latitude'] as num?)?.toDouble() ?? 0.0,
(json['longitude'] as num?)?.toDouble() ?? 0.0,
),
geometryOffset: (json['geometryOffset'] as num?)?.toInt(),
);
}
}
/// Routing optimization preferences supported by MotorSignal and AWS Routes V2.
enum RoutingPreference {
fastest,
shortest,
twisty,
scenic;
String get label {
switch (this) {
case RoutingPreference.fastest:
return 'Fastest';
case RoutingPreference.shortest:
return 'Shortest';
case RoutingPreference.twisty:
return 'Twisty (Curvature)';
case RoutingPreference.scenic:
return 'Scenic (No Highways)';
}
}
String get subtitle {
switch (this) {
case RoutingPreference.fastest:
return 'Optimized for minimum travel time';
case RoutingPreference.shortest:
return 'Shortest total distance';
case RoutingPreference.twisty:
return 'Maximizes curves and elevation changes';
case RoutingPreference.scenic:
return 'Avoids limited-access highways and tolls';
}
}
}
/// Routing options provided to route calculation requests.
@immutable
class RoutingOptions {
final RoutingPreference preference;
final bool avoidHighways;
final bool avoidTolls;
final bool avoidFerries;
const RoutingOptions({
this.preference = RoutingPreference.fastest,
this.avoidHighways = false,
this.avoidTolls = false,
this.avoidFerries = false,
});
RoutingOptions copyWith({
RoutingPreference? preference,
bool? avoidHighways,
bool? avoidTolls,
bool? avoidFerries,
}) {
return RoutingOptions(
preference: preference ?? this.preference,
avoidHighways: avoidHighways ?? this.avoidHighways,
avoidTolls: avoidTolls ?? this.avoidTolls,
avoidFerries: avoidFerries ?? this.avoidFerries,
);
}
}

View File

@ -84,6 +84,9 @@ class RouteCalculationResult {
final bool isStraightLine;
final String providerName;
final List<String> warnings;
final List<RouteManeuver> maneuvers;
final RoutingOptions? options;
final double? curvatureMetric;
const RouteCalculationResult({
required this.straightLineDistanceMeters,
@ -93,6 +96,9 @@ class RouteCalculationResult {
required this.isStraightLine,
required this.providerName,
this.warnings = const [],
this.maneuvers = const [],
this.options,
this.curvatureMetric,
});
/// Alias for geometry points.
@ -117,6 +123,28 @@ class RouteCalculationResult {
}
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
}
/// Human-readable estimated travel time.
String get formattedDuration {
if (estimatedDurationSeconds == null) return '-- min';
final totalSec = estimatedDurationSeconds!;
final hours = totalSec ~/ 3600;
final minutes = (totalSec % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes.clamp(1, 60)} min';
}
/// Categorized motorcycle twistiness rating based on curvature metric (degrees per mile).
String get twistinessRating {
if (curvatureMetric == null) return 'Standard';
final c = curvatureMetric!;
if (c >= 120.0) return 'Very Twisty (Canyon)';
if (c >= 60.0) return 'Twisty Curves';
if (c >= 25.0) return 'Moderate Curves';
return 'Highway / Straight';
}
}
/// Abstract provider interface for calculating routes and searching places.
@ -129,6 +157,7 @@ abstract class RouteProvider {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
});
/// Searches for places by textual query.
@ -157,6 +186,7 @@ class StraightLineRouteProvider implements RouteProvider {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) async {
final ordered = List<RouteStop>.from(stops);
ordered.sort((a, b) => a.order.compareTo(b.order));

View File

@ -240,6 +240,7 @@ class ValhallaRouteProvider implements RouteProvider {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) async {
final ordered = List<RouteStop>.from(stops);
ordered.sort((a, b) => a.order.compareTo(b.order));

View File

@ -44,7 +44,7 @@ class SocialFeedService {
regionName: 'Bay Area, CA',
isFeatured: true,
snapshotType: FeedSnapshotType.routeTrack,
isGpsVerified: true,
isGpsVerified: false,
fistBumpCount: 42,
fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'],
routePoints: const [
@ -90,7 +90,7 @@ class SocialFeedService {
snapshotType: FeedSnapshotType.scenicPhoto,
photoUrl: 'assets/images/rock_store_lookout.png',
photoCaption: 'Rock Store Lookout',
isGpsVerified: true,
isGpsVerified: false,
fistBumpCount: 28,
fistBumpUserIds: const ['uid_marcus'],
comments: const [],
@ -118,7 +118,7 @@ class SocialFeedService {
regionName: 'Angeles Crest, CA',
isFeatured: true,
snapshotType: FeedSnapshotType.routeTrack,
isGpsVerified: true,
isGpsVerified: false,
fistBumpCount: 65,
fistBumpUserIds: const ['uid_marcus', 'uid_sarah'],
routePoints: const [
@ -151,7 +151,7 @@ class SocialFeedService {
regionName: 'North Georgia, GA',
isFeatured: true,
snapshotType: FeedSnapshotType.routeTrack,
isGpsVerified: true,
isGpsVerified: false,
fistBumpCount: 39,
fistBumpUserIds: const ['uid_marcus'],
routePoints: const [
@ -184,7 +184,7 @@ class SocialFeedService {
regionName: 'Tellico Plains, TN',
isFeatured: true,
snapshotType: FeedSnapshotType.routeTrack,
isGpsVerified: true,
isGpsVerified: false,
fistBumpCount: 51,
fistBumpUserIds: const ['uid_sarah', 'uid_alex'],
routePoints: const [

View File

@ -220,6 +220,100 @@ void main() {
expect(result.geometry[2].longitude, equals(-84.5));
expect(result.isStraightLine, isFalse);
expect(result.providerName, equals('Amazon AWS Location'));
// Fallback maneuvers synthesized from geometry
expect(result.maneuvers.isNotEmpty, isTrue);
expect(result.maneuvers.first.type, equals(ManeuverType.depart));
expect(result.maneuvers.last.type, equals(ManeuverType.arrive));
});
test('calculateRoute with RoutingOptions applies avoidHighways and TravelSteps maneuvers', () async {
final mockClient = MockClient((request) async {
final body = jsonDecode(request.body);
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
expect(body['OptimizeRoutingFor'], equals('ShortestRoute'));
expect(body['LegAdditionalInputs'], contains('TravelSteps'));
return http.Response(
jsonEncode({
'Routes': [
{
'Summary': {
'Distance': 15000.0,
'Duration': 900,
},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.05, 34.05],
[-84.10, 34.10],
],
},
'VehicleLegDetails': {
'TravelSteps': [
{
'Instruction': 'Head north on Main St',
'Type': 'Depart',
'Distance': 5000.0,
'Duration': 300,
'GeometryOffset': 0,
'CurrentRoad': 'Main St',
},
{
'Instruction': 'Turn right onto Canyon Rd',
'Type': 'Turn',
'Turn': 'Right',
'Distance': 10000.0,
'Duration': 600,
'GeometryOffset': 1,
'NextRoad': 'Canyon Rd',
},
{
'Instruction': 'Arrive at destination',
'Type': 'Arrive',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
];
final result = await provider.calculateRoute(
stops,
options: const RoutingOptions(
preference: RoutingPreference.shortest,
avoidHighways: true,
),
);
expect(result.maneuvers.length, equals(3));
expect(result.maneuvers[0].instruction, equals('Head north on Main St'));
expect(result.maneuvers[0].type, equals(ManeuverType.depart));
expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Rd'));
expect(result.maneuvers[1].type, equals(ManeuverType.turnRight));
expect(result.maneuvers[1].streetName, equals('Canyon Rd'));
expect(result.maneuvers[2].type, equals(ManeuverType.arrive));
expect(result.formattedDuration, equals('15 min'));
expect(result.curvatureMetric, isNotNull);
});
});
}

View File

@ -30,6 +30,7 @@ class FakeRouteProvider implements RouteProvider {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) async {
lastCapturedStops = stops;

View File

@ -0,0 +1,183 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/models/maintenance_models.dart';
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
void main() {
group('MaintenanceData & Countdown Pill Logic', () {
test('calculatePills returns empty when no odometer set and no records exist', () {
const data = MaintenanceData(baseOdometer: 0.0, serviceRecords: []);
final pills = data.calculatePills(0.0);
expect(pills, isEmpty, reason: 'Must not synthesize fake countdowns for untracked motorcycles');
});
test('calculatePills calculates correct countdown when base odometer is set', () {
const data = MaintenanceData(baseOdometer: 1000.0, serviceRecords: []);
final pills = data.calculatePills(500.0); // current odo = 1500
expect(pills.length, 4);
final oilPill = pills.firstWhere((p) => p.key == 'oil');
expect(oilPill.intervalMiles, 3000);
expect(oilPill.milesRemaining, 2500.0);
expect(oilPill.urgency, MaintenanceUrgency.ok);
final chainPill = pills.firstWhere((p) => p.key == 'chain');
expect(chainPill.intervalMiles, 600);
// current odo 1500, last serviced 1000 -> 500 miles ridden -> 600 - 500 = 100 remaining
expect(chainPill.milesRemaining, 100.0);
});
test('calculatePills marks overdue and due soon appropriately based on last service', () {
final record = ServiceRecord(
id: 'srv_1',
date: DateTime.now().subtract(const Duration(days: 30)),
odometer: 10000.0,
serviceType: 'Engine Oil & Filter',
cost: 45.0,
);
final data = MaintenanceData(
baseOdometer: 9000.0,
serviceRecords: [record],
);
// Current odo = 9000 + 3800 = 12800. Oil last serviced at 10000. Miles since service = 2800.
// Oil interval = 3000. Remaining = 200 mi (<= 350 dueSoonThreshold)
final pillsDueSoon = data.calculatePills(3800.0);
final oilDueSoon = pillsDueSoon.firstWhere((p) => p.key == 'oil');
expect(oilDueSoon.urgency, MaintenanceUrgency.dueSoon);
expect(oilDueSoon.milesRemaining, 200.0);
// Current odo = 9000 + 4200 = 13200. Miles since service = 3200. Remaining = -200 (overdue)
final pillsOverdue = data.calculatePills(4200.0);
final oilOverdue = pillsOverdue.firstWhere((p) => p.key == 'oil');
expect(oilOverdue.urgency, MaintenanceUrgency.overdue);
expect(oilOverdue.milesRemaining, -200.0);
});
test('updateSpecJsonWithMaintenance preserves existing specs while modifying maintenance', () {
final initialSpec = json.encode({
'displacementCc': 599.0,
'horsepower': 120.0,
});
final updatedJson = MaintenanceData.updateSpecJsonWithMaintenance(
originalSpecJson: initialSpec,
baseOdometer: 14500.0,
serviceRecords: [
ServiceRecord(
id: 'srv_100',
date: DateTime(2026, 5, 1),
odometer: 14500.0,
serviceType: 'Chain Clean & Lube',
notes: 'Motul paste',
),
],
customIntervals: {'chain': 500},
);
final parsed = json.decode(updatedJson);
expect(parsed['displacementCc'], 599.0);
expect(parsed['horsepower'], 120.0);
expect(parsed['baseOdometer'], 14500.0);
expect(parsed['serviceRecords'], hasLength(1));
expect(parsed['customIntervals']['chain'], 500);
final data = MaintenanceData.fromSpecificationJson(updatedJson);
expect(data.baseOdometer, 14500.0);
expect(data.serviceRecords.first.serviceType, 'Chain Clean & Lube');
expect(data.customIntervals['chain'], 500);
});
});
group('GarageController Maintenance CRUD Operations', () {
late AppDatabase db;
late GarageController controller;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
controller = GarageController(db, HybridMotorcycleRepository());
});
tearDown(() async {
await db.close();
});
test('add, update, and delete service records in database', () async {
// 1. Insert a test motorcycle
final bikeId = await db.into(db.garageMotorcycles).insert(
GarageMotorcyclesCompanion.insert(
year: 2001,
makeId: 'yamaha',
makeName: 'Yamaha',
modelId: 'yzf-r6',
modelName: 'YZF-R6',
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
),
);
// 2. Set base odometer
await controller.updateBaseOdometer(bikeId, 21500.0);
var bike = await (db.select(db.garageMotorcycles)..where((t) => t.id.equals(bikeId))).getSingle();
var data = MaintenanceData.fromSpecificationJson(bike.specificationJson);
expect(data.baseOdometer, 21500.0);
expect(data.serviceRecords, isEmpty);
// 3. Add a service record
final record1 = ServiceRecord(
id: 'srv_test_1',
date: DateTime(2026, 4, 10),
odometer: 21500.0,
serviceType: 'Engine Oil & Filter',
notes: 'Castrol Power1',
cost: 39.99,
);
await controller.addServiceRecord(bikeId, record1);
bike = await (db.select(db.garageMotorcycles)..where((t) => t.id.equals(bikeId))).getSingle();
data = MaintenanceData.fromSpecificationJson(bike.specificationJson);
expect(data.serviceRecords, hasLength(1));
expect(data.serviceRecords.first.serviceType, 'Engine Oil & Filter');
expect(data.serviceRecords.first.cost, 39.99);
// 4. Update the service record
final updatedRecord = ServiceRecord(
id: 'srv_test_1',
date: DateTime(2026, 4, 10),
odometer: 21550.0,
serviceType: 'Engine Oil & Filter (Full Synthetic)',
notes: 'Castrol Power1 4T + K&N Filter',
cost: 54.99,
);
await controller.updateServiceRecord(bikeId, updatedRecord);
bike = await (db.select(db.garageMotorcycles)..where((t) => t.id.equals(bikeId))).getSingle();
data = MaintenanceData.fromSpecificationJson(bike.specificationJson);
expect(data.serviceRecords, hasLength(1));
expect(data.serviceRecords.first.odometer, 21550.0);
expect(data.serviceRecords.first.serviceType, 'Engine Oil & Filter (Full Synthetic)');
expect(data.serviceRecords.first.cost, 54.99);
// 5. Update custom interval
await controller.updateCustomInterval(bikeId, 'oil', 2500);
bike = await (db.select(db.garageMotorcycles)..where((t) => t.id.equals(bikeId))).getSingle();
data = MaintenanceData.fromSpecificationJson(bike.specificationJson);
expect(data.customIntervals['oil'], 2500);
// 6. Delete service record
await controller.deleteServiceRecord(bikeId, 'srv_test_1');
bike = await (db.select(db.garageMotorcycles)..where((t) => t.id.equals(bikeId))).getSingle();
data = MaintenanceData.fromSpecificationJson(bike.specificationJson);
expect(data.serviceRecords, isEmpty);
// base odometer and custom intervals should still be intact
expect(data.baseOdometer, 21500.0);
expect(data.customIntervals['oil'], 2500);
});
});
}

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/features/garage/garage_screen.dart';
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
import 'package:motorcycle_dash/services/sync/sync_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
void main() {
testWidgets('GarageScreen displays sync spinner when isSyncProcessingProvider is true', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
garageMotorcyclesStreamProvider.overrideWith((ref) => Stream.value([])),
isSyncProcessingProvider.overrideWithValue(true),
pendingSyncCountProvider.overrideWithValue(0),
],
child: const MaterialApp(
home: GarageScreen(),
),
),
);
await tester.pump();
// Verify spinner is displayed instead of standard button
expect(find.byKey(const ValueKey('garage_sync_spinner')), findsOneWidget);
expect(find.byKey(const ValueKey('garage_sync_button')), findsNothing);
});
testWidgets('GarageScreen displays pending badge count when sync is idle', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
garageMotorcyclesStreamProvider.overrideWith((ref) => Stream.value([])),
isSyncProcessingProvider.overrideWithValue(false),
pendingSyncCountProvider.overrideWithValue(4),
],
child: const MaterialApp(
home: GarageScreen(),
),
),
);
await tester.pump();
expect(find.byKey(const ValueKey('garage_sync_button')), findsOneWidget);
expect(find.byType(Badge), findsOneWidget);
expect(find.text('4'), findsOneWidget);
});
testWidgets('GarageScreen sync button informs user when signed out', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
garageMotorcyclesStreamProvider.overrideWith((ref) => Stream.value([])),
isSyncProcessingProvider.overrideWithValue(false),
pendingSyncCountProvider.overrideWithValue(1),
authNotifierProvider.overrideWith((ref) => MockAuthNotifier(const AuthState.unauthenticated())),
],
child: const MaterialApp(
home: GarageScreen(),
),
),
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('garage_sync_button')));
await tester.pump();
expect(find.byKey(const ValueKey('garage_sync_auth_required_snackbar')), findsOneWidget);
expect(find.text('Sign in to sync your garage with MotorCloud.'), findsOneWidget);
});
}
class MockAuthNotifier extends StateNotifier<AuthState> implements AuthNotifier {
MockAuthNotifier(super.state);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart';
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
import 'package:motorcycle_dash/services/calibration_service.dart';
void main() {
testWidgets('LeanAngleIndicator tap while moving (> 3.0 MPH) blocks calibration with warning', (tester) async {
final sample = TelemetrySample(
timestamp: DateTime.now(),
leanAngle: 12.0,
speed: 25.0, // Moving at 25 MPH
);
await tester.pumpWidget(
ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith(
(ref) => Stream.value(sample),
),
rideStateMachineProvider.overrideWith(
(ref) => MockRideStateMachine(
RideSession(state: RideState.riding),
),
),
],
child: const MaterialApp(
home: Scaffold(
body: Center(
child: LeanAngleIndicator(),
),
),
),
),
);
await tester.pumpAndSettle();
// Verify indicator rendered
expect(find.byKey(const ValueKey('lean_angle_indicator_tap')), findsOneWidget);
// Tap on the indicator
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
await tester.pump();
// Verify warning snackbar is shown
expect(find.byKey(const ValueKey('lean_calibrate_moving_warning')), findsOneWidget);
expect(find.textContaining('Cannot calibrate while moving (> 3.0 MPH)'), findsOneWidget);
// Calibration dialog must NOT appear
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsNothing);
});
testWidgets('LeanAngleIndicator tap while stationary (< 3.0 MPH) prompts confirmation and calibrates upright', (tester) async {
final sample = TelemetrySample(
timestamp: DateTime.now(),
leanAngle: 0.0,
speed: 0.0, // Stationary
);
var calibrated = false;
await tester.pumpWidget(
ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith(
(ref) => Stream.value(sample),
),
rideStateMachineProvider.overrideWith(
(ref) => MockRideStateMachine(
RideSession(state: RideState.idle),
),
),
sensorFusionServiceProvider.overrideWith((ref) => SensorFusionServiceMock(() {
calibrated = true;
})),
],
child: const MaterialApp(
home: Scaffold(
body: Center(
child: LeanAngleIndicator(),
),
),
),
),
);
await tester.pumpAndSettle();
// Tap on indicator
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
await tester.pumpAndSettle();
// Verify dialog appears
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsOneWidget);
expect(find.byKey(const ValueKey('confirm_lean_calibrate_button')), findsOneWidget);
// Confirm calibration
await tester.tap(find.byKey(const ValueKey('confirm_lean_calibrate_button')));
await tester.pumpAndSettle();
expect(calibrated, isTrue, reason: 'Must invoke calibrateUpright() on SensorFusionService');
expect(find.byKey(const ValueKey('lean_calibrate_success_snackbar')), findsOneWidget);
});
}
class MockRideStateMachine extends StateNotifier<RideSession> implements RideStateMachine {
MockRideStateMachine(super.initialState);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class SensorFusionServiceMock extends SensorFusionService {
final VoidCallback onCalibrate;
SensorFusionServiceMock(this.onCalibrate) : super(CalibrationService());
@override
Future<void> calibrateUpright({int? motorcycleId}) async {
onCalibrate();
}
}

View File

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/map/map_screen.dart';
import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_provider.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class _FakeRouteProvider implements RouteProvider {
@override
String get name => 'Fake AWS Provider';
@override
Future<List<PlaceSearchResult>> searchPlaces(
String query, {
AppLatLng? nearLocation,
}) async {
if (query.toLowerCase().contains('blood mountain')) {
return const [
PlaceSearchResult(
providerId: 'place_blood_mtn',
primaryText: 'Blood Mountain Gap',
secondaryText: 'US-19 / GA-11, Blairsville, GA',
latitude: 34.7330,
longitude: -83.9180,
formattedAddress: 'Blood Mountain Gap, GA',
),
];
}
return [
PlaceSearchResult(
providerId: 'place_gas',
primaryText: 'Shell Gas Station',
secondaryText: 'Main St',
latitude: 34.7200,
longitude: -83.9100,
formattedAddress: 'Shell Gas Station, Main St',
),
];
}
@override
Future<ResolvedPlace> resolvePlace(String placeId) async {
return const ResolvedPlace(
providerId: 'place_blood_mtn',
displayName: 'Blood Mountain Gap',
formattedAddress: 'Blood Mountain Gap, GA',
latitude: 34.7330,
longitude: -83.9180,
);
}
@override
Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) async {
final geom = [
AppLatLng(stops.first.latitude, stops.first.longitude),
const AppLatLng(34.7250, -83.9150),
AppLatLng(stops.last.latitude, stops.last.longitude),
];
return RouteCalculationResult(
straightLineDistanceMeters: 12000.0,
estimatedDistanceMeters: 14500.0,
estimatedDurationSeconds: 1080,
geometry: geom,
isStraightLine: false,
providerName: name,
options: options,
curvatureMetric: 85.0, // Twisty curves
maneuvers: const [
RouteManeuver(
index: 0,
instruction: 'Depart on GA-60',
type: ManeuverType.depart,
distanceMeters: 4500.0,
durationSeconds: 360,
location: AppLatLng(34.7200, -83.9100),
),
RouteManeuver(
index: 1,
instruction: 'Turn right on US-19 N',
type: ManeuverType.turnRight,
distanceMeters: 10000.0,
durationSeconds: 720,
location: AppLatLng(34.7250, -83.9150),
),
RouteManeuver(
index: 2,
instruction: 'Arrive at Blood Mountain Gap',
type: ManeuverType.arrive,
distanceMeters: 0.0,
durationSeconds: 0,
location: AppLatLng(34.7330, -83.9180),
),
],
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Widget buildSubject() {
return ProviderScope(
overrides: [
activeRouteCalculationProvider.overrideWithValue(_FakeRouteProvider()),
locationStreamProvider.overrideWith((ref) => Stream.value(
Position(
latitude: 34.7200,
longitude: -83.9100,
timestamp: DateTime.now(),
accuracy: 5.0,
altitude: 300.0,
altitudeAccuracy: 1.0,
heading: 45.0,
headingAccuracy: 1.0,
speed: 15.0,
speedAccuracy: 0.5,
),
)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const MapScreen(),
),
);
}
testWidgets('MapScreen destination search autocomplete and route preview flow', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 915)); // Google Pixel 8a portrait size
await tester.pumpWidget(buildSubject());
await tester.pumpAndSettle();
// Verify search input is displayed
final searchField = find.byType(TextField);
expect(searchField, findsOneWidget);
// Enter search query
await tester.enterText(searchField, 'Blood Mountain');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
// Verify search results overlay rendered
expect(find.byKey(const ValueKey('search_results_overlay')), findsOneWidget);
expect(find.text('Blood Mountain Gap'), findsOneWidget);
// Tap on search result item
await tester.tap(find.text('Blood Mountain Gap'));
await tester.pumpAndSettle();
// Verify route preview card appears
expect(find.byKey(const ValueKey('route_preview_card')), findsOneWidget);
expect(find.textContaining('Twisty Curves'), findsOneWidget);
expect(find.byKey(const ValueKey('start_navigation_button')), findsOneWidget);
// Tap "START NAVIGATION"
await tester.tap(find.byKey(const ValueKey('start_navigation_button')));
await tester.pumpAndSettle();
// Verify navigation maneuver card appears on map
expect(find.byKey(const ValueKey('navigation_maneuver_card')), findsOneWidget);
expect(find.text('Depart on GA-60'), findsOneWidget);
// Exit navigation
await tester.tap(find.byKey(const ValueKey('nav_stop_button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('navigation_maneuver_card')), findsNothing);
});
}

View File

@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/navigation/navigation_session_service.dart';
import 'package:motorcycle_dash/services/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_provider.dart';
void main() {
group('NavigationSessionNotifier Tests', () {
test('initial state is idle and empty', () {
final notifier = NavigationSessionNotifier();
expect(notifier.state.status, equals(NavigationStatus.idle));
expect(notifier.state.isNavigating, isFalse);
expect(notifier.state.hasActiveRoute, isFalse);
expect(notifier.state.maneuvers, isEmpty);
});
test('startNavigation initiates turn-by-turn guidance', () async {
final notifier = NavigationSessionNotifier();
final maneuvers = [
const RouteManeuver(
index: 0,
instruction: 'Head north on Peachtree St',
type: ManeuverType.depart,
distanceMeters: 500.0,
durationSeconds: 60,
streetName: 'Peachtree St',
location: AppLatLng(33.750, -84.388),
),
const RouteManeuver(
index: 1,
instruction: 'Turn right on 10th St',
type: ManeuverType.turnRight,
distanceMeters: 1000.0,
durationSeconds: 120,
streetName: '10th St',
location: AppLatLng(33.780, -84.385),
),
const RouteManeuver(
index: 2,
instruction: 'Arrive at Piedmont Park',
type: ManeuverType.arrive,
distanceMeters: 0.0,
durationSeconds: 0,
streetName: 'Piedmont Park',
location: AppLatLng(33.790, -84.370),
),
];
final calcResult = RouteCalculationResult(
straightLineDistanceMeters: 1500.0,
estimatedDistanceMeters: 1500.0,
estimatedDurationSeconds: 180,
geometry: const [
AppLatLng(33.750, -84.388),
AppLatLng(33.780, -84.385),
AppLatLng(33.790, -84.370),
],
isStraightLine: false,
providerName: 'AWS Location',
maneuvers: maneuvers,
);
await notifier.startNavigation(
calculationResult: calcResult,
destination: const ResolvedPlace(
providerId: 'dest_1',
displayName: 'Piedmont Park',
latitude: 33.790,
longitude: -84.370,
),
);
expect(notifier.state.isNavigating, isTrue);
expect(notifier.state.status, equals(NavigationStatus.navigating));
expect(notifier.state.maneuvers.length, equals(3));
expect(notifier.state.currentManeuverIndex, equals(0));
expect(notifier.state.currentManeuver?.instruction, equals('Head north on Peachtree St'));
expect(notifier.state.nextManeuver?.instruction, equals('Turn right on 10th St'));
expect(notifier.state.remainingDistanceMeters, equals(1500.0));
expect(notifier.state.remainingDurationSeconds, equals(180));
});
test('updateLocation advances maneuvers when rider approaches waypoint', () async {
final notifier = NavigationSessionNotifier();
final maneuvers = [
const RouteManeuver(
index: 0,
instruction: 'Head north on Main',
type: ManeuverType.depart,
distanceMeters: 200.0,
durationSeconds: 30,
location: AppLatLng(33.7500, -84.3880),
),
const RouteManeuver(
index: 1,
instruction: 'Turn left on Elm',
type: ManeuverType.turnLeft,
distanceMeters: 800.0,
durationSeconds: 90,
location: AppLatLng(33.7518, -84.3880), // ~200m north
),
const RouteManeuver(
index: 2,
instruction: 'Arrive at destination',
type: ManeuverType.arrive,
distanceMeters: 0.0,
durationSeconds: 0,
location: AppLatLng(33.7518, -84.3960),
),
];
final calcResult = RouteCalculationResult(
straightLineDistanceMeters: 1000.0,
estimatedDistanceMeters: 1000.0,
estimatedDurationSeconds: 120,
geometry: const [
AppLatLng(33.7500, -84.3880),
AppLatLng(33.7518, -84.3880),
AppLatLng(33.7518, -84.3960),
],
isStraightLine: false,
providerName: 'AWS Location',
maneuvers: maneuvers,
);
await notifier.startNavigation(calculationResult: calcResult);
// Rider is approaching starting waypoint (approx 100m south)
notifier.updateLocation(const AppLatLng(33.7490, -84.3880), speedMph: 25.0);
expect(notifier.state.currentManeuverIndex, equals(0));
// Rider reaches within 10 meters of maneuver 0 point
notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0);
expect(notifier.state.currentManeuverIndex, equals(1));
expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm'));
// Pause and resume
notifier.pauseNavigation();
expect(notifier.state.status, equals(NavigationStatus.paused));
expect(notifier.state.isNavigating, isFalse);
notifier.resumeNavigation();
expect(notifier.state.status, equals(NavigationStatus.navigating));
// Stop navigation
await notifier.stopNavigation();
expect(notifier.state.status, equals(NavigationStatus.idle));
expect(notifier.state.hasActiveRoute, isFalse);
});
test('toggleTts toggles voice guidance', () {
final notifier = NavigationSessionNotifier();
expect(notifier.state.ttsEnabled, isTrue);
notifier.toggleTts();
expect(notifier.state.ttsEnabled, isFalse);
notifier.toggleTts();
expect(notifier.state.ttsEnabled, isTrue);
});
});
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
import 'package:motorcycle_dash/services/social/social_feed_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Social Hybrid UI & Feed Verification Tests', () {
testWidgets('SocialHubScreen AppBar uses surfaceContainer theme color', (tester) async {
final testRouter = GoRouter(
initialLocation: '/social',
routes: [
GoRoute(
path: '/social',
builder: (context, state) => const SocialHubScreen(),
),
],
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp.router(
theme: AppTheme.darkTheme,
routerConfig: testRouter,
),
),
);
await tester.pumpAndSettle();
final appBarFinder = find.byType(AppBar);
expect(appBarFinder, findsOneWidget);
final appBar = tester.widget<AppBar>(appBarFinder);
expect(appBar.elevation, 0);
expect(appBar.backgroundColor, MotorSignalThemeColors.dark.surfaceContainer);
});
test('SocialFeedService mock sample feeds do not claim false GPS verification', () {
final service = SocialFeedService();
final items = service.currentFeed;
expect(items, isNotEmpty);
for (final item in items) {
expect(
item.isGpsVerified,
isFalse,
reason: 'Item ${item.id} (${item.rideTitle}) must not have unverified GPS claim.',
);
}
});
testWidgets('SocialFeedScreen renders on Pixel 8a portrait without overflows and without false GPS verification badge', (tester) async {
// Pixel 8a physical portrait display is 412x915 dp
await tester.binding.setSurfaceSize(const Size(412, 915));
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [
MotorSignalThemeColors.dark,
],
),
home: const Scaffold(
body: SocialFeedScreen(),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Verify header and filter components render cleanly
expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
expect(find.text('24 ACTIVE'), findsOneWidget);
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
// Verify that NO false GPS Verified badge is rendered
expect(find.text('GPS VERIFIED'), findsNothing);
// Verify no RenderFlex overflow exceptions occurred
expect(tester.takeException(), isNull);
});
});
}