diff --git a/lib/features/dashboard/widgets/dashboard_alert_region.dart b/lib/features/dashboard/widgets/dashboard_alert_region.dart index 2b62e38..43ff963 100644 --- a/lib/features/dashboard/widgets/dashboard_alert_region.dart +++ b/lib/features/dashboard/widgets/dashboard_alert_region.dart @@ -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), ], diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index 16f09dc..a53d591 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -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 { ), ], ), - ); + ), + ), + ); }, ); } diff --git a/lib/features/dashboard/widgets/navigation_maneuver_card.dart b/lib/features/dashboard/widgets/navigation_maneuver_card.dart new file mode 100644 index 0000000..07a675b --- /dev/null +++ b/lib/features/dashboard/widgets/navigation_maneuver_card.dart @@ -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, + ), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/garage/garage_providers.dart b/lib/features/garage/garage_providers.dart index 6197808..c2cdd10 100644 --- a/lib/features/garage/garage_providers.dart +++ b/lib/features/garage/garage_providers.dart @@ -205,6 +205,44 @@ class GarageController { )); } + Future 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 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.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 updateBaseOdometer(int motorcycleId, double baseOdometer) async { final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull(); if (bike == null) return; diff --git a/lib/features/garage/garage_screen.dart b/lib/features/garage/garage_screen.dart index 9b1eaef..e5915ca 100644 --- a/lib/features/garage/garage_screen.dart +++ b/lib/features/garage/garage_screen.dart @@ -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(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( diff --git a/lib/features/garage/motorcycle_detail_screen.dart b/lib/features/garage/motorcycle_detail_screen.dart index 4e41596..54cd9c1 100644 --- a/lib/features/garage/motorcycle_detail_screen.dart +++ b/lib/features/garage/motorcycle_detail_screen.dart @@ -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(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(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( + 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, diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 19d40eb..8e771dd 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -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 { bool _isNorthUp = true; String _destinationQuery = ''; late final TextEditingController _searchController; + Timer? _searchDebounce; + bool _isSearching = false; + List _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 { @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 _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 _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 _initLiveLocation() async { final locationService = ref.read(locationServiceProvider); await locationService.requestPermissions(); @@ -743,6 +886,50 @@ class _MapScreenState extends ConsumerState { ); } + 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 = []; + 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 { 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 { ), // 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 { ), ); - 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 { 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 { 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 { 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 { 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 { ), ); } + + 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 { diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index e1c88ba..23ff3ec 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -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 [ diff --git a/lib/features/social/social_hub_screen.dart b/lib/features/social/social_hub_screen.dart index fa7c081..b981e6e 100644 --- a/lib/features/social/social_hub_screen.dart +++ b/lib/features/social/social_hub_screen.dart @@ -56,6 +56,8 @@ class _SocialHubScreenState extends ConsumerState { 'Social', style: TextStyle(fontWeight: FontWeight.w700), ), + backgroundColor: colors.surfaceContainer, + elevation: 0, actions: [ Padding( padding: const EdgeInsets.only(right: 12), diff --git a/lib/models/maintenance_models.dart b/lib/models/maintenance_models.dart index 5b83c50..fc0d47d 100644 --- a/lib/models/maintenance_models.dart +++ b/lib/models/maintenance_models.dart @@ -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 calculatePills(double totalRiddenMiles) { + if (baseOdometer == 0.0 && serviceRecords.isEmpty) { + return const []; + } + final currentOdometer = baseOdometer + totalRiddenMiles; final oilInterval = customIntervals['oil'] ?? defaultOilInterval; diff --git a/lib/services/navigation/navigation_session_service.dart b/lib/services/navigation/navigation_session_service.dart new file mode 100644 index 0000000..0d04d6e --- /dev/null +++ b/lib/services/navigation/navigation_session_service.dart @@ -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 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? 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 { + 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 _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 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 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 _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( + (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; + }, +); diff --git a/lib/services/routes/aws_location_route_provider.dart b/lib/services/routes/aws_location_route_provider.dart index 326d78b..859fb4e 100644 --- a/lib/services/routes/aws_location_route_provider.dart +++ b/lib/services/routes/aws_location_route_provider.dart @@ -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.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 = { + 'Car': { + 'Avoid': { + if (effectiveAvoidHighways) 'Highways': true, + if (effectiveAvoidTolls) 'Tolls': true, + if (effectiveAvoidFerries) 'Ferries': true, + }, + }, + }; + final bodyMap = { '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 _extractManeuvers( + List legs, + List geometry, + List orderedStops, + ) { + final maneuvers = []; + + for (final leg in legs) { + if (leg is! Map) continue; + final legDetails = leg['VehicleLegDetails'] as Map?; + final travelSteps = (legDetails?['TravelSteps'] ?? leg['TravelSteps']) + as List? ?? []; + + for (final rawStep in travelSteps) { + if (rawStep is! Map) 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 _synthesizeManeuvers( + List geometry, + List orderedStops, + ) { + final list = []; + 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 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; + } } diff --git a/lib/services/routes/route_models.dart b/lib/services/routes/route_models.dart index b54d51a..ef66ade 100644 --- a/lib/services/routes/route_models.dart +++ b/lib/services/routes/route_models.dart @@ -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 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 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, + ); + } +} diff --git a/lib/services/routes/route_provider.dart b/lib/services/routes/route_provider.dart index dfd1eb6..7eff799 100644 --- a/lib/services/routes/route_provider.dart +++ b/lib/services/routes/route_provider.dart @@ -84,6 +84,9 @@ class RouteCalculationResult { final bool isStraightLine; final String providerName; final List warnings; + final List 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.from(stops); ordered.sort((a, b) => a.order.compareTo(b.order)); diff --git a/lib/services/routes/valhalla_route_provider.dart b/lib/services/routes/valhalla_route_provider.dart index abddb38..2730326 100644 --- a/lib/services/routes/valhalla_route_provider.dart +++ b/lib/services/routes/valhalla_route_provider.dart @@ -240,6 +240,7 @@ class ValhallaRouteProvider implements RouteProvider { bool avoidHighways = false, bool avoidTolls = false, bool avoidFerries = false, + RoutingOptions? options, }) async { final ordered = List.from(stops); ordered.sort((a, b) => a.order.compareTo(b.order)); diff --git a/lib/services/social/social_feed_service.dart b/lib/services/social/social_feed_service.dart index b43068d..5fedab8 100644 --- a/lib/services/social/social_feed_service.dart +++ b/lib/services/social/social_feed_service.dart @@ -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 [ diff --git a/test/aws_location_route_provider_test.dart b/test/aws_location_route_provider_test.dart index 0d7d5d5..f80d3b9 100644 --- a/test/aws_location_route_provider_test.dart +++ b/test/aws_location_route_provider_test.dart @@ -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); }); }); } diff --git a/test/fake_route_provider.dart b/test/fake_route_provider.dart index 328c631..1a344d6 100644 --- a/test/fake_route_provider.dart +++ b/test/fake_route_provider.dart @@ -30,6 +30,7 @@ class FakeRouteProvider implements RouteProvider { bool avoidHighways = false, bool avoidTolls = false, bool avoidFerries = false, + RoutingOptions? options, }) async { lastCapturedStops = stops; diff --git a/test/garage_maintenance_test.dart b/test/garage_maintenance_test.dart new file mode 100644 index 0000000..0c03ff5 --- /dev/null +++ b/test/garage_maintenance_test.dart @@ -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); + }); + }); +} diff --git a/test/garage_sync_ui_test.dart b/test/garage_sync_ui_test.dart new file mode 100644 index 0000000..018a766 --- /dev/null +++ b/test/garage_sync_ui_test.dart @@ -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 implements AuthNotifier { + MockAuthNotifier(super.state); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/lean_angle_tap_calibrate_test.dart b/test/lean_angle_tap_calibrate_test.dart new file mode 100644 index 0000000..997d48c --- /dev/null +++ b/test/lean_angle_tap_calibrate_test.dart @@ -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 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 calibrateUpright({int? motorcycleId}) async { + onCalibrate(); + } +} diff --git a/test/map_destination_search_test.dart b/test/map_destination_search_test.dart new file mode 100644 index 0000000..33c4767 --- /dev/null +++ b/test/map_destination_search_test.dart @@ -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> 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 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 calculateRoute( + List 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); + }); +} diff --git a/test/navigation_session_service_test.dart b/test/navigation_session_service_test.dart new file mode 100644 index 0000000..678d6ae --- /dev/null +++ b/test/navigation_session_service_test.dart @@ -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); + }); + }); +} diff --git a/test/social_hybrid_ui_test.dart b/test/social_hybrid_ui_test.dart new file mode 100644 index 0000000..4223fde --- /dev/null +++ b/test/social_hybrid_ui_test.dart @@ -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(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); + }); + }); +}