fix(mobile): October 11 Pixel 8a field-test stabilization (navigation, sensors, sync, UI) (#33)

* fix(navigation): canonicalize turn maneuvers and protect road bends from false announcements

* fix(sensors): expand mount pitch tolerance, suppress dynamic zero drift, and add diagnostics

* feat(sync): add itemized SyncStatusInspectorScreen and non-destructive queue diagnostics

* fix(map): dismiss search keyboard on selection, add compact address card, and wire map controls

* fix(ui): split pause action with hazard report, harden profile contrast, and bind social ride picker

* ci(android): enable stacked PR validation, verify package identity, and guard release to master

* fix(auth): register CI debug SHA-1 in Firebase and wire authoritative webClientId to GoogleSignIn

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-12 06:34:32 -04:00 committed by GitHub
parent 92d883ff90
commit f9e2bd6bed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 3741 additions and 665 deletions

View File

@ -4,7 +4,7 @@ on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ]
workflow_dispatch:
permissions:
@ -166,6 +166,13 @@ jobs:
test -s "$APK"
file "$APK"
sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256
if [ -n "${ANDROID_SDK_ROOT:-}" ]; then
AAPT="$(find "$ANDROID_SDK_ROOT/build-tools" -type f -name aapt -print -quit 2>/dev/null || true)"
if [ -x "$AAPT" ]; then
"$AAPT" dump badging "$APK" | grep "package: name='app.motorsignal.motorcycle'"
echo "Package identity verified: app.motorsignal.motorcycle"
fi
fi
- name: Upload debug APK
uses: actions/upload-artifact@v5
with:
@ -180,7 +187,7 @@ jobs:
name: Build signed release APK and AAB
runs-on: ubuntu-latest
needs: [detect, analyze-and-build]
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.detect.outputs.app_changed == 'true')
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.app_changed == 'true'))
permissions:
contents: read
steps:

View File

@ -13,6 +13,14 @@
}
},
"oauth_client": [
{
"client_id": "809431485362-0ev25bbjbjjm5tj6nitinefhg4fi7vvj.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "app.motorsignal.motorcycle",
"certificate_hash": "4ab4d3266a5b50ef31e46a5dccb8d42f295bc635"
}
},
{
"client_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com",
"client_type": 1,

View File

@ -4,24 +4,94 @@ import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
import '../theme/theme_extensions.dart';
import '../../features/rides/ride_state_machine.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
/// Pinned riding action controls for MotorSignal Hybrid v1.
///
/// Provides 56dp minimum height buttons for glove-friendly cockpit control.
/// In active ride state, provides high-contrast Pause Ride (Amber) and
/// Finish Ride (Dark/Slate with Cyan/Green accent) action controls.
/// In active ride state, provides high-contrast Pause Ride (Amber),
/// Report Hazard (Caution/Warning), and Finish Ride controls.
class HybridRidingActionBar extends ConsumerWidget {
final bool compact;
final String? finishLabel;
final String? logTag;
final bool requireConfirmation;
const HybridRidingActionBar({
super.key,
this.compact = false,
this.finishLabel,
this.logTag,
this.requireConfirmation = true,
});
Future<void> _handleFinish(
BuildContext context,
RideStateMachine notifier,
String effectiveTag,
) async {
if (!requireConfirmation) {
notifier.finishRide().catchError((Object error) {
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
});
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
key: const ValueKey('confirm_finish_ride_dialog'),
backgroundColor: const Color(0xFF161B22),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFF30363D)),
),
title: const Text(
'End Ride?',
style: TextStyle(
color: Colors.white,
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
content: const Text(
'Are you sure you want to end this ride session? Telemetry recording will stop and finalize.',
style: TextStyle(color: Color(0xFF8B949E), fontSize: 14),
),
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
actions: [
TextButton(
key: const ValueKey('confirm_finish_ride_cancel_button'),
onPressed: () => Navigator.of(ctx).pop(false),
style: TextButton.styleFrom(
minimumSize: const Size(80, 48),
foregroundColor: const Color(0xFF8B949E),
),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.w600)),
),
ElevatedButton(
key: const ValueKey('confirm_finish_ride_confirm_button'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 48),
backgroundColor: const Color(0xFFFF334B),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('End Ride', style: TextStyle(fontWeight: FontWeight.w700)),
),
],
),
);
if (confirmed == true && context.mounted) {
notifier.finishRide().catchError((Object error) {
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
});
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(
@ -37,7 +107,7 @@ class HybridRidingActionBar extends ConsumerWidget {
if (isRiding || isPaused) {
return Row(
children: [
// Pause / Resume button
// 1. Pause / Resume button
Expanded(
flex: 1,
child: SizedBox(
@ -52,7 +122,7 @@ class HybridRidingActionBar extends ConsumerWidget {
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6),
),
onPressed: () {
if (isPaused) {
@ -63,7 +133,7 @@ class HybridRidingActionBar extends ConsumerWidget {
},
icon: Icon(
isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
size: 24,
size: 22,
color: colors.cautionAmber,
),
label: FittedBox(
@ -72,17 +142,58 @@ class HybridRidingActionBar extends ConsumerWidget {
isPaused ? 'Resume' : 'Pause Ride',
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
letterSpacing: 0.2,
),
),
),
),
),
),
const SizedBox(width: 10),
// Finish Ride button
const SizedBox(width: 8),
// 2. Report Hazard button (Split from pause bar for quick emergency access)
Expanded(
flex: 1,
child: SizedBox(
height: buttonHeight,
child: ElevatedButton.icon(
key: const ValueKey('hybrid_report_hazard_button'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF9800).withValues(alpha: 0.16),
foregroundColor: const Color(0xFFFF9800),
side: const BorderSide(color: Color(0xFFFF9800), width: 1.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 6),
),
onPressed: () => QuickSwarmReportModal.show(context),
icon: const Icon(
Icons.warning_amber_rounded,
size: 22,
color: Color(0xFFFF9800),
),
label: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Hazard',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
),
),
),
const SizedBox(width: 8),
// 3. Finish Ride / Stop Session button (Guarded with confirmation dialog)
Expanded(
flex: 1,
child: SizedBox(
@ -97,17 +208,15 @@ class HybridRidingActionBar extends ConsumerWidget {
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6),
),
onPressed: () {
final effectiveTag = logTag ?? 'HybridRidingActionBar';
notifier.finishRide().catchError((Object error) {
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
});
_handleFinish(context, notifier, effectiveTag);
},
icon: Icon(
Icons.stop_rounded,
size: 24,
size: 22,
color: colors.brandCyan,
),
label: FittedBox(
@ -116,9 +225,9 @@ class HybridRidingActionBar extends ConsumerWidget {
finishLabel ?? 'Finish Ride',
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
letterSpacing: 0.2,
),
),
),
@ -129,6 +238,7 @@ class HybridRidingActionBar extends ConsumerWidget {
);
}
// Ready / Standby state: Single prominent start button
return SizedBox(
width: double.infinity,

View File

@ -82,13 +82,15 @@ class DashboardAlertRegion extends ConsumerWidget {
}
Widget _buildRoadAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) {
final isSwarm = alert.providerId == 'swarm_alerts';
final isDark = Theme.of(context).brightness == Brightness.dark;
final isSwarm = alert.providerId == 'swarm_alerts' || alert.providerId == 'swarm';
final subtitleColor = isDark ? Colors.white70 : Theme.of(context).colorScheme.onSurfaceVariant;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.2),
color: alert.category.color.withValues(alpha: isDark ? 0.2 : 0.12),
border: Border(bottom: BorderSide(color: alert.category.color, width: 2)),
),
child: Row(
@ -101,9 +103,11 @@ class DashboardAlertRegion extends ConsumerWidget {
children: [
Row(
children: [
Text(
Flexible(
child: Text(
alert.category.displayName.toUpperCase(),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
color: alert.category.color,
@ -112,6 +116,7 @@ class DashboardAlertRegion extends ConsumerWidget {
letterSpacing: 0.8,
),
),
),
if (isSwarm) ...[
const SizedBox(width: 6),
Container(
@ -135,11 +140,12 @@ class DashboardAlertRegion extends ConsumerWidget {
],
),
Text(
'${alert.distanceFromUser?.toStringAsFixed(0) ?? '0'}m Ahead • ${alert.roadName ?? alert.description}',
_formatAlertSubtitle(alert),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontFamily: AppTypography.fontBody,
color: Colors.white,
color: subtitleColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
@ -155,6 +161,7 @@ class DashboardAlertRegion extends ConsumerWidget {
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Confirm (Still there)',
onPressed: () {
if (alert.id.isNotEmpty) {
ref.read(swarmRoadAlertServiceProvider).confirmAlert(alert.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -162,6 +169,7 @@ class DashboardAlertRegion extends ConsumerWidget {
duration: Duration(seconds: 2),
),
);
}
},
),
IconButton(
@ -170,6 +178,7 @@ class DashboardAlertRegion extends ConsumerWidget {
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Clear (No longer there)',
onPressed: () {
if (alert.id.isNotEmpty) {
ref.read(swarmRoadAlertServiceProvider).clearAlert(alert.id);
ref.read(roadAlertServiceProvider).dismissAlert(alert.id);
ScaffoldMessenger.of(context).showSnackBar(
@ -178,11 +187,12 @@ class DashboardAlertRegion extends ConsumerWidget {
duration: Duration(seconds: 2),
),
);
}
},
),
],
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
icon: Icon(Icons.close, color: subtitleColor, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Dismiss alert',
@ -256,4 +266,30 @@ class DashboardAlertRegion extends ConsumerWidget {
),
);
}
String _formatAlertSubtitle(RoadAlert alert) {
final distText = _formatAlertDistance(alert.distanceFromUser);
final detail = alert.roadName ?? alert.description;
if (distText.isEmpty) {
return detail.isNotEmpty ? detail : 'Road Alert';
}
if (detail.isEmpty) {
return distText;
}
return '$distText • $detail';
}
String _formatAlertDistance(double? meters) {
if (meters == null) return '';
if (meters < 15.0) {
return 'Nearby';
}
final miles = meters / 1609.344;
if (miles < 0.1) {
final feet = ((meters * 3.28084) / 10).round() * 10;
return '${feet > 0 ? feet : 50} ft Ahead';
} else {
return '${miles.toStringAsFixed(1)} mi Ahead';
}
}
}

View File

@ -6,6 +6,7 @@ import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_service.dart';
import '../../../services/map/map_provider_interface.dart';
import '../../../services/map/unified_map_view.dart';
import '../../../services/navigation/navigation_session_service.dart';
import '../../../services/telemetry_service.dart';
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
@ -80,6 +81,20 @@ class DashboardMapPanel extends ConsumerWidget {
);
}
final navSession = ref.watch(navigationSessionProvider);
final polylines = <AppPolyline>[];
if (navSession.isNavigating &&
navSession.calculationResult?.geometry.isNotEmpty == true) {
polylines.add(
AppPolyline(
id: 'dash_active_nav_route',
points: navSession.calculationResult!.geometry,
color: const Color(0xFF00E5FF),
width: 5.0,
),
);
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
@ -103,7 +118,9 @@ class DashboardMapPanel extends ConsumerWidget {
key: const ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng),
zoom: zoom,
bearing: 0.0,
markers: mapMarkers,
polylines: polylines,
),
),
if (!isFresh && hasCoordinates)

View File

@ -257,10 +257,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
size: const Size(double.infinity, double.infinity),
painter: _GForcePodPainter(accelG: accelG, lateralG: lateralG),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 18),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'${totalG.toStringAsFixed(2)} G',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
@ -269,10 +270,12 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
color: Colors.white,
),
),
],
),
),
const Positioned(
top: 8,
top: 6,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'BRAKING',
style: TextStyle(
@ -284,8 +287,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
),
),
),
),
const Positioned(
bottom: 8,
bottom: 6,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'CORNERING',
style: TextStyle(
@ -297,6 +303,7 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
),
),
),
),
],
),
),
@ -317,6 +324,10 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
maxLeft: session.maxLeanLeft,
maxRight: session.maxLeanRight,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -354,6 +365,8 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
),
),
),
),
),
],
),
),
@ -393,8 +406,57 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: () {
onPressed: () async {
if (session.state == RideState.riding) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
key: const ValueKey('confirm_stop_session_dialog'),
backgroundColor: const Color(0xFF161B22),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFF30363D)),
),
title: const Text(
'End Ride?',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
content: const Text(
'Are you sure you want to end this ride session? Telemetry recording will stop and finalize.',
style: TextStyle(color: Color(0xFF8B949E), fontSize: 14),
),
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
actions: [
TextButton(
key: const ValueKey('confirm_stop_session_cancel_button'),
onPressed: () => Navigator.of(ctx).pop(false),
style: TextButton.styleFrom(
minimumSize: const Size(80, 48),
foregroundColor: const Color(0xFF8B949E),
),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.w600)),
),
ElevatedButton(
key: const ValueKey('confirm_stop_session_confirm_button'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 48),
backgroundColor: const Color(0xFFFF334B),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('End Ride', style: TextStyle(fontWeight: FontWeight.w700)),
),
],
),
);
if (confirmed != true || !context.mounted) return;
// RID-2: contain the fire-and-forget Stop action at the UI
// boundary. finishRide() keeps its Future error semantics
// for awaiting callers; only the exception class is logged,

View File

@ -52,6 +52,18 @@ class LeanAngleIndicator extends ConsumerWidget {
final String angleText = isAvailable ? '${displayLean.abs().toStringAsFixed(0)}°' : '--°';
void showDiagnosticsSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF14171A),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => const _SensorDiagnosticsSheet(),
);
}
void handleTapToCalibrate() {
final currentSpeed = sample?.speed ?? 0.0;
final currentLean = displayLean.abs();
@ -148,6 +160,15 @@ class LeanAngleIndicator extends ConsumerWidget {
),
),
actions: [
TextButton.icon(
key: const ValueKey('lean_calibrate_diagnostics_button'),
icon: const Icon(Icons.analytics_outlined, size: 16),
label: const Text('Diagnostics'),
onPressed: () {
Navigator.pop(ctx);
showDiagnosticsSheet(context);
},
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
@ -193,14 +214,15 @@ class LeanAngleIndicator extends ConsumerWidget {
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;
final double gaugeHeight = compact ? 46.0 : 60.0;
return Tooltip(
message: 'Tap to Calibrate Upright (Stationary)',
message: 'Tap to Calibrate | Long press for Diagnostics',
child: InkWell(
key: const ValueKey('lean_angle_indicator_tap'),
borderRadius: BorderRadius.circular(12),
onTap: handleTapToCalibrate,
onLongPress: () => showDiagnosticsSheet(context),
child: Semantics(
label: isAvailable
? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
@ -302,22 +324,31 @@ class LeanAngleIndicator extends ConsumerWidget {
),
),
// Preserves backward-compatible telemetry text matchers for existing tests
SizedBox(
width: 0,
height: 0,
child: OverflowBox(
minWidth: 0,
maxWidth: 300,
minHeight: 0,
maxHeight: 50,
// Instant Live Lean Angle telemetry
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('L: ${currentLeft.toStringAsFixed(0)}°'),
Text('R: ${currentRight.toStringAsFixed(0)}°'),
],
Text(
'L: ${currentLeft.toStringAsFixed(0)}°',
style: TextStyle(
fontSize: compact ? 8 : 9.5,
fontWeight: FontWeight.w600,
color: colors.textMuted,
),
),
SizedBox(width: compact ? 8 : 12),
Text(
'R: ${currentRight.toStringAsFixed(0)}°',
style: TextStyle(
fontSize: compact ? 8 : 9.5,
fontWeight: FontWeight.w600,
color: colors.textMuted,
),
),
],
),
),
],
),
@ -492,3 +523,131 @@ class _MotorcycleLeanPainter extends CustomPainter {
oldDelegate.isDark != isDark;
}
}
class _SensorDiagnosticsSheet extends ConsumerWidget {
const _SensorDiagnosticsSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final diagAsync = ref.watch(sensorDiagnosticsStreamProvider);
final fallbackDiag = ref.read(sensorFusionServiceProvider).latestDiagnostics;
final diag = diagAsync.value ?? fallbackDiag;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
color: Color(0xFF14171A),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.sensors, color: Color(0xFF00E5FF), size: 20),
SizedBox(width: 8),
Text(
'IMU Sensor Diagnostics',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: (diag?.isCalibrated ?? false)
? const Color(0xFF10B981).withValues(alpha: 0.2)
: const Color(0xFFEF4444).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: (diag?.isCalibrated ?? false)
? const Color(0xFF10B981)
: const Color(0xFFEF4444),
width: 1,
),
),
child: Text(
(diag?.isCalibrated ?? false) ? 'CALIBRATED' : 'UNCALIBRATED',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w700,
color: (diag?.isCalibrated ?? false)
? const Color(0xFF10B981)
: const Color(0xFFEF4444),
),
),
),
],
),
const SizedBox(height: 16),
if (diag == null)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Awaiting IMU sensor data...',
style: TextStyle(color: Colors.white54),
),
),
)
else ...[
_buildDiagRow('Event Rate', '${diag.eventRateHz.toStringAsFixed(1)} Hz (target 50 Hz)'),
_buildDiagRow('Mount Mode', diag.mountOrientation),
_buildDiagRow('Fused Roll (Lean)', '${diag.finalLeanAngle.toStringAsFixed(1)}° (${diag.dashboardDirection})'),
_buildDiagRow('Fused Pitch', '${diag.transformedPitch.toStringAsFixed(1)}° (Offset: ${diag.pitchOffset.toStringAsFixed(1)}°)'),
_buildDiagRow('Longitudinal G', '${diag.longitudinalG >= 0 ? "+" : ""}${diag.longitudinalG.toStringAsFixed(2)} G'),
_buildDiagRow('Lateral G', '${diag.lateralG >= 0 ? "+" : ""}${diag.lateralG.toStringAsFixed(2)} G'),
_buildDiagRow('Raw Accel [X, Y, Z]', '${diag.rawAccelX.toStringAsFixed(2)}, ${diag.rawAccelY.toStringAsFixed(2)}, ${diag.rawAccelZ.toStringAsFixed(2)} m/s²'),
_buildDiagRow('Raw Gyro [X, Y, Z]', '${diag.rawGyroX.toStringAsFixed(3)}, ${diag.rawGyroY.toStringAsFixed(3)}, ${diag.rawGyroZ.toStringAsFixed(3)} rad/s'),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('close_sensor_diagnostics_button'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF222831),
),
onPressed: () => Navigator.pop(context),
child: const Text('Close Diagnostics', style: TextStyle(color: Colors.white)),
),
),
],
),
),
);
}
Widget _buildDiagRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 13, color: Colors.white70)),
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF00E5FF),
),
),
],
),
);
}
}

View File

@ -22,7 +22,6 @@ class NavigationManeuverCard extends ConsumerWidget {
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}.',
@ -81,10 +80,12 @@ class NavigationManeuverCard extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
navState.formattedDistanceToNext,
@ -96,6 +97,7 @@ class NavigationManeuverCard extends ConsumerWidget {
letterSpacing: -0.5,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@ -118,16 +120,17 @@ class NavigationManeuverCard extends ConsumerWidget {
),
],
),
),
const SizedBox(height: 2),
Text(
currentManeuver.streetName ?? currentManeuver.instruction,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
color: Colors.white,
),
),
],
@ -137,6 +140,8 @@ class NavigationManeuverCard extends ConsumerWidget {
// Quick Audio Mute & End Navigation Buttons
IconButton(
key: const ValueKey('nav_toggle_tts_button'),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
icon: Icon(
navState.ttsEnabled ? Icons.volume_up : Icons.volume_off,
color: navState.ttsEnabled ? colors.cyberCyan : Colors.white54,
@ -149,6 +154,8 @@ class NavigationManeuverCard extends ConsumerWidget {
),
IconButton(
key: const ValueKey('nav_stop_button'),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
icon: const Icon(Icons.close, color: Colors.white70, size: 22),
tooltip: 'Exit Navigation',
onPressed: () {

View File

@ -62,7 +62,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
return LayoutBuilder(
builder: (context, constraints) {
// Group-ride alerts reduce the usable height above this layout.
final isShortPortrait = constraints.maxHeight < 760;
final isShortPortrait = constraints.maxHeight < 880;
final isUltraShortPortrait = constraints.maxHeight < 500;
return Column(
@ -200,8 +200,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: Center(
child: MetricTile(
label: 'ELEV',
value: session.elevationGain.toStringAsFixed(0),
unit: 'M',
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
unit: 'FT',
compact: true,
),
),
@ -229,13 +229,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
if (!isShortPortrait) const Spacer(flex: 1),
Flexible(
flex: isShortPortrait ? 3 : 4,
child: Center(
child: Transform.translate(
offset: Offset(0, -12),
child: const Center(
child: SpeedReadout(size: SpeedReadoutSize.hero),
),
),
),
if (!isShortPortrait) const Spacer(flex: 1),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
@ -351,8 +348,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
),
MetricTile(
label: 'ELEV',
value: session.elevationGain.toStringAsFixed(0),
unit: 'M',
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
unit: 'FT',
compact: true,
),
],
@ -559,8 +556,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
),
MetricTile(
label: 'ELEV GAIN',
value: session.elevationGain.toStringAsFixed(0),
unit: 'M',
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
unit: 'FT',
),
],
),

View File

@ -53,12 +53,13 @@ class SpeedReadout extends ConsumerWidget {
final sample = s.value;
if (sample == null) {
// Pre-telemetry initial state (before first tick)
return (speed: 0.0, isFresh: true, isInitial: true);
return (speed: 0.0, isFresh: true, isInitial: true, rawSpeed: null);
}
return (
speed: sample.freshSpeed,
isFresh: sample.isSpeedFresh,
isInitial: false,
rawSpeed: sample.speed,
);
}),
);
@ -70,12 +71,12 @@ class SpeedReadout extends ConsumerWidget {
final Color speedColor;
final Color unitColor;
if (speedState.isInitial) {
if (speedState.isInitial || (speedState.rawSpeed == null && !speedState.isFresh)) {
speedText = '0';
speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
unitColor = baseUnitColor;
} else if (!speedState.isFresh) {
// Stale or unavailable GNSS fix: display '--' to reflect truthful uncertainty
// Stale or unavailable GNSS fix during active motion: display '--' to reflect truthful uncertainty
speedText = '--';
speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38);
unitColor = baseUnitColor.withValues(alpha: 0.5);

View File

@ -137,8 +137,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
.where((p) => p.id == ProductCatalog.proLifetime)
.firstOrNull;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: Colors.black,
backgroundColor: isDark ? const Color(0xFF0B0D0F) : Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
@ -359,9 +360,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
: (entitlement.expiresAt != null
? 'Subscription active. Renews ${entitlement.expiresAt!.toLocal().toString().split(' ')[0]}.'
: 'Subscription active.'),
style: const TextStyle(
style: TextStyle(
fontSize: 11,
color: Colors.white70,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@ -373,7 +374,8 @@ class _ProScreenState extends ConsumerState<ProScreen> {
}
Widget _buildPitchHeader() {
return const Column(
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@ -382,17 +384,17 @@ class _ProScreenState extends ConsumerState<ProScreen> {
fontFamily: 'Sora',
fontSize: 22,
fontWeight: FontWeight.w900,
color: Colors.white,
color: theme.colorScheme.onSurface,
letterSpacing: 0.2,
height: 1.2,
),
),
SizedBox(height: 8),
const SizedBox(height: 8),
Text(
'Unlock 20Hz deep telemetry analysis, multiple motorcycle profiles, unlimited cloud sync, and forensic data exports.',
style: TextStyle(
fontSize: 13,
color: Colors.white70,
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
@ -426,7 +428,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
title: 'PRO LIFETIME',
subtitle: 'One-time purchase • Permanent access',
priceDisplay: lifetimeProd?.price ?? 'Unavailable',
badgeText: 'BEST VALUE',
badgeText: lifetimeProd != null ? 'BEST VALUE' : null,
isHighlighted: true,
isLoading: _currentStep == ProPurchaseStep.purchasing,
isCurrentPlan:
@ -528,12 +530,12 @@ class _ProScreenState extends ConsumerState<ProScreen> {
}
Widget _buildDisclaimer() {
return const Text(
return Text(
'Basic ride recording, cockpit instruments, offline history, local SQLite storage, and crash detection remain 100% free forever. MotorSignal Pro subscriptions can be managed or canceled anytime in Google Play Store settings.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Colors.white38,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
height: 1.4,
),
);
@ -557,10 +559,10 @@ class _ProScreenState extends ConsumerState<ProScreen> {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 6),
const Text(
Text(
'Cannot connect to Google Play. Please check your internet connection or Google Play Store service status.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.white60),
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 14),
OutlinedButton(
@ -619,11 +621,11 @@ class _ProScreenState extends ConsumerState<ProScreen> {
children: [
Text(
title,
style: const TextStyle(
style: TextStyle(
fontFamily: 'Sora',
fontSize: 15,
fontWeight: FontWeight.w900,
color: Colors.white,
color: Theme.of(context).colorScheme.onSurface,
letterSpacing: 0.5,
),
),
@ -675,9 +677,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
style: TextStyle(
fontSize: 11,
color: Colors.white60,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@ -700,18 +702,22 @@ class _ProScreenState extends ConsumerState<ProScreen> {
fontWeight: FontWeight.w900,
color: isHighlighted
? colors.cyberCyan
: Colors.white,
: Theme.of(context).colorScheme.onSurface,
),
),
),
const SizedBox(height: 2),
Text(
isCurrentPlan ? 'Active' : 'Select',
isCurrentPlan
? 'Active'
: (onTap == null ? 'Unavailable' : 'Select'),
style: TextStyle(
fontSize: 11,
color: isHighlighted
? colors.cyberCyan
: Colors.white54,
: (onTap == null
? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: Theme.of(context).colorScheme.onSurfaceVariant),
fontWeight: FontWeight.bold,
),
),
@ -751,18 +757,18 @@ class _ProScreenState extends ConsumerState<ProScreen> {
children: [
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
desc,
style: const TextStyle(
style: TextStyle(
fontSize: 11,
color: Colors.white60,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.3,
),
),

View File

@ -56,7 +56,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
List<PlaceSearchResult> _searchResults = [];
ResolvedPlace? _selectedDestination;
RouteCalculationResult? _previewRouteResult;
bool _isAddressExpanded = false;
bool _isCalculatingRoute = false;
int _routeCalculationRequestId = 0;
RoutingOptions _routingOptions = const RoutingOptions();
static const double _hardAccelThresholdG = 0.35;
@ -119,8 +121,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
AppLatLng currentPos,
) async {
_searchDebounce?.cancel();
FocusScope.of(context).unfocus();
setState(() {
_searchResults = [];
_isAddressExpanded = false;
_searchController.text = place.primaryText;
_destinationQuery = place.primaryText;
});
@ -148,6 +152,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
ResolvedPlace dest,
AppLatLng currentPos,
) async {
final reqId = ++_routeCalculationRequestId;
setState(() => _isCalculatingRoute = true);
try {
@ -170,14 +175,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
.read(activeRouteCalculationProvider)
.calculateRoute([origin, destinationStop], options: _routingOptions);
if (mounted) {
if (mounted && reqId == _routeCalculationRequestId) {
setState(() {
_previewRouteResult = result;
_isCalculatingRoute = false;
});
}
} catch (e) {
if (mounted) {
if (mounted && reqId == _routeCalculationRequestId) {
setState(() => _isCalculatingRoute = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route calculation error: $e')),
@ -194,19 +199,25 @@ class _MapScreenState extends ConsumerState<MapScreen> {
_searchResults = [];
_selectedDestination = null;
_previewRouteResult = null;
_isAddressExpanded = false;
});
}
void _startTurnByTurnNavigation() {
if (_previewRouteResult == null || _selectedDestination == null) return;
final preview = _previewRouteResult!;
final dest = _selectedDestination!;
setState(() {
_previewRouteResult = null;
});
ref.read(navigationSessionProvider.notifier).startNavigation(
calculationResult: _previewRouteResult!,
destination: _selectedDestination,
calculationResult: preview,
destination: dest,
options: _routingOptions,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigation started to ${_selectedDestination!.displayName}'),
content: Text('Navigation started to ${dest.displayName}'),
backgroundColor: const Color(0xFF00E5FF),
),
);
@ -880,6 +891,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
if (_isLoadingHistory) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
ref.listen<NavigationSessionState>(navigationSessionProvider, (prev, next) {
if (prev?.isNavigating == true && !next.isNavigating && mounted) {
setState(() {
_previewRouteResult = null;
_selectedDestination = null;
});
}
});
final isFollowMode = ref.watch(mapFollowStateProvider);
final layerSettings = ref.watch(mapLayerSettingsProvider);
final colors = context.motorColors;
@ -1158,6 +1178,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
key: const ValueKey('live_map_view'),
center: currentPos,
zoom: _currentZoom,
bearing: _isNorthUp ? 0.0 : currentHeading,
markers: mapMarkers,
polylines: livePolylines,
),
@ -1288,10 +1309,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
),
// Bottom-left Quick Swarm Report Button
if (_previewRouteResult == null && !navSession.isNavigating)
// Bottom-left Quick Swarm Report Button (Repositioned to prevent card collision and hidden during search/typing)
if (MediaQuery.of(context).viewInsets.bottom == 0 && _searchResults.isEmpty)
Positioned(
bottom: 24,
bottom: _previewRouteResult != null ? 295 : 24,
left: 20,
child: FloatingActionButton.extended(
key: const ValueKey('quick_swarm_report_button'),
@ -1502,7 +1523,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
child: Row(
children: [
_buildQuickDestinationChip(
label: 'Gas & Fuel',
label: 'Gas Stations',
customKey: 'chip_gas_and_fuel',
icon: Icons.local_gas_station,
colors: colors,
currentPos: currentPos,
@ -1542,10 +1564,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
required IconData icon,
required MotorSignalThemeColors colors,
required AppLatLng currentPos,
String? customKey,
}) {
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
final keyString = customKey ??
'chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}';
return InkWell(
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
key: ValueKey(keyString),
borderRadius: BorderRadius.circular(20),
onTap: () {
if (isSelected) {
@ -1575,7 +1600,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
Icon(
icon,
size: 13,
color: isSelected ? colors.cyberCyan : Colors.white70,
color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
@ -1584,7 +1609,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
fontFamily: 'Inter',
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? colors.cyberCyan : Colors.white70,
color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@ -1597,15 +1622,17 @@ class _MapScreenState extends ConsumerState<MapScreen> {
MotorSignalThemeColors colors,
AppLatLng currentPos,
) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Card(
key: const ValueKey('search_results_overlay'),
elevation: 8,
shadowColor: Colors.black87,
color: colors.surfaceContainer.withValues(alpha: 0.98),
color: theme.colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: 0.4),
color: colors.cyberCyan.withValues(alpha: isDark ? 0.4 : 0.6),
width: 1.0,
),
),
@ -1617,7 +1644,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
itemCount: _searchResults.length,
separatorBuilder: (_, _) => Divider(
height: 1,
color: colors.outlineVariant.withValues(alpha: 0.3),
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
itemBuilder: (context, index) {
final item = _searchResults[index];
@ -1637,11 +1664,11 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
title: Text(
item.primaryText,
style: const TextStyle(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
color: theme.colorScheme.onSurface,
),
),
subtitle: item.secondaryText.isNotEmpty
@ -1649,13 +1676,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
item.secondaryText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
color: Colors.white70,
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
selectedTileColor: colors.cyberCyan.withValues(alpha: 0.12),
hoverColor: colors.cyberCyan.withValues(alpha: 0.08),
onTap: () => _onSelectPlace(item, currentPos),
);
},
@ -1670,6 +1699,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
) {
final preview = _previewRouteResult!;
final dest = _selectedDestination;
final confirmedPref = preview.options?.preference ?? _routingOptions.preference;
return Card(
key: const ValueKey('route_preview_card'),
@ -1694,18 +1724,36 @@ class _MapScreenState extends ConsumerState<MapScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
InkWell(
key: const ValueKey('destination_address_expand_toggle'),
borderRadius: BorderRadius.circular(6),
onTap: () => setState(() => _isAddressExpanded = !_isAddressExpanded),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
dest?.displayName ?? 'Destination Route',
maxLines: 1,
maxLines: _isAddressExpanded ? 4 : 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
),
const SizedBox(width: 4),
Icon(
_isAddressExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: colors.cyberCyan,
),
],
),
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
@ -1720,6 +1768,70 @@ class _MapScreenState extends ConsumerState<MapScreen> {
color: colors.cyberCyan,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: colors.cyberCyan,
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isCalculatingRoute) ...[
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colors.cyberCyan,
),
),
const SizedBox(width: 4),
],
Text(
confirmedPref.name.toUpperCase(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
],
),
),
if (preview.options?.avoidHighways == true)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFF59E0B),
width: 0.8,
),
),
child: const Text(
'NO HIGHWAYS',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: Color(0xFFF59E0B),
),
),
),
if (preview.curvatureMetric != null && preview.curvatureMetric! >= 25.0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@ -1734,7 +1846,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
),
child: Text(
preview.twistinessRating,
'CURVES: ${preview.twistinessRating}',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
@ -1750,7 +1862,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
IconButton(
key: const ValueKey('clear_route_button'),
icon: const Icon(Icons.close, color: Colors.white70),
icon: Icon(Icons.close, color: Theme.of(context).colorScheme.onSurfaceVariant),
onPressed: _clearDestination,
tooltip: 'Cancel Destination',
),
@ -1790,6 +1902,28 @@ class _MapScreenState extends ConsumerState<MapScreen> {
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
FilterChip(
key: const ValueKey('preference_avoid_highways'),
label: const Text('Avoid Highways'),
selected: _routingOptions.avoidHighways,
labelStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: _routingOptions.avoidHighways ? FontWeight.bold : FontWeight.normal,
color: _routingOptions.avoidHighways ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant,
),
selectedColor: colors.cyberCyan,
backgroundColor: colors.surfaceContainerHigh,
onSelected: (selected) {
setState(() {
_routingOptions = _routingOptions.copyWith(avoidHighways: selected);
});
if (_selectedDestination != null) {
_calculateRouteToDestination(_selectedDestination!, currentPos);
}
},
),
],
),
),
@ -1860,14 +1994,16 @@ class _MapScreenState extends ConsumerState<MapScreen> {
required AppLatLng currentPos,
}) {
final isSelected = _routingOptions.preference == preference;
final onVariant = Theme.of(context).colorScheme.onSurfaceVariant;
return ChoiceChip(
key: ValueKey('preference_${preference.name}'),
label: Text(label),
selected: isSelected,
labelStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.black : Colors.white70,
color: isSelected ? Colors.black : onVariant,
),
selectedColor: colors.cyberCyan,
backgroundColor: colors.surfaceContainerHigh,
@ -1908,8 +2044,10 @@ class _HudIconButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final isDark = Theme.of(context).brightness == Brightness.dark;
final defaultBg = isDark ? colors.surfaceContainer : const Color(0xFF1E293B);
final button = Material(
color: backgroundColor ?? colors.surfaceContainer.withValues(alpha: 0.9),
color: backgroundColor ?? defaultBg.withValues(alpha: 0.9),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(

View File

@ -74,6 +74,8 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
double _playbackSpeed = 1.0;
int _currentIndex = 0;
Timer? _playbackTimer;
double _zoom = 17.0;
bool _isFollowingRider = true;
@override
void initState() {
@ -325,6 +327,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Widget build(BuildContext context) {
final colors = context.motorColors;
final theme = Theme.of(context);
final hasLeanData = _telemetry.any((t) => t.leanAngle != null);
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
if (_telemetry.isEmpty) {
@ -480,10 +483,49 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
children: [
UnifiedMapView(
center: currentPos,
zoom: 17.0,
zoom: _zoom,
markers: allMarkers,
polylines: polylines,
isFollowModeOverride: true,
isFollowModeOverride: _isFollowingRider,
onUserInteraction: () {
if (_isFollowingRider) {
setState(() => _isFollowingRider = false);
}
},
),
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
Positioned(
right: 14,
bottom: 14,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isFollowingRider) ...[
_ReplayMapButton(
key: const ValueKey('replay_recenter_button'),
icon: Icons.my_location,
tooltip: 'Recenter on Rider',
color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
onTap: () => setState(() => _isFollowingRider = true),
),
const SizedBox(height: 8),
],
_ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'),
icon: Icons.add,
tooltip: 'Zoom In',
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
),
const SizedBox(height: 8),
_ReplayMapButton(
key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
),
],
),
),
// Telemetry Overlay
Positioned(
@ -619,7 +661,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
// Corner Entry / Apex / Exit Breakdown Table
Expanded(
flex: 4,
flex: _corners.isEmpty ? 3 : 4,
child: Container(
color: theme.colorScheme.surfaceContainerLow,
child: Column(
@ -646,7 +688,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
const SizedBox(width: 8),
Text(
'Tap row to seek apex',
_corners.isEmpty ? '' : 'Tap row to seek apex',
style: TextStyle(
fontSize: 10,
fontFamily: 'JetBrains Mono',
@ -659,14 +701,54 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
if (_corners.isEmpty)
Expanded(
child: Center(
child: Text(
'No distinct corners detected over 15° lean threshold.',
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hasLeanData
? Icons.straighten_rounded
: Icons.sensors_off_rounded,
size: 26,
color: hasLeanData
? colors.cyberCyan.withValues(alpha: 0.7)
: colors.warningAmber.withValues(alpha: 0.8),
),
const SizedBox(height: 6),
Text(
hasLeanData
? 'No distinct corners detected over 15° lean threshold.'
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
fontFamily: 'JetBrains Mono',
),
),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
hasLeanData
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
fontFamily: 'Inter',
),
),
),
],
),
),
),
),
)
else
@ -861,3 +943,48 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
);
}
}
class _ReplayMapButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
final Color? color;
final Color? backgroundColor;
const _ReplayMapButton({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
this.color,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return Material(
color: backgroundColor ?? Colors.black87,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: (color ?? Colors.white).withValues(alpha: 0.3)),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Tooltip(
message: tooltip,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
icon,
color: color ?? Colors.white,
size: 24,
),
),
),
),
);
}
}

View File

@ -11,6 +11,7 @@ import '../../services/sync/cloud_restore_controller.dart';
import '../../services/sync/sync_providers.dart';
import '../../services/group_rides/group_ride_providers.dart';
import 'widgets/current_plan_card.dart';
import 'widgets/sync_status_inspector_screen.dart';
class AccountScreen extends ConsumerStatefulWidget {
const AccountScreen({super.key});
@ -1031,14 +1032,27 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
),
),
] else ...[
Container(
InkWell(
key: const ValueKey('open_sync_inspector_button'),
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SyncStatusInspectorScreen(),
),
);
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildSyncStatItem(
@ -1058,6 +1072,32 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.list_alt_rounded, size: 12, color: colors.cyberCyan),
const SizedBox(width: 4),
Flexible(
child: Text(
'Inspect Queue',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: colors.cyberCyan,
),
),
),
const SizedBox(width: 2),
Icon(Icons.chevron_right, size: 12, color: colors.cyberCyan),
],
),
],
),
),
),
const SizedBox(height: 12),
if (syncState.failedCount > 0) ...[
@ -1083,7 +1123,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
'${syncState.failedCount} ride(s) need attention. Tap Retry to process.',
'${syncState.failedCount} ride(s) need attention.',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
@ -1093,6 +1135,31 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(40, 28),
foregroundColor: colors.cyberCyan,
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SyncStatusInspectorScreen(),
),
);
},
child: const Text(
'INSPECT',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(40, 28),
foregroundColor: colors.raceRed,
),
onPressed: () => syncManager.retryAllPending(),

View File

@ -1238,13 +1238,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
setState(() => _isTestingGa511 = false);
if (mounted) {
String statusMsg;
switch (status) {
case RoadAlertProviderStatus.connected:
statusMsg = 'Georgia 511 feed connected!';
break;
case RoadAlertProviderStatus.authError:
statusMsg = 'Georgia 511 gateway: Authentication required (Sign in to MotorSignal)';
break;
case RoadAlertProviderStatus.networkError:
statusMsg = 'Georgia 511 gateway: Unreachable (Network error)';
break;
default:
statusMsg = 'Georgia 511 gateway status: ${status.name}';
}
messenger.showSnackBar(
SnackBar(
content: Text(
status == RoadAlertProviderStatus.connected
? 'Georgia 511 feed connected!'
: 'Georgia 511 gateway status: ${status.name}',
),
content: Text(statusMsg),
),
);
}
@ -1357,8 +1367,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Waze OSINT adapter configured successfully!'),
SnackBar(
content: Text(
status == RoadAlertProviderStatus.connected
? 'Waze OSINT feed connected!'
: 'Waze OSINT adapter configured locally (live upstream queries disabled by capability gate)',
),
),
);
}

View File

@ -0,0 +1,500 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../database/database.dart';
import '../../../services/sync/cloud_restore_service.dart';
import '../../../services/sync/sync_models.dart';
import '../../../services/sync/sync_outbox.dart';
import '../../../services/sync/sync_providers.dart';
class SyncStatusInspectorScreen extends ConsumerStatefulWidget {
const SyncStatusInspectorScreen({super.key});
@override
ConsumerState<SyncStatusInspectorScreen> createState() =>
_SyncStatusInspectorScreenState();
}
enum _SyncFilter { all, pending, failed, synced }
class _SyncStatusInspectorScreenState
extends ConsumerState<SyncStatusInspectorScreen> {
_SyncFilter _currentFilter = _SyncFilter.all;
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final theme = Theme.of(context);
final outbox = ref.watch(syncOutboxProvider);
final syncManager = ref.watch(syncManagerProvider);
final syncState = ref.watch(syncStateProvider);
final restoreState = ref.watch(cloudRestoreControllerProvider);
final allItems = outbox.getAllItems().reversed.toList();
final filteredItems = allItems.where((item) {
switch (_currentFilter) {
case _SyncFilter.all:
return true;
case _SyncFilter.pending:
return item.status.isPending || item.status.isInProgress;
case _SyncFilter.failed:
return item.status == SyncStatus.failed ||
item.status == SyncStatus.retryWait ||
item.status == SyncStatus.authRequired;
case _SyncFilter.synced:
return item.status == SyncStatus.synced;
}
}).toList();
return Scaffold(
appBar: AppBar(
title: const Text(
'Sync Queue & Diagnostics',
style: TextStyle(fontFamily: 'Sora', fontSize: 16, fontWeight: FontWeight.bold),
),
actions: [
IconButton(
key: const ValueKey('refresh_sync_inspector_button'),
tooltip: 'Refresh Outbox',
icon: const Icon(Icons.refresh),
onPressed: () {
outbox.load();
setState(() {});
},
),
TextButton.icon(
key: const ValueKey('retry_all_sync_inspector_button'),
icon: const Icon(Icons.replay, size: 16),
label: const Text('RETRY ALL', style: TextStyle(fontWeight: FontWeight.bold)),
onPressed: () async {
await syncManager.retryAllPending();
syncManager.processQueue();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Re-armed pending outbox items and started sync.'),
duration: Duration(seconds: 2),
),
);
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Restore reconciliation banner if items need attention
if (restoreState.result != null && restoreState.result!.failures > 0)
_buildRestoreNoticeBanner(restoreState.result!),
// Status counter overview
_buildSummaryCard(outbox, syncState),
const SizedBox(height: 16),
// Filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('All (${allItems.length})', _SyncFilter.all),
const SizedBox(width: 8),
_buildFilterChip(
'Pending (${allItems.where((i) => i.status.isPending || i.status.isInProgress).length})',
_SyncFilter.pending,
),
const SizedBox(width: 8),
_buildFilterChip(
'Needs Attention (${allItems.where((i) => i.status == SyncStatus.failed || i.status == SyncStatus.retryWait || i.status == SyncStatus.authRequired).length})',
_SyncFilter.failed,
),
const SizedBox(width: 8),
_buildFilterChip(
'Synced (${allItems.where((i) => i.status == SyncStatus.synced).length})',
_SyncFilter.synced,
),
],
),
),
const SizedBox(height: 16),
if (filteredItems.isEmpty)
Container(
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
alignment: Alignment.center,
child: Column(
children: [
Icon(Icons.inbox_outlined, size: 48, color: colors.outlineVariant),
const SizedBox(height: 12),
Text(
'No sync items in this view',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Local rides are queued for backup when completed.',
style: TextStyle(fontSize: 12, color: colors.outlineVariant),
),
],
),
)
else
...filteredItems.map((item) => _buildItemCard(item)),
],
),
);
}
Widget _buildRestoreNoticeBanner(CloudRestoreResult result) {
final colors = context.motorColors;
final reasons = <String>[];
if (result.telemetryFailed > 0) {
reasons.add(
'${result.telemetryFailed} ride(s) have cloud metadata but no telemetry bundle in cloud storage yet (pending local upload).',
);
}
if (result.telemetryAuthFailures > 0) {
reasons.add('${result.telemetryAuthFailures} ride(s) require re-authentication.');
}
if (result.metadata.conflicts > 0) {
reasons.add('${result.metadata.conflicts} ride(s) had server/local metadata conflicts.');
}
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.warningAmber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.warningAmber.withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: colors.warningAmber, size: 18),
const SizedBox(width: 8),
Text(
'RESTORE STATUS — ${result.failures} ITEM(S) UNRESOLVED',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 11,
fontWeight: FontWeight.w800,
color: colors.warningAmber,
letterSpacing: 0.5,
),
),
],
),
const SizedBox(height: 6),
...reasons.map(
(r) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'• $r',
style: const TextStyle(fontSize: 11, height: 1.35),
),
),
),
],
),
);
}
Widget _buildSummaryCard(SyncOutbox outbox, dynamic syncState) {
final colors = context.motorColors;
final items = outbox.getAllItems();
final synced = items.where((i) => i.status == SyncStatus.synced).length;
final pending = items.where((i) => i.status.isPending || i.status.isInProgress).length;
final failed = items.where((i) => i.status == SyncStatus.failed || i.status == SyncStatus.retryWait || i.status == SyncStatus.authRequired).length;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatColumn('Total Items', '${items.length}', colors.cyberCyan),
_buildStatColumn('Synced', '$synced', colors.launchGreen),
_buildStatColumn('Pending', '$pending', colors.warningAmber),
_buildStatColumn('Needs Action', '$failed', colors.raceRed),
],
),
);
}
Widget _buildStatColumn(String label, String value, Color color) {
return Column(
children: [
Text(
value,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
);
}
Widget _buildFilterChip(String label, _SyncFilter filter) {
final selected = _currentFilter == filter;
final colors = context.motorColors;
return ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => setState(() => _currentFilter = filter),
selectedColor: colors.cyberCyan.withValues(alpha: 0.2),
labelStyle: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
color: selected ? colors.cyberCyan : null,
),
);
}
Widget _buildItemCard(SyncItem item) {
final colors = context.motorColors;
final statusColor = _statusColor(item.status);
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.directions_bike, size: 16, color: colors.cyberCyan),
const SizedBox(width: 6),
Text(
'Ride #${item.localRideId}',
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: statusColor.withValues(alpha: 0.6)),
),
child: Text(
item.status.name.toUpperCase(),
style: TextStyle(
fontFamily: 'Sora',
fontSize: 9,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
),
],
),
const SizedBox(height: 8),
_buildDetailRow('Provider', item.providerId),
if (item.remoteId != null)
_buildDetailRow('Cloud Ride ID', item.remoteId!, isMono: true)
else
_buildDetailRow('Cloud Ride ID', 'Pending server assignment', isDim: true),
_buildDetailRow('Attempts', '${item.attemptCount}'),
if (item.lastAttemptAt != null)
_buildDetailRow('Last Attempt', _formatDate(item.lastAttemptAt!)),
if (item.nextRetryAt != null && item.status == SyncStatus.retryWait)
_buildDetailRow('Next Retry', _formatDate(item.nextRetryAt!), isHighlight: true),
if (item.uploadedAt != null)
_buildDetailRow('Uploaded At', _formatDate(item.uploadedAt!)),
// Safe error reason display
if (item.lastError != null) ...[
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.raceRed.withValues(alpha: 0.3)),
),
child: Text(
'Reason: ${item.lastError}',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
color: colors.raceRed,
),
),
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.info_outline, size: 14),
label: const Text('INSPECT', style: TextStyle(fontSize: 11)),
onPressed: () => _inspectItem(item),
),
if (item.status != SyncStatus.synced) ...[
const SizedBox(width: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
icon: const Icon(Icons.refresh, size: 14),
label: const Text('RETRY', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
onPressed: () => _retryItem(item),
),
],
],
),
],
),
),
);
}
Widget _buildDetailRow(String label, String value, {bool isMono = false, bool isDim = false, bool isHighlight = false}) {
final colors = context.motorColors;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant)),
Text(
value,
style: TextStyle(
fontFamily: isMono ? 'JetBrains Mono' : null,
fontSize: 11,
fontWeight: isHighlight ? FontWeight.bold : FontWeight.normal,
color: isHighlight
? colors.warningAmber
: (isDim ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6) : null),
),
),
],
),
);
}
Color _statusColor(SyncStatus status) {
final colors = context.motorColors;
switch (status) {
case SyncStatus.synced:
return colors.launchGreen;
case SyncStatus.uploading:
return colors.cyberCyan;
case SyncStatus.queued:
return Colors.blueAccent;
case SyncStatus.waitingForNetwork:
case SyncStatus.retryWait:
return colors.warningAmber;
case SyncStatus.authRequired:
return Colors.purpleAccent;
case SyncStatus.failed:
case SyncStatus.cancelled:
return colors.raceRed;
case SyncStatus.localOnly:
return colors.outlineVariant;
}
}
String _formatDate(DateTime dt) {
final l = dt.toLocal();
return '${l.month.toString().padLeft(2, '0')}/${l.day.toString().padLeft(2, '0')} ${l.hour.toString().padLeft(2, '0')}:${l.minute.toString().padLeft(2, '0')}:${l.second.toString().padLeft(2, '0')}';
}
Future<void> _retryItem(SyncItem item) async {
final outbox = ref.read(syncOutboxProvider);
final syncManager = ref.read(syncManagerProvider);
await outbox.enqueue(item.localRideId, item.providerId);
syncManager.processQueue();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Re-queued ride #${item.localRideId} for immediate sync.'),
duration: const Duration(seconds: 2),
),
);
setState(() {});
}
}
Future<void> _inspectItem(SyncItem item) async {
final db = ref.read(databaseProvider);
final ride = await (db.select(db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull();
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Ride #${item.localRideId} Details'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Sync Outbox Key: ${item.key}'),
Text('Status: ${item.status.name}'),
Text('Created: ${_formatDate(item.createdAt)}'),
if (item.remoteId != null) Text('Cloud Ride ID: ${item.remoteId}'),
if (item.lastError != null) ...[
const SizedBox(height: 8),
const Text('Last Error:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(item.lastError!),
],
const Divider(height: 20),
if (ride != null) ...[
const Text('Local Database Ride:', style: TextStyle(fontWeight: FontWeight.bold)),
Text('Distance: ${ride.distanceMiles.toStringAsFixed(1)} mi'),
Text('Moving Time: ${ride.movingTime} s'),
Text('Cloud Owner: ${ride.cloudOwnerUid ?? 'Unclaimed (device local)'}'),
Text('Cloud Ride ID: ${ride.cloudRideId ?? 'None'}'),
] else ...[
const Text('Local ride not found in database (may have been purged).'),
],
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('CLOSE')),
],
),
);
}
}

View File

@ -122,15 +122,15 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
if (!widget.showAppBar) {
return Container(
color: const Color(0xFF0D1117),
color: Theme.of(context).colorScheme.surface,
child: content,
);
}
return Scaffold(
backgroundColor: const Color(0xFF0D1117),
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.95),
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
@ -232,25 +232,27 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
color: const Color(0xFF0D1117),
color: Theme.of(context).colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: Status LED, Active metric & Search toggle
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 6,
// Row 1: Status LED & Search toggle
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Status LED pill (Required by test suite)
// Status LED pill
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFF161B22),
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFFE0F7FA)
: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.25),
color: AppColors.electricCyan.withValues(
alpha: Theme.of(context).brightness == Brightness.light ? 0.6 : 0.25,
),
),
),
child: Row(
@ -272,48 +274,40 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
),
),
const SizedBox(width: 8),
const Text(
Text(
'SQUAD & CANYON FEED',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Color(0xFF9CF0FF),
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFF006064)
: const Color(0xFF9CF0FF),
),
),
],
),
),
// Live sensor metric pill (Required by test suite)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFF1E2530),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.sensors_rounded,
size: 14,
// Search toggle button
IconButton(
key: const ValueKey('feed_search_toggle_button'),
icon: Icon(
_isSearchExpanded ? Icons.close_rounded : Icons.search_rounded,
color: AppColors.electricCyan,
size: 20,
),
SizedBox(width: 6),
Text(
'24 ACTIVE',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Color(0xFF9CF0FF),
),
),
],
),
tooltip: 'Search Feed',
onPressed: () {
setState(() {
_isSearchExpanded = !_isSearchExpanded;
if (!_isSearchExpanded) {
_searchController.clear();
ref.read(feedSearchQueryProvider.notifier).state = '';
}
});
},
),
],
),
@ -372,20 +366,12 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
),
],
const SizedBox(height: 10),
const SizedBox(height: 8),
// Stream Selector (Scrollable horizontally to guarantee zero RenderFlex overflow)
// Unified Single Streamlined Filter Row (Streams + Disciplines)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFF0B0E13),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1E2633)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildFilterTab(
tab: FeedFilter.allActivity,
@ -416,18 +402,13 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
title: 'Following',
isSelected: activeFilter == FeedFilter.following,
),
],
const SizedBox(width: 8),
Container(
width: 1,
height: 20,
color: colors.outlineVariant.withValues(alpha: 0.4),
),
),
),
const SizedBox(height: 8),
// Riding Discipline Filter Chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 8),
_buildDisciplineChip(
discipline: RidingDiscipline.all,
label: 'All Styles',
@ -553,7 +534,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
letterSpacing: 0.1,
color: isSelected
? const Color(0xFF00363D)
: const Color(0xFFCBD5E1),
: (Theme.of(context).brightness == Brightness.light
? const Color(0xFF334155)
: const Color(0xFFCBD5E1)),
),
),
),
@ -566,6 +549,7 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
required bool isSelected,
}) {
final chipColor = _getDisciplineColor(discipline);
final isLight = Theme.of(context).brightness == Brightness.light;
return InkWell(
borderRadius: BorderRadius.circular(8),
@ -578,12 +562,12 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
decoration: BoxDecoration(
color: isSelected
? chipColor.withValues(alpha: 0.22)
: const Color(0xFF161B22),
: (isLight ? const Color(0xFFF1F5F9) : const Color(0xFF161B22)),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? chipColor.withValues(alpha: 0.7)
: const Color(0xFF263238),
: (isLight ? const Color(0xFFCBD5E1) : const Color(0xFF263238)),
width: isSelected ? 1.2 : 1.0,
),
),
@ -593,7 +577,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
fontFamily: AppTypography.fontHeadline,
fontSize: 11.5,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? Colors.white : const Color(0xFF94A3B8),
color: isSelected
? (isLight ? const Color(0xFF0F172A) : Colors.white)
: (isLight ? const Color(0xFF475569) : const Color(0xFF94A3B8)),
),
),
),
@ -626,27 +612,71 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
hasCustomFilters
? 'No matching ride sessions'
: 'No posts in ${activeFilter.displayName}',
style: const TextStyle(
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 6),
Text(
hasCustomFilters
? 'Try relaxing your style filters or search query to discover more community rides.'
: 'Hit "Share Recent Session" below to publish telemetry from your latest ride.',
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
textAlign: TextAlign.center,
style: const TextStyle(
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: Color(0xFF94A3B8),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
OutlinedButton.icon(
key: const ValueKey('empty_feed_group_rides_button'),
icon: const Icon(Icons.two_wheeler_rounded, size: 16),
label: const Text('Group Rides'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.electricCyan,
side: BorderSide(
color: AppColors.electricCyan.withValues(alpha: 0.5),
),
),
onPressed: () => context.push('/group-rides'),
),
OutlinedButton.icon(
key: const ValueKey('empty_feed_clubs_button'),
icon: const Icon(Icons.groups_rounded, size: 16),
label: const Text('Clubs'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.electricCyan,
side: BorderSide(
color: AppColors.electricCyan.withValues(alpha: 0.5),
),
),
onPressed: () => context.push('/social/clubs'),
),
OutlinedButton.icon(
key: const ValueKey('empty_feed_friends_button'),
icon: const Icon(Icons.person_add_rounded, size: 16),
label: const Text('Friends'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.electricCyan,
side: BorderSide(
color: AppColors.electricCyan.withValues(alpha: 0.5),
),
),
onPressed: () => context.push('/social/friends'),
),
],
),
if (hasCustomFilters) ...[
const SizedBox(height: 16),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Reset All Filters'),

View File

@ -110,7 +110,11 @@ class _FeedCommentsBottomSheetState
? livePost.comments
: _localComments;
return Container(
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF101419),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
@ -126,10 +130,6 @@ class _FeedCommentsBottomSheetState
padding: EdgeInsets.only(bottom: bottomInset),
child: SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [

View File

@ -1,8 +1,11 @@
import 'dart:math' as math;
import 'package:drift/drift.dart' as drift;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/theme_extensions.dart';
import '../../../../database/database.dart';
import '../../../../services/auth/auth_providers.dart';
import '../../../../services/map/map_provider_interface.dart';
import '../../../../services/social/social_feed_models.dart';
@ -29,10 +32,8 @@ class ShareSessionModal extends ConsumerStatefulWidget {
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final _formKey = GlobalKey<FormState>();
final _titleController =
TextEditingController(text: 'Morning Canyon Carve');
final _locationController =
TextEditingController(text: 'Skyline Blvd & Highway 9');
final _titleController = TextEditingController(text: 'Morning Canyon Carve');
final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9');
final _bikeController = TextEditingController();
final _distanceController = TextEditingController(text: '48.5');
final _durationController = TextEditingController(text: '52');
@ -42,21 +43,96 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
bool _isSubmitting = false;
List<Ride> _recentRides = [];
Ride? _selectedRide;
List<AppLatLng> _selectedRoutePoints = const [
AppLatLng(37.3861, -122.2641),
AppLatLng(37.3500, -122.2300),
AppLatLng(37.3100, -122.2000),
AppLatLng(37.2800, -122.2200),
];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final defaultBike = ref.read(defaultMotorcycleProvider);
if (defaultBike != null) {
_bikeController.text =
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'
.trim();
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim();
} else {
_bikeController.text = '2024 Ducati Streetfighter V4';
}
await _loadRecentRides();
});
}
Future<void> _loadRecentRides() async {
try {
final db = ref.read(databaseProvider);
final rides = await (db.select(db.rides)
..orderBy([(t) => drift.OrderingTerm(expression: t.id, mode: drift.OrderingMode.desc)])
..limit(10))
.get();
if (mounted) {
setState(() {
_recentRides = rides;
if (rides.isNotEmpty) {
_selectRide(rides.first);
}
});
}
} catch (_) {
// Ignore database retrieval errors on rides
}
}
Future<void> _selectRide(Ride ride) async {
_selectedRide = ride;
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
final durMins = ride.movingTime > 0
? (ride.movingTime / 60).round()
: (ride.endTime?.difference(ride.startTime).inMinutes ?? 35).clamp(1, 999);
final maxLean = math.max(ride.maxLeanLeft, ride.maxLeanRight);
final side = ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT';
_titleController.text = 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride';
_distanceController.text = dist > 0 ? dist.toStringAsFixed(1) : '24.5';
_durationController.text = durMins.toString();
_maxLeanController.text = maxLean > 0 ? maxLean.toStringAsFixed(0) : '38';
_selectedLeanSide = side;
if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) {
_bikeController.text =
'${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim();
}
try {
final db = ref.read(databaseProvider);
final samples = await (db.select(db.telemetrySamples)
..where((t) => t.rideId.equals(ride.id))
..orderBy([(t) => drift.OrderingTerm(expression: t.id)]))
.get();
final validSamples = samples.where((s) => s.latitude != null && s.longitude != null).toList();
if (validSamples.isNotEmpty) {
final step = (validSamples.length / 25).ceil().clamp(1, validSamples.length);
final downsampled = [
for (int i = 0; i < validSamples.length; i += step)
AppLatLng(validSamples[i].latitude!, validSamples[i].longitude!)
];
if (mounted) {
setState(() {
_selectedRoutePoints = downsampled;
});
}
}
} catch (_) {}
if (mounted) setState(() {});
}
@override
void dispose() {
_titleController.dispose();
@ -107,12 +183,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
elevationFt: 1840,
discipline: _selectedDiscipline,
snapshotType: FeedSnapshotType.routeTrack,
routePoints: const [
AppLatLng(37.3861, -122.2641),
AppLatLng(37.3500, -122.2300),
AppLatLng(37.3100, -122.2000),
AppLatLng(37.2800, -122.2200),
],
routePoints: _selectedRoutePoints,
);
// Reset filter to All Activity so published session is immediately displayed
@ -155,7 +226,11 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final colors = context.motorColors;
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Container(
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF101419),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
@ -171,12 +246,15 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
padding: EdgeInsets.only(bottom: bottomInset),
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// Modal Header & Handle
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
@ -191,7 +269,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
),
),
),
// Modal Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -248,7 +325,66 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
),
],
),
const SizedBox(height: 16),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// Recent Database Rides Selector
if (_recentRides.isNotEmpty) ...[
_buildFieldLabel('SELECT RECORDED SESSION', colors),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<Ride>(
key: const ValueKey('share_session_ride_picker'),
value: _selectedRide,
isExpanded: true,
dropdownColor: const Color(0xFF161B22),
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
items: _recentRides.map((ride) {
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
final maxL = math.max(ride.maxLeanLeft, ride.maxLeanRight);
return DropdownMenuItem<Ride>(
value: ride,
child: Row(
children: [
const Icon(Icons.two_wheeler_rounded, size: 16, color: AppColors.electricCyan),
const SizedBox(width: 8),
Expanded(
child: Text(
'Ride #${ride.id} • ${dist.toStringAsFixed(1)} mi • ${maxL.toStringAsFixed(0)}° lean • ${ride.startTime.month}/${ride.startTime.day}',
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
onChanged: (val) {
if (val != null) _selectRide(val);
},
),
),
),
const SizedBox(height: 14),
],
// Ride Title Field
_buildFieldLabel('SESSION / ROUTE TITLE', colors),
@ -491,11 +627,17 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
),
],
),
const SizedBox(height: 24),
],
),
),
),
// Publish CTA Button
SizedBox(
// Publish CTA Button (pinned at modal bottom)
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox(
height: 52,
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan,
@ -536,13 +678,14 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
),
),
),
),
],
),
),
),
),
);
}
),
),
);
}
Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) {
return Padding(

View File

@ -393,15 +393,15 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
const SizedBox(height: 20),
TextFormField(
controller: _handleController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Rider Handle',
hintText: 'e.g. canyon_runner',
prefixText: '@',
helperText:
'Unique, 3-20 chars: letters, numbers, underscores',
helperStyle: const TextStyle(
color: Colors.white38, fontSize: 11),
helperStyle: TextStyle(
color: colors.outline, fontSize: 11),
border: const OutlineInputBorder(),
filled: true,
fillColor: colors.surfaceContainer,
@ -439,7 +439,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
const SizedBox(height: 16),
TextFormField(
controller: _displayNameController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Display Name',
hintText: 'e.g. Alex Rivera',
@ -476,7 +476,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
children: [
TextFormField(
controller: _regionController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Riding Region (Optional)',
hintText: 'e.g. Blue Ridge Mountains, NC',
@ -484,11 +484,13 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
filled: true,
fillColor: colors.surfaceContainer,
),
onChanged: (_) => setState(() {}),
),
_buildRegionChips(colors),
const SizedBox(height: 16),
TextFormField(
controller: _bioController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 3,
decoration: InputDecoration(
labelText: 'Rider Bio (Optional)',
@ -569,14 +571,14 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
TextFormField(
controller: _handleController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Rider Handle',
hintText: 'e.g. canyon_runner',
prefixText: '@',
helperText: 'Unique, 3-20 characters: letters, numbers, underscores',
helperStyle:
const TextStyle(color: Colors.white38, fontSize: 11),
TextStyle(color: colors.outline, fontSize: 11),
border: const OutlineInputBorder(),
filled: true,
fillColor: colors.surfaceContainerHigh,
@ -615,7 +617,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
TextFormField(
controller: _displayNameController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Display Name',
hintText: 'e.g. Alex Rivera',
@ -629,7 +631,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
TextFormField(
controller: _regionController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Riding Region (Optional)',
hintText: 'e.g. Blue Ridge Mountains, NC',
@ -637,12 +639,14 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
filled: true,
fillColor: colors.surfaceContainerHigh,
),
onChanged: (_) => setState(() {}),
),
_buildRegionChips(colors),
const SizedBox(height: 16),
TextFormField(
controller: _bioController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 3,
decoration: InputDecoration(
labelText: 'Rider Bio (Optional)',
@ -730,11 +734,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
SwitchListTile(
contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan,
title: const Text('Discoverable by Handle',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
title: Text('Discoverable by Handle',
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
subtitle: Text(
'Allow other riders to find your profile by searching your @handle.',
style: TextStyle(fontSize: 11, color: Colors.white60),
style: TextStyle(fontSize: 11, color: colors.outline),
),
value: _isSearchableByHandle,
onChanged: (val) =>
@ -744,11 +748,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
SwitchListTile(
contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan,
title: const Text('Public Profile',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
title: Text('Public Profile',
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
subtitle: Text(
'When off, non-friends only see your handle and avatar, hiding bio and region.',
style: TextStyle(fontSize: 11, color: Colors.white60),
style: TextStyle(fontSize: 11, color: colors.outline),
),
value: _isProfilePublic,
onChanged: (val) =>
@ -758,11 +762,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
SwitchListTile(
contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan,
title: const Text('Allow Friend Requests',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
title: Text('Allow Friend Requests',
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
subtitle: Text(
'Allow other riders to send you friend requests.',
style: TextStyle(fontSize: 11, color: Colors.white60),
style: TextStyle(fontSize: 11, color: colors.outline),
),
value: _allowFriendRequests,
onChanged: (val) =>
@ -774,6 +778,68 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
);
}
static const List<String> _popularRegions = [
'Tail of the Dragon',
'Bay Area & NorCal Canyons',
'SoCal Canyons (ACH / PCH)',
'Blue Ridge Parkway',
'Twisted Sisters (TX)',
'Smoky Mountains',
'Colorado Rockies',
'Ozark Twisties',
];
Widget _buildRegionChips(MotorSignalThemeColors colors) {
return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POPULAR RIDING REGIONS',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: colors.outline,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 8,
children: _popularRegions.map((region) {
final isSelected = _regionController.text.trim().toLowerCase() == region.toLowerCase();
return FilterChip(
label: Text(
region,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? const Color(0xFF003840) : Theme.of(context).colorScheme.onSurface,
),
),
selected: isSelected,
selectedColor: colors.cyberCyan,
backgroundColor: colors.surfaceContainer,
checkmarkColor: const Color(0xFF003840),
side: BorderSide(
color: isSelected ? colors.cyberCyan : colors.outlineVariant.withValues(alpha: 0.6),
),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
onSelected: (_) {
setState(() {
_regionController.text = region;
});
},
);
}).toList(),
),
],
),
);
}
Widget _buildSaveButton(MotorSignalThemeColors colors) {
return SizedBox(
height: 48,

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_typography.dart';
import '../../../services/social/rider_profile_models.dart';
/// Renders a rider's avatar using their preset styling or custom photo URL.
@ -35,10 +34,6 @@ class AvatarBadge extends StatelessWidget {
backgroundImage: NetworkImage(photoUrl!),
);
} else {
final initial = displayName.trim().isNotEmpty
? displayName.trim()[0].toUpperCase()
: 'R';
inner = CircleAvatar(
radius: radius,
backgroundColor: secondaryColor,
@ -51,14 +46,10 @@ class AvatarBadge extends StatelessWidget {
),
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
child: Icon(
preset.icon,
color: primaryColor,
fontWeight: FontWeight.w900,
fontSize: radius * 0.9,
fontFamily: AppTypography.fontHeadline,
),
size: radius * 1.15,
),
),
);

View File

@ -18,10 +18,10 @@ class AvatarSelector extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'SELECT AVATAR PRESET',
style: TextStyle(
color: Colors.white70,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,

View File

@ -90,4 +90,8 @@ class DefaultFirebaseOptions {
projectId: 'motoapp-d22e0',
storageBucket: 'motoapp-d22e0.firebasestorage.app',
);
/// Authoritative Web OAuth client ID for Google Sign-In on Android/Web.
static const String webClientId =
'809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com';
}

View File

@ -9,10 +9,12 @@ import 'road_alert_provider_interface.dart';
class Georgia511RoadAlertProvider implements RoadAlertProvider {
final http.Client _client;
final String backendBaseUrl;
final Future<String?> Function()? getAuthToken;
Georgia511RoadAlertProvider({
http.Client? client,
String? backendBaseUrl,
this.getAuthToken,
}) : _client = client ?? http.Client(),
backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl();
@ -38,13 +40,39 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
RoadAlertCategory.hazard,
};
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
final headers = <String, String>{
'Accept': 'application/json',
};
if (getAuthToken != null) {
try {
final token = await getAuthToken!();
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
return headers;
}
} catch (e) {
if (kDebugMode) {
debugPrint('[Georgia511RoadAlertProvider] Error resolving auth token: $e');
}
}
}
if (apiKey != null && apiKey.isNotEmpty) {
headers['Authorization'] = 'Bearer $apiKey';
}
return headers;
}
@override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
try {
final headers = await _buildHeaders(apiKey: apiKey);
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
final res = await _client.get(uri).timeout(const Duration(seconds: 4));
final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
if (res.statusCode == 200) {
return RoadAlertProviderStatus.connected;
} else if (res.statusCode == 401 || res.statusCode == 403) {
return RoadAlertProviderStatus.authError;
}
return RoadAlertProviderStatus.networkError;
} catch (e) {
@ -81,7 +109,8 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
);
try {
final response = await _client.get(uri).timeout(const Duration(seconds: 5));
final headers = await _buildHeaders(apiKey: apiKey);
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
return [];
}

View File

@ -23,7 +23,10 @@ final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) {
});
final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) {
return Georgia511RoadAlertProvider();
final authService = ref.watch(authServiceProvider);
return Georgia511RoadAlertProvider(
getAuthToken: () => authService.getIdToken(),
);
});
final wazeProviderInstance = Provider<WazeRoadAlertProvider>((ref) {
@ -522,7 +525,7 @@ final ga511AlertsEnabledProvider =
final ga511StatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
final enabled = ref.watch(ga511AlertsEnabledProvider);
return enabled ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured;
return enabled ? RoadAlertProviderStatus.configured : RoadAlertProviderStatus.notConfigured;
});
// Active Providers List

View File

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:google_sign_in/google_sign_in.dart';
import '../../firebase_options.dart';
import 'auth_state.dart';
import 'auth_user.dart';
@ -44,7 +45,10 @@ class FirebaseAuthService implements AuthService {
fb.FirebaseAuth? firebaseAuth,
GoogleSignIn? googleSignIn,
}) : _firebaseAuth = firebaseAuth ?? _resolveFirebaseAuth(),
_googleSignIn = googleSignIn ?? GoogleSignIn() {
_googleSignIn = googleSignIn ??
GoogleSignIn(
serverClientId: DefaultFirebaseOptions.webClientId,
) {
_init();
}
@ -274,7 +278,10 @@ class FirebaseAuthService implements AuthService {
));
try {
final googleSignIn = _googleSignIn ?? GoogleSignIn();
final googleSignIn = _googleSignIn ??
GoogleSignIn(
serverClientId: DefaultFirebaseOptions.webClientId,
);
final GoogleSignInAccount? googleUser = await googleSignIn.signIn();
if (googleUser == null) {
@ -442,7 +449,11 @@ class FirebaseAuthService implements AuthService {
if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.');
credential = fb.EmailAuthProvider.credential(email: user.email!, password: password);
} else if (providers.contains('google.com')) {
final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn();
final googleUser = await (_googleSignIn ??
GoogleSignIn(
serverClientId: DefaultFirebaseOptions.webClientId,
))
.signIn();
if (googleUser == null) throw StateError('Google reauthentication was cancelled.');
final googleAuth = await googleUser.authentication;
credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);

View File

@ -35,6 +35,7 @@ class AwsMapProvider implements AppMapProvider {
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
@ -59,6 +60,7 @@ class AwsMapProvider implements AppMapProvider {
return _AwsMapView(
center: center,
zoom: zoom,
bearing: bearing,
markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,
@ -72,6 +74,7 @@ class AwsMapProvider implements AppMapProvider {
class _AwsMapView extends StatefulWidget {
final AppLatLng center;
final double zoom;
final double bearing;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final bool isFollowMode;
@ -82,6 +85,7 @@ class _AwsMapView extends StatefulWidget {
const _AwsMapView({
required this.center,
required this.zoom,
this.bearing = 0.0,
required this.markers,
required this.polylines,
required this.isFollowMode,
@ -108,16 +112,22 @@ class _AwsMapViewState extends State<_AwsMapView> {
@override
void didUpdateWidget(covariant _AwsMapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isFollowMode) {
final centerLl =
ll.LatLng(widget.center.latitude, widget.center.longitude);
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
if (widget.isFollowMode) {
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs();
final shouldMove = _lastFollowPos == null ||
final shouldMove = justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
(posDelta > 0.00005 &&
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
@ -133,6 +143,24 @@ class _AwsMapViewState extends State<_AwsMapView> {
}
});
}
} else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
}
if (bearingChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.rotate(widget.bearing);
} catch (_) {}
}
});
}
}
@ -156,6 +184,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
options: MapOptions(
initialCenter: centerLl,
initialZoom: widget.zoom,
initialRotation: widget.bearing,
minZoom: widget.config.minZoom,
maxZoom: widget.config.maxZoom,
backgroundColor: const Color(0xFF0A0F14),

View File

@ -17,6 +17,7 @@ class GoogleMapProvider implements AppMapProvider {
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,

View File

@ -129,6 +129,7 @@ abstract class AppMapProvider {
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,

View File

@ -16,6 +16,7 @@ class OpenStreetMapProvider implements AppMapProvider {
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
@ -26,6 +27,7 @@ class OpenStreetMapProvider implements AppMapProvider {
return _OsmMapView(
center: center,
zoom: zoom,
bearing: bearing,
markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,
@ -39,6 +41,7 @@ class OpenStreetMapProvider implements AppMapProvider {
class _OsmMapView extends StatefulWidget {
final AppLatLng center;
final double zoom;
final double bearing;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final bool isFollowMode;
@ -49,6 +52,7 @@ class _OsmMapView extends StatefulWidget {
const _OsmMapView({
required this.center,
required this.zoom,
this.bearing = 0.0,
required this.markers,
required this.polylines,
required this.isFollowMode,
@ -75,16 +79,24 @@ class _OsmMapViewState extends State<_OsmMapView> {
@override
void didUpdateWidget(covariant _OsmMapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isFollowMode) {
final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude);
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
if (widget.isFollowMode) {
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs();
final shouldMove = _lastFollowPos == null ||
(posDelta > 0.00005 && (_lastFollowTime == null || now.difference(_lastFollowTime!).inMilliseconds > 500));
final shouldMove = justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
(posDelta > 0.00005 &&
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
if (shouldMove) {
_lastFollowPos = centerLl;
@ -97,6 +109,24 @@ class _OsmMapViewState extends State<_OsmMapView> {
}
});
}
} else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
}
if (bearingChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.rotate(widget.bearing);
} catch (_) {}
}
});
}
}
@ -118,9 +148,12 @@ class _OsmMapViewState extends State<_OsmMapView> {
options: MapOptions(
initialCenter: centerLl,
initialZoom: widget.zoom,
initialRotation: widget.bearing,
minZoom: widget.config.minZoom,
maxZoom: widget.config.maxZoom,
backgroundColor: const Color(0xFF0F0F0F),
backgroundColor: widget.config.isDarkMode
? const Color(0xFF0F0F0F)
: const Color(0xFFE5E5E5),
onPositionChanged: (position, hasGesture) {
if (hasGesture) {
widget.onUserInteraction?.call();

View File

@ -7,6 +7,7 @@ import 'map_providers.dart';
class UnifiedMapView extends ConsumerWidget {
final AppLatLng center;
final double zoom;
final double bearing;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final ValueChanged<AppLatLng>? onPositionChanged;
@ -18,6 +19,7 @@ class UnifiedMapView extends ConsumerWidget {
super.key,
required this.center,
this.zoom = 16.0,
this.bearing = 0.0,
this.markers = const [],
this.polylines = const [],
this.onPositionChanged,
@ -42,7 +44,13 @@ class UnifiedMapView extends ConsumerWidget {
switch (layerSettings.style) {
case TacticalMapStyle.tacticalDark:
provider = ref.watch(openStreetMapProviderInstance);
activeConfig = MapConfig.tacticalDark(offlineOnly: layerSettings.offlineOnly);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
activeConfig = isDarkTheme
? MapConfig.tacticalDark(offlineOnly: layerSettings.offlineOnly)
: MapConfig(
isDarkMode: false,
offlineOnly: layerSettings.offlineOnly,
);
break;
case TacticalMapStyle.satellite4K:
final aws = ref.watch(awsMapProviderInstance);
@ -75,6 +83,7 @@ class UnifiedMapView extends ConsumerWidget {
context: context,
center: center,
zoom: zoom,
bearing: bearing,
markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,

View File

@ -145,6 +145,7 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
bool _ttsInitialized = false;
bool _isRecalculating = false;
DateTime? _lastRecalculationAttempt;
int _lastMatchedSegment = 0;
static const double defaultOffRouteThresholdMeters = 65.0;
@ -202,6 +203,8 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
return;
}
_lastMatchedSegment = 0;
final initialDist = calculationResult.estimatedDistanceMeters ??
calculationResult.straightLineDistanceMeters;
final initialDuration = calculationResult.estimatedDurationSeconds ??
@ -274,11 +277,16 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
if (!state.isNavigating || state.maneuvers.isEmpty) return;
// Off-route detection check against polyline points
final points = state.calculationResult?.points ?? [];
if (points.isNotEmpty) {
final distToRoute = _distanceToPolylineMeters(riderPos, points);
if (distToRoute > defaultOffRouteThresholdMeters) {
final proj = _projectPointOntoPolyline(
riderPos,
points,
startSearchSegment: math.max(0, _lastMatchedSegment - 2),
);
// Off-route detection check against polyline
if (proj.distanceToRouteMeters > defaultOffRouteThresholdMeters) {
if (!_isRecalculating) {
final now = DateTime.now();
if (_lastRecalculationAttempt == null ||
@ -289,8 +297,79 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
}
return;
}
_lastMatchedSegment = proj.segmentIndex;
// Check arrival at final destination stop
final lastManeuver = state.maneuvers.last;
final distToDest = _haversineMeters(riderPos, lastManeuver.location);
if (distToDest <= 25.0 &&
(proj.segmentIndex >= points.length - 2 || state.currentManeuverIndex >= state.maneuvers.length - 1)) {
_completeArrival();
return;
}
// Remaining distance strictly counts down along route polyline
final remainingMeters = _remainingDistanceAlongPolyline(
points,
proj.segmentIndex,
proj.projectedPoint,
);
// Maneuver advancement based on polyline progression
final offsets = _getManeuverOffsets(state.maneuvers, points);
int currIdx = state.currentManeuverIndex;
// If at step 0 (Depart), advance only once rider has genuinely departed (moving > 3.0 mph or past start point)
if (currIdx == 0 && state.maneuvers.first.type == ManeuverType.depart && state.maneuvers.length > 1) {
final distFromStart = _haversineMeters(riderPos, state.maneuvers.first.location);
if (speedMph > 3.0 || distFromStart > 35.0 || proj.segmentIndex > 0) {
currIdx = 1;
}
}
// Advance through completed subsequent maneuvers along the route (step 1+)
while (currIdx > 0 && currIdx < state.maneuvers.length - 1) {
final targetOffset = offsets[currIdx];
final targetLoc = state.maneuvers[currIdx].location;
final distToTarget = _haversineMeters(riderPos, targetLoc);
// Advance if rider reached within 25m of maneuver point or passed its polyline index
if (distToTarget <= 25.0 || (proj.segmentIndex >= targetOffset && targetOffset > 0)) {
currIdx++;
} else {
break;
}
}
// Announce updated maneuver if advanced
if (currIdx != state.currentManeuverIndex && currIdx < state.maneuvers.length) {
_speakInstruction(state.maneuvers[currIdx].instruction);
}
// Distance to next active maneuver along polyline
final targetOffset = offsets[currIdx];
final distToNext = _distanceToOffsetAlongPolyline(
points,
proj.segmentIndex,
proj.projectedPoint,
targetOffset,
targetLocation: state.maneuvers[currIdx].location,
);
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
state = state.copyWith(
currentManeuverIndex: currIdx,
distanceToNextManeuverMeters: distToNext,
remainingDistanceMeters: remainingMeters,
remainingDurationSeconds: remainingSec,
);
return;
}
// Fallback when polyline points are not available
final currIdx = state.currentManeuverIndex;
if (currIdx >= state.maneuvers.length) {
_completeArrival();
@ -300,7 +379,6 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
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];
@ -315,13 +393,11 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
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();
@ -383,6 +459,7 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
final initialDuration = newResult.estimatedDurationSeconds ??
(initialDist / 15.0).round();
_lastMatchedSegment = 0;
state = state.copyWith(
status: NavigationStatus.navigating,
calculationResult: newResult,
@ -421,35 +498,159 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
} catch (_) {}
}
/// Computes shortest distance from point to polyline in meters.
static double _distanceToPolylineMeters(AppLatLng point, List<AppLatLng> polyline) {
if (polyline.isEmpty) return double.infinity;
if (polyline.length == 1) return _haversineMeters(point, polyline.first);
/// Projects a GPS point onto the route polyline with segment index tracking.
static _PolylineProjection _projectPointOntoPolyline(
AppLatLng point,
List<AppLatLng> polyline, {
int startSearchSegment = 0,
}) {
if (polyline.isEmpty) {
return _PolylineProjection(
segmentIndex: 0,
projectedPoint: point,
distanceToRouteMeters: 0.0,
);
}
if (polyline.length == 1) {
return _PolylineProjection(
segmentIndex: 0,
projectedPoint: polyline.first,
distanceToRouteMeters: _haversineMeters(point, polyline.first),
);
}
int bestSegment = 0;
AppLatLng bestProj = polyline.first;
double minDistance = double.infinity;
for (int i = 0; i < polyline.length - 1; i++) {
final segDist = _distanceToSegmentMeters(point, polyline[i], polyline[i + 1]);
if (segDist < minDistance) {
minDistance = segDist;
final searchStart = startSearchSegment.clamp(0, polyline.length - 2);
for (int i = searchStart; i < polyline.length - 1; i++) {
final a = polyline[i];
final b = polyline[i + 1];
final proj = _projectPointOntoSegment(point, a, b);
final dist = _haversineMeters(point, proj);
if (dist < minDistance) {
minDistance = dist;
bestSegment = i;
bestProj = proj;
}
}
return minDistance;
}
static double _distanceToSegmentMeters(AppLatLng p, AppLatLng a, AppLatLng b) {
// If searching forward yielded an unacceptable match (> threshold)
// and search started midway, search earlier segments in case rider turned around
if (minDistance > defaultOffRouteThresholdMeters && searchStart > 0) {
for (int i = 0; i < searchStart; i++) {
final a = polyline[i];
final b = polyline[i + 1];
final proj = _projectPointOntoSegment(point, a, b);
final dist = _haversineMeters(point, proj);
if (dist < minDistance) {
minDistance = dist;
bestSegment = i;
bestProj = proj;
}
}
}
return _PolylineProjection(
segmentIndex: bestSegment,
projectedPoint: bestProj,
distanceToRouteMeters: minDistance,
);
}
static AppLatLng _projectPointOntoSegment(AppLatLng p, AppLatLng a, AppLatLng b) {
final dAB = _haversineMeters(a, b);
if (dAB == 0) return _haversineMeters(p, a);
if (dAB == 0.0) return a;
final dAP = _haversineMeters(a, p);
final dBP = _haversineMeters(b, p);
final latRad = a.latitude * (math.pi / 180.0);
final cosLat = math.cos(latRad);
final dx = (b.longitude - a.longitude) * cosLat;
final dy = b.latitude - a.latitude;
final px = (p.longitude - a.longitude) * cosLat;
final py = p.latitude - a.latitude;
if (dBP * dBP >= dAP * dAP + dAB * dAB) return dAP;
if (dAP * dAP >= dBP * dBP + dAB * dAB) return dBP;
final lenSq = dx * dx + dy * dy;
if (lenSq == 0.0) return a;
final s = (dAB + dAP + dBP) / 2.0;
final areaSq = s * (s - dAB) * (s - dAP) * (s - dBP);
if (areaSq <= 0.0) return 0.0;
return (2.0 * math.sqrt(areaSq)) / dAB;
final t = ((px * dx + py * dy) / lenSq).clamp(0.0, 1.0);
return AppLatLng(
a.latitude + t * (b.latitude - a.latitude),
a.longitude + t * (b.longitude - a.longitude),
);
}
/// Calculates strictly decreasing remaining distance along the polyline.
static double _remainingDistanceAlongPolyline(
List<AppLatLng> polyline,
int segmentIndex,
AppLatLng projectedPoint,
) {
if (polyline.isEmpty) return 0.0;
if (polyline.length == 1) return 0.0;
if (segmentIndex >= polyline.length - 1) {
return _haversineMeters(projectedPoint, polyline.last);
}
double remaining = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]);
for (int i = segmentIndex + 1; i < polyline.length - 1; i++) {
remaining += _haversineMeters(polyline[i], polyline[i + 1]);
}
return remaining;
}
/// Calculates distance along polyline from projected point to a maneuver offset.
static double _distanceToOffsetAlongPolyline(
List<AppLatLng> polyline,
int segmentIndex,
AppLatLng projectedPoint,
int targetOffset, {
required AppLatLng targetLocation,
}) {
if (polyline.isEmpty) return _haversineMeters(projectedPoint, targetLocation);
if (segmentIndex >= targetOffset) {
return _haversineMeters(projectedPoint, targetLocation);
}
double dist = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]);
final limit = math.min(targetOffset, polyline.length - 1);
for (int i = segmentIndex + 1; i < limit; i++) {
dist += _haversineMeters(polyline[i], polyline[i + 1]);
}
return dist;
}
/// Resolves monotonically increasing polyline indices for each maneuver.
static List<int> _getManeuverOffsets(
List<RouteManeuver> maneuvers,
List<AppLatLng> points,
) {
final offsets = <int>[];
for (int i = 0; i < maneuvers.length; i++) {
final m = maneuvers[i];
if (m.geometryOffset != null &&
m.geometryOffset! >= 0 &&
m.geometryOffset! < points.length) {
offsets.add(m.geometryOffset!);
} else {
int bestIdx = 0;
double bestDist = double.infinity;
for (int j = 0; j < points.length; j++) {
final d = _haversineMeters(points[j], m.location);
if (d < bestDist) {
bestDist = d;
bestIdx = j;
}
}
offsets.add(bestIdx);
}
}
for (int i = 1; i < offsets.length; i++) {
if (offsets[i] < offsets[i - 1]) {
offsets[i] = offsets[i - 1];
}
}
return offsets;
}
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
@ -469,6 +670,18 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
}
}
class _PolylineProjection {
final int segmentIndex;
final AppLatLng projectedPoint;
final double distanceToRouteMeters;
const _PolylineProjection({
required this.segmentIndex,
required this.projectedPoint,
required this.distanceToRouteMeters,
});
}
/// Global shared navigation session provider across Map and Dashboard.
final navigationSessionProvider =
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(

View File

@ -64,6 +64,19 @@ class AwsLocationRouteProvider implements RouteProvider {
final seenIds = <String>{};
final combined = <PlaceSearchResult>[];
// Sort text results with coordinates by physical proximity to rider
if (nearLocation != null) {
textItems.sort((a, b) {
final da = (a.latitude != null && a.longitude != null)
? _distanceMeters(nearLocation, AppLatLng(a.latitude!, a.longitude!))
: double.infinity;
final db = (b.latitude != null && b.longitude != null)
? _distanceMeters(nearLocation, AppLatLng(b.latitude!, b.longitude!))
: double.infinity;
return da.compareTo(db);
});
}
// Prefer text items first because coordinates are already populated
for (final item in textItems) {
if (seenIds.add(item.providerId)) {
@ -101,6 +114,9 @@ class AwsLocationRouteProvider implements RouteProvider {
body: jsonEncode({
'QueryText': clean,
'BiasPosition': bias,
'Filter': {
'IncludeCountries': ['USA'],
},
}),
)
.timeout(const Duration(seconds: 8));
@ -142,6 +158,9 @@ class AwsLocationRouteProvider implements RouteProvider {
body: jsonEncode({
'QueryText': clean,
'BiasPosition': bias,
'Filter': {
'IncludeCountries': ['USA'],
},
}),
)
.timeout(const Duration(seconds: 8));
@ -276,6 +295,12 @@ class AwsLocationRouteProvider implements RouteProvider {
.toList()
: null;
final avoidMap = <String, dynamic>{
if (effectiveAvoidHighways) 'ControlledAccessHighways': true,
if (effectiveAvoidTolls) 'TollRoads': true,
if (effectiveAvoidFerries) 'Ferries': true,
};
final travelOptions = <String, dynamic>{
'Car': {
'Avoid': {
@ -291,12 +316,15 @@ class AwsLocationRouteProvider implements RouteProvider {
'Destination': destination,
'TravelMode': 'Car',
'TravelModeOptions': travelOptions,
if (avoidMap.isNotEmpty) 'Avoid': avoidMap,
'OptimizeRoutingFor': optimizeFor,
'LegGeometryFormat': 'Simple',
'LegAdditionalInputs': ['TravelSteps'],
'Instructions': true,
if (options?.preference == RoutingPreference.twisty ||
options?.preference == RoutingPreference.scenic)
options?.preference == RoutingPreference.scenic ||
options?.preference == RoutingPreference.fastest ||
options?.preference == RoutingPreference.shortest)
'MaxAlternatives': 2,
};
if (waypoints != null && waypoints.isNotEmpty) {
@ -358,6 +386,18 @@ class AwsLocationRouteProvider implements RouteProvider {
}
}
if (geom.isEmpty) {
final rGMap = r['Geometry'] as Map<String, dynamic>?;
final rLineString = rGMap?['LineString'] as List<dynamic>?;
if (rLineString != null) {
for (final pt in rLineString) {
if (pt is List && pt.length >= 2) {
geom.add(AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()));
}
}
}
}
final effectiveGeom = geom.isNotEmpty ? geom : ordered.map((s) => s.latLng).toList();
final effectiveDist = distanceM > 0 ? distanceM : straightLine;
final distMi = effectiveDist * 0.000621371;
@ -393,6 +433,9 @@ class AwsLocationRouteProvider implements RouteProvider {
} else if (options?.preference == RoutingPreference.fastest) {
candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds));
selected = candidates.first;
} else if (options?.preference == RoutingPreference.scenic) {
candidates.sort((a, b) => b.curvature.compareTo(a.curvature));
selected = candidates.first;
}
}
@ -431,15 +474,38 @@ class AwsLocationRouteProvider implements RouteProvider {
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 stepType = rawStep['Type']?.toString() ?? 'Continue';
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();
// Extract steering direction from AWS Location Routes V2 details objects
final turnStep = rawStep['TurnStepDetails'] as Map<String, dynamic>?;
final rampStep = rawStep['RampStepDetails'] as Map<String, dynamic>?;
final keepStep = rawStep['KeepStepDetails'] as Map<String, dynamic>?;
final uTurnStep = rawStep['UTurnStepDetails'] as Map<String, dynamic>?;
final roundaboutStep = rawStep['RoundaboutStepDetails'] as Map<String, dynamic>?;
final turn = turnStep?['SteeringDirection']?.toString() ??
rampStep?['SteeringDirection']?.toString() ??
keepStep?['SteeringDirection']?.toString() ??
uTurnStep?['SteeringDirection']?.toString() ??
roundaboutStep?['SteeringDirection']?.toString() ??
rawStep['Turn']?.toString();
final initialType = _mapManeuverType(stepType, turn);
final canonical = _canonicalizeManeuver(
stepType: stepType,
turn: turn,
road: road,
rawInstruction: rawStep['Instruction']?.toString(),
initialType: initialType,
offset: offset,
geometry: geometry,
);
AppLatLng loc;
if (offset != null && offset >= 0 && offset < geometry.length) {
loc = geometry[offset];
@ -452,8 +518,8 @@ class AwsLocationRouteProvider implements RouteProvider {
maneuvers.add(
RouteManeuver(
index: maneuvers.length,
instruction: instruction,
type: _mapManeuverType(stepType, turn),
instruction: canonical.instruction,
type: canonical.type,
distanceMeters: dist,
durationSeconds: dur,
streetName: road,
@ -472,6 +538,202 @@ class AwsLocationRouteProvider implements RouteProvider {
return maneuvers;
}
static ({ManeuverType type, String instruction}) _canonicalizeManeuver({
required String stepType,
required String? turn,
required String? road,
required String? rawInstruction,
required ManeuverType initialType,
required int? offset,
required List<AppLatLng> geometry,
}) {
ManeuverType resolvedType = initialType;
String resolvedInstruction = _buildManeuverInstruction(
stepType: stepType,
turn: turn,
road: road,
rawInstruction: rawInstruction,
maneuverType: initialType,
);
// Calculate polyline bearing delta at offset if geometry is available
double? bearingDelta;
if (geometry.length >= 2 && offset != null && offset >= 0 && offset < geometry.length - 1) {
final prevIdx = (offset > 0) ? offset - 1 : 0;
final nextIdx = (offset + 1 < geometry.length) ? offset + 1 : offset;
if (prevIdx != offset && offset != nextIdx) {
final b1 = _bearing(geometry[prevIdx], geometry[offset]);
final b2 = _bearing(geometry[offset], geometry[nextIdx]);
var d = b2 - b1;
while (d < -180.0) {
d += 360.0;
}
while (d > 180.0) {
d -= 360.0;
}
bearingDelta = d;
}
}
final rawLower = (rawInstruction ?? '').toLowerCase().trim();
final bool rawHasLeft = rawLower.contains('left');
final bool rawHasRight = rawLower.contains('right');
final bool rawHasUturn = rawLower.contains('u-turn') || rawLower.contains('uturn');
// 1. If raw instruction explicitly states U-turn
if (rawHasUturn) {
resolvedType = ManeuverType.uTurn;
}
// 2. If raw instruction specifies turn direction but resolvedType is continueStraight
else if (resolvedType == ManeuverType.continueStraight) {
if (rawHasLeft) {
if (rawLower.contains('sharp')) {
resolvedType = ManeuverType.turnSharpLeft;
} else if (rawLower.contains('slight') || rawLower.contains('bear')) {
resolvedType = ManeuverType.turnSlightLeft;
} else if (rawLower.contains('keep')) {
resolvedType = ManeuverType.keepLeft;
} else {
resolvedType = ManeuverType.turnLeft;
}
} else if (rawHasRight) {
if (rawLower.contains('sharp')) {
resolvedType = ManeuverType.turnSharpRight;
} else if (rawLower.contains('slight') || rawLower.contains('bear')) {
resolvedType = ManeuverType.turnSlightRight;
} else if (rawLower.contains('keep')) {
resolvedType = ManeuverType.keepRight;
} else {
resolvedType = ManeuverType.turnRight;
}
} else if (stepType.toLowerCase() == 'turn' &&
bearingDelta != null &&
bearingDelta.abs() >= 20.0) {
// Only convert to turn if step was an explicit Turn step lacking steering direction.
// Pure road bends (Type: 'Continue' / 'Follow') along the existing road remain continueStraight.
if (bearingDelta > 120.0) {
resolvedType = ManeuverType.turnSharpRight;
} else if (bearingDelta > 45.0) {
resolvedType = ManeuverType.turnRight;
} else if (bearingDelta > 20.0) {
resolvedType = ManeuverType.turnSlightRight;
} else if (bearingDelta < -120.0) {
resolvedType = ManeuverType.turnSharpLeft;
} else if (bearingDelta < -45.0) {
resolvedType = ManeuverType.turnLeft;
} else if (bearingDelta < -20.0) {
resolvedType = ManeuverType.turnSlightLeft;
}
resolvedInstruction = _buildManeuverInstruction(
stepType: stepType,
turn: resolvedType.name,
road: road,
rawInstruction: null,
maneuverType: resolvedType,
);
}
}
// 3. Direct conflict resolution: instruction says left but type says right (or vice versa)
else if (resolvedType.isLeft && rawHasRight && !rawHasLeft) {
if (bearingDelta != null && bearingDelta > 20.0) {
resolvedType = ManeuverType.turnRight;
} else if (bearingDelta != null && bearingDelta < -20.0) {
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
resolvedInstruction = 'Turn left$roadSuffix';
} else {
resolvedType = ManeuverType.turnRight;
}
} else if (resolvedType.isRight && rawHasLeft && !rawHasRight) {
if (bearingDelta != null && bearingDelta < -20.0) {
resolvedType = ManeuverType.turnLeft;
} else if (bearingDelta != null && bearingDelta > 20.0) {
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
resolvedInstruction = 'Turn right$roadSuffix';
} else {
resolvedType = ManeuverType.turnLeft;
}
}
// 4. Authoritative geometry override: only for explicit Turn steps where provider claimed wrong direction
if (bearingDelta != null && (stepType.toLowerCase() == 'turn' || rawHasLeft || rawHasRight)) {
if (resolvedType.isRight && bearingDelta < -35.0) {
resolvedType = bearingDelta < -120.0
? ManeuverType.turnSharpLeft
: (bearingDelta < -45.0 ? ManeuverType.turnLeft : ManeuverType.turnSlightLeft);
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
resolvedInstruction = 'Turn left$roadSuffix';
} else if (resolvedType.isLeft && bearingDelta > 35.0) {
resolvedType = bearingDelta > 120.0
? ManeuverType.turnSharpRight
: (bearingDelta > 45.0 ? ManeuverType.turnRight : ManeuverType.turnSlightRight);
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
resolvedInstruction = 'Turn right$roadSuffix';
}
}
// Re-verify that resolved instruction and resolved type agree on direction
if (resolvedType.isLeft && resolvedInstruction.toLowerCase().contains('right')) {
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
resolvedInstruction = 'Turn left$roadSuffix';
} else if (resolvedType.isRight && resolvedInstruction.toLowerCase().contains('left')) {
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
resolvedInstruction = 'Turn right$roadSuffix';
}
return (type: resolvedType, instruction: resolvedInstruction);
}
static String _buildManeuverInstruction({
required String stepType,
required String? turn,
required String? road,
required String? rawInstruction,
required ManeuverType maneuverType,
}) {
if (rawInstruction != null &&
rawInstruction.trim().isNotEmpty &&
rawInstruction.trim().toLowerCase() != 'continue') {
return rawInstruction.trim();
}
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
switch (maneuverType) {
case ManeuverType.depart:
return roadSuffix.isNotEmpty ? 'Head out$roadSuffix' : 'Head towards destination';
case ManeuverType.arrive:
return 'Arrive at destination';
case ManeuverType.turnRight:
return 'Turn right$roadSuffix';
case ManeuverType.turnLeft:
return 'Turn left$roadSuffix';
case ManeuverType.turnSharpRight:
return 'Turn sharp right$roadSuffix';
case ManeuverType.turnSharpLeft:
return 'Turn sharp left$roadSuffix';
case ManeuverType.turnSlightRight:
return 'Turn slight right$roadSuffix';
case ManeuverType.turnSlightLeft:
return 'Turn slight left$roadSuffix';
case ManeuverType.uTurn:
return 'Make a U-turn$roadSuffix';
case ManeuverType.ramp:
final side = (turn?.toLowerCase() == 'left') ? ' on the left' : ' on the right';
return 'Take the ramp$side$roadSuffix';
case ManeuverType.keepRight:
return 'Keep right$roadSuffix';
case ManeuverType.keepLeft:
return 'Keep left$roadSuffix';
case ManeuverType.enterRoundabout:
return 'Enter roundabout$roadSuffix';
case ManeuverType.exitRoundabout:
return 'Exit roundabout$roadSuffix';
case ManeuverType.continueStraight:
default:
return roadSuffix.isNotEmpty ? 'Continue$roadSuffix' : 'Continue straight';
}
}
static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) {
final t = (typeStr ?? '').toLowerCase();
final turn = (turnStr ?? '').toLowerCase();
@ -492,6 +754,15 @@ class AwsLocationRouteProvider implements RouteProvider {
if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft;
if (turn == 'keepright') return ManeuverType.keepRight;
if (turn == 'keepleft') return ManeuverType.keepLeft;
if (t == 'keep') {
if (turn.contains('left')) return ManeuverType.keepLeft;
if (turn.contains('right')) return ManeuverType.keepRight;
return ManeuverType.continueStraight;
}
if (t == 'exit') {
if (turn.contains('left')) return ManeuverType.turnLeft;
return ManeuverType.turnRight;
}
if (t == 'continue') return ManeuverType.continueStraight;
return ManeuverType.continueStraight;
}
@ -606,6 +877,18 @@ class AwsLocationRouteProvider implements RouteProvider {
final brng = math.atan2(y, x) * (180.0 / math.pi);
return (brng + 360.0) % 360.0;
}
static double _distanceMeters(AppLatLng p1, AppLatLng p2) {
const double r = 6371000.0;
final lat1 = p1.latitude * (math.pi / 180.0);
final lat2 = p2.latitude * (math.pi / 180.0);
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(lat1) * math.cos(lat2) * math.sin(dLon / 2) * math.sin(dLon / 2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return r * c;
}
}
class _CandidateRoute {

View File

@ -518,6 +518,18 @@ enum ManeuverType {
return 'CONTINUE';
}
}
bool get isLeft =>
this == ManeuverType.turnLeft ||
this == ManeuverType.turnSharpLeft ||
this == ManeuverType.turnSlightLeft ||
this == ManeuverType.keepLeft;
bool get isRight =>
this == ManeuverType.turnRight ||
this == ManeuverType.turnSharpRight ||
this == ManeuverType.turnSlightRight ||
this == ManeuverType.keepRight;
}
/// A single turn-by-turn navigation maneuver step.

View File

@ -47,6 +47,9 @@ class SensorFusionDiagnostics {
final String dashboardDirection;
final bool isCalibrated;
final String mountOrientation;
final double eventRateHz;
final double longitudinalG;
final double lateralG;
const SensorFusionDiagnostics({
required this.rawAccelX,
@ -64,6 +67,9 @@ class SensorFusionDiagnostics {
required this.dashboardDirection,
required this.isCalibrated,
required this.mountOrientation,
this.eventRateHz = 50.0,
this.longitudinalG = 0.0,
this.lateralG = 0.0,
});
Map<String, dynamic> toMap() => {
@ -78,6 +84,9 @@ class SensorFusionDiagnostics {
'dashboardDirection': dashboardDirection,
'isCalibrated': isCalibrated,
'mountOrientation': mountOrientation,
'eventRateHz': eventRateHz,
'longitudinalG': longitudinalG,
'lateralG': lateralG,
};
}
@ -135,6 +144,7 @@ class SensorFusionService with WidgetsBindingObserver {
bool _isCalibrated = false;
bool get isCalibrated => _isCalibrated;
int _remountAnomalyCount = 0;
int _remountReturnCount = 0;
// Stored baseline gravity vector at calibration for remount detection
double _calGravityX = 0.0;
@ -149,6 +159,9 @@ class SensorFusionService with WidgetsBindingObserver {
double _latestRawGyroY = 0.0;
double _latestRawGyroZ = 0.0;
double _latestRollRateDegPerSec = 0.0;
double _eventRateHz = 50.0;
int _sensorEventCounter = 0;
DateTime? _lastRateWindow;
double get latestRawAccelX => _latestRawAccelX;
double get latestRawAccelY => _latestRawAccelY;
@ -157,6 +170,7 @@ class SensorFusionService with WidgetsBindingObserver {
double get latestRawGyroY => _latestRawGyroY;
double get latestRawGyroZ => _latestRawGyroZ;
double get latestRollRateDegPerSec => _latestRollRateDegPerSec;
double get eventRateHz => _eventRateHz;
// Sensor fusion state
double _currentRoll = 0.0;
@ -182,6 +196,12 @@ class SensorFusionService with WidgetsBindingObserver {
MountOrientation _mountOrientation = MountOrientation.portraitUpright;
MountOrientation get mountOrientation => _mountOrientation;
double _speedMph = 0.0;
double get speedMph => _speedMph;
void updateVehicleSpeed(double speedMph) {
_speedMph = speedMph;
}
static const double _gravityConst = 9.80665;
// Low-pass filter for G-Force
@ -304,13 +324,29 @@ class SensorFusionService with WidgetsBindingObserver {
_updateDisplayOrientation();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_updateDisplayOrientation();
_ensureSensorsListening();
if (!_isCalibrated) {
loadCalibrationForMotorcycle(null);
}
}
}
void _ensureSensorsListening() {
if (_accelSub == null || _gyroSub == null) {
_startSensors();
}
}
void _updateDisplayOrientation() {
try {
final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull;
if (view != null && view.physicalSize.height > 0) {
final size = view.physicalSize;
// Only adapt automatically if display dimensions are realistic mobile dimensions
if (size.width > size.height && size.width < 1500) {
if (size.width > size.height) {
_isLandscape = true;
_updateMountOrientation();
_recomputeBasisVectors();
@ -345,7 +381,6 @@ class SensorFusionService with WidgetsBindingObserver {
}
void _checkRemountValidity(double ax, double ay, double az) {
if (!_isCalibrated) return;
final calNorm = math.sqrt(_calGravityX * _calGravityX + _calGravityY * _calGravityY + _calGravityZ * _calGravityZ);
final curNorm = math.sqrt(ax * ax + ay * ay + az * az);
if (calNorm < 3.0 || curNorm < 3.0) return;
@ -355,35 +390,56 @@ class SensorFusionService with WidgetsBindingObserver {
final ny = ay / curNorm;
final nz = az / curNorm;
// Project current vector onto 3D Vehicle Coordinate Frame:
// vUp: component along calibrated upright axis
// vForward: component along motorcycle longitudinal/forward axis
final vUp = (nx * _upVector[0] + ny * _upVector[1] + nz * _upVector[2]).clamp(-1.0, 1.0);
final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0);
// Total 3D angular deviation from calibrated upright gravity
// Project current vector onto calibrated upright gravity axis
final vUp = (nx * (_calGravityX / calNorm) + ny * (_calGravityY / calNorm) + nz * (_calGravityZ / calNorm)).clamp(-1.0, 1.0);
final totalAngleDiffDeg = math.acos(vUp) * (180.0 / math.pi);
// Pitch tilt (tilt out of motorcycle roll plane)
if (!_isCalibrated) {
// Automatic re-lock: if the device returns to the motorcycle mount (within 25° of calibrated gravity)
if (totalAngleDiffDeg < 25.0) {
_remountReturnCount++;
if (_remountReturnCount >= 15) {
_isCalibrated = true;
_remountAnomalyCount = 0;
_remountReturnCount = 0;
_recomputeBasisVectors();
_emitDiagnostics(0.0);
}
} else {
_remountReturnCount = 0;
}
return;
}
_remountReturnCount = 0;
// Do not invalidate calibration while the vehicle is actively in motion or experiencing dynamic rotation
if (_speedMph > 2.0 || _latestRollRateDegPerSec.abs() > 3.0) {
_remountAnomalyCount = 0;
return;
}
// Project current vector onto 3D Vehicle Coordinate Frame:
final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0);
final pitchTiltDeg = (math.asin(vForward)).abs() * (180.0 / math.pi);
// Motorcycle Lean & Remount Physics:
// 1. Roll-axis rotation (up to 65°) is normal motorcycle riding, cornering, and kickstand parking (20°-35°).
// Deviations within the roll plane with pitchTiltDeg <= 35° and totalAngleDiffDeg <= 68°
// must NEVER invalidate calibration!
// 2. True remount / phone removal:
// Deviations within the roll plane must NEVER invalidate calibration.
// 2. Cockpit mounts: Handlebar mounts (QuadLock, RAM) are commonly pitched backwards 35°-55° facing the rider.
// 3. True remount / phone removal:
// - Placed flat on desk (screen up): totalAngleDiffDeg ~ 90°, pitchTiltDeg ~ 90°
// - Placed in pocket/bag: totalAngleDiffDeg >= 90°
// - Mount rotated or twisted: totalAngleDiffDeg > 68° or pitchTiltDeg > 38°
final isImmediateRemount = totalAngleDiffDeg >= 75.0 || pitchTiltDeg >= 42.0;
final isSuspectedRemount = totalAngleDiffDeg > 68.0 || pitchTiltDeg > 35.0;
// - Placed in pocket/bag: totalAngleDiffDeg >= 80°
// - Mount rotated or twisted when stationary: totalAngleDiffDeg >= 80° or pitchTiltDeg >= 65°
final isImmediateRemount = totalAngleDiffDeg >= 80.0 || pitchTiltDeg >= 65.0;
final isSuspectedRemount = totalAngleDiffDeg > 70.0 || pitchTiltDeg > 55.0;
if (isImmediateRemount) {
_invalidateRemount(totalAngleDiffDeg);
} else if (isSuspectedRemount) {
_remountAnomalyCount++;
// Require 5 consecutive stationary samples (100ms) to filter out transient bumps or noise
if (_remountAnomalyCount >= 5) {
// Require 50 consecutive stationary samples (1000ms) to filter out transient bumps, fork dive, or vibrations
if (_remountAnomalyCount >= 50) {
_invalidateRemount(totalAngleDiffDeg);
}
} else {
@ -392,17 +448,14 @@ class SensorFusionService with WidgetsBindingObserver {
}
void _invalidateRemount(double angleDiffDeg) {
debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating calibration.');
debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating runtime calibration.');
_calibrationVersion++;
_isCalibrated = false;
_remountAnomalyCount = 0;
_remountReturnCount = 0;
_rollOffset = 0.0;
_pitchOffset = 0.0;
_calGravityX = 0.0;
_calGravityY = 0.0;
_calGravityZ = 0.0;
_recomputeBasisVectors();
_calibrationService.clearCalibration();
_emitDiagnostics(0.0);
}
@ -494,6 +547,19 @@ class SensorFusionService with WidgetsBindingObserver {
}
void handleAccelerometerEvent(AccelerometerEvent event) {
_sensorEventCounter++;
final now = DateTime.now();
if (_lastRateWindow == null) {
_lastRateWindow = now;
} else {
final elapsedMs = now.difference(_lastRateWindow!).inMilliseconds;
if (elapsedMs >= 1000) {
_eventRateHz = (_sensorEventCounter * 1000.0) / elapsedMs;
_sensorEventCounter = 0;
_lastRateWindow = now;
}
}
_latestRawAccelX = event.x;
_latestRawAccelY = event.y;
_latestRawAccelZ = event.z;
@ -685,6 +751,9 @@ class SensorFusionService with WidgetsBindingObserver {
dashboardDirection: direction,
isCalibrated: _isCalibrated,
mountOrientation: _mountOrientation.name,
eventRateHz: _eventRateHz,
longitudinalG: _filteredLongitudinalG,
lateralG: _filteredLateralG,
);
_latestDiagnostics = diag;
@ -728,7 +797,13 @@ class SensorFusionService with WidgetsBindingObserver {
_isCalibrated = isCal;
_remountAnomalyCount = 0;
_remountReturnCount = 0;
_recomputeBasisVectors();
if (_isCalibrated) {
_leanAngleController.add(0.0);
_pitchAngleController.add(0.0);
_emitDiagnostics(0.0);
}
} catch (_) {
if (version != _calibrationVersion) return;
_isCalibrated = false;
@ -746,6 +821,7 @@ class SensorFusionService with WidgetsBindingObserver {
_calGravityZ = _latestRawAccelZ;
_isCalibrated = true;
_remountAnomalyCount = 0;
_remountReturnCount = 0;
// Auto-detect mount orientation from gravity
final ax = _calGravityX.abs();
@ -793,6 +869,7 @@ class SensorFusionService with WidgetsBindingObserver {
_calibrationVersion++;
_isCalibrated = false;
_remountAnomalyCount = 0;
_remountReturnCount = 0;
_rollOffset = 0.0;
_pitchOffset = 0.0;
_calGravityX = 0.0;

View File

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Preset avatar options available for all riders without external image hosting.
class PresetAvatar {
@ -6,12 +6,14 @@ class PresetAvatar {
final String label;
final int primaryColorHex;
final int secondaryColorHex;
final IconData icon;
const PresetAvatar({
required this.id,
required this.label,
required this.primaryColorHex,
required this.secondaryColorHex,
this.icon = Icons.two_wheeler,
});
static const List<PresetAvatar> availablePresets = [
@ -20,48 +22,56 @@ class PresetAvatar {
label: 'Cyan Apex',
primaryColorHex: 0xFF00E5FF,
secondaryColorHex: 0xFF003840,
icon: Icons.sports_motorsports,
),
PresetAvatar(
id: 'helmet_amber',
label: 'Amber Sector',
primaryColorHex: 0xFFFFB300,
secondaryColorHex: 0xFF4A3200,
icon: Icons.two_wheeler,
),
PresetAvatar(
id: 'tach_neon',
label: 'Neon Tach',
primaryColorHex: 0xFF00E676,
secondaryColorHex: 0xFF00381B,
icon: Icons.speed,
),
PresetAvatar(
id: 'speed_violet',
label: 'Violet Velocity',
primaryColorHex: 0xFFD500F9,
secondaryColorHex: 0xFF3E004A,
icon: Icons.electric_bolt,
),
PresetAvatar(
id: 'cruiser_red',
label: 'Crimson Cruiser',
primaryColorHex: 0xFFFF1744,
secondaryColorHex: 0xFF4A0513,
icon: Icons.motorcycle,
),
PresetAvatar(
id: 'adv_blue',
label: 'Adventure Azure',
primaryColorHex: 0xFF2979FF,
secondaryColorHex: 0xFF0A224A,
icon: Icons.terrain,
),
PresetAvatar(
id: 'cafe_gold',
label: 'Cafe Gold',
primaryColorHex: 0xFFFFD700,
secondaryColorHex: 0xFF4A3E00,
icon: Icons.workspace_premium,
),
PresetAvatar(
id: 'touring_slate',
label: 'Touring Slate',
primaryColorHex: 0xFF90A4AE,
secondaryColorHex: 0xFF263238,
icon: Icons.navigation,
),
];

View File

@ -138,12 +138,34 @@ class CloudRestoreController extends StateNotifier<CloudRestoreUiState> {
: result.telemetryAlreadyPresent;
parts.add('$alreadyPresent ride(s) already on this device');
}
final failureDetails = <String>[];
if (result.telemetryFailed > 0) {
failureDetails.add(
'${result.telemetryFailed} ride(s) pending telemetry upload from source device',
);
}
if (result.telemetryAuthFailures > 0) {
failureDetails.add('${result.telemetryAuthFailures} ride(s) require re-authentication');
}
if (result.metadata.conflicts > 0) {
failureDetails.add('${result.metadata.conflicts} conflict(s)');
}
if (result.metadata.skipped > 0) {
failureDetails.add('${result.metadata.skipped} skipped');
}
if (result.failures > 0) {
parts.add('${result.failures} item(s) need attention');
final explanation = failureDetails.isNotEmpty
? failureDetails.join('; ')
: '${result.failures} item(s) need attention';
parts.add('Unresolved: $explanation');
}
if (parts.isEmpty) {
return 'No restorable MotorCloud rides were found for this account.';
}
return 'MotorCloud restore complete: ${parts.join(', ')}.';
final prefix = result.failures > 0
? 'MotorCloud restore partially completed'
: 'MotorCloud restore complete';
return '$prefix: ${parts.join(', ')}.';
}
}

View File

@ -90,6 +90,9 @@ class TelemetryService {
};
_locationSub = _locationService.getFilteredPositionStream().listen((pos) {
final speedMph = pos.speed * 2.23694;
_sensorFusionService.updateVehicleSpeed(speedMph);
if (_latestPosition != null) {
final dt = pos.timestamp.difference(_latestPosition!.timestamp).inMicroseconds.abs() / 1000000.0;
if (dt >= 0.05 && dt <= 3.0) {
@ -98,7 +101,6 @@ class TelemetryService {
final accelMps2 = (currentSpeedMps - prevSpeedMps) / dt;
_gpsLongitudinalG = (accelMps2 / 9.80665).clamp(-1.5, 1.5);
final speedMph = pos.speed * 2.23694;
if (speedMph >= 2.5 && pos.heading >= 0 && _latestPosition!.heading >= 0) {
var dHeading = pos.heading - _latestPosition!.heading;
while (dHeading > 180) {

View File

@ -31,6 +31,7 @@ void main() {
expect(request.url.queryParameters['key'], equals('test-key'));
final body = jsonDecode(request.body);
expect(body['QueryText'], equals('Atlanta'));
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
return http.Response(
jsonEncode({
'ResultItems': [
@ -44,6 +45,8 @@ void main() {
200,
);
} else if (request.url.path.contains('/v2/search-text')) {
final body = jsonDecode(request.body);
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
return http.Response(
jsonEncode({
'ResultItems': [
@ -423,5 +426,392 @@ void main() {
),
);
});
test('calculateRoute parses real AWS Routes V2 TurnStepDetails and ControlledAccessHighways', () async {
final mockClient = MockClient((request) async {
final body = jsonDecode(request.body);
expect(body['Avoid']['ControlledAccessHighways'], isTrue);
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
return http.Response(
jsonEncode({
'Routes': [
{
'Summary': {'Distance': 8500.0, 'Duration': 540},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.02, 34.02],
[-84.05, 34.05],
[-84.08, 34.08],
],
},
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Distance': 100.0,
'Duration': 15,
'GeometryOffset': 0,
'CurrentRoad': 'Main St',
},
{
'Type': 'Turn',
'Distance': 2400.0,
'Duration': 180,
'GeometryOffset': 1,
'NextRoad': 'Canyon Blvd',
'TurnStepDetails': {
'SteeringDirection': 'Right',
'TurnIntensity': 'Typical',
},
},
{
'Type': 'Ramp',
'Distance': 3000.0,
'Duration': 200,
'GeometryOffset': 2,
'NextRoad': 'Mountain Pkwy',
'RampStepDetails': {
'SteeringDirection': 'Left',
},
},
{
'Type': 'Arrive',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 3,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'End', latitude: 34.08, longitude: -84.08),
];
final result = await provider.calculateRoute(
stops,
options: const RoutingOptions(
avoidHighways: true,
),
);
expect(result.maneuvers.length, equals(4));
expect(result.maneuvers[0].type, equals(ManeuverType.depart));
expect(result.maneuvers[0].instruction, equals('Head out onto Main St'));
expect(result.maneuvers[1].type, equals(ManeuverType.turnRight));
expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Blvd'));
expect(result.maneuvers[2].type, equals(ManeuverType.ramp));
expect(result.maneuvers[2].instruction, equals('Take the ramp on the left onto Mountain Pkwy'));
expect(result.maneuvers[3].type, equals(ManeuverType.arrive));
expect(result.maneuvers[3].instruction, equals('Arrive at destination'));
});
test('calculateRoute canonicalizes conflicting maneuver instructions and types', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 5000.0,
'Duration': 300,
'Geometry': {
'LineString': [
[-84.00, 34.00],
[-84.00, 34.01],
[-84.02, 34.01], // Turns left
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out',
'NextRoad': 'Main St',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
// Raw instruction says left, but Type is generic Continue and no turn detail
'Type': 'Continue',
'Instruction': 'Turn left onto Peachtree St',
'NextRoad': 'Peachtree St',
'Distance': 2000.0,
'Duration': 120,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at destination',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Step 1 MUST be canonicalized to turnLeft matching its text instruction and left geometry
expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft));
expect(result.maneuvers[1].instruction, equals('Turn left onto Peachtree St'));
});
test('calculateRoute handles bearing wraparound across North 0/360 degrees', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 3000.0,
'Duration': 180,
'Geometry': {
'LineString': [
[-84.00000, 34.00000], // Start
[-84.00176, 34.01000], // Heading ~350 degrees NNW
[-83.99824, 34.02000], // Turns to heading ~20 degrees NNE (delta = +30 deg right)
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
'Type': 'Turn',
'Instruction': 'Turn onto Mountain Pass Rd',
'NextRoad': 'Mountain Pass Rd',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at summit',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'Summit', latitude: 34.02, longitude: -83.99824),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Wrapped delta from 350 deg to 20 deg is +30 deg, which resolves to slight right turn
expect(result.maneuvers[1].type.isRight, isTrue);
expect(result.maneuvers[1].instruction.toLowerCase(), contains('right'));
});
test('calculateRoute resolves provider text vs geometry mismatch using geometry truth', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 4000.0,
'Duration': 240,
'Geometry': {
'LineString': [
[-84.00, 34.00],
[-84.00, 34.01], // Heading 0 degrees North
[-84.02, 34.01], // Heading 270 degrees West (delta = -90 deg Left turn)
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
// Bug in raw provider: says Turn right, but geometry is 90 degree left turn!
'Type': 'Turn',
'TurnDetail': {'SteeringDirection': 'Right'},
'Instruction': 'Turn right onto West Pkwy',
'NextRoad': 'West Pkwy',
'Distance': 2000.0,
'Duration': 120,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at destination',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Step 1 MUST be corrected to turnLeft because geometry bearing delta is -90 degrees
expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft));
expect(result.maneuvers[1].instruction, equals('Turn left onto West Pkwy'));
expect(result.maneuvers[1].type.isLeft, isTrue);
expect(result.maneuvers[1].type.isRight, isFalse);
});
test('calculateRoute preserves continueStraight for pure road bends without generating false turn announcements', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 6000.0,
'Duration': 360,
'Geometry': {
'LineString': [
[-84.00, 34.00],
[-84.00, 34.01],
[-84.02, 34.01], // Curving road bend
[-84.02, 34.02],
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out onto US-129',
'NextRoad': 'US-129',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
// Pure road bend on US-129 - Must NOT be announced as a turn!
'Type': 'Continue',
'Instruction': 'Continue onto US-129',
'NextRoad': 'US-129',
'Distance': 4000.0,
'Duration': 240,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at Deals Gap',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 3,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'Deals Gap', latitude: 34.02, longitude: -84.02),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Step 1 MUST remain continueStraight, NOT turn left or right
expect(result.maneuvers[1].type, equals(ManeuverType.continueStraight));
expect(result.maneuvers[1].instruction, equals('Continue onto US-129'));
expect(result.maneuvers[1].type.isLeft, isFalse);
expect(result.maneuvers[1].type.isRight, isFalse);
});
});
}

View File

@ -324,7 +324,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('HAZARD'), findsOneWidget);
expect(find.text('250m Ahead • Skyline Blvd'), findsOneWidget);
expect(find.text('0.2 mi Ahead • Skyline Blvd'), findsOneWidget);
expect(find.byIcon(Icons.close), findsOneWidget);
});

View File

@ -407,6 +407,7 @@ class _FakeMapProvider implements AppMapProvider {
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,

View File

@ -207,7 +207,7 @@ void main() {
expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
expect(find.text('24 ACTIVE'), findsOneWidget);
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
expect(find.text('All Activity'), findsOneWidget);
expect(find.text('Club Only'), findsOneWidget);
expect(find.text('Nearby Packs'), findsOneWidget);

View File

@ -0,0 +1,285 @@
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/features/dashboard/widgets/dashboard_alert_region.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.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 {
final RoutingPreference preference;
final bool avoidHighways;
_FakeRouteProvider({
this.preference = RoutingPreference.fastest,
this.avoidHighways = false,
});
@override
String get name => 'Fake Provider';
@override
Future<List<PlaceSearchResult>> searchPlaces(
String query, {
AppLatLng? nearLocation,
}) async {
return const [
PlaceSearchResult(
providerId: 'blood_mtn',
primaryText: 'Blood Mountain Gap',
secondaryText: 'US-19, Blairsville, GA',
latitude: 34.7330,
longitude: -83.9180,
),
];
}
@override
Future<ResolvedPlace> resolvePlace(String placeId) async {
return const ResolvedPlace(
providerId: '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 {
return RouteCalculationResult(
straightLineDistanceMeters: 10000.0,
estimatedDistanceMeters: 12000.0,
estimatedDurationSeconds: 900,
geometry: [
AppLatLng(stops.first.latitude, stops.first.longitude),
AppLatLng(stops.last.latitude, stops.last.longitude),
],
isStraightLine: false,
providerName: name,
options: options ?? RoutingOptions(
preference: preference,
avoidHighways: avoidHighways,
),
curvatureMetric: 85.0, // Twisty curves
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Widget buildSubject({
ThemeMode themeMode = ThemeMode.light,
RoutingPreference preference = RoutingPreference.fastest,
bool avoidHighways = false,
}) {
return ProviderScope(
overrides: [
activeRouteCalculationProvider.overrideWithValue(
_FakeRouteProvider(
preference: preference,
avoidHighways: avoidHighways,
),
),
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(
themeMode: themeMode,
theme: ThemeData.light().copyWith(
extensions: const [MotorSignalThemeColors.light],
),
darkTheme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const MapScreen(),
),
);
}
group('Destination Autocomplete & Map Screen Remediations', () {
testWidgets('Search results overlay uses high contrast theme tokens in light mode', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 915));
await tester.pumpWidget(buildSubject(themeMode: ThemeMode.light));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'Blood Mountain');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
final overlayCard = tester.widget<Card>(find.byKey(const ValueKey('search_results_overlay')));
expect(overlayCard.color, isNotNull);
// Verify title text is rendered and readable
final titleText = tester.widget<Text>(find.text('Blood Mountain Gap'));
expect(titleText.style?.color, isNotNull);
// In light mode, text color should not be hardcoded white
expect(titleText.style?.color != Colors.white, isTrue);
final subtitleText = tester.widget<Text>(find.text('US-19, Blairsville, GA'));
expect(subtitleText.style?.color != Colors.white70, isTrue);
// Report button is hidden while search results are showing
expect(find.byKey(const ValueKey('quick_swarm_report_button')), findsNothing);
});
testWidgets('Route preview displays confirmed mode badge, labeled curves, and repositioned report FAB', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 915));
await tester.pumpWidget(buildSubject(
themeMode: ThemeMode.dark,
preference: RoutingPreference.fastest,
avoidHighways: true,
));
await tester.pumpAndSettle();
// Enter search and select destination
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'Blood Mountain');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Blood Mountain Gap'));
await tester.pumpAndSettle();
// Verify preview card rendered
expect(find.byKey(const ValueKey('route_preview_card')), findsOneWidget);
// Confirmed mode badge displays FASTEST
expect(find.text('FASTEST'), findsOneWidget);
// Twistiness is clearly labeled as CURVES: Twisty Curves
expect(find.text('CURVES: Twisty Curves'), findsOneWidget);
// Scroll preference chips row horizontally to reveal Avoid Highways chip
final chipFinder = find.byKey(const ValueKey('preference_avoid_highways'));
await tester.scrollUntilVisible(
chipFinder,
150.0,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
await tester.tap(chipFinder);
await tester.pumpAndSettle();
// Avoid highways badge is displayed when enabled
expect(find.text('NO HIGHWAYS'), findsOneWidget);
// Report FAB is visible and repositioned safely above route preview card (bottom: 295)
final reportFabPositioned = tester.widget<Positioned>(
find.ancestor(
of: find.byKey(const ValueKey('quick_swarm_report_button')),
matching: find.byType(Positioned),
).first,
);
expect(reportFabPositioned.bottom, 295.0);
});
testWidgets('Dashboard alert distance formatting avoids 1 ft Ahead and uses Nearby for close alerts', (tester) async {
final closeAlert = RoadAlert(
id: 'alert_close',
providerId: 'swarm_alerts',
category: RoadAlertCategory.hazard,
latitude: 34.7200,
longitude: -83.9100,
description: 'Road gravel',
reportedAt: DateTime.now(),
lastUpdated: DateTime.now(),
source: 'swarm',
distanceFromUser: 5.0, // 5 meters
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light().copyWith(
extensions: const [MotorSignalThemeColors.light],
),
home: Scaffold(
body: ProviderScope(
overrides: [
locationPermissionGrantedProvider.overrideWith((ref) => true),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([closeAlert])),
],
child: const DashboardAlertRegion(),
),
),
),
);
await tester.pumpAndSettle();
// Close alert formats as "Nearby"
expect(find.textContaining('Nearby'), findsOneWidget);
expect(find.textContaining('1 ft Ahead'), findsNothing);
});
testWidgets('Dashboard alert distance formatting rounds mid-distance to clean 10ft units', (tester) async {
final midAlert = RoadAlert(
id: 'alert_mid',
providerId: 'swarm_alerts',
category: RoadAlertCategory.police,
latitude: 34.7200,
longitude: -83.9100,
description: 'Speed trap',
reportedAt: DateTime.now(),
lastUpdated: DateTime.now(),
source: 'swarm',
distanceFromUser: 45.0, // 45 meters (~150 ft)
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light().copyWith(
extensions: const [MotorSignalThemeColors.light],
),
home: Scaffold(
body: ProviderScope(
overrides: [
locationPermissionGrantedProvider.overrideWith((ref) => true),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([midAlert])),
],
child: const DashboardAlertRegion(),
),
),
),
);
await tester.pumpAndSettle();
// Mid alert formats in clean rounded feet increments (e.g. 150 ft Ahead)
expect(find.textContaining('150 ft Ahead'), findsOneWidget);
expect(find.textContaining('1 ft Ahead'), findsNothing);
});
});
}

View File

@ -13,6 +13,13 @@ void main() {
expect(DefaultFirebaseOptions.ios.projectId, equals('motoapp-d22e0'));
});
test('DefaultFirebaseOptions exposes authoritative webClientId for Google Sign-In', () {
expect(
DefaultFirebaseOptions.webClientId,
equals('809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com'),
);
});
test('androidQa options strictly target demo-motorsignal-qa and non-prod credentials', () {
expect(DefaultFirebaseOptions.androidQa.projectId, equals('demo-motorsignal-qa'));
expect(DefaultFirebaseOptions.androidQa.apiKey, equals('demo-key'));

View File

@ -188,7 +188,7 @@ void main() {
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
fakeService.getParticipantController('user_accepted').add(
GroupRideParticipant(
@ -224,7 +224,7 @@ void main() {
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
final presenceAlice = LiveGroupPresence(
userId: 'user_accepted',
@ -238,7 +238,7 @@ void main() {
);
fakeService.getPresenceController('user_accepted').add(presenceAlice);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(states.isNotEmpty, isTrue);
expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue);
@ -282,7 +282,7 @@ void main() {
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
@ -307,7 +307,7 @@ void main() {
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
final presenceAlice = LiveGroupPresence(
userId: 'user_alice',
@ -322,7 +322,7 @@ void main() {
fakeService.getPresenceController('user_alice').add(presenceAlice);
fakeService.getPresenceController('user_bob').addError('permission-denied');
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 1);
@ -365,7 +365,7 @@ void main() {
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
@ -390,7 +390,7 @@ void main() {
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 2);

View File

@ -593,11 +593,14 @@ void main() {
expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsNothing);
final pauseFinder = find.byKey(const ValueKey('hybrid_pause_resume_ride_button'));
final hazardFinder = find.byKey(const ValueKey('hybrid_report_hazard_button'));
final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button'));
expect(pauseFinder, findsOneWidget);
expect(hazardFinder, findsOneWidget);
expect(finishFinder, findsOneWidget);
expect(find.text('Pause Ride'), findsOneWidget);
expect(find.text('Hazard'), findsOneWidget);
expect(find.text('Finish Ride'), findsOneWidget);
// Tap Pause
@ -605,12 +608,49 @@ void main() {
await tester.pumpAndSettle();
expect(fakeMachine.pauseRideCalled, isTrue);
// Tap Finish
// Tap Finish & Confirm Dialog
await tester.tap(finishFinder);
await tester.pumpAndSettle();
if (find.text('End Ride').evaluate().isNotEmpty) {
await tester.tap(find.text('End Ride'));
await tester.pumpAndSettle();
}
expect(fakeMachine.finishRideCalled, isTrue);
});
testWidgets('cancelling End Ride confirmation dialog preserves active ride session', (tester) async {
final fakeMachine = _FakeRideStateMachine(
RideSession(state: RideState.riding, startTime: DateTime.now()),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
rideStateMachineProvider.overrideWith((ref) => fakeMachine),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const Scaffold(
body: HybridRidingActionBar(),
),
),
),
);
final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button'));
await tester.tap(finishFinder);
await tester.pumpAndSettle();
expect(find.text('End Ride?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(fakeMachine.finishRideCalled, isFalse);
expect(find.text('End Ride?'), findsNothing);
});
testWidgets('renders Resume button when paused and invokes resumeRide', (tester) async {
final fakeMachine = _FakeRideStateMachine(
RideSession(state: RideState.paused, startTime: DateTime.now()),

View File

@ -188,7 +188,12 @@ void main() {
await notifier.startNavigation(calculationResult: calcResult);
// Rider reaches within 10 meters of maneuver 0 point along the route
// Stationary rider at departure does NOT prematurely advance to step 1
notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 0.0);
expect(notifier.state.currentManeuverIndex, equals(0));
expect(notifier.state.currentManeuver?.instruction, equals('Head north on Main'));
// Rider departs and is moving (> 3.0 mph), advancing to step 1
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'));
@ -280,5 +285,88 @@ void main() {
notifier.toggleTts();
expect(notifier.state.ttsEnabled, isTrue);
});
test('remaining distance strictly counts down and maneuvers advance as rider travels', () async {
final notifier = NavigationSessionNotifier();
// Polyline with 5 points, ~1000m total
const p0 = AppLatLng(33.7500, -84.3880);
const p1 = AppLatLng(33.7525, -84.3880); // ~278m north
const p2 = AppLatLng(33.7550, -84.3880); // ~278m north (turn right)
const p3 = AppLatLng(33.7550, -84.3850); // ~278m east
const p4 = AppLatLng(33.7550, -84.3820); // ~278m east (destination)
final maneuvers = <RouteManeuver>[
const RouteManeuver(
index: 0,
instruction: 'Depart north on Main St',
type: ManeuverType.depart,
distanceMeters: 556.0,
durationSeconds: 60,
location: p0,
geometryOffset: 0,
),
const RouteManeuver(
index: 1,
instruction: 'Turn right on Oak St',
type: ManeuverType.turnRight,
distanceMeters: 556.0,
durationSeconds: 60,
location: p2,
geometryOffset: 2,
),
const RouteManeuver(
index: 2,
instruction: 'Arrive at destination',
type: ManeuverType.arrive,
distanceMeters: 0.0,
durationSeconds: 0,
location: p4,
geometryOffset: 4,
),
];
final calcResult = RouteCalculationResult(
straightLineDistanceMeters: 1000.0,
estimatedDistanceMeters: 1112.0,
estimatedDurationSeconds: 120,
geometry: const [p0, p1, p2, p3, p4],
isStraightLine: false,
providerName: 'AWS Location',
maneuvers: maneuvers,
);
await notifier.startNavigation(calculationResult: calcResult);
final initialRemaining = notifier.state.remainingDistanceMeters;
expect(initialRemaining, greaterThan(1000.0));
// Rider moves to p1 (halfway to turn)
notifier.updateLocation(p1, speedMph: 30.0);
final remainingAtP1 = notifier.state.remainingDistanceMeters;
// Remaining distance MUST decrease (never count up!)
expect(remainingAtP1, lessThan(initialRemaining));
expect(notifier.state.currentManeuverIndex, equals(1));
expect(notifier.state.currentManeuver?.instruction, equals('Turn right on Oak St'));
final distToTurnP1 = notifier.state.distanceToNextManeuverMeters;
expect(distToTurnP1, greaterThan(200.0));
expect(distToTurnP1, lessThan(350.0));
// Rider approaches turn at p2 (within 10m of p2)
notifier.updateLocation(p2, speedMph: 20.0);
final remainingAtP2 = notifier.state.remainingDistanceMeters;
expect(remainingAtP2, lessThan(remainingAtP1));
// Maneuver advances to arrival!
expect(notifier.state.currentManeuverIndex, equals(2));
expect(notifier.state.currentManeuver?.instruction, equals('Arrive at destination'));
// Rider continues to p3
notifier.updateLocation(p3, speedMph: 25.0);
final remainingAtP3 = notifier.state.remainingDistanceMeters;
expect(remainingAtP3, lessThan(remainingAtP2));
// Rider arrives at destination p4
notifier.updateLocation(p4, speedMph: 5.0);
expect(notifier.state.status, equals(NavigationStatus.arrived));
expect(notifier.state.remainingDistanceMeters, equals(0.0));
});
});
}

View File

@ -101,10 +101,14 @@ void main() {
await tester.tap(find.text('Stop Session'));
await tester.pumpAndSettle();
if (find.text('End Ride').evaluate().isNotEmpty) {
await tester.tap(find.text('End Ride'));
await tester.pumpAndSettle();
}
// Had the rejected Future been left unhandled, flutter_test would have
// surfaced it as an unhandled async error and failed this test.
expect(fake.finishCalls, 1);
final rideStopLines =
logLines.where((l) => l.contains('[RideStop]')).toList();
expect(rideStopLines, hasLength(1),
@ -136,6 +140,11 @@ void main() {
await tester.tap(find.text('Stop Session'));
await tester.pumpAndSettle();
if (find.text('End Ride').evaluate().isNotEmpty) {
await tester.tap(find.text('End Ride'));
await tester.pumpAndSettle();
}
expect(fake.finishCalls, 1);
expect(logLines.where((l) => l.contains('[RideStop]')), isEmpty,
reason: 'no diagnostic on a successful finish');

View File

@ -580,6 +580,35 @@ void main() {
service.dispose();
});
test('18. Vehicle in motion (>2.0 mph) preserves calibration even with high pitch tilt during hard braking', () async {
final calibrationService = CalibrationService();
final service = SensorFusionService(calibrationService, autoStartSensors: false);
service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now()));
await service.calibrateUpright();
expect(service.isCalibrated, isTrue);
// Simulate riding at speed (35 mph)
service.updateVehicleSpeed(35.0);
// Simulate hard braking pitch tilt / hill climb (>65° apparent gravity shift due to deceleration)
final hardBrakeAngleRad = 70.0 * (math.pi / 180.0);
for (int i = 0; i < 60; i++) {
service.handleAccelerometerEvent(AccelerometerEvent(
0.0,
g * math.cos(hardBrakeAngleRad),
g * math.sin(hardBrakeAngleRad),
DateTime.now(),
));
}
await Future<void>.delayed(const Duration(milliseconds: 15));
// Calibration must NOT be wiped while actively riding
expect(service.isCalibrated, isTrue);
service.dispose();
});
});
group('Dashboard UI: Lean Gauge Display & Availability Semantics', () {

View File

@ -79,7 +79,7 @@ void main() {
// 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.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
// Verify that NO false GPS Verified badge is rendered

View File

@ -70,6 +70,8 @@ class _NullSensorFusionService extends Fake implements SensorFusionService {
double get latestRawGyroY => 0.0;
@override
double get latestRawGyroZ => 0.0;
@override
void updateVehicleSpeed(double speedMph) {}
}
void main() {