feat(navigation): implement shared turn-by-turn navigation, route preferences, and garage/telemetry enhancements
This commit is contained in:
parent
28dc54a0c5
commit
618ef8798b
|
|
@ -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_models.dart';
|
||||||
import '../../../services/group_rides/group_ride_providers.dart';
|
import '../../../services/group_rides/group_ride_providers.dart';
|
||||||
import '../../../services/location_service.dart';
|
import '../../../services/location_service.dart';
|
||||||
|
import 'navigation_maneuver_card.dart';
|
||||||
|
|
||||||
/// Top alert and notification banner region for the dashboard.
|
/// Top alert and notification banner region for the dashboard.
|
||||||
///
|
///
|
||||||
|
|
@ -32,6 +33,7 @@ class DashboardAlertRegion extends ConsumerWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (!hasLocPermission) _buildPermissionBanner(context, ref),
|
if (!hasLocPermission) _buildPermissionBanner(context, ref),
|
||||||
|
const NavigationManeuverCard(),
|
||||||
if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref),
|
if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref),
|
||||||
if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide),
|
if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/theme/theme_extensions.dart';
|
import '../../../core/theme/theme_extensions.dart';
|
||||||
import '../../rides/ride_state_machine.dart';
|
import '../../rides/ride_state_machine.dart';
|
||||||
import '../../../services/telemetry_service.dart';
|
import '../../../services/telemetry_service.dart';
|
||||||
|
import '../../../services/sensor_fusion_service.dart';
|
||||||
|
|
||||||
/// Leaf visualizer for motorcycle lean angle with authentic motorcycle gauge.
|
/// 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)}°' : '--°';
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final availableWidth = constraints.maxWidth;
|
final availableWidth = constraints.maxWidth;
|
||||||
final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0);
|
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;
|
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
|
label: isAvailable
|
||||||
? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
|
? '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.',
|
: 'Lean angle: uncalibrated. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
|
||||||
|
|
@ -177,7 +236,9 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -205,6 +205,44 @@ class GarageController {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateServiceRecord(int motorcycleId, ServiceRecord updatedRecord) async {
|
||||||
|
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
|
||||||
|
if (bike == null) return;
|
||||||
|
|
||||||
|
final currentData = MaintenanceData.fromSpecificationJson(bike.specificationJson);
|
||||||
|
final updatedRecords = currentData.serviceRecords.map((r) => r.id == updatedRecord.id ? updatedRecord : r).toList();
|
||||||
|
|
||||||
|
final updatedJson = MaintenanceData.updateSpecJsonWithMaintenance(
|
||||||
|
originalSpecJson: bike.specificationJson,
|
||||||
|
serviceRecords: updatedRecords,
|
||||||
|
);
|
||||||
|
|
||||||
|
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId)))
|
||||||
|
.write(GarageMotorcyclesCompanion(
|
||||||
|
specificationJson: Value(updatedJson),
|
||||||
|
lastUpdated: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateCustomInterval(int motorcycleId, String key, int intervalMiles) async {
|
||||||
|
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
|
||||||
|
if (bike == null) return;
|
||||||
|
|
||||||
|
final currentData = MaintenanceData.fromSpecificationJson(bike.specificationJson);
|
||||||
|
final updatedIntervals = Map<String, int>.from(currentData.customIntervals)..[key] = intervalMiles;
|
||||||
|
|
||||||
|
final updatedJson = MaintenanceData.updateSpecJsonWithMaintenance(
|
||||||
|
originalSpecJson: bike.specificationJson,
|
||||||
|
customIntervals: updatedIntervals,
|
||||||
|
);
|
||||||
|
|
||||||
|
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId)))
|
||||||
|
.write(GarageMotorcyclesCompanion(
|
||||||
|
specificationJson: Value(updatedJson),
|
||||||
|
lastUpdated: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateBaseOdometer(int motorcycleId, double baseOdometer) async {
|
Future<void> updateBaseOdometer(int motorcycleId, double baseOdometer) async {
|
||||||
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
|
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
|
||||||
if (bike == null) return;
|
if (bike == null) return;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'widgets/motorcycle_photo.dart';
|
||||||
import '../../services/entitlements/entitlement_providers.dart';
|
import '../../services/entitlements/entitlement_providers.dart';
|
||||||
import '../../services/entitlements/feature_access_service.dart';
|
import '../../services/entitlements/feature_access_service.dart';
|
||||||
import '../../services/sync/sync_providers.dart';
|
import '../../services/sync/sync_providers.dart';
|
||||||
|
import '../../services/auth/auth_providers.dart';
|
||||||
import '../entitlements/widgets/pro_lock_badge.dart';
|
import '../entitlements/widgets/pro_lock_badge.dart';
|
||||||
|
|
||||||
class GarageScreen extends ConsumerWidget {
|
class GarageScreen extends ConsumerWidget {
|
||||||
|
|
@ -20,6 +21,8 @@ class GarageScreen extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
final isSyncing = ref.watch(isSyncProcessingProvider);
|
||||||
|
final pendingCount = ref.watch(pendingSyncCountProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -28,16 +31,72 @@ class GarageScreen extends ConsumerWidget {
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (isSyncing)
|
||||||
tooltip: 'Sync Garage with Cloud',
|
const Padding(
|
||||||
icon: const Icon(Icons.cloud_sync_outlined),
|
padding: EdgeInsets.symmetric(horizontal: 14),
|
||||||
onPressed: () async {
|
child: SizedBox(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
width: 20,
|
||||||
const SnackBar(content: Text('Syncing garage with cloud...')),
|
height: 20,
|
||||||
);
|
child: CircularProgressIndicator(
|
||||||
await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
|
key: ValueKey('garage_sync_spinner'),
|
||||||
},
|
strokeWidth: 2,
|
||||||
),
|
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF00E5FF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('garage_sync_button'),
|
||||||
|
tooltip: pendingCount > 0
|
||||||
|
? 'Sync Garage ($pendingCount pending)'
|
||||||
|
: 'Sync Garage with Cloud',
|
||||||
|
icon: Badge(
|
||||||
|
isLabelVisible: pendingCount > 0,
|
||||||
|
label: Text('$pendingCount'),
|
||||||
|
child: const Icon(Icons.cloud_sync_outlined),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final auth = ref.read(authNotifierProvider);
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
key: ValueKey('garage_sync_auth_required_snackbar'),
|
||||||
|
content: Text('Sign in to sync your garage with MotorCloud.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
key: ValueKey('garage_sync_started_snackbar'),
|
||||||
|
content: Text('Syncing garage and telemetry with cloud...'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
|
||||||
|
await ref.read(syncManagerProvider).processQueue();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
key: ValueKey('garage_sync_success_snackbar'),
|
||||||
|
content: Text('Garage and rides synced successfully.'),
|
||||||
|
backgroundColor: Color(0xFF10B981),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
key: const ValueKey('garage_sync_error_snackbar'),
|
||||||
|
content: Text('Sync failed: $e. Changes are queued offline.'),
|
||||||
|
backgroundColor: const Color(0xFFEF4444),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: bikesAsync.when(
|
body: bikesAsync.when(
|
||||||
|
|
|
||||||
|
|
@ -789,26 +789,55 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Countdown Pills Grid
|
// Countdown Pills Grid
|
||||||
LayoutBuilder(
|
if (pills.isEmpty)
|
||||||
builder: (context, constraints) {
|
Container(
|
||||||
final width = constraints.maxWidth;
|
width: double.infinity,
|
||||||
final itemWidth = width >= 480
|
padding: const EdgeInsets.all(12),
|
||||||
? (width - 20) / 3
|
decoration: BoxDecoration(
|
||||||
: (width >= 280 ? (width - 10) / 2 : width);
|
color: colors.surfaceContainerHigh.withValues(alpha: 0.5),
|
||||||
return Wrap(
|
borderRadius: BorderRadius.circular(10),
|
||||||
spacing: 10,
|
border: Border.all(
|
||||||
runSpacing: 10,
|
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||||
children: pills
|
),
|
||||||
.map(
|
),
|
||||||
(pill) => SizedBox(
|
child: Row(
|
||||||
width: itemWidth,
|
children: [
|
||||||
child: _buildMaintenancePill(context, pill, colors, theme),
|
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),
|
const SizedBox(height: 20),
|
||||||
Divider(color: colors.outlineVariant.withValues(alpha: 0.4), height: 1),
|
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(
|
IconButton(
|
||||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.outlineVariant),
|
icon: Icon(Icons.delete_outline, size: 16, color: colors.outlineVariant),
|
||||||
tooltip: 'Delete Service Record',
|
tooltip: 'Delete Service Record',
|
||||||
|
|
@ -979,70 +1016,113 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
|
|
||||||
Widget _buildMaintenancePill(
|
Widget _buildMaintenancePill(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
GarageMotorcycle bike,
|
||||||
MaintenanceItemStatus pill,
|
MaintenanceItemStatus pill,
|
||||||
|
double currentOdometer,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
) {
|
) {
|
||||||
final badgeColor = pill.getBadgeColor(context);
|
final badgeColor = pill.getBadgeColor(context);
|
||||||
final isUrgent = pill.urgency == MaintenanceUrgency.overdue || pill.urgency == MaintenanceUrgency.dueSoon;
|
final isUrgent = pill.urgency == MaintenanceUrgency.overdue || pill.urgency == MaintenanceUrgency.dueSoon;
|
||||||
|
|
||||||
return Container(
|
return InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
decoration: BoxDecoration(
|
onTap: () => _showLogServiceForPill(context, ref, bike, pill, currentOdometer, colors, theme),
|
||||||
color: badgeColor.withValues(alpha: 0.08),
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||||
border: Border.all(
|
decoration: BoxDecoration(
|
||||||
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
|
color: badgeColor.withValues(alpha: 0.08),
|
||||||
width: isUrgent ? 1.5 : 1.0,
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
|
||||||
|
width: isUrgent ? 1.5 : 1.0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Icon(pill.icon, size: 15, color: badgeColor),
|
||||||
Icon(pill.icon, size: 15, color: badgeColor),
|
const SizedBox(width: 5),
|
||||||
const SizedBox(width: 5),
|
Expanded(
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Text(
|
pill.title,
|
||||||
pill.title,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontSize: 11,
|
||||||
fontSize: 11,
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
fontFamily: 'JetBrains Mono',
|
||||||
fontFamily: 'JetBrains Mono',
|
color: theme.colorScheme.onSurface,
|
||||||
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),
|
||||||
const SizedBox(height: 6),
|
ClipRRect(
|
||||||
ClipRRect(
|
borderRadius: BorderRadius.circular(2),
|
||||||
borderRadius: BorderRadius.circular(2),
|
child: LinearProgressIndicator(
|
||||||
child: LinearProgressIndicator(
|
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
|
||||||
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
|
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
|
||||||
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
|
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
|
minHeight: 3,
|
||||||
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(
|
void _showSetOdometerDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|
@ -1097,8 +1177,9 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
GarageMotorcycle bike,
|
GarageMotorcycle bike,
|
||||||
double currentOdometer,
|
double currentOdometer,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
ThemeData theme,
|
ThemeData theme, {
|
||||||
) {
|
String? initialServiceType,
|
||||||
|
}) {
|
||||||
final serviceTypes = [
|
final serviceTypes = [
|
||||||
'Engine Oil & Filter',
|
'Engine Oil & Filter',
|
||||||
'Chain Clean & Lube',
|
'Chain Clean & Lube',
|
||||||
|
|
@ -1113,7 +1194,9 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
'General Maintenance',
|
'General Maintenance',
|
||||||
];
|
];
|
||||||
|
|
||||||
String selectedType = serviceTypes[0];
|
String selectedType = (initialServiceType != null && serviceTypes.contains(initialServiceType))
|
||||||
|
? initialServiceType
|
||||||
|
: serviceTypes[0];
|
||||||
final odoController = TextEditingController(text: currentOdometer.round().toString());
|
final odoController = TextEditingController(text: currentOdometer.round().toString());
|
||||||
final notesController = TextEditingController();
|
final notesController = TextEditingController();
|
||||||
final costController = TextEditingController();
|
final costController = TextEditingController();
|
||||||
|
|
@ -1213,6 +1296,130 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showEditServiceRecordDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
GarageMotorcycle bike,
|
||||||
|
ServiceRecord record,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
final serviceTypes = [
|
||||||
|
'Engine Oil & Filter',
|
||||||
|
'Chain Clean & Lube',
|
||||||
|
'Tire Replacement (Rear)',
|
||||||
|
'Tire Replacement (Front)',
|
||||||
|
'Tire Replacement (Set)',
|
||||||
|
'Brake Fluid Flush',
|
||||||
|
'Brake Pads Replacement',
|
||||||
|
'Air Filter Replacement',
|
||||||
|
'Spark Plugs',
|
||||||
|
'Valve Clearance Check',
|
||||||
|
'General Maintenance',
|
||||||
|
];
|
||||||
|
|
||||||
|
String selectedType = serviceTypes.contains(record.serviceType)
|
||||||
|
? record.serviceType
|
||||||
|
: serviceTypes.last;
|
||||||
|
final odoController = TextEditingController(text: record.odometer.round().toString());
|
||||||
|
final notesController = TextEditingController(text: record.notes ?? '');
|
||||||
|
final costController = TextEditingController(text: record.cost != null ? record.cost!.toStringAsFixed(2) : '');
|
||||||
|
DateTime serviceDate = record.date;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Edit Service Record'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Service Type'),
|
||||||
|
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setState(() => selectedType = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: odoController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Odometer at Service',
|
||||||
|
suffixText: 'mi',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Service Date', style: TextStyle(fontSize: 12)),
|
||||||
|
subtitle: Text(DateFormat('MMMM d, yyyy').format(serviceDate)),
|
||||||
|
trailing: const Icon(Icons.calendar_today, size: 18),
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: serviceDate,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => serviceDate = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: costController,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Cost (Optional)',
|
||||||
|
prefixText: '\$ ',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: notesController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes (e.g. Brand, weight, shop)',
|
||||||
|
hintText: 'e.g. Motul 300V 10W-40, OEM filter',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final odo = double.tryParse(odoController.text.trim()) ?? record.odometer;
|
||||||
|
final cost = double.tryParse(costController.text.trim());
|
||||||
|
final updated = ServiceRecord(
|
||||||
|
id: record.id,
|
||||||
|
date: serviceDate,
|
||||||
|
odometer: odo,
|
||||||
|
serviceType: selectedType,
|
||||||
|
notes: notesController.text.trim().isNotEmpty ? notesController.text.trim() : null,
|
||||||
|
cost: cost,
|
||||||
|
);
|
||||||
|
ref.read(garageControllerProvider).updateServiceRecord(bike.id, updated);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Save Changes'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _confirmDeleteServiceRecord(
|
void _confirmDeleteServiceRecord(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
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/road_alert_service.dart';
|
||||||
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||||
import '../../services/map/map_layer_settings.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 'widgets/tactical_layers_bottom_sheet.dart';
|
||||||
import '../replay/replay_screen.dart';
|
import '../replay/replay_screen.dart';
|
||||||
|
|
||||||
|
|
@ -43,6 +50,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
bool _isNorthUp = true;
|
bool _isNorthUp = true;
|
||||||
String _destinationQuery = '';
|
String _destinationQuery = '';
|
||||||
late final TextEditingController _searchController;
|
late final TextEditingController _searchController;
|
||||||
|
Timer? _searchDebounce;
|
||||||
|
bool _isSearching = false;
|
||||||
|
List<PlaceSearchResult> _searchResults = [];
|
||||||
|
ResolvedPlace? _selectedDestination;
|
||||||
|
RouteCalculationResult? _previewRouteResult;
|
||||||
|
bool _isCalculatingRoute = false;
|
||||||
|
RoutingOptions _routingOptions = const RoutingOptions();
|
||||||
|
|
||||||
static const double _hardAccelThresholdG = 0.35;
|
static const double _hardAccelThresholdG = 0.35;
|
||||||
static const double _hardBrakingThresholdG = -0.35;
|
static const double _hardBrakingThresholdG = -0.35;
|
||||||
|
|
@ -64,10 +78,139 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onSearchQueryChanged(String query, AppLatLng currentPos) {
|
||||||
|
setState(() => _destinationQuery = query);
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
final clean = query.trim();
|
||||||
|
if (clean.length < 2) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = [];
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSearching = true);
|
||||||
|
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
|
||||||
|
try {
|
||||||
|
final results = await ref
|
||||||
|
.read(activeRouteCalculationProvider)
|
||||||
|
.searchPlaces(clean, nearLocation: currentPos);
|
||||||
|
if (mounted && _destinationQuery.trim() == clean) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = results;
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _isSearching = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectPlace(
|
||||||
|
PlaceSearchResult place,
|
||||||
|
AppLatLng currentPos,
|
||||||
|
) async {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_searchResults = [];
|
||||||
|
_searchController.text = place.primaryText;
|
||||||
|
_destinationQuery = place.primaryText;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resolved = place.hasCoordinates
|
||||||
|
? place.toResolvedPlace()!
|
||||||
|
: await ref
|
||||||
|
.read(activeRouteCalculationProvider)
|
||||||
|
.resolvePlace(place.providerId);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _selectedDestination = resolved);
|
||||||
|
await _calculateRouteToDestination(resolved, currentPos);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to resolve place: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _calculateRouteToDestination(
|
||||||
|
ResolvedPlace dest,
|
||||||
|
AppLatLng currentPos,
|
||||||
|
) async {
|
||||||
|
setState(() => _isCalculatingRoute = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final origin = RouteStop(
|
||||||
|
id: 'origin',
|
||||||
|
order: 0,
|
||||||
|
displayName: 'Current Location',
|
||||||
|
latitude: currentPos.latitude,
|
||||||
|
longitude: currentPos.longitude,
|
||||||
|
);
|
||||||
|
final destinationStop = RouteStop(
|
||||||
|
id: 'dest',
|
||||||
|
order: 1,
|
||||||
|
displayName: dest.displayName,
|
||||||
|
latitude: dest.latitude,
|
||||||
|
longitude: dest.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await ref
|
||||||
|
.read(activeRouteCalculationProvider)
|
||||||
|
.calculateRoute([origin, destinationStop], options: _routingOptions);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_previewRouteResult = result;
|
||||||
|
_isCalculatingRoute = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isCalculatingRoute = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Route calculation error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearDestination() {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_destinationQuery = '';
|
||||||
|
_searchResults = [];
|
||||||
|
_selectedDestination = null;
|
||||||
|
_previewRouteResult = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startTurnByTurnNavigation() {
|
||||||
|
if (_previewRouteResult == null || _selectedDestination == null) return;
|
||||||
|
ref.read(navigationSessionProvider.notifier).startNavigation(
|
||||||
|
calculationResult: _previewRouteResult!,
|
||||||
|
destination: _selectedDestination,
|
||||||
|
options: _routingOptions,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Navigation started to ${_selectedDestination!.displayName}'),
|
||||||
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initLiveLocation() async {
|
Future<void> _initLiveLocation() async {
|
||||||
final locationService = ref.read(locationServiceProvider);
|
final locationService = ref.read(locationServiceProvider);
|
||||||
await locationService.requestPermissions();
|
await locationService.requestPermissions();
|
||||||
|
|
@ -743,6 +886,50 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final navSession = ref.watch(navigationSessionProvider);
|
||||||
|
|
||||||
|
if (_selectedDestination != null) {
|
||||||
|
mapMarkers.add(
|
||||||
|
AppMapMarker(
|
||||||
|
id: 'destination_marker',
|
||||||
|
position: _selectedDestination!.latLng,
|
||||||
|
label: _selectedDestination!.displayName,
|
||||||
|
iconWidget: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFF0055),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.flag, color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final livePolylines = <AppPolyline>[];
|
||||||
|
if (_previewRouteResult != null &&
|
||||||
|
_previewRouteResult!.geometry.isNotEmpty) {
|
||||||
|
livePolylines.add(
|
||||||
|
AppPolyline(
|
||||||
|
id: 'route_preview_polyline',
|
||||||
|
points: _previewRouteResult!.geometry,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
width: 5.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (navSession.isNavigating &&
|
||||||
|
navSession.calculationResult?.geometry.isNotEmpty == true) {
|
||||||
|
livePolylines.add(
|
||||||
|
AppPolyline(
|
||||||
|
id: 'nav_active_polyline',
|
||||||
|
points: navSession.calculationResult!.geometry,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
width: 5.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBodyBehindAppBar: isShortLandscape,
|
extendBodyBehindAppBar: isShortLandscape,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -761,17 +948,40 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
center: currentPos,
|
center: currentPos,
|
||||||
zoom: _currentZoom,
|
zoom: _currentZoom,
|
||||||
markers: mapMarkers,
|
markers: mapMarkers,
|
||||||
|
polylines: livePolylines,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Top Floating Search Card (portrait and large screens)
|
// Active Navigation Maneuver HUD or Top Search & Preview Cards
|
||||||
if (!isShortLandscape)
|
if (navSession.isNavigating)
|
||||||
|
const Positioned(
|
||||||
|
top: 10,
|
||||||
|
left: 14,
|
||||||
|
right: 14,
|
||||||
|
child: NavigationManeuverCard(),
|
||||||
|
)
|
||||||
|
else if (!isShortLandscape) ...[
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 14,
|
left: 14,
|
||||||
right: 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
|
// Offline Cached Only Warning Pill
|
||||||
if (layerSettings.offlineOnly)
|
if (layerSettings.offlineOnly)
|
||||||
|
|
@ -868,24 +1078,25 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom-left Quick Swarm Report Button
|
// Bottom-left Quick Swarm Report Button
|
||||||
Positioned(
|
if (_previewRouteResult == null && !navSession.isNavigating)
|
||||||
bottom: 24,
|
Positioned(
|
||||||
left: 20,
|
bottom: 24,
|
||||||
child: FloatingActionButton.extended(
|
left: 20,
|
||||||
key: const ValueKey('quick_swarm_report_button'),
|
child: FloatingActionButton.extended(
|
||||||
backgroundColor: const Color(0xFFFF9100),
|
key: const ValueKey('quick_swarm_report_button'),
|
||||||
foregroundColor: Colors.black,
|
backgroundColor: const Color(0xFFFF9100),
|
||||||
icon: const Icon(Icons.campaign, size: 22),
|
foregroundColor: Colors.black,
|
||||||
label: const Text(
|
icon: const Icon(Icons.campaign, size: 22),
|
||||||
'REPORT',
|
label: const Text(
|
||||||
style: TextStyle(
|
'REPORT',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
letterSpacing: 0.6,
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
onPressed: () => QuickSwarmReportModal.show(context),
|
||||||
),
|
),
|
||||||
onPressed: () => QuickSwarmReportModal.show(context),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1009,7 +1220,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildTopSearchCard(MotorSignalThemeColors colors) {
|
Widget _buildTopSearchCard(MotorSignalThemeColors colors, AppLatLng currentPos) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
|
|
@ -1030,7 +1241,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
|
@ -1051,18 +1269,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
),
|
),
|
||||||
onChanged: (val) {
|
onChanged: (val) => _onSearchQueryChanged(val, currentPos),
|
||||||
setState(() => _destinationQuery = val);
|
onSubmitted: (val) => _onSearchQueryChanged(val, currentPos),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_destinationQuery.isNotEmpty)
|
if (_destinationQuery.isNotEmpty)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
key: const ValueKey('clear_search_button'),
|
key: const ValueKey('clear_search_button'),
|
||||||
onTap: () {
|
onTap: _clearDestination,
|
||||||
_searchController.clear();
|
|
||||||
setState(() => _destinationQuery = '');
|
|
||||||
},
|
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
size: 18,
|
size: 18,
|
||||||
|
|
@ -1080,24 +1294,28 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
label: 'Gas & Fuel',
|
label: 'Gas & Fuel',
|
||||||
icon: Icons.local_gas_station,
|
icon: Icons.local_gas_station,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
_buildQuickDestinationChip(
|
_buildQuickDestinationChip(
|
||||||
label: 'Twisty Passes',
|
label: 'Twisty Passes',
|
||||||
icon: Icons.alt_route,
|
icon: Icons.alt_route,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
_buildQuickDestinationChip(
|
_buildQuickDestinationChip(
|
||||||
label: 'Scenic Rest',
|
label: 'Scenic Rest',
|
||||||
icon: Icons.photo_camera,
|
icon: Icons.photo_camera,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
_buildQuickDestinationChip(
|
_buildQuickDestinationChip(
|
||||||
label: 'Repair/Moto',
|
label: 'Repair/Moto',
|
||||||
icon: Icons.build,
|
icon: Icons.build,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1112,21 +1330,19 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
required String label,
|
required String label,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required MotorSignalThemeColors colors,
|
required MotorSignalThemeColors colors,
|
||||||
|
required AppLatLng currentPos,
|
||||||
}) {
|
}) {
|
||||||
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
|
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
|
||||||
return InkWell(
|
return InkWell(
|
||||||
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
|
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
if (isSelected) {
|
||||||
if (isSelected) {
|
_clearDestination();
|
||||||
_destinationQuery = '';
|
} else {
|
||||||
_searchController.clear();
|
_searchController.text = label;
|
||||||
} else {
|
_onSearchQueryChanged(label, currentPos);
|
||||||
_destinationQuery = label;
|
}
|
||||||
_searchController.text = label;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
|
@ -1165,6 +1381,301 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchResultsOverlay(
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
AppLatLng currentPos,
|
||||||
|
) {
|
||||||
|
return Card(
|
||||||
|
key: const ValueKey('search_results_overlay'),
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: Colors.black87,
|
||||||
|
color: colors.surfaceContainer.withValues(alpha: 0.98),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: colors.cyberCyan.withValues(alpha: 0.4),
|
||||||
|
width: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 280),
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
itemCount: _searchResults.length,
|
||||||
|
separatorBuilder: (_, _) => Divider(
|
||||||
|
height: 1,
|
||||||
|
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _searchResults[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.cyberCyan.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
item.hasCoordinates ? Icons.pin_drop : Icons.place_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
item.primaryText,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: item.secondaryText.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
item.secondaryText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _onSelectPlace(item, currentPos),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRoutePreviewCard(
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
AppLatLng currentPos,
|
||||||
|
) {
|
||||||
|
final preview = _previewRouteResult!;
|
||||||
|
final dest = _selectedDestination;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
key: const ValueKey('route_preview_card'),
|
||||||
|
elevation: 8,
|
||||||
|
color: colors.surfaceContainer.withValues(alpha: 0.95),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: colors.cyberCyan.withValues(alpha: 0.6),
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
dest?.displayName ?? 'Destination Route',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${preview.distanceMiles.toStringAsFixed(1)} mi • ${preview.formattedDuration}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF10B981).withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFF10B981),
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
preview.twistinessRating,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: 9.5,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF10B981),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('clear_route_button'),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white70),
|
||||||
|
onPressed: _clearDestination,
|
||||||
|
tooltip: 'Cancel Destination',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Routing Preferences Selector Row
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildPreferenceChip(
|
||||||
|
preference: RoutingPreference.fastest,
|
||||||
|
label: 'Fastest',
|
||||||
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildPreferenceChip(
|
||||||
|
preference: RoutingPreference.shortest,
|
||||||
|
label: 'Shortest',
|
||||||
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildPreferenceChip(
|
||||||
|
preference: RoutingPreference.twisty,
|
||||||
|
label: 'Twisty (Curves)',
|
||||||
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildPreferenceChip(
|
||||||
|
preference: RoutingPreference.scenic,
|
||||||
|
label: 'Scenic (No Hwy)',
|
||||||
|
colors: colors,
|
||||||
|
currentPos: currentPos,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Navigation Actions: Start Turn-by-Turn & External Handoff
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const ValueKey('start_navigation_button'),
|
||||||
|
icon: const Icon(Icons.navigation, size: 18),
|
||||||
|
label: const Text(
|
||||||
|
'START NAVIGATION',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: colors.cyberCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _startTurnByTurnNavigation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
key: const ValueKey('handoff_navigation_button'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colors.cyberCyan,
|
||||||
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (dest != null) {
|
||||||
|
NavigationHandoffService.showHandoffSheet(
|
||||||
|
context: context,
|
||||||
|
latitude: dest.latitude,
|
||||||
|
longitude: dest.longitude,
|
||||||
|
displayName: dest.displayName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPreferenceChip({
|
||||||
|
required RoutingPreference preference,
|
||||||
|
required String label,
|
||||||
|
required MotorSignalThemeColors colors,
|
||||||
|
required AppLatLng currentPos,
|
||||||
|
}) {
|
||||||
|
final isSelected = _routingOptions.preference == preference;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isSelected ? Colors.black : Colors.white70,
|
||||||
|
),
|
||||||
|
selectedColor: colors.cyberCyan,
|
||||||
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() {
|
||||||
|
_routingOptions = _routingOptions.copyWith(
|
||||||
|
preference: preference,
|
||||||
|
avoidHighways: preference == RoutingPreference.twisty ||
|
||||||
|
preference == RoutingPreference.scenic,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (_selectedDestination != null) {
|
||||||
|
_calculateRouteToDestination(_selectedDestination!, currentPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HudIconButton extends StatelessWidget {
|
class _HudIconButton extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -1636,13 +1636,14 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
if (item.isGpsVerified)
|
||||||
padding: const EdgeInsets.symmetric(
|
Container(
|
||||||
horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: 8, vertical: 4),
|
||||||
color: const Color(0xFF101419).withValues(alpha: 0.85),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(6),
|
color: const Color(0xFF101419).withValues(alpha: 0.85),
|
||||||
),
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: const [
|
children: const [
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
|
||||||
'Social',
|
'Social',
|
||||||
style: TextStyle(fontWeight: FontWeight.w700),
|
style: TextStyle(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
|
backgroundColor: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,13 @@ class MaintenanceData {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates real-time countdown pills for all standard maintenance categories.
|
/// Calculates real-time countdown pills for all standard maintenance categories.
|
||||||
|
/// Returns empty list if no odometer is set and no service records exist yet,
|
||||||
|
/// avoiding fake synthetic countdowns.
|
||||||
List<MaintenanceItemStatus> calculatePills(double totalRiddenMiles) {
|
List<MaintenanceItemStatus> calculatePills(double totalRiddenMiles) {
|
||||||
|
if (baseOdometer == 0.0 && serviceRecords.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
final currentOdometer = baseOdometer + totalRiddenMiles;
|
final currentOdometer = baseOdometer + totalRiddenMiles;
|
||||||
|
|
||||||
final oilInterval = customIntervals['oil'] ?? defaultOilInterval;
|
final oilInterval = customIntervals['oil'] ?? defaultOilInterval;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../location_service.dart';
|
||||||
|
import '../map/map_provider_interface.dart';
|
||||||
|
import '../routes/route_models.dart';
|
||||||
|
import '../routes/route_provider.dart';
|
||||||
|
|
||||||
|
/// Navigation session status lifecycle.
|
||||||
|
enum NavigationStatus {
|
||||||
|
idle,
|
||||||
|
navigating,
|
||||||
|
paused,
|
||||||
|
rerouting,
|
||||||
|
arrived,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of an active turn-by-turn navigation session.
|
||||||
|
@immutable
|
||||||
|
class NavigationSessionState {
|
||||||
|
final NavigationStatus status;
|
||||||
|
final MotorSignalRoute? route;
|
||||||
|
final ResolvedPlace? destination;
|
||||||
|
final RouteCalculationResult? calculationResult;
|
||||||
|
final List<RouteManeuver> maneuvers;
|
||||||
|
final int currentManeuverIndex;
|
||||||
|
final double distanceToNextManeuverMeters;
|
||||||
|
final double remainingDistanceMeters;
|
||||||
|
final int remainingDurationSeconds;
|
||||||
|
final bool ttsEnabled;
|
||||||
|
final String? lastAnnouncedInstruction;
|
||||||
|
final RoutingOptions routingOptions;
|
||||||
|
final DateTime? startedAt;
|
||||||
|
|
||||||
|
const NavigationSessionState({
|
||||||
|
this.status = NavigationStatus.idle,
|
||||||
|
this.route,
|
||||||
|
this.destination,
|
||||||
|
this.calculationResult,
|
||||||
|
this.maneuvers = const [],
|
||||||
|
this.currentManeuverIndex = 0,
|
||||||
|
this.distanceToNextManeuverMeters = 0.0,
|
||||||
|
this.remainingDistanceMeters = 0.0,
|
||||||
|
this.remainingDurationSeconds = 0,
|
||||||
|
this.ttsEnabled = true,
|
||||||
|
this.lastAnnouncedInstruction,
|
||||||
|
this.routingOptions = const RoutingOptions(),
|
||||||
|
this.startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isNavigating => status == NavigationStatus.navigating;
|
||||||
|
bool get hasActiveRoute => maneuvers.isNotEmpty;
|
||||||
|
|
||||||
|
RouteManeuver? get currentManeuver =>
|
||||||
|
currentManeuverIndex < maneuvers.length ? maneuvers[currentManeuverIndex] : null;
|
||||||
|
|
||||||
|
RouteManeuver? get nextManeuver =>
|
||||||
|
(currentManeuverIndex + 1) < maneuvers.length
|
||||||
|
? maneuvers[currentManeuverIndex + 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
double get distanceToNextMiles =>
|
||||||
|
distanceToNextManeuverMeters * 0.000621371;
|
||||||
|
|
||||||
|
double get remainingDistanceMiles =>
|
||||||
|
remainingDistanceMeters * 0.000621371;
|
||||||
|
|
||||||
|
String get formattedDistanceToNext {
|
||||||
|
if (distanceToNextMiles < 0.1) {
|
||||||
|
final feet = (distanceToNextManeuverMeters * 3.28084).round();
|
||||||
|
return '$feet ft';
|
||||||
|
}
|
||||||
|
return '${distanceToNextMiles.toStringAsFixed(1)} mi';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedRemainingDistance {
|
||||||
|
return '${remainingDistanceMiles.toStringAsFixed(1)} mi';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedEta {
|
||||||
|
if (remainingDurationSeconds <= 0) return '--:--';
|
||||||
|
final arrival = DateTime.now().add(Duration(seconds: remainingDurationSeconds));
|
||||||
|
return DateFormat('h:mm a').format(arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedDurationRemaining {
|
||||||
|
if (remainingDurationSeconds <= 0) return '0 min';
|
||||||
|
final totalSec = remainingDurationSeconds;
|
||||||
|
final hours = totalSec ~/ 3600;
|
||||||
|
final minutes = (totalSec % 3600) ~/ 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes.clamp(1, 60)} min';
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationSessionState copyWith({
|
||||||
|
NavigationStatus? status,
|
||||||
|
MotorSignalRoute? route,
|
||||||
|
ResolvedPlace? destination,
|
||||||
|
RouteCalculationResult? calculationResult,
|
||||||
|
List<RouteManeuver>? maneuvers,
|
||||||
|
int? currentManeuverIndex,
|
||||||
|
double? distanceToNextManeuverMeters,
|
||||||
|
double? remainingDistanceMeters,
|
||||||
|
int? remainingDurationSeconds,
|
||||||
|
bool? ttsEnabled,
|
||||||
|
String? lastAnnouncedInstruction,
|
||||||
|
RoutingOptions? routingOptions,
|
||||||
|
DateTime? startedAt,
|
||||||
|
}) {
|
||||||
|
return NavigationSessionState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
route: route ?? this.route,
|
||||||
|
destination: destination ?? this.destination,
|
||||||
|
calculationResult: calculationResult ?? this.calculationResult,
|
||||||
|
maneuvers: maneuvers ?? this.maneuvers,
|
||||||
|
currentManeuverIndex: currentManeuverIndex ?? this.currentManeuverIndex,
|
||||||
|
distanceToNextManeuverMeters:
|
||||||
|
distanceToNextManeuverMeters ?? this.distanceToNextManeuverMeters,
|
||||||
|
remainingDistanceMeters:
|
||||||
|
remainingDistanceMeters ?? this.remainingDistanceMeters,
|
||||||
|
remainingDurationSeconds:
|
||||||
|
remainingDurationSeconds ?? this.remainingDurationSeconds,
|
||||||
|
ttsEnabled: ttsEnabled ?? this.ttsEnabled,
|
||||||
|
lastAnnouncedInstruction:
|
||||||
|
lastAnnouncedInstruction ?? this.lastAnnouncedInstruction,
|
||||||
|
routingOptions: routingOptions ?? this.routingOptions,
|
||||||
|
startedAt: startedAt ?? this.startedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller managing live motorcycle turn-by-turn navigation session.
|
||||||
|
class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
|
final FlutterTts? _tts;
|
||||||
|
bool _ttsInitialized = false;
|
||||||
|
|
||||||
|
NavigationSessionNotifier({FlutterTts? tts})
|
||||||
|
: _tts = tts ?? _createTtsSafe(),
|
||||||
|
super(const NavigationSessionState()) {
|
||||||
|
_initTts();
|
||||||
|
}
|
||||||
|
|
||||||
|
static FlutterTts? _createTtsSafe() {
|
||||||
|
try {
|
||||||
|
return FlutterTts();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isNavigating => state.isNavigating;
|
||||||
|
|
||||||
|
Future<void> _initTts() async {
|
||||||
|
if (_tts == null) return;
|
||||||
|
try {
|
||||||
|
await _tts.setLanguage('en-US');
|
||||||
|
await _tts.setSpeechRate(0.5); // Clear motorcycle cadence
|
||||||
|
await _tts.setVolume(1.0);
|
||||||
|
await _tts.setPitch(1.0);
|
||||||
|
_ttsInitialized = true;
|
||||||
|
} catch (_) {
|
||||||
|
_ttsInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begins turn-by-turn guidance along a calculated route.
|
||||||
|
Future<void> startNavigation({
|
||||||
|
required RouteCalculationResult calculationResult,
|
||||||
|
ResolvedPlace? destination,
|
||||||
|
MotorSignalRoute? route,
|
||||||
|
RoutingOptions? options,
|
||||||
|
}) async {
|
||||||
|
final maneuvers = calculationResult.maneuvers;
|
||||||
|
if (maneuvers.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initialDist = calculationResult.estimatedDistanceMeters ??
|
||||||
|
calculationResult.straightLineDistanceMeters;
|
||||||
|
final initialDuration = calculationResult.estimatedDurationSeconds ??
|
||||||
|
(initialDist / 15.0).round(); // Default ~33 mph assumption if null
|
||||||
|
|
||||||
|
final firstManeuverDist = maneuvers.first.distanceMeters > 0
|
||||||
|
? maneuvers.first.distanceMeters
|
||||||
|
: (maneuvers.length > 1 ? maneuvers[1].distanceMeters : initialDist);
|
||||||
|
|
||||||
|
state = NavigationSessionState(
|
||||||
|
status: NavigationStatus.navigating,
|
||||||
|
route: route,
|
||||||
|
destination: destination,
|
||||||
|
calculationResult: calculationResult,
|
||||||
|
maneuvers: maneuvers,
|
||||||
|
currentManeuverIndex: 0,
|
||||||
|
distanceToNextManeuverMeters: firstManeuverDist,
|
||||||
|
remainingDistanceMeters: initialDist,
|
||||||
|
remainingDurationSeconds: initialDuration,
|
||||||
|
ttsEnabled: state.ttsEnabled,
|
||||||
|
routingOptions: options ?? const RoutingOptions(),
|
||||||
|
startedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Speak initial maneuver
|
||||||
|
final firstInstruction = maneuvers.first.instruction;
|
||||||
|
_speakInstruction(firstInstruction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Halts and clears active navigation.
|
||||||
|
Future<void> stopNavigation() async {
|
||||||
|
if (_tts != null && _ttsInitialized) {
|
||||||
|
try {
|
||||||
|
await _tts.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
state = const NavigationSessionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pauses turn-by-turn tracking (e.g. during a rest stop).
|
||||||
|
void pauseNavigation() {
|
||||||
|
if (state.isNavigating) {
|
||||||
|
state = state.copyWith(status: NavigationStatus.paused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes paused navigation.
|
||||||
|
void resumeNavigation() {
|
||||||
|
if (state.status == NavigationStatus.paused) {
|
||||||
|
state = state.copyWith(status: NavigationStatus.navigating);
|
||||||
|
final current = state.currentManeuver;
|
||||||
|
if (current != null) {
|
||||||
|
_speakInstruction('Resuming route. ${current.instruction}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles spoken voice guidance.
|
||||||
|
void toggleTts() {
|
||||||
|
final newEnabled = !state.ttsEnabled;
|
||||||
|
state = state.copyWith(ttsEnabled: newEnabled);
|
||||||
|
if (!newEnabled && _tts != null && _ttsInitialized) {
|
||||||
|
try {
|
||||||
|
_tts.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates navigation progress against rider's current location and speed.
|
||||||
|
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
|
||||||
|
if (!state.isNavigating || state.maneuvers.isEmpty) return;
|
||||||
|
|
||||||
|
final currIdx = state.currentManeuverIndex;
|
||||||
|
if (currIdx >= state.maneuvers.length) {
|
||||||
|
_completeArrival();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentManeuver = state.maneuvers[currIdx];
|
||||||
|
final distToManeuver = _haversineMeters(riderPos, currentManeuver.location);
|
||||||
|
|
||||||
|
// If within 25 meters of maneuver point, advance to next maneuver
|
||||||
|
if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) {
|
||||||
|
final nextIdx = currIdx + 1;
|
||||||
|
final nextManeuver = state.maneuvers[nextIdx];
|
||||||
|
final distToNext = _haversineMeters(riderPos, nextManeuver.location);
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
currentManeuverIndex: nextIdx,
|
||||||
|
distanceToNextManeuverMeters: distToNext,
|
||||||
|
);
|
||||||
|
|
||||||
|
_speakInstruction(nextManeuver.instruction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute remaining distance along maneuvers
|
||||||
|
double remainingMeters = distToManeuver;
|
||||||
|
for (int i = currIdx + 1; i < state.maneuvers.length; i++) {
|
||||||
|
remainingMeters += state.maneuvers[i].distanceMeters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic duration based on speed (or default 30 mph if stationary)
|
||||||
|
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
|
||||||
|
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
distanceToNextManeuverMeters: distToManeuver,
|
||||||
|
remainingDistanceMeters: remainingMeters,
|
||||||
|
remainingDurationSeconds: remainingSec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _completeArrival() {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: NavigationStatus.arrived,
|
||||||
|
remainingDistanceMeters: 0.0,
|
||||||
|
remainingDurationSeconds: 0,
|
||||||
|
distanceToNextManeuverMeters: 0.0,
|
||||||
|
);
|
||||||
|
_speakInstruction('You have arrived at your destination.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _speakInstruction(String instruction) async {
|
||||||
|
if (!state.ttsEnabled || _tts == null || !_ttsInitialized) return;
|
||||||
|
if (state.lastAnnouncedInstruction == instruction) return;
|
||||||
|
|
||||||
|
state = state.copyWith(lastAnnouncedInstruction: instruction);
|
||||||
|
try {
|
||||||
|
await _tts.speak(instruction);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
|
||||||
|
const r = 6371000.0; // Earth radius in meters
|
||||||
|
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
|
||||||
|
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
|
||||||
|
final lat1 = p1.latitude * (math.pi / 180.0);
|
||||||
|
final lat2 = p2.latitude * (math.pi / 180.0);
|
||||||
|
|
||||||
|
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||||
|
math.sin(dLon / 2) *
|
||||||
|
math.sin(dLon / 2) *
|
||||||
|
math.cos(lat1) *
|
||||||
|
math.cos(lat2);
|
||||||
|
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||||
|
return r * c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global shared navigation session provider across Map and Dashboard.
|
||||||
|
final navigationSessionProvider =
|
||||||
|
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
|
||||||
|
(ref) {
|
||||||
|
final notifier = NavigationSessionNotifier();
|
||||||
|
// Connect live location updates when navigating
|
||||||
|
ref.listen(locationStreamProvider, (_, next) {
|
||||||
|
final pos = next.value;
|
||||||
|
if (pos != null && notifier.isNavigating) {
|
||||||
|
notifier.updateLocation(
|
||||||
|
AppLatLng(pos.latitude, pos.longitude),
|
||||||
|
speedMph: pos.speed * 2.23694,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return notifier;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
import 'route_models.dart';
|
import 'route_models.dart';
|
||||||
|
|
@ -239,6 +240,7 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
bool avoidHighways = false,
|
bool avoidHighways = false,
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
}) async {
|
}) async {
|
||||||
final ordered = List<RouteStop>.from(stops);
|
final ordered = List<RouteStop>.from(stops);
|
||||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
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 {
|
try {
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'https://routes.geo.$region.amazonaws.com/v2/routes?key=$apiKey',
|
'https://routes.geo.$region.amazonaws.com/v2/routes?key=$apiKey',
|
||||||
|
|
@ -264,11 +276,25 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
.toList()
|
.toList()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
final travelOptions = <String, dynamic>{
|
||||||
|
'Car': {
|
||||||
|
'Avoid': {
|
||||||
|
if (effectiveAvoidHighways) 'Highways': true,
|
||||||
|
if (effectiveAvoidTolls) 'Tolls': true,
|
||||||
|
if (effectiveAvoidFerries) 'Ferries': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
final bodyMap = <String, dynamic>{
|
final bodyMap = <String, dynamic>{
|
||||||
'Origin': origin,
|
'Origin': origin,
|
||||||
'Destination': destination,
|
'Destination': destination,
|
||||||
'TravelMode': 'Car',
|
'TravelMode': 'Car',
|
||||||
|
'TravelModeOptions': travelOptions,
|
||||||
|
'OptimizeRoutingFor': optimizeFor,
|
||||||
'LegGeometryFormat': 'Simple',
|
'LegGeometryFormat': 'Simple',
|
||||||
|
'LegAdditionalInputs': ['TravelSteps'],
|
||||||
|
'Instructions': true,
|
||||||
};
|
};
|
||||||
if (waypoints != null && waypoints.isNotEmpty) {
|
if (waypoints != null && waypoints.isNotEmpty) {
|
||||||
bodyMap['Waypoints'] = waypoints;
|
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 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(
|
return RouteCalculationResult(
|
||||||
straightLineDistanceMeters: straightLine,
|
straightLineDistanceMeters: straightLine,
|
||||||
estimatedDistanceMeters:
|
estimatedDistanceMeters: effectiveDist,
|
||||||
distanceMeters > 0 ? distanceMeters : straightLine,
|
|
||||||
estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null,
|
estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null,
|
||||||
geometry: geometry.isNotEmpty
|
geometry: effectiveGeometry,
|
||||||
? geometry
|
|
||||||
: ordered.map((s) => s.latLng).toList(),
|
|
||||||
isStraightLine: geometry.isEmpty,
|
isStraightLine: geometry.isEmpty,
|
||||||
providerName: name,
|
providerName: name,
|
||||||
|
maneuvers: maneuvers,
|
||||||
|
options: options,
|
||||||
|
curvatureMetric: curvature,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is RouteProviderException) rethrow;
|
if (e is RouteProviderException) rethrow;
|
||||||
|
|
@ -341,4 +379,195 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<RouteManeuver> _extractManeuvers(
|
||||||
|
List<dynamic> legs,
|
||||||
|
List<AppLatLng> geometry,
|
||||||
|
List<RouteStop> orderedStops,
|
||||||
|
) {
|
||||||
|
final maneuvers = <RouteManeuver>[];
|
||||||
|
|
||||||
|
for (final leg in legs) {
|
||||||
|
if (leg is! Map<String, dynamic>) continue;
|
||||||
|
final legDetails = leg['VehicleLegDetails'] as Map<String, dynamic>?;
|
||||||
|
final travelSteps = (legDetails?['TravelSteps'] ?? leg['TravelSteps'])
|
||||||
|
as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
for (final rawStep in travelSteps) {
|
||||||
|
if (rawStep is! Map<String, dynamic>) continue;
|
||||||
|
final instruction = rawStep['Instruction']?.toString() ?? 'Continue';
|
||||||
|
final stepType = rawStep['Type']?.toString();
|
||||||
|
final turn = rawStep['Turn']?.toString();
|
||||||
|
final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0;
|
||||||
|
final road = rawStep['NextRoad']?.toString() ??
|
||||||
|
rawStep['CurrentRoad']?.toString();
|
||||||
|
final offset = (rawStep['GeometryOffset'] as num?)?.toInt();
|
||||||
|
|
||||||
|
AppLatLng loc;
|
||||||
|
if (offset != null && offset >= 0 && offset < geometry.length) {
|
||||||
|
loc = geometry[offset];
|
||||||
|
} else if (geometry.isNotEmpty) {
|
||||||
|
loc = geometry.first;
|
||||||
|
} else {
|
||||||
|
loc = orderedStops.first.latLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
maneuvers.add(
|
||||||
|
RouteManeuver(
|
||||||
|
index: maneuvers.length,
|
||||||
|
instruction: instruction,
|
||||||
|
type: _mapManeuverType(stepType, turn),
|
||||||
|
distanceMeters: dist,
|
||||||
|
durationSeconds: dur,
|
||||||
|
streetName: road,
|
||||||
|
location: loc,
|
||||||
|
geometryOffset: offset,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no provider steps were supplied, synthesize maneuvers from geometry
|
||||||
|
if (maneuvers.isEmpty && geometry.length >= 2) {
|
||||||
|
maneuvers.addAll(_synthesizeManeuvers(geometry, orderedStops));
|
||||||
|
}
|
||||||
|
|
||||||
|
return maneuvers;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) {
|
||||||
|
final t = (typeStr ?? '').toLowerCase();
|
||||||
|
final turn = (turnStr ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (t == 'arrive') return ManeuverType.arrive;
|
||||||
|
if (t == 'depart') return ManeuverType.depart;
|
||||||
|
if (t == 'uturn' || turn == 'uturn') return ManeuverType.uTurn;
|
||||||
|
if (t.contains('roundabout') || turn.contains('roundabout')) {
|
||||||
|
if (t.contains('exit')) return ManeuverType.exitRoundabout;
|
||||||
|
return ManeuverType.enterRoundabout;
|
||||||
|
}
|
||||||
|
if (t == 'ramp') return ManeuverType.ramp;
|
||||||
|
if (turn == 'sharpright') return ManeuverType.turnSharpRight;
|
||||||
|
if (turn == 'sharpleft') return ManeuverType.turnSharpLeft;
|
||||||
|
if (turn == 'slightright') return ManeuverType.turnSlightRight;
|
||||||
|
if (turn == 'slightleft') return ManeuverType.turnSlightLeft;
|
||||||
|
if (turn == 'right' || t == 'turnright') return ManeuverType.turnRight;
|
||||||
|
if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft;
|
||||||
|
if (turn == 'keepright') return ManeuverType.keepRight;
|
||||||
|
if (turn == 'keepleft') return ManeuverType.keepLeft;
|
||||||
|
if (t == 'continue') return ManeuverType.continueStraight;
|
||||||
|
return ManeuverType.continueStraight;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<RouteManeuver> _synthesizeManeuvers(
|
||||||
|
List<AppLatLng> geometry,
|
||||||
|
List<RouteStop> orderedStops,
|
||||||
|
) {
|
||||||
|
final list = <RouteManeuver>[];
|
||||||
|
final destinationName = orderedStops.isNotEmpty
|
||||||
|
? orderedStops.last.displayName
|
||||||
|
: 'Destination';
|
||||||
|
|
||||||
|
list.add(
|
||||||
|
RouteManeuver(
|
||||||
|
index: 0,
|
||||||
|
instruction: 'Head towards $destinationName',
|
||||||
|
type: ManeuverType.depart,
|
||||||
|
distanceMeters: 0.0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
location: geometry.first,
|
||||||
|
geometryOffset: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < geometry.length - 2; i++) {
|
||||||
|
final b1 = _bearing(geometry[i], geometry[i + 1]);
|
||||||
|
final b2 = _bearing(geometry[i + 1], geometry[i + 2]);
|
||||||
|
double diff = (b2 - b1);
|
||||||
|
while (diff < -180.0) {
|
||||||
|
diff += 360.0;
|
||||||
|
}
|
||||||
|
while (diff > 180.0) {
|
||||||
|
diff -= 360.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff.abs() >= 35.0) {
|
||||||
|
final ManeuverType type;
|
||||||
|
final String dirText;
|
||||||
|
if (diff > 120.0) {
|
||||||
|
type = ManeuverType.turnSharpRight;
|
||||||
|
dirText = 'Sharp right';
|
||||||
|
} else if (diff > 60.0) {
|
||||||
|
type = ManeuverType.turnRight;
|
||||||
|
dirText = 'Turn right';
|
||||||
|
} else if (diff > 35.0) {
|
||||||
|
type = ManeuverType.turnSlightRight;
|
||||||
|
dirText = 'Slight right';
|
||||||
|
} else if (diff < -120.0) {
|
||||||
|
type = ManeuverType.turnSharpLeft;
|
||||||
|
dirText = 'Sharp left';
|
||||||
|
} else if (diff < -60.0) {
|
||||||
|
type = ManeuverType.turnLeft;
|
||||||
|
dirText = 'Turn left';
|
||||||
|
} else {
|
||||||
|
type = ManeuverType.turnSlightLeft;
|
||||||
|
dirText = 'Slight left';
|
||||||
|
}
|
||||||
|
|
||||||
|
list.add(
|
||||||
|
RouteManeuver(
|
||||||
|
index: list.length,
|
||||||
|
instruction: '$dirText onto road',
|
||||||
|
type: type,
|
||||||
|
distanceMeters: 0.0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
location: geometry[i + 1],
|
||||||
|
geometryOffset: i + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.add(
|
||||||
|
RouteManeuver(
|
||||||
|
index: list.length,
|
||||||
|
instruction: 'Arrive at $destinationName',
|
||||||
|
type: ManeuverType.arrive,
|
||||||
|
distanceMeters: 0.0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
location: geometry.last,
|
||||||
|
geometryOffset: geometry.length - 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _calculateCurvatureMetric(
|
||||||
|
List<AppLatLng> points,
|
||||||
|
double distanceMiles,
|
||||||
|
) {
|
||||||
|
if (points.length < 3 || distanceMiles <= 0.05) return 0.0;
|
||||||
|
double totalTurnDeg = 0.0;
|
||||||
|
for (int i = 0; i < points.length - 2; i++) {
|
||||||
|
final b1 = _bearing(points[i], points[i + 1]);
|
||||||
|
final b2 = _bearing(points[i + 1], points[i + 2]);
|
||||||
|
double diff = (b2 - b1).abs();
|
||||||
|
if (diff > 180.0) diff = 360.0 - diff;
|
||||||
|
totalTurnDeg += diff;
|
||||||
|
}
|
||||||
|
return totalTurnDeg / distanceMiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _bearing(AppLatLng from, AppLatLng to) {
|
||||||
|
final lat1 = from.latitude * (math.pi / 180.0);
|
||||||
|
final lat2 = to.latitude * (math.pi / 180.0);
|
||||||
|
final dLon = (to.longitude - from.longitude) * (math.pi / 180.0);
|
||||||
|
final y = math.sin(dLon) * math.cos(lat2);
|
||||||
|
final x = math.cos(lat1) * math.sin(lat2) -
|
||||||
|
math.sin(lat1) * math.cos(lat2) * math.cos(dLon);
|
||||||
|
final brng = math.atan2(y, x) * (180.0 / math.pi);
|
||||||
|
return (brng + 360.0) % 360.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart' show IconData, Icons;
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
|
|
||||||
/// Semantic types of route stops and waypoints.
|
/// Semantic types of route stops and waypoints.
|
||||||
|
|
@ -435,3 +436,215 @@ class MotorSignalRoute {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Semantic maneuver types for turn-by-turn navigation.
|
||||||
|
enum ManeuverType {
|
||||||
|
depart,
|
||||||
|
turnRight,
|
||||||
|
turnLeft,
|
||||||
|
turnSlightRight,
|
||||||
|
turnSlightLeft,
|
||||||
|
turnSharpRight,
|
||||||
|
turnSharpLeft,
|
||||||
|
keepRight,
|
||||||
|
keepLeft,
|
||||||
|
uTurn,
|
||||||
|
continueStraight,
|
||||||
|
enterRoundabout,
|
||||||
|
exitRoundabout,
|
||||||
|
ramp,
|
||||||
|
arrive,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
IconData get icon {
|
||||||
|
switch (this) {
|
||||||
|
case ManeuverType.depart:
|
||||||
|
return Icons.navigation;
|
||||||
|
case ManeuverType.turnRight:
|
||||||
|
case ManeuverType.turnSharpRight:
|
||||||
|
return Icons.turn_right;
|
||||||
|
case ManeuverType.turnLeft:
|
||||||
|
case ManeuverType.turnSharpLeft:
|
||||||
|
return Icons.turn_left;
|
||||||
|
case ManeuverType.turnSlightRight:
|
||||||
|
case ManeuverType.keepRight:
|
||||||
|
return Icons.turn_slight_right;
|
||||||
|
case ManeuverType.turnSlightLeft:
|
||||||
|
case ManeuverType.keepLeft:
|
||||||
|
return Icons.turn_slight_left;
|
||||||
|
case ManeuverType.uTurn:
|
||||||
|
return Icons.u_turn_left;
|
||||||
|
case ManeuverType.continueStraight:
|
||||||
|
return Icons.straight;
|
||||||
|
case ManeuverType.enterRoundabout:
|
||||||
|
case ManeuverType.exitRoundabout:
|
||||||
|
return Icons.roundabout_right;
|
||||||
|
case ManeuverType.ramp:
|
||||||
|
return Icons.ramp_right;
|
||||||
|
case ManeuverType.arrive:
|
||||||
|
return Icons.flag;
|
||||||
|
case ManeuverType.unknown:
|
||||||
|
return Icons.navigation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get directionShort {
|
||||||
|
switch (this) {
|
||||||
|
case ManeuverType.turnRight:
|
||||||
|
return 'RIGHT';
|
||||||
|
case ManeuverType.turnLeft:
|
||||||
|
return 'LEFT';
|
||||||
|
case ManeuverType.turnSlightRight:
|
||||||
|
return 'SLIGHT RIGHT';
|
||||||
|
case ManeuverType.turnSlightLeft:
|
||||||
|
return 'SLIGHT LEFT';
|
||||||
|
case ManeuverType.turnSharpRight:
|
||||||
|
return 'HARD RIGHT';
|
||||||
|
case ManeuverType.turnSharpLeft:
|
||||||
|
return 'HARD LEFT';
|
||||||
|
case ManeuverType.uTurn:
|
||||||
|
return 'U-TURN';
|
||||||
|
case ManeuverType.keepRight:
|
||||||
|
return 'KEEP RIGHT';
|
||||||
|
case ManeuverType.keepLeft:
|
||||||
|
return 'KEEP LEFT';
|
||||||
|
case ManeuverType.continueStraight:
|
||||||
|
return 'STRAIGHT';
|
||||||
|
case ManeuverType.depart:
|
||||||
|
return 'HEAD OUT';
|
||||||
|
case ManeuverType.arrive:
|
||||||
|
return 'ARRIVE';
|
||||||
|
default:
|
||||||
|
return 'CONTINUE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single turn-by-turn navigation maneuver step.
|
||||||
|
@immutable
|
||||||
|
class RouteManeuver {
|
||||||
|
final int index;
|
||||||
|
final String instruction;
|
||||||
|
final ManeuverType type;
|
||||||
|
final double distanceMeters;
|
||||||
|
final int durationSeconds;
|
||||||
|
final String? streetName;
|
||||||
|
final AppLatLng location;
|
||||||
|
final int? geometryOffset;
|
||||||
|
|
||||||
|
const RouteManeuver({
|
||||||
|
required this.index,
|
||||||
|
required this.instruction,
|
||||||
|
required this.type,
|
||||||
|
required this.distanceMeters,
|
||||||
|
required this.durationSeconds,
|
||||||
|
this.streetName,
|
||||||
|
required this.location,
|
||||||
|
this.geometryOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get distanceMiles => distanceMeters * 0.000621371;
|
||||||
|
|
||||||
|
String get formattedDistance {
|
||||||
|
if (distanceMiles < 0.1) {
|
||||||
|
final feet = (distanceMeters * 3.28084).round();
|
||||||
|
return '$feet ft';
|
||||||
|
}
|
||||||
|
return '${distanceMiles.toStringAsFixed(1)} mi';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'index': index,
|
||||||
|
'instruction': instruction,
|
||||||
|
'type': type.name,
|
||||||
|
'distanceMeters': distanceMeters,
|
||||||
|
'durationSeconds': durationSeconds,
|
||||||
|
if (streetName != null) 'streetName': streetName,
|
||||||
|
'latitude': location.latitude,
|
||||||
|
'longitude': location.longitude,
|
||||||
|
if (geometryOffset != null) 'geometryOffset': geometryOffset,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory RouteManeuver.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RouteManeuver(
|
||||||
|
index: (json['index'] as num?)?.toInt() ?? 0,
|
||||||
|
instruction: json['instruction'] as String? ?? '',
|
||||||
|
type: ManeuverType.values.firstWhere(
|
||||||
|
(t) => t.name == (json['type'] as String?),
|
||||||
|
orElse: () => ManeuverType.unknown,
|
||||||
|
),
|
||||||
|
distanceMeters: (json['distanceMeters'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
|
||||||
|
streetName: json['streetName'] as String?,
|
||||||
|
location: AppLatLng(
|
||||||
|
(json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
(json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
),
|
||||||
|
geometryOffset: (json['geometryOffset'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routing optimization preferences supported by MotorSignal and AWS Routes V2.
|
||||||
|
enum RoutingPreference {
|
||||||
|
fastest,
|
||||||
|
shortest,
|
||||||
|
twisty,
|
||||||
|
scenic;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case RoutingPreference.fastest:
|
||||||
|
return 'Fastest';
|
||||||
|
case RoutingPreference.shortest:
|
||||||
|
return 'Shortest';
|
||||||
|
case RoutingPreference.twisty:
|
||||||
|
return 'Twisty (Curvature)';
|
||||||
|
case RoutingPreference.scenic:
|
||||||
|
return 'Scenic (No Highways)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get subtitle {
|
||||||
|
switch (this) {
|
||||||
|
case RoutingPreference.fastest:
|
||||||
|
return 'Optimized for minimum travel time';
|
||||||
|
case RoutingPreference.shortest:
|
||||||
|
return 'Shortest total distance';
|
||||||
|
case RoutingPreference.twisty:
|
||||||
|
return 'Maximizes curves and elevation changes';
|
||||||
|
case RoutingPreference.scenic:
|
||||||
|
return 'Avoids limited-access highways and tolls';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routing options provided to route calculation requests.
|
||||||
|
@immutable
|
||||||
|
class RoutingOptions {
|
||||||
|
final RoutingPreference preference;
|
||||||
|
final bool avoidHighways;
|
||||||
|
final bool avoidTolls;
|
||||||
|
final bool avoidFerries;
|
||||||
|
|
||||||
|
const RoutingOptions({
|
||||||
|
this.preference = RoutingPreference.fastest,
|
||||||
|
this.avoidHighways = false,
|
||||||
|
this.avoidTolls = false,
|
||||||
|
this.avoidFerries = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
RoutingOptions copyWith({
|
||||||
|
RoutingPreference? preference,
|
||||||
|
bool? avoidHighways,
|
||||||
|
bool? avoidTolls,
|
||||||
|
bool? avoidFerries,
|
||||||
|
}) {
|
||||||
|
return RoutingOptions(
|
||||||
|
preference: preference ?? this.preference,
|
||||||
|
avoidHighways: avoidHighways ?? this.avoidHighways,
|
||||||
|
avoidTolls: avoidTolls ?? this.avoidTolls,
|
||||||
|
avoidFerries: avoidFerries ?? this.avoidFerries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ class RouteCalculationResult {
|
||||||
final bool isStraightLine;
|
final bool isStraightLine;
|
||||||
final String providerName;
|
final String providerName;
|
||||||
final List<String> warnings;
|
final List<String> warnings;
|
||||||
|
final List<RouteManeuver> maneuvers;
|
||||||
|
final RoutingOptions? options;
|
||||||
|
final double? curvatureMetric;
|
||||||
|
|
||||||
const RouteCalculationResult({
|
const RouteCalculationResult({
|
||||||
required this.straightLineDistanceMeters,
|
required this.straightLineDistanceMeters,
|
||||||
|
|
@ -93,6 +96,9 @@ class RouteCalculationResult {
|
||||||
required this.isStraightLine,
|
required this.isStraightLine,
|
||||||
required this.providerName,
|
required this.providerName,
|
||||||
this.warnings = const [],
|
this.warnings = const [],
|
||||||
|
this.maneuvers = const [],
|
||||||
|
this.options,
|
||||||
|
this.curvatureMetric,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Alias for geometry points.
|
/// Alias for geometry points.
|
||||||
|
|
@ -117,6 +123,28 @@ class RouteCalculationResult {
|
||||||
}
|
}
|
||||||
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
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.
|
/// Abstract provider interface for calculating routes and searching places.
|
||||||
|
|
@ -129,6 +157,7 @@ abstract class RouteProvider {
|
||||||
bool avoidHighways = false,
|
bool avoidHighways = false,
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Searches for places by textual query.
|
/// Searches for places by textual query.
|
||||||
|
|
@ -157,6 +186,7 @@ class StraightLineRouteProvider implements RouteProvider {
|
||||||
bool avoidHighways = false,
|
bool avoidHighways = false,
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
}) async {
|
}) async {
|
||||||
final ordered = List<RouteStop>.from(stops);
|
final ordered = List<RouteStop>.from(stops);
|
||||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ class ValhallaRouteProvider implements RouteProvider {
|
||||||
bool avoidHighways = false,
|
bool avoidHighways = false,
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
}) async {
|
}) async {
|
||||||
final ordered = List<RouteStop>.from(stops);
|
final ordered = List<RouteStop>.from(stops);
|
||||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class SocialFeedService {
|
||||||
regionName: 'Bay Area, CA',
|
regionName: 'Bay Area, CA',
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: true,
|
isGpsVerified: false,
|
||||||
fistBumpCount: 42,
|
fistBumpCount: 42,
|
||||||
fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'],
|
fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -90,7 +90,7 @@ class SocialFeedService {
|
||||||
snapshotType: FeedSnapshotType.scenicPhoto,
|
snapshotType: FeedSnapshotType.scenicPhoto,
|
||||||
photoUrl: 'assets/images/rock_store_lookout.png',
|
photoUrl: 'assets/images/rock_store_lookout.png',
|
||||||
photoCaption: 'Rock Store Lookout',
|
photoCaption: 'Rock Store Lookout',
|
||||||
isGpsVerified: true,
|
isGpsVerified: false,
|
||||||
fistBumpCount: 28,
|
fistBumpCount: 28,
|
||||||
fistBumpUserIds: const ['uid_marcus'],
|
fistBumpUserIds: const ['uid_marcus'],
|
||||||
comments: const [],
|
comments: const [],
|
||||||
|
|
@ -118,7 +118,7 @@ class SocialFeedService {
|
||||||
regionName: 'Angeles Crest, CA',
|
regionName: 'Angeles Crest, CA',
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: true,
|
isGpsVerified: false,
|
||||||
fistBumpCount: 65,
|
fistBumpCount: 65,
|
||||||
fistBumpUserIds: const ['uid_marcus', 'uid_sarah'],
|
fistBumpUserIds: const ['uid_marcus', 'uid_sarah'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -151,7 +151,7 @@ class SocialFeedService {
|
||||||
regionName: 'North Georgia, GA',
|
regionName: 'North Georgia, GA',
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: true,
|
isGpsVerified: false,
|
||||||
fistBumpCount: 39,
|
fistBumpCount: 39,
|
||||||
fistBumpUserIds: const ['uid_marcus'],
|
fistBumpUserIds: const ['uid_marcus'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -184,7 +184,7 @@ class SocialFeedService {
|
||||||
regionName: 'Tellico Plains, TN',
|
regionName: 'Tellico Plains, TN',
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: true,
|
isGpsVerified: false,
|
||||||
fistBumpCount: 51,
|
fistBumpCount: 51,
|
||||||
fistBumpUserIds: const ['uid_sarah', 'uid_alex'],
|
fistBumpUserIds: const ['uid_sarah', 'uid_alex'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,100 @@ void main() {
|
||||||
expect(result.geometry[2].longitude, equals(-84.5));
|
expect(result.geometry[2].longitude, equals(-84.5));
|
||||||
expect(result.isStraightLine, isFalse);
|
expect(result.isStraightLine, isFalse);
|
||||||
expect(result.providerName, equals('Amazon AWS Location'));
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class FakeRouteProvider implements RouteProvider {
|
||||||
bool avoidHighways = false,
|
bool avoidHighways = false,
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
}) async {
|
}) async {
|
||||||
lastCapturedStops = stops;
|
lastCapturedStops = stops;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:motorcycle_dash/features/garage/garage_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('GarageScreen displays sync spinner when isSyncProcessingProvider is true', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
garageMotorcyclesStreamProvider.overrideWith((ref) => Stream.value([])),
|
||||||
|
isSyncProcessingProvider.overrideWithValue(true),
|
||||||
|
pendingSyncCountProvider.overrideWithValue(0),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GarageScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify spinner is displayed instead of standard button
|
||||||
|
expect(find.byKey(const ValueKey('garage_sync_spinner')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('garage_sync_button')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('GarageScreen displays pending badge count when sync is idle', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
garageMotorcyclesStreamProvider.overrideWith((ref) => Stream.value([])),
|
||||||
|
isSyncProcessingProvider.overrideWithValue(false),
|
||||||
|
pendingSyncCountProvider.overrideWithValue(4),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GarageScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('garage_sync_button')), findsOneWidget);
|
||||||
|
expect(find.byType(Badge), findsOneWidget);
|
||||||
|
expect(find.text('4'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('GarageScreen sync button informs user when signed out', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
garageMotorcyclesStreamProvider.overrideWith((ref) => Stream.value([])),
|
||||||
|
isSyncProcessingProvider.overrideWithValue(false),
|
||||||
|
pendingSyncCountProvider.overrideWithValue(1),
|
||||||
|
authNotifierProvider.overrideWith((ref) => MockAuthNotifier(const AuthState.unauthenticated())),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GarageScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('garage_sync_button')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('garage_sync_auth_required_snackbar')), findsOneWidget);
|
||||||
|
expect(find.text('Sign in to sync your garage with MotorCloud.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockAuthNotifier extends StateNotifier<AuthState> implements AuthNotifier {
|
||||||
|
MockAuthNotifier(super.state);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart';
|
||||||
|
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
|
||||||
|
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||||
|
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/calibration_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('LeanAngleIndicator tap while moving (> 3.0 MPH) blocks calibration with warning', (tester) async {
|
||||||
|
final sample = TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
leanAngle: 12.0,
|
||||||
|
speed: 25.0, // Moving at 25 MPH
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
telemetryStreamProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(sample),
|
||||||
|
),
|
||||||
|
rideStateMachineProvider.overrideWith(
|
||||||
|
(ref) => MockRideStateMachine(
|
||||||
|
RideSession(state: RideState.riding),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: LeanAngleIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify indicator rendered
|
||||||
|
expect(find.byKey(const ValueKey('lean_angle_indicator_tap')), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap on the indicator
|
||||||
|
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify warning snackbar is shown
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_moving_warning')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Cannot calibrate while moving (> 3.0 MPH)'), findsOneWidget);
|
||||||
|
|
||||||
|
// Calibration dialog must NOT appear
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('LeanAngleIndicator tap while stationary (< 3.0 MPH) prompts confirmation and calibrates upright', (tester) async {
|
||||||
|
final sample = TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
leanAngle: 0.0,
|
||||||
|
speed: 0.0, // Stationary
|
||||||
|
);
|
||||||
|
|
||||||
|
var calibrated = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
telemetryStreamProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(sample),
|
||||||
|
),
|
||||||
|
rideStateMachineProvider.overrideWith(
|
||||||
|
(ref) => MockRideStateMachine(
|
||||||
|
RideSession(state: RideState.idle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sensorFusionServiceProvider.overrideWith((ref) => SensorFusionServiceMock(() {
|
||||||
|
calibrated = true;
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: LeanAngleIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap on indicator
|
||||||
|
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify dialog appears
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('confirm_lean_calibrate_button')), findsOneWidget);
|
||||||
|
|
||||||
|
// Confirm calibration
|
||||||
|
await tester.tap(find.byKey(const ValueKey('confirm_lean_calibrate_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(calibrated, isTrue, reason: 'Must invoke calibrateUpright() on SensorFusionService');
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_success_snackbar')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockRideStateMachine extends StateNotifier<RideSession> implements RideStateMachine {
|
||||||
|
MockRideStateMachine(super.initialState);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensorFusionServiceMock extends SensorFusionService {
|
||||||
|
final VoidCallback onCalibrate;
|
||||||
|
|
||||||
|
SensorFusionServiceMock(this.onCalibrate) : super(CalibrationService());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> calibrateUpright({int? motorcycleId}) async {
|
||||||
|
onCalibrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||||
|
import 'package:motorcycle_dash/features/map/map_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/location_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
|
|
||||||
|
class _FakeRouteProvider implements RouteProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'Fake AWS Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PlaceSearchResult>> searchPlaces(
|
||||||
|
String query, {
|
||||||
|
AppLatLng? nearLocation,
|
||||||
|
}) async {
|
||||||
|
if (query.toLowerCase().contains('blood mountain')) {
|
||||||
|
return const [
|
||||||
|
PlaceSearchResult(
|
||||||
|
providerId: 'place_blood_mtn',
|
||||||
|
primaryText: 'Blood Mountain Gap',
|
||||||
|
secondaryText: 'US-19 / GA-11, Blairsville, GA',
|
||||||
|
latitude: 34.7330,
|
||||||
|
longitude: -83.9180,
|
||||||
|
formattedAddress: 'Blood Mountain Gap, GA',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
PlaceSearchResult(
|
||||||
|
providerId: 'place_gas',
|
||||||
|
primaryText: 'Shell Gas Station',
|
||||||
|
secondaryText: 'Main St',
|
||||||
|
latitude: 34.7200,
|
||||||
|
longitude: -83.9100,
|
||||||
|
formattedAddress: 'Shell Gas Station, Main St',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResolvedPlace> resolvePlace(String placeId) async {
|
||||||
|
return const ResolvedPlace(
|
||||||
|
providerId: 'place_blood_mtn',
|
||||||
|
displayName: 'Blood Mountain Gap',
|
||||||
|
formattedAddress: 'Blood Mountain Gap, GA',
|
||||||
|
latitude: 34.7330,
|
||||||
|
longitude: -83.9180,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RouteCalculationResult> calculateRoute(
|
||||||
|
List<RouteStop> stops, {
|
||||||
|
bool avoidHighways = false,
|
||||||
|
bool avoidTolls = false,
|
||||||
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
|
}) async {
|
||||||
|
final geom = [
|
||||||
|
AppLatLng(stops.first.latitude, stops.first.longitude),
|
||||||
|
const AppLatLng(34.7250, -83.9150),
|
||||||
|
AppLatLng(stops.last.latitude, stops.last.longitude),
|
||||||
|
];
|
||||||
|
return RouteCalculationResult(
|
||||||
|
straightLineDistanceMeters: 12000.0,
|
||||||
|
estimatedDistanceMeters: 14500.0,
|
||||||
|
estimatedDurationSeconds: 1080,
|
||||||
|
geometry: geom,
|
||||||
|
isStraightLine: false,
|
||||||
|
providerName: name,
|
||||||
|
options: options,
|
||||||
|
curvatureMetric: 85.0, // Twisty curves
|
||||||
|
maneuvers: const [
|
||||||
|
RouteManeuver(
|
||||||
|
index: 0,
|
||||||
|
instruction: 'Depart on GA-60',
|
||||||
|
type: ManeuverType.depart,
|
||||||
|
distanceMeters: 4500.0,
|
||||||
|
durationSeconds: 360,
|
||||||
|
location: AppLatLng(34.7200, -83.9100),
|
||||||
|
),
|
||||||
|
RouteManeuver(
|
||||||
|
index: 1,
|
||||||
|
instruction: 'Turn right on US-19 N',
|
||||||
|
type: ManeuverType.turnRight,
|
||||||
|
distanceMeters: 10000.0,
|
||||||
|
durationSeconds: 720,
|
||||||
|
location: AppLatLng(34.7250, -83.9150),
|
||||||
|
),
|
||||||
|
RouteManeuver(
|
||||||
|
index: 2,
|
||||||
|
instruction: 'Arrive at Blood Mountain Gap',
|
||||||
|
type: ManeuverType.arrive,
|
||||||
|
distanceMeters: 0.0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
location: AppLatLng(34.7330, -83.9180),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget buildSubject() {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(_FakeRouteProvider()),
|
||||||
|
locationStreamProvider.overrideWith((ref) => Stream.value(
|
||||||
|
Position(
|
||||||
|
latitude: 34.7200,
|
||||||
|
longitude: -83.9100,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
accuracy: 5.0,
|
||||||
|
altitude: 300.0,
|
||||||
|
altitudeAccuracy: 1.0,
|
||||||
|
heading: 45.0,
|
||||||
|
headingAccuracy: 1.0,
|
||||||
|
speed: 15.0,
|
||||||
|
speedAccuracy: 0.5,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: const [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const MapScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('MapScreen destination search autocomplete and route preview flow', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 915)); // Google Pixel 8a portrait size
|
||||||
|
await tester.pumpWidget(buildSubject());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify search input is displayed
|
||||||
|
final searchField = find.byType(TextField);
|
||||||
|
expect(searchField, findsOneWidget);
|
||||||
|
|
||||||
|
// Enter search query
|
||||||
|
await tester.enterText(searchField, 'Blood Mountain');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify search results overlay rendered
|
||||||
|
expect(find.byKey(const ValueKey('search_results_overlay')), findsOneWidget);
|
||||||
|
expect(find.text('Blood Mountain Gap'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap on search result item
|
||||||
|
await tester.tap(find.text('Blood Mountain Gap'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify route preview card appears
|
||||||
|
expect(find.byKey(const ValueKey('route_preview_card')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Twisty Curves'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('start_navigation_button')), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap "START NAVIGATION"
|
||||||
|
await tester.tap(find.byKey(const ValueKey('start_navigation_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify navigation maneuver card appears on map
|
||||||
|
expect(find.byKey(const ValueKey('navigation_maneuver_card')), findsOneWidget);
|
||||||
|
expect(find.text('Depart on GA-60'), findsOneWidget);
|
||||||
|
|
||||||
|
// Exit navigation
|
||||||
|
await tester.tap(find.byKey(const ValueKey('nav_stop_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('navigation_maneuver_card')), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:motorcycle_dash/core/theme/theme.dart';
|
||||||
|
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||||
|
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/social/social_feed_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('Social Hybrid UI & Feed Verification Tests', () {
|
||||||
|
testWidgets('SocialHubScreen AppBar uses surfaceContainer theme color', (tester) async {
|
||||||
|
final testRouter = GoRouter(
|
||||||
|
initialLocation: '/social',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/social',
|
||||||
|
builder: (context, state) => const SocialHubScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MaterialApp.router(
|
||||||
|
theme: AppTheme.darkTheme,
|
||||||
|
routerConfig: testRouter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final appBarFinder = find.byType(AppBar);
|
||||||
|
expect(appBarFinder, findsOneWidget);
|
||||||
|
|
||||||
|
final appBar = tester.widget<AppBar>(appBarFinder);
|
||||||
|
expect(appBar.elevation, 0);
|
||||||
|
expect(appBar.backgroundColor, MotorSignalThemeColors.dark.surfaceContainer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SocialFeedService mock sample feeds do not claim false GPS verification', () {
|
||||||
|
final service = SocialFeedService();
|
||||||
|
final items = service.currentFeed;
|
||||||
|
|
||||||
|
expect(items, isNotEmpty);
|
||||||
|
for (final item in items) {
|
||||||
|
expect(
|
||||||
|
item.isGpsVerified,
|
||||||
|
isFalse,
|
||||||
|
reason: 'Item ${item.id} (${item.rideTitle}) must not have unverified GPS claim.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('SocialFeedScreen renders on Pixel 8a portrait without overflows and without false GPS verification badge', (tester) async {
|
||||||
|
// Pixel 8a physical portrait display is 412x915 dp
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 915));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: const [
|
||||||
|
MotorSignalThemeColors.dark,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: SocialFeedScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Verify header and filter components render cleanly
|
||||||
|
expect(find.text('Community Feed'), findsOneWidget);
|
||||||
|
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||||
|
expect(find.text('24 ACTIVE'), findsOneWidget);
|
||||||
|
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify that NO false GPS Verified badge is rendered
|
||||||
|
expect(find.text('GPS VERIFIED'), findsNothing);
|
||||||
|
|
||||||
|
// Verify no RenderFlex overflow exceptions occurred
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue