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_providers.dart';
|
||||
import '../../../services/location_service.dart';
|
||||
import 'navigation_maneuver_card.dart';
|
||||
|
||||
/// Top alert and notification banner region for the dashboard.
|
||||
///
|
||||
|
|
@ -32,6 +33,7 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!hasLocPermission) _buildPermissionBanner(context, ref),
|
||||
const NavigationManeuverCard(),
|
||||
if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref),
|
||||
if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../../core/theme/theme_extensions.dart';
|
||||
import '../../rides/ride_state_machine.dart';
|
||||
import '../../../services/telemetry_service.dart';
|
||||
import '../../../services/sensor_fusion_service.dart';
|
||||
|
||||
/// Leaf visualizer for motorcycle lean angle with authentic motorcycle gauge.
|
||||
///
|
||||
|
|
@ -51,13 +52,71 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
|
||||
final String angleText = isAvailable ? '${displayLean.abs().toStringAsFixed(0)}°' : '--°';
|
||||
|
||||
void handleTapToCalibrate() {
|
||||
final currentSpeed = sample?.speed ?? 0.0;
|
||||
final rideState = ref.read(rideStateMachineProvider).state;
|
||||
final isMoving = currentSpeed > 3.0 || rideState == RideState.riding;
|
||||
|
||||
if (isMoving) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
key: ValueKey('lean_calibrate_moving_warning'),
|
||||
content: Text('Cannot calibrate while moving (> 3.0 MPH). Come to a complete stop upright.'),
|
||||
backgroundColor: Color(0xFFEF4444),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
key: const ValueKey('lean_calibrate_dialog'),
|
||||
title: const Text('Calibrate Lean Angle (Upright)'),
|
||||
content: const Text(
|
||||
'Ensure your motorcycle is completely upright and centered (off kickstand or on level ground).\n\nThis will calibrate 0° upright roll.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey('confirm_lean_calibrate_button'),
|
||||
style: FilledButton.styleFrom(backgroundColor: colors.brandCyan),
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
await ref.read(sensorFusionServiceProvider).calibrateUpright();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
key: ValueKey('lean_calibrate_success_snackbar'),
|
||||
content: Text('Lean angle calibrated upright (0°).'),
|
||||
backgroundColor: Color(0xFF10B981),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Calibrate Upright', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.maxWidth;
|
||||
final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0);
|
||||
final double gaugeHeight = compact ? 52.0 : 68.0;
|
||||
|
||||
return Semantics(
|
||||
return Tooltip(
|
||||
message: 'Tap to Calibrate Upright (Stationary)',
|
||||
child: InkWell(
|
||||
key: const ValueKey('lean_angle_indicator_tap'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: handleTapToCalibrate,
|
||||
child: Semantics(
|
||||
label: isAvailable
|
||||
? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
|
||||
: 'Lean angle: uncalibrated. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
|
||||
|
|
@ -177,7 +236,9 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
final bike = await (_db.select(_db.garageMotorcycles)..where((t) => t.id.equals(motorcycleId))).getSingleOrNull();
|
||||
if (bike == null) return;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'widgets/motorcycle_photo.dart';
|
|||
import '../../services/entitlements/entitlement_providers.dart';
|
||||
import '../../services/entitlements/feature_access_service.dart';
|
||||
import '../../services/sync/sync_providers.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import '../entitlements/widgets/pro_lock_badge.dart';
|
||||
|
||||
class GarageScreen extends ConsumerWidget {
|
||||
|
|
@ -20,6 +21,8 @@ class GarageScreen extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
||||
final colors = context.motorColors;
|
||||
final isSyncing = ref.watch(isSyncProcessingProvider);
|
||||
final pendingCount = ref.watch(pendingSyncCountProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -28,16 +31,72 @@ class GarageScreen extends ConsumerWidget {
|
|||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Sync Garage with Cloud',
|
||||
icon: const Icon(Icons.cloud_sync_outlined),
|
||||
onPressed: () async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Syncing garage with cloud...')),
|
||||
);
|
||||
await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
|
||||
},
|
||||
),
|
||||
if (isSyncing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
key: ValueKey('garage_sync_spinner'),
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF00E5FF)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
key: const ValueKey('garage_sync_button'),
|
||||
tooltip: pendingCount > 0
|
||||
? 'Sync Garage ($pendingCount pending)'
|
||||
: 'Sync Garage with Cloud',
|
||||
icon: Badge(
|
||||
isLabelVisible: pendingCount > 0,
|
||||
label: Text('$pendingCount'),
|
||||
child: const Icon(Icons.cloud_sync_outlined),
|
||||
),
|
||||
onPressed: () async {
|
||||
final auth = ref.read(authNotifierProvider);
|
||||
if (!auth.isAuthenticated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
key: ValueKey('garage_sync_auth_required_snackbar'),
|
||||
content: Text('Sign in to sync your garage with MotorCloud.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
key: ValueKey('garage_sync_started_snackbar'),
|
||||
content: Text('Syncing garage and telemetry with cloud...'),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
|
||||
await ref.read(syncManagerProvider).processQueue();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
key: ValueKey('garage_sync_success_snackbar'),
|
||||
content: Text('Garage and rides synced successfully.'),
|
||||
backgroundColor: Color(0xFF10B981),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
key: const ValueKey('garage_sync_error_snackbar'),
|
||||
content: Text('Sync failed: $e. Changes are queued offline.'),
|
||||
backgroundColor: const Color(0xFFEF4444),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: bikesAsync.when(
|
||||
|
|
|
|||
|
|
@ -789,26 +789,55 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
const SizedBox(height: 16),
|
||||
|
||||
// Countdown Pills Grid
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final itemWidth = width >= 480
|
||||
? (width - 20) / 3
|
||||
: (width >= 280 ? (width - 10) / 2 : width);
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: pills
|
||||
.map(
|
||||
(pill) => SizedBox(
|
||||
width: itemWidth,
|
||||
child: _buildMaintenancePill(context, pill, colors, theme),
|
||||
if (pills.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 18, color: colors.cyberCyan),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Set your odometer or log your first service above to activate maintenance countdowns.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontFamily: 'Inter',
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final itemWidth = width >= 480
|
||||
? (width - 20) / 3
|
||||
: (width >= 280 ? (width - 10) / 2 : width);
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: pills
|
||||
.map(
|
||||
(pill) => SizedBox(
|
||||
width: itemWidth,
|
||||
child: _buildMaintenancePill(context, ref, bike, pill, currentOdometer, colors, theme),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
Divider(color: colors.outlineVariant.withValues(alpha: 0.4), height: 1),
|
||||
|
|
@ -959,6 +988,14 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit_outlined, size: 16, color: colors.cyberCyan),
|
||||
tooltip: 'Edit Service Record',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _showEditServiceRecordDialog(context, ref, bike, record, colors, theme),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.outlineVariant),
|
||||
tooltip: 'Delete Service Record',
|
||||
|
|
@ -979,70 +1016,113 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
|
||||
Widget _buildMaintenancePill(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
GarageMotorcycle bike,
|
||||
MaintenanceItemStatus pill,
|
||||
double currentOdometer,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final badgeColor = pill.getBadgeColor(context);
|
||||
final isUrgent = pill.urgency == MaintenanceUrgency.overdue || pill.urgency == MaintenanceUrgency.dueSoon;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
|
||||
width: isUrgent ? 1.5 : 1.0,
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _showLogServiceForPill(context, ref, bike, pill, currentOdometer, colors, theme),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
|
||||
width: isUrgent ? 1.5 : 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(pill.icon, size: 15, color: badgeColor),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pill.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: theme.colorScheme.onSurface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(pill.icon, size: 15, color: badgeColor),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pill.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add_task, size: 13, color: badgeColor.withValues(alpha: 0.7)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
pill.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: badgeColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
pill.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
|
||||
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
|
||||
minHeight: 3,
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
|
||||
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLogServiceForPill(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
GarageMotorcycle bike,
|
||||
MaintenanceItemStatus pill,
|
||||
double currentOdometer,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
String serviceType = 'General Maintenance';
|
||||
switch (pill.key) {
|
||||
case 'oil':
|
||||
serviceType = 'Engine Oil & Filter';
|
||||
break;
|
||||
case 'chain':
|
||||
serviceType = 'Chain Clean & Lube';
|
||||
break;
|
||||
case 'tire':
|
||||
serviceType = 'Tire Replacement (Rear)';
|
||||
break;
|
||||
case 'brake':
|
||||
serviceType = 'Brake Fluid Flush';
|
||||
break;
|
||||
}
|
||||
_showLogServiceDialog(
|
||||
context,
|
||||
ref,
|
||||
bike,
|
||||
currentOdometer,
|
||||
colors,
|
||||
theme,
|
||||
initialServiceType: serviceType,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSetOdometerDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
|
@ -1097,8 +1177,9 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
GarageMotorcycle bike,
|
||||
double currentOdometer,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
ThemeData theme, {
|
||||
String? initialServiceType,
|
||||
}) {
|
||||
final serviceTypes = [
|
||||
'Engine Oil & Filter',
|
||||
'Chain Clean & Lube',
|
||||
|
|
@ -1113,7 +1194,9 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
'General Maintenance',
|
||||
];
|
||||
|
||||
String selectedType = serviceTypes[0];
|
||||
String selectedType = (initialServiceType != null && serviceTypes.contains(initialServiceType))
|
||||
? initialServiceType
|
||||
: serviceTypes[0];
|
||||
final odoController = TextEditingController(text: currentOdometer.round().toString());
|
||||
final notesController = TextEditingController();
|
||||
final costController = TextEditingController();
|
||||
|
|
@ -1213,6 +1296,130 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _showEditServiceRecordDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
GarageMotorcycle bike,
|
||||
ServiceRecord record,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final serviceTypes = [
|
||||
'Engine Oil & Filter',
|
||||
'Chain Clean & Lube',
|
||||
'Tire Replacement (Rear)',
|
||||
'Tire Replacement (Front)',
|
||||
'Tire Replacement (Set)',
|
||||
'Brake Fluid Flush',
|
||||
'Brake Pads Replacement',
|
||||
'Air Filter Replacement',
|
||||
'Spark Plugs',
|
||||
'Valve Clearance Check',
|
||||
'General Maintenance',
|
||||
];
|
||||
|
||||
String selectedType = serviceTypes.contains(record.serviceType)
|
||||
? record.serviceType
|
||||
: serviceTypes.last;
|
||||
final odoController = TextEditingController(text: record.odometer.round().toString());
|
||||
final notesController = TextEditingController(text: record.notes ?? '');
|
||||
final costController = TextEditingController(text: record.cost != null ? record.cost!.toStringAsFixed(2) : '');
|
||||
DateTime serviceDate = record.date;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Edit Service Record'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedType,
|
||||
decoration: const InputDecoration(labelText: 'Service Type'),
|
||||
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => selectedType = val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: odoController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Odometer at Service',
|
||||
suffixText: 'mi',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Service Date', style: TextStyle(fontSize: 12)),
|
||||
subtitle: Text(DateFormat('MMMM d, yyyy').format(serviceDate)),
|
||||
trailing: const Icon(Icons.calendar_today, size: 18),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: serviceDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => serviceDate = picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: costController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cost (Optional)',
|
||||
prefixText: '\$ ',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes (e.g. Brand, weight, shop)',
|
||||
hintText: 'e.g. Motul 300V 10W-40, OEM filter',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final odo = double.tryParse(odoController.text.trim()) ?? record.odometer;
|
||||
final cost = double.tryParse(costController.text.trim());
|
||||
final updated = ServiceRecord(
|
||||
id: record.id,
|
||||
date: serviceDate,
|
||||
odometer: odo,
|
||||
serviceType: selectedType,
|
||||
notes: notesController.text.trim().isNotEmpty ? notesController.text.trim() : null,
|
||||
cost: cost,
|
||||
);
|
||||
ref.read(garageControllerProvider).updateServiceRecord(bike.id, updated);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteServiceRecord(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
|
@ -14,6 +15,12 @@ import '../../services/alerts/road_alert_models.dart';
|
|||
import '../../services/alerts/road_alert_service.dart';
|
||||
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||
import '../../services/map/map_layer_settings.dart';
|
||||
import '../../services/routes/route_models.dart';
|
||||
import '../../services/routes/route_provider.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
import '../../services/routes/navigation_handoff.dart';
|
||||
import '../../services/navigation/navigation_session_service.dart';
|
||||
import '../dashboard/widgets/navigation_maneuver_card.dart';
|
||||
import 'widgets/tactical_layers_bottom_sheet.dart';
|
||||
import '../replay/replay_screen.dart';
|
||||
|
||||
|
|
@ -43,6 +50,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
bool _isNorthUp = true;
|
||||
String _destinationQuery = '';
|
||||
late final TextEditingController _searchController;
|
||||
Timer? _searchDebounce;
|
||||
bool _isSearching = false;
|
||||
List<PlaceSearchResult> _searchResults = [];
|
||||
ResolvedPlace? _selectedDestination;
|
||||
RouteCalculationResult? _previewRouteResult;
|
||||
bool _isCalculatingRoute = false;
|
||||
RoutingOptions _routingOptions = const RoutingOptions();
|
||||
|
||||
static const double _hardAccelThresholdG = 0.35;
|
||||
static const double _hardBrakingThresholdG = -0.35;
|
||||
|
|
@ -64,10 +78,139 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchQueryChanged(String query, AppLatLng currentPos) {
|
||||
setState(() => _destinationQuery = query);
|
||||
_searchDebounce?.cancel();
|
||||
final clean = query.trim();
|
||||
if (clean.length < 2) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_isSearching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSearching = true);
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
|
||||
try {
|
||||
final results = await ref
|
||||
.read(activeRouteCalculationProvider)
|
||||
.searchPlaces(clean, nearLocation: currentPos);
|
||||
if (mounted && _destinationQuery.trim() == clean) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isSearching = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onSelectPlace(
|
||||
PlaceSearchResult place,
|
||||
AppLatLng currentPos,
|
||||
) async {
|
||||
_searchDebounce?.cancel();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_searchController.text = place.primaryText;
|
||||
_destinationQuery = place.primaryText;
|
||||
});
|
||||
|
||||
try {
|
||||
final resolved = place.hasCoordinates
|
||||
? place.toResolvedPlace()!
|
||||
: await ref
|
||||
.read(activeRouteCalculationProvider)
|
||||
.resolvePlace(place.providerId);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedDestination = resolved);
|
||||
await _calculateRouteToDestination(resolved, currentPos);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to resolve place: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _calculateRouteToDestination(
|
||||
ResolvedPlace dest,
|
||||
AppLatLng currentPos,
|
||||
) async {
|
||||
setState(() => _isCalculatingRoute = true);
|
||||
|
||||
try {
|
||||
final origin = RouteStop(
|
||||
id: 'origin',
|
||||
order: 0,
|
||||
displayName: 'Current Location',
|
||||
latitude: currentPos.latitude,
|
||||
longitude: currentPos.longitude,
|
||||
);
|
||||
final destinationStop = RouteStop(
|
||||
id: 'dest',
|
||||
order: 1,
|
||||
displayName: dest.displayName,
|
||||
latitude: dest.latitude,
|
||||
longitude: dest.longitude,
|
||||
);
|
||||
|
||||
final result = await ref
|
||||
.read(activeRouteCalculationProvider)
|
||||
.calculateRoute([origin, destinationStop], options: _routingOptions);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_previewRouteResult = result;
|
||||
_isCalculatingRoute = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isCalculatingRoute = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Route calculation error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDestination() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_destinationQuery = '';
|
||||
_searchResults = [];
|
||||
_selectedDestination = null;
|
||||
_previewRouteResult = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _startTurnByTurnNavigation() {
|
||||
if (_previewRouteResult == null || _selectedDestination == null) return;
|
||||
ref.read(navigationSessionProvider.notifier).startNavigation(
|
||||
calculationResult: _previewRouteResult!,
|
||||
destination: _selectedDestination,
|
||||
options: _routingOptions,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Navigation started to ${_selectedDestination!.displayName}'),
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initLiveLocation() async {
|
||||
final locationService = ref.read(locationServiceProvider);
|
||||
await locationService.requestPermissions();
|
||||
|
|
@ -743,6 +886,50 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
final navSession = ref.watch(navigationSessionProvider);
|
||||
|
||||
if (_selectedDestination != null) {
|
||||
mapMarkers.add(
|
||||
AppMapMarker(
|
||||
id: 'destination_marker',
|
||||
position: _selectedDestination!.latLng,
|
||||
label: _selectedDestination!.displayName,
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFF0055),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
child: const Icon(Icons.flag, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final livePolylines = <AppPolyline>[];
|
||||
if (_previewRouteResult != null &&
|
||||
_previewRouteResult!.geometry.isNotEmpty) {
|
||||
livePolylines.add(
|
||||
AppPolyline(
|
||||
id: 'route_preview_polyline',
|
||||
points: _previewRouteResult!.geometry,
|
||||
color: colors.cyberCyan,
|
||||
width: 5.5,
|
||||
),
|
||||
);
|
||||
} else if (navSession.isNavigating &&
|
||||
navSession.calculationResult?.geometry.isNotEmpty == true) {
|
||||
livePolylines.add(
|
||||
AppPolyline(
|
||||
id: 'nav_active_polyline',
|
||||
points: navSession.calculationResult!.geometry,
|
||||
color: colors.cyberCyan,
|
||||
width: 5.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: isShortLandscape,
|
||||
appBar: AppBar(
|
||||
|
|
@ -761,17 +948,40 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
center: currentPos,
|
||||
zoom: _currentZoom,
|
||||
markers: mapMarkers,
|
||||
polylines: livePolylines,
|
||||
),
|
||||
),
|
||||
|
||||
// Top Floating Search Card (portrait and large screens)
|
||||
if (!isShortLandscape)
|
||||
// Active Navigation Maneuver HUD or Top Search & Preview Cards
|
||||
if (navSession.isNavigating)
|
||||
const Positioned(
|
||||
top: 10,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: NavigationManeuverCard(),
|
||||
)
|
||||
else if (!isShortLandscape) ...[
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: _buildTopSearchCard(colors),
|
||||
child: _buildTopSearchCard(colors, currentPos),
|
||||
),
|
||||
if (_searchResults.isNotEmpty)
|
||||
Positioned(
|
||||
top: 106,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: _buildSearchResultsOverlay(colors, currentPos),
|
||||
),
|
||||
if (_previewRouteResult != null)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: _buildRoutePreviewCard(colors, currentPos),
|
||||
),
|
||||
],
|
||||
|
||||
// Offline Cached Only Warning Pill
|
||||
if (layerSettings.offlineOnly)
|
||||
|
|
@ -868,24 +1078,25 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
|
||||
// Bottom-left Quick Swarm Report Button
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 20,
|
||||
child: FloatingActionButton.extended(
|
||||
key: const ValueKey('quick_swarm_report_button'),
|
||||
backgroundColor: const Color(0xFFFF9100),
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.campaign, size: 22),
|
||||
label: const Text(
|
||||
'REPORT',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.6,
|
||||
if (_previewRouteResult == null && !navSession.isNavigating)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 20,
|
||||
child: FloatingActionButton.extended(
|
||||
key: const ValueKey('quick_swarm_report_button'),
|
||||
backgroundColor: const Color(0xFFFF9100),
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.campaign, size: 22),
|
||||
label: const Text(
|
||||
'REPORT',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
onPressed: () => QuickSwarmReportModal.show(context),
|
||||
),
|
||||
onPressed: () => QuickSwarmReportModal.show(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -1009,7 +1220,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
);
|
||||
|
||||
Widget _buildTopSearchCard(MotorSignalThemeColors colors) {
|
||||
Widget _buildTopSearchCard(MotorSignalThemeColors colors, AppLatLng currentPos) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 6,
|
||||
|
|
@ -1030,7 +1241,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.search, size: 20, color: colors.cyberCyan),
|
||||
if (_isSearching || _isCalculatingRoute)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
Icon(Icons.search, size: 20, color: colors.cyberCyan),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
|
|
@ -1051,18 +1269,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||||
),
|
||||
onChanged: (val) {
|
||||
setState(() => _destinationQuery = val);
|
||||
},
|
||||
onChanged: (val) => _onSearchQueryChanged(val, currentPos),
|
||||
onSubmitted: (val) => _onSearchQueryChanged(val, currentPos),
|
||||
),
|
||||
),
|
||||
if (_destinationQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
key: const ValueKey('clear_search_button'),
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
setState(() => _destinationQuery = '');
|
||||
},
|
||||
onTap: _clearDestination,
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
|
|
@ -1080,24 +1294,28 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
label: 'Gas & Fuel',
|
||||
icon: Icons.local_gas_station,
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildQuickDestinationChip(
|
||||
label: 'Twisty Passes',
|
||||
icon: Icons.alt_route,
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildQuickDestinationChip(
|
||||
label: 'Scenic Rest',
|
||||
icon: Icons.photo_camera,
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildQuickDestinationChip(
|
||||
label: 'Repair/Moto',
|
||||
icon: Icons.build,
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1112,21 +1330,19 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
required String label,
|
||||
required IconData icon,
|
||||
required MotorSignalThemeColors colors,
|
||||
required AppLatLng currentPos,
|
||||
}) {
|
||||
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
|
||||
return InkWell(
|
||||
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_destinationQuery = '';
|
||||
_searchController.clear();
|
||||
} else {
|
||||
_destinationQuery = label;
|
||||
_searchController.text = label;
|
||||
}
|
||||
});
|
||||
if (isSelected) {
|
||||
_clearDestination();
|
||||
} else {
|
||||
_searchController.text = label;
|
||||
_onSearchQueryChanged(label, currentPos);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
|
|
@ -1165,6 +1381,301 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultsOverlay(
|
||||
MotorSignalThemeColors colors,
|
||||
AppLatLng currentPos,
|
||||
) {
|
||||
return Card(
|
||||
key: const ValueKey('search_results_overlay'),
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black87,
|
||||
color: colors.surfaceContainer.withValues(alpha: 0.98),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.4),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 280),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: _searchResults.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _searchResults[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
item.hasCoordinates ? Icons.pin_drop : Icons.place_outlined,
|
||||
size: 18,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.primaryText,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
subtitle: item.secondaryText.isNotEmpty
|
||||
? Text(
|
||||
item.secondaryText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
color: Colors.white70,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _onSelectPlace(item, currentPos),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoutePreviewCard(
|
||||
MotorSignalThemeColors colors,
|
||||
AppLatLng currentPos,
|
||||
) {
|
||||
final preview = _previewRouteResult!;
|
||||
final dest = _selectedDestination;
|
||||
|
||||
return Card(
|
||||
key: const ValueKey('route_preview_card'),
|
||||
elevation: 8,
|
||||
color: colors.surfaceContainer.withValues(alpha: 0.95),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.6),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dest?.displayName ?? 'Destination Route',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${preview.distanceMiles.toStringAsFixed(1)} mi • ${preview.formattedDuration}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF10B981),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
preview.twistinessRating,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF10B981),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('clear_route_button'),
|
||||
icon: const Icon(Icons.close, color: Colors.white70),
|
||||
onPressed: _clearDestination,
|
||||
tooltip: 'Cancel Destination',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Routing Preferences Selector Row
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildPreferenceChip(
|
||||
preference: RoutingPreference.fastest,
|
||||
label: 'Fastest',
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildPreferenceChip(
|
||||
preference: RoutingPreference.shortest,
|
||||
label: 'Shortest',
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildPreferenceChip(
|
||||
preference: RoutingPreference.twisty,
|
||||
label: 'Twisty (Curves)',
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildPreferenceChip(
|
||||
preference: RoutingPreference.scenic,
|
||||
label: 'Scenic (No Hwy)',
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Navigation Actions: Start Turn-by-Turn & External Handoff
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
key: const ValueKey('start_navigation_button'),
|
||||
icon: const Icon(Icons.navigation, size: 18),
|
||||
label: const Text(
|
||||
'START NAVIGATION',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
onPressed: _startTurnByTurnNavigation,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('handoff_navigation_button'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
if (dest != null) {
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: dest.latitude,
|
||||
longitude: dest.longitude,
|
||||
displayName: dest.displayName,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.open_in_new, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreferenceChip({
|
||||
required RoutingPreference preference,
|
||||
required String label,
|
||||
required MotorSignalThemeColors colors,
|
||||
required AppLatLng currentPos,
|
||||
}) {
|
||||
final isSelected = _routingOptions.preference == preference;
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? Colors.black : Colors.white70,
|
||||
),
|
||||
selectedColor: colors.cyberCyan,
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_routingOptions = _routingOptions.copyWith(
|
||||
preference: preference,
|
||||
avoidHighways: preference == RoutingPreference.twisty ||
|
||||
preference == RoutingPreference.scenic,
|
||||
);
|
||||
});
|
||||
if (_selectedDestination != null) {
|
||||
_calculateRouteToDestination(_selectedDestination!, currentPos);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HudIconButton extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -1636,13 +1636,14 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
|||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF101419).withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
if (item.isGpsVerified)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF101419).withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
|
|||
'Social',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
|
|
|
|||
|
|
@ -151,7 +151,13 @@ class MaintenanceData {
|
|||
}
|
||||
|
||||
/// Calculates real-time countdown pills for all standard maintenance categories.
|
||||
/// Returns empty list if no odometer is set and no service records exist yet,
|
||||
/// avoiding fake synthetic countdowns.
|
||||
List<MaintenanceItemStatus> calculatePills(double totalRiddenMiles) {
|
||||
if (baseOdometer == 0.0 && serviceRecords.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final currentOdometer = baseOdometer + totalRiddenMiles;
|
||||
|
||||
final oilInterval = customIntervals['oil'] ?? defaultOilInterval;
|
||||
|
|
|
|||
|
|
@ -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:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../map/map_provider_interface.dart';
|
||||
import 'route_models.dart';
|
||||
|
|
@ -239,6 +240,7 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
RoutingOptions? options,
|
||||
}) async {
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
|
@ -250,6 +252,16 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
);
|
||||
}
|
||||
|
||||
final effectiveAvoidHighways = options?.avoidHighways ??
|
||||
(options?.preference == RoutingPreference.twisty ||
|
||||
options?.preference == RoutingPreference.scenic ||
|
||||
avoidHighways);
|
||||
final effectiveAvoidTolls = options?.avoidTolls ?? avoidTolls;
|
||||
final effectiveAvoidFerries = options?.avoidFerries ?? avoidFerries;
|
||||
final optimizeFor = options?.preference == RoutingPreference.shortest
|
||||
? 'ShortestRoute'
|
||||
: 'FastestRoute';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'https://routes.geo.$region.amazonaws.com/v2/routes?key=$apiKey',
|
||||
|
|
@ -264,11 +276,25 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
.toList()
|
||||
: null;
|
||||
|
||||
final travelOptions = <String, dynamic>{
|
||||
'Car': {
|
||||
'Avoid': {
|
||||
if (effectiveAvoidHighways) 'Highways': true,
|
||||
if (effectiveAvoidTolls) 'Tolls': true,
|
||||
if (effectiveAvoidFerries) 'Ferries': true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final bodyMap = <String, dynamic>{
|
||||
'Origin': origin,
|
||||
'Destination': destination,
|
||||
'TravelMode': 'Car',
|
||||
'TravelModeOptions': travelOptions,
|
||||
'OptimizeRoutingFor': optimizeFor,
|
||||
'LegGeometryFormat': 'Simple',
|
||||
'LegAdditionalInputs': ['TravelSteps'],
|
||||
'Instructions': true,
|
||||
};
|
||||
if (waypoints != null && waypoints.isNotEmpty) {
|
||||
bodyMap['Waypoints'] = waypoints;
|
||||
|
|
@ -320,18 +346,30 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
}
|
||||
}
|
||||
|
||||
final effectiveGeometry = geometry.isNotEmpty
|
||||
? geometry
|
||||
: ordered.map((s) => s.latLng).toList();
|
||||
|
||||
final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||
final effectiveDist = distanceMeters > 0 ? distanceMeters : straightLine;
|
||||
final distMiles = effectiveDist * 0.000621371;
|
||||
|
||||
// Extract maneuvers from TravelSteps if available
|
||||
final maneuvers = _extractManeuvers(legs, effectiveGeometry, ordered);
|
||||
|
||||
// Compute geometric curvature metric
|
||||
final curvature = _calculateCurvatureMetric(effectiveGeometry, distMiles);
|
||||
|
||||
return RouteCalculationResult(
|
||||
straightLineDistanceMeters: straightLine,
|
||||
estimatedDistanceMeters:
|
||||
distanceMeters > 0 ? distanceMeters : straightLine,
|
||||
estimatedDistanceMeters: effectiveDist,
|
||||
estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null,
|
||||
geometry: geometry.isNotEmpty
|
||||
? geometry
|
||||
: ordered.map((s) => s.latLng).toList(),
|
||||
geometry: effectiveGeometry,
|
||||
isStraightLine: geometry.isEmpty,
|
||||
providerName: name,
|
||||
maneuvers: maneuvers,
|
||||
options: options,
|
||||
curvatureMetric: curvature,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is RouteProviderException) rethrow;
|
||||
|
|
@ -341,4 +379,195 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
static List<RouteManeuver> _extractManeuvers(
|
||||
List<dynamic> legs,
|
||||
List<AppLatLng> geometry,
|
||||
List<RouteStop> orderedStops,
|
||||
) {
|
||||
final maneuvers = <RouteManeuver>[];
|
||||
|
||||
for (final leg in legs) {
|
||||
if (leg is! Map<String, dynamic>) continue;
|
||||
final legDetails = leg['VehicleLegDetails'] as Map<String, dynamic>?;
|
||||
final travelSteps = (legDetails?['TravelSteps'] ?? leg['TravelSteps'])
|
||||
as List<dynamic>? ?? [];
|
||||
|
||||
for (final rawStep in travelSteps) {
|
||||
if (rawStep is! Map<String, dynamic>) continue;
|
||||
final instruction = rawStep['Instruction']?.toString() ?? 'Continue';
|
||||
final stepType = rawStep['Type']?.toString();
|
||||
final turn = rawStep['Turn']?.toString();
|
||||
final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0;
|
||||
final road = rawStep['NextRoad']?.toString() ??
|
||||
rawStep['CurrentRoad']?.toString();
|
||||
final offset = (rawStep['GeometryOffset'] as num?)?.toInt();
|
||||
|
||||
AppLatLng loc;
|
||||
if (offset != null && offset >= 0 && offset < geometry.length) {
|
||||
loc = geometry[offset];
|
||||
} else if (geometry.isNotEmpty) {
|
||||
loc = geometry.first;
|
||||
} else {
|
||||
loc = orderedStops.first.latLng;
|
||||
}
|
||||
|
||||
maneuvers.add(
|
||||
RouteManeuver(
|
||||
index: maneuvers.length,
|
||||
instruction: instruction,
|
||||
type: _mapManeuverType(stepType, turn),
|
||||
distanceMeters: dist,
|
||||
durationSeconds: dur,
|
||||
streetName: road,
|
||||
location: loc,
|
||||
geometryOffset: offset,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no provider steps were supplied, synthesize maneuvers from geometry
|
||||
if (maneuvers.isEmpty && geometry.length >= 2) {
|
||||
maneuvers.addAll(_synthesizeManeuvers(geometry, orderedStops));
|
||||
}
|
||||
|
||||
return maneuvers;
|
||||
}
|
||||
|
||||
static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) {
|
||||
final t = (typeStr ?? '').toLowerCase();
|
||||
final turn = (turnStr ?? '').toLowerCase();
|
||||
|
||||
if (t == 'arrive') return ManeuverType.arrive;
|
||||
if (t == 'depart') return ManeuverType.depart;
|
||||
if (t == 'uturn' || turn == 'uturn') return ManeuverType.uTurn;
|
||||
if (t.contains('roundabout') || turn.contains('roundabout')) {
|
||||
if (t.contains('exit')) return ManeuverType.exitRoundabout;
|
||||
return ManeuverType.enterRoundabout;
|
||||
}
|
||||
if (t == 'ramp') return ManeuverType.ramp;
|
||||
if (turn == 'sharpright') return ManeuverType.turnSharpRight;
|
||||
if (turn == 'sharpleft') return ManeuverType.turnSharpLeft;
|
||||
if (turn == 'slightright') return ManeuverType.turnSlightRight;
|
||||
if (turn == 'slightleft') return ManeuverType.turnSlightLeft;
|
||||
if (turn == 'right' || t == 'turnright') return ManeuverType.turnRight;
|
||||
if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft;
|
||||
if (turn == 'keepright') return ManeuverType.keepRight;
|
||||
if (turn == 'keepleft') return ManeuverType.keepLeft;
|
||||
if (t == 'continue') return ManeuverType.continueStraight;
|
||||
return ManeuverType.continueStraight;
|
||||
}
|
||||
|
||||
static List<RouteManeuver> _synthesizeManeuvers(
|
||||
List<AppLatLng> geometry,
|
||||
List<RouteStop> orderedStops,
|
||||
) {
|
||||
final list = <RouteManeuver>[];
|
||||
final destinationName = orderedStops.isNotEmpty
|
||||
? orderedStops.last.displayName
|
||||
: 'Destination';
|
||||
|
||||
list.add(
|
||||
RouteManeuver(
|
||||
index: 0,
|
||||
instruction: 'Head towards $destinationName',
|
||||
type: ManeuverType.depart,
|
||||
distanceMeters: 0.0,
|
||||
durationSeconds: 0,
|
||||
location: geometry.first,
|
||||
geometryOffset: 0,
|
||||
),
|
||||
);
|
||||
|
||||
for (int i = 0; i < geometry.length - 2; i++) {
|
||||
final b1 = _bearing(geometry[i], geometry[i + 1]);
|
||||
final b2 = _bearing(geometry[i + 1], geometry[i + 2]);
|
||||
double diff = (b2 - b1);
|
||||
while (diff < -180.0) {
|
||||
diff += 360.0;
|
||||
}
|
||||
while (diff > 180.0) {
|
||||
diff -= 360.0;
|
||||
}
|
||||
|
||||
if (diff.abs() >= 35.0) {
|
||||
final ManeuverType type;
|
||||
final String dirText;
|
||||
if (diff > 120.0) {
|
||||
type = ManeuverType.turnSharpRight;
|
||||
dirText = 'Sharp right';
|
||||
} else if (diff > 60.0) {
|
||||
type = ManeuverType.turnRight;
|
||||
dirText = 'Turn right';
|
||||
} else if (diff > 35.0) {
|
||||
type = ManeuverType.turnSlightRight;
|
||||
dirText = 'Slight right';
|
||||
} else if (diff < -120.0) {
|
||||
type = ManeuverType.turnSharpLeft;
|
||||
dirText = 'Sharp left';
|
||||
} else if (diff < -60.0) {
|
||||
type = ManeuverType.turnLeft;
|
||||
dirText = 'Turn left';
|
||||
} else {
|
||||
type = ManeuverType.turnSlightLeft;
|
||||
dirText = 'Slight left';
|
||||
}
|
||||
|
||||
list.add(
|
||||
RouteManeuver(
|
||||
index: list.length,
|
||||
instruction: '$dirText onto road',
|
||||
type: type,
|
||||
distanceMeters: 0.0,
|
||||
durationSeconds: 0,
|
||||
location: geometry[i + 1],
|
||||
geometryOffset: i + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
list.add(
|
||||
RouteManeuver(
|
||||
index: list.length,
|
||||
instruction: 'Arrive at $destinationName',
|
||||
type: ManeuverType.arrive,
|
||||
distanceMeters: 0.0,
|
||||
durationSeconds: 0,
|
||||
location: geometry.last,
|
||||
geometryOffset: geometry.length - 1,
|
||||
),
|
||||
);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
static double _calculateCurvatureMetric(
|
||||
List<AppLatLng> points,
|
||||
double distanceMiles,
|
||||
) {
|
||||
if (points.length < 3 || distanceMiles <= 0.05) return 0.0;
|
||||
double totalTurnDeg = 0.0;
|
||||
for (int i = 0; i < points.length - 2; i++) {
|
||||
final b1 = _bearing(points[i], points[i + 1]);
|
||||
final b2 = _bearing(points[i + 1], points[i + 2]);
|
||||
double diff = (b2 - b1).abs();
|
||||
if (diff > 180.0) diff = 360.0 - diff;
|
||||
totalTurnDeg += diff;
|
||||
}
|
||||
return totalTurnDeg / distanceMiles;
|
||||
}
|
||||
|
||||
static double _bearing(AppLatLng from, AppLatLng to) {
|
||||
final lat1 = from.latitude * (math.pi / 180.0);
|
||||
final lat2 = to.latitude * (math.pi / 180.0);
|
||||
final dLon = (to.longitude - from.longitude) * (math.pi / 180.0);
|
||||
final y = math.sin(dLon) * math.cos(lat2);
|
||||
final x = math.cos(lat1) * math.sin(lat2) -
|
||||
math.sin(lat1) * math.cos(lat2) * math.cos(dLon);
|
||||
final brng = math.atan2(y, x) * (180.0 / math.pi);
|
||||
return (brng + 360.0) % 360.0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show IconData, Icons;
|
||||
import '../map/map_provider_interface.dart';
|
||||
|
||||
/// Semantic types of route stops and waypoints.
|
||||
|
|
@ -435,3 +436,215 @@ class MotorSignalRoute {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic maneuver types for turn-by-turn navigation.
|
||||
enum ManeuverType {
|
||||
depart,
|
||||
turnRight,
|
||||
turnLeft,
|
||||
turnSlightRight,
|
||||
turnSlightLeft,
|
||||
turnSharpRight,
|
||||
turnSharpLeft,
|
||||
keepRight,
|
||||
keepLeft,
|
||||
uTurn,
|
||||
continueStraight,
|
||||
enterRoundabout,
|
||||
exitRoundabout,
|
||||
ramp,
|
||||
arrive,
|
||||
unknown;
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ManeuverType.depart:
|
||||
return Icons.navigation;
|
||||
case ManeuverType.turnRight:
|
||||
case ManeuverType.turnSharpRight:
|
||||
return Icons.turn_right;
|
||||
case ManeuverType.turnLeft:
|
||||
case ManeuverType.turnSharpLeft:
|
||||
return Icons.turn_left;
|
||||
case ManeuverType.turnSlightRight:
|
||||
case ManeuverType.keepRight:
|
||||
return Icons.turn_slight_right;
|
||||
case ManeuverType.turnSlightLeft:
|
||||
case ManeuverType.keepLeft:
|
||||
return Icons.turn_slight_left;
|
||||
case ManeuverType.uTurn:
|
||||
return Icons.u_turn_left;
|
||||
case ManeuverType.continueStraight:
|
||||
return Icons.straight;
|
||||
case ManeuverType.enterRoundabout:
|
||||
case ManeuverType.exitRoundabout:
|
||||
return Icons.roundabout_right;
|
||||
case ManeuverType.ramp:
|
||||
return Icons.ramp_right;
|
||||
case ManeuverType.arrive:
|
||||
return Icons.flag;
|
||||
case ManeuverType.unknown:
|
||||
return Icons.navigation;
|
||||
}
|
||||
}
|
||||
|
||||
String get directionShort {
|
||||
switch (this) {
|
||||
case ManeuverType.turnRight:
|
||||
return 'RIGHT';
|
||||
case ManeuverType.turnLeft:
|
||||
return 'LEFT';
|
||||
case ManeuverType.turnSlightRight:
|
||||
return 'SLIGHT RIGHT';
|
||||
case ManeuverType.turnSlightLeft:
|
||||
return 'SLIGHT LEFT';
|
||||
case ManeuverType.turnSharpRight:
|
||||
return 'HARD RIGHT';
|
||||
case ManeuverType.turnSharpLeft:
|
||||
return 'HARD LEFT';
|
||||
case ManeuverType.uTurn:
|
||||
return 'U-TURN';
|
||||
case ManeuverType.keepRight:
|
||||
return 'KEEP RIGHT';
|
||||
case ManeuverType.keepLeft:
|
||||
return 'KEEP LEFT';
|
||||
case ManeuverType.continueStraight:
|
||||
return 'STRAIGHT';
|
||||
case ManeuverType.depart:
|
||||
return 'HEAD OUT';
|
||||
case ManeuverType.arrive:
|
||||
return 'ARRIVE';
|
||||
default:
|
||||
return 'CONTINUE';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single turn-by-turn navigation maneuver step.
|
||||
@immutable
|
||||
class RouteManeuver {
|
||||
final int index;
|
||||
final String instruction;
|
||||
final ManeuverType type;
|
||||
final double distanceMeters;
|
||||
final int durationSeconds;
|
||||
final String? streetName;
|
||||
final AppLatLng location;
|
||||
final int? geometryOffset;
|
||||
|
||||
const RouteManeuver({
|
||||
required this.index,
|
||||
required this.instruction,
|
||||
required this.type,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
this.streetName,
|
||||
required this.location,
|
||||
this.geometryOffset,
|
||||
});
|
||||
|
||||
double get distanceMiles => distanceMeters * 0.000621371;
|
||||
|
||||
String get formattedDistance {
|
||||
if (distanceMiles < 0.1) {
|
||||
final feet = (distanceMeters * 3.28084).round();
|
||||
return '$feet ft';
|
||||
}
|
||||
return '${distanceMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'index': index,
|
||||
'instruction': instruction,
|
||||
'type': type.name,
|
||||
'distanceMeters': distanceMeters,
|
||||
'durationSeconds': durationSeconds,
|
||||
if (streetName != null) 'streetName': streetName,
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
if (geometryOffset != null) 'geometryOffset': geometryOffset,
|
||||
};
|
||||
|
||||
factory RouteManeuver.fromJson(Map<String, dynamic> json) {
|
||||
return RouteManeuver(
|
||||
index: (json['index'] as num?)?.toInt() ?? 0,
|
||||
instruction: json['instruction'] as String? ?? '',
|
||||
type: ManeuverType.values.firstWhere(
|
||||
(t) => t.name == (json['type'] as String?),
|
||||
orElse: () => ManeuverType.unknown,
|
||||
),
|
||||
distanceMeters: (json['distanceMeters'] as num?)?.toDouble() ?? 0.0,
|
||||
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
|
||||
streetName: json['streetName'] as String?,
|
||||
location: AppLatLng(
|
||||
(json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||
(json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||
),
|
||||
geometryOffset: (json['geometryOffset'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing optimization preferences supported by MotorSignal and AWS Routes V2.
|
||||
enum RoutingPreference {
|
||||
fastest,
|
||||
shortest,
|
||||
twisty,
|
||||
scenic;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case RoutingPreference.fastest:
|
||||
return 'Fastest';
|
||||
case RoutingPreference.shortest:
|
||||
return 'Shortest';
|
||||
case RoutingPreference.twisty:
|
||||
return 'Twisty (Curvature)';
|
||||
case RoutingPreference.scenic:
|
||||
return 'Scenic (No Highways)';
|
||||
}
|
||||
}
|
||||
|
||||
String get subtitle {
|
||||
switch (this) {
|
||||
case RoutingPreference.fastest:
|
||||
return 'Optimized for minimum travel time';
|
||||
case RoutingPreference.shortest:
|
||||
return 'Shortest total distance';
|
||||
case RoutingPreference.twisty:
|
||||
return 'Maximizes curves and elevation changes';
|
||||
case RoutingPreference.scenic:
|
||||
return 'Avoids limited-access highways and tolls';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing options provided to route calculation requests.
|
||||
@immutable
|
||||
class RoutingOptions {
|
||||
final RoutingPreference preference;
|
||||
final bool avoidHighways;
|
||||
final bool avoidTolls;
|
||||
final bool avoidFerries;
|
||||
|
||||
const RoutingOptions({
|
||||
this.preference = RoutingPreference.fastest,
|
||||
this.avoidHighways = false,
|
||||
this.avoidTolls = false,
|
||||
this.avoidFerries = false,
|
||||
});
|
||||
|
||||
RoutingOptions copyWith({
|
||||
RoutingPreference? preference,
|
||||
bool? avoidHighways,
|
||||
bool? avoidTolls,
|
||||
bool? avoidFerries,
|
||||
}) {
|
||||
return RoutingOptions(
|
||||
preference: preference ?? this.preference,
|
||||
avoidHighways: avoidHighways ?? this.avoidHighways,
|
||||
avoidTolls: avoidTolls ?? this.avoidTolls,
|
||||
avoidFerries: avoidFerries ?? this.avoidFerries,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ class RouteCalculationResult {
|
|||
final bool isStraightLine;
|
||||
final String providerName;
|
||||
final List<String> warnings;
|
||||
final List<RouteManeuver> maneuvers;
|
||||
final RoutingOptions? options;
|
||||
final double? curvatureMetric;
|
||||
|
||||
const RouteCalculationResult({
|
||||
required this.straightLineDistanceMeters,
|
||||
|
|
@ -93,6 +96,9 @@ class RouteCalculationResult {
|
|||
required this.isStraightLine,
|
||||
required this.providerName,
|
||||
this.warnings = const [],
|
||||
this.maneuvers = const [],
|
||||
this.options,
|
||||
this.curvatureMetric,
|
||||
});
|
||||
|
||||
/// Alias for geometry points.
|
||||
|
|
@ -117,6 +123,28 @@ class RouteCalculationResult {
|
|||
}
|
||||
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
||||
}
|
||||
|
||||
/// Human-readable estimated travel time.
|
||||
String get formattedDuration {
|
||||
if (estimatedDurationSeconds == null) return '-- min';
|
||||
final totalSec = estimatedDurationSeconds!;
|
||||
final hours = totalSec ~/ 3600;
|
||||
final minutes = (totalSec % 3600) ~/ 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes.clamp(1, 60)} min';
|
||||
}
|
||||
|
||||
/// Categorized motorcycle twistiness rating based on curvature metric (degrees per mile).
|
||||
String get twistinessRating {
|
||||
if (curvatureMetric == null) return 'Standard';
|
||||
final c = curvatureMetric!;
|
||||
if (c >= 120.0) return 'Very Twisty (Canyon)';
|
||||
if (c >= 60.0) return 'Twisty Curves';
|
||||
if (c >= 25.0) return 'Moderate Curves';
|
||||
return 'Highway / Straight';
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract provider interface for calculating routes and searching places.
|
||||
|
|
@ -129,6 +157,7 @@ abstract class RouteProvider {
|
|||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
RoutingOptions? options,
|
||||
});
|
||||
|
||||
/// Searches for places by textual query.
|
||||
|
|
@ -157,6 +186,7 @@ class StraightLineRouteProvider implements RouteProvider {
|
|||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
RoutingOptions? options,
|
||||
}) async {
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ class ValhallaRouteProvider implements RouteProvider {
|
|||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
RoutingOptions? options,
|
||||
}) async {
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class SocialFeedService {
|
|||
regionName: 'Bay Area, CA',
|
||||
isFeatured: true,
|
||||
snapshotType: FeedSnapshotType.routeTrack,
|
||||
isGpsVerified: true,
|
||||
isGpsVerified: false,
|
||||
fistBumpCount: 42,
|
||||
fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'],
|
||||
routePoints: const [
|
||||
|
|
@ -90,7 +90,7 @@ class SocialFeedService {
|
|||
snapshotType: FeedSnapshotType.scenicPhoto,
|
||||
photoUrl: 'assets/images/rock_store_lookout.png',
|
||||
photoCaption: 'Rock Store Lookout',
|
||||
isGpsVerified: true,
|
||||
isGpsVerified: false,
|
||||
fistBumpCount: 28,
|
||||
fistBumpUserIds: const ['uid_marcus'],
|
||||
comments: const [],
|
||||
|
|
@ -118,7 +118,7 @@ class SocialFeedService {
|
|||
regionName: 'Angeles Crest, CA',
|
||||
isFeatured: true,
|
||||
snapshotType: FeedSnapshotType.routeTrack,
|
||||
isGpsVerified: true,
|
||||
isGpsVerified: false,
|
||||
fistBumpCount: 65,
|
||||
fistBumpUserIds: const ['uid_marcus', 'uid_sarah'],
|
||||
routePoints: const [
|
||||
|
|
@ -151,7 +151,7 @@ class SocialFeedService {
|
|||
regionName: 'North Georgia, GA',
|
||||
isFeatured: true,
|
||||
snapshotType: FeedSnapshotType.routeTrack,
|
||||
isGpsVerified: true,
|
||||
isGpsVerified: false,
|
||||
fistBumpCount: 39,
|
||||
fistBumpUserIds: const ['uid_marcus'],
|
||||
routePoints: const [
|
||||
|
|
@ -184,7 +184,7 @@ class SocialFeedService {
|
|||
regionName: 'Tellico Plains, TN',
|
||||
isFeatured: true,
|
||||
snapshotType: FeedSnapshotType.routeTrack,
|
||||
isGpsVerified: true,
|
||||
isGpsVerified: false,
|
||||
fistBumpCount: 51,
|
||||
fistBumpUserIds: const ['uid_sarah', 'uid_alex'],
|
||||
routePoints: const [
|
||||
|
|
|
|||
|
|
@ -220,6 +220,100 @@ void main() {
|
|||
expect(result.geometry[2].longitude, equals(-84.5));
|
||||
expect(result.isStraightLine, isFalse);
|
||||
expect(result.providerName, equals('Amazon AWS Location'));
|
||||
// Fallback maneuvers synthesized from geometry
|
||||
expect(result.maneuvers.isNotEmpty, isTrue);
|
||||
expect(result.maneuvers.first.type, equals(ManeuverType.depart));
|
||||
expect(result.maneuvers.last.type, equals(ManeuverType.arrive));
|
||||
});
|
||||
|
||||
test('calculateRoute with RoutingOptions applies avoidHighways and TravelSteps maneuvers', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
final body = jsonDecode(request.body);
|
||||
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
|
||||
expect(body['OptimizeRoutingFor'], equals('ShortestRoute'));
|
||||
expect(body['LegAdditionalInputs'], contains('TravelSteps'));
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Routes': [
|
||||
{
|
||||
'Summary': {
|
||||
'Distance': 15000.0,
|
||||
'Duration': 900,
|
||||
},
|
||||
'Legs': [
|
||||
{
|
||||
'Geometry': {
|
||||
'LineString': [
|
||||
[-84.0, 34.0],
|
||||
[-84.05, 34.05],
|
||||
[-84.10, 34.10],
|
||||
],
|
||||
},
|
||||
'VehicleLegDetails': {
|
||||
'TravelSteps': [
|
||||
{
|
||||
'Instruction': 'Head north on Main St',
|
||||
'Type': 'Depart',
|
||||
'Distance': 5000.0,
|
||||
'Duration': 300,
|
||||
'GeometryOffset': 0,
|
||||
'CurrentRoad': 'Main St',
|
||||
},
|
||||
{
|
||||
'Instruction': 'Turn right onto Canyon Rd',
|
||||
'Type': 'Turn',
|
||||
'Turn': 'Right',
|
||||
'Distance': 10000.0,
|
||||
'Duration': 600,
|
||||
'GeometryOffset': 1,
|
||||
'NextRoad': 'Canyon Rd',
|
||||
},
|
||||
{
|
||||
'Instruction': 'Arrive at destination',
|
||||
'Type': 'Arrive',
|
||||
'Distance': 0.0,
|
||||
'Duration': 0,
|
||||
'GeometryOffset': 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final stops = [
|
||||
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
|
||||
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(
|
||||
stops,
|
||||
options: const RoutingOptions(
|
||||
preference: RoutingPreference.shortest,
|
||||
avoidHighways: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.maneuvers.length, equals(3));
|
||||
expect(result.maneuvers[0].instruction, equals('Head north on Main St'));
|
||||
expect(result.maneuvers[0].type, equals(ManeuverType.depart));
|
||||
expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Rd'));
|
||||
expect(result.maneuvers[1].type, equals(ManeuverType.turnRight));
|
||||
expect(result.maneuvers[1].streetName, equals('Canyon Rd'));
|
||||
expect(result.maneuvers[2].type, equals(ManeuverType.arrive));
|
||||
expect(result.formattedDuration, equals('15 min'));
|
||||
expect(result.curvatureMetric, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class FakeRouteProvider implements RouteProvider {
|
|||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
RoutingOptions? options,
|
||||
}) async {
|
||||
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