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: push:
branches: [ master ] branches: [ master ]
pull_request: pull_request:
branches: [ master ] branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -166,6 +166,13 @@ jobs:
test -s "$APK" test -s "$APK"
file "$APK" file "$APK"
sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256 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 - name: Upload debug APK
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
with: with:
@ -180,7 +187,7 @@ jobs:
name: Build signed release APK and AAB name: Build signed release APK and AAB
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [detect, analyze-and-build] 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: permissions:
contents: read contents: read
steps: steps:

View File

@ -13,6 +13,14 @@
} }
}, },
"oauth_client": [ "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_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,

View File

@ -4,24 +4,94 @@ import '../theme/app_spacing.dart';
import '../theme/app_typography.dart'; import '../theme/app_typography.dart';
import '../theme/theme_extensions.dart'; import '../theme/theme_extensions.dart';
import '../../features/rides/ride_state_machine.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. /// Pinned riding action controls for MotorSignal Hybrid v1.
/// ///
/// Provides 56dp minimum height buttons for glove-friendly cockpit control. /// Provides 56dp minimum height buttons for glove-friendly cockpit control.
/// In active ride state, provides high-contrast Pause Ride (Amber) and /// In active ride state, provides high-contrast Pause Ride (Amber),
/// Finish Ride (Dark/Slate with Cyan/Green accent) action controls. /// Report Hazard (Caution/Warning), and Finish Ride controls.
class HybridRidingActionBar extends ConsumerWidget { class HybridRidingActionBar extends ConsumerWidget {
final bool compact; final bool compact;
final String? finishLabel; final String? finishLabel;
final String? logTag; final String? logTag;
final bool requireConfirmation;
const HybridRidingActionBar({ const HybridRidingActionBar({
super.key, super.key,
this.compact = false, this.compact = false,
this.finishLabel, this.finishLabel,
this.logTag, 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch( final state = ref.watch(
@ -37,7 +107,7 @@ class HybridRidingActionBar extends ConsumerWidget {
if (isRiding || isPaused) { if (isRiding || isPaused) {
return Row( return Row(
children: [ children: [
// Pause / Resume button // 1. Pause / Resume button
Expanded( Expanded(
flex: 1, flex: 1,
child: SizedBox( child: SizedBox(
@ -52,7 +122,7 @@ class HybridRidingActionBar extends ConsumerWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 6),
), ),
onPressed: () { onPressed: () {
if (isPaused) { if (isPaused) {
@ -63,7 +133,7 @@ class HybridRidingActionBar extends ConsumerWidget {
}, },
icon: Icon( icon: Icon(
isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
size: 24, size: 22,
color: colors.cautionAmber, color: colors.cautionAmber,
), ),
label: FittedBox( label: FittedBox(
@ -72,17 +142,58 @@ class HybridRidingActionBar extends ConsumerWidget {
isPaused ? 'Resume' : 'Pause Ride', isPaused ? 'Resume' : 'Pause Ride',
style: const TextStyle( style: const TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.3, letterSpacing: 0.2,
), ),
), ),
), ),
), ),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 8),
// Finish Ride button
// 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( Expanded(
flex: 1, flex: 1,
child: SizedBox( child: SizedBox(
@ -97,17 +208,15 @@ class HybridRidingActionBar extends ConsumerWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 6),
), ),
onPressed: () { onPressed: () {
final effectiveTag = logTag ?? 'HybridRidingActionBar'; final effectiveTag = logTag ?? 'HybridRidingActionBar';
notifier.finishRide().catchError((Object error) { _handleFinish(context, notifier, effectiveTag);
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
});
}, },
icon: Icon( icon: Icon(
Icons.stop_rounded, Icons.stop_rounded,
size: 24, size: 22,
color: colors.brandCyan, color: colors.brandCyan,
), ),
label: FittedBox( label: FittedBox(
@ -116,9 +225,9 @@ class HybridRidingActionBar extends ConsumerWidget {
finishLabel ?? 'Finish Ride', finishLabel ?? 'Finish Ride',
style: const TextStyle( style: const TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.3, letterSpacing: 0.2,
), ),
), ),
), ),
@ -129,6 +238,7 @@ class HybridRidingActionBar extends ConsumerWidget {
); );
} }
// Ready / Standby state: Single prominent start button // Ready / Standby state: Single prominent start button
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,

View File

@ -82,13 +82,15 @@ class DashboardAlertRegion extends ConsumerWidget {
} }
Widget _buildRoadAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) { 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( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( 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)), border: Border(bottom: BorderSide(color: alert.category.color, width: 2)),
), ),
child: Row( child: Row(
@ -101,9 +103,11 @@ class DashboardAlertRegion extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text( Flexible(
child: Text(
alert.category.displayName.toUpperCase(), alert.category.displayName.toUpperCase(),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
color: alert.category.color, color: alert.category.color,
@ -112,6 +116,7 @@ class DashboardAlertRegion extends ConsumerWidget {
letterSpacing: 0.8, letterSpacing: 0.8,
), ),
), ),
),
if (isSwarm) ...[ if (isSwarm) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
@ -135,11 +140,12 @@ class DashboardAlertRegion extends ConsumerWidget {
], ],
), ),
Text( Text(
'${alert.distanceFromUser?.toStringAsFixed(0) ?? '0'}m Ahead • ${alert.roadName ?? alert.description}', _formatAlertSubtitle(alert),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
color: Colors.white, color: subtitleColor,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -155,6 +161,7 @@ class DashboardAlertRegion extends ConsumerWidget {
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Confirm (Still there)', tooltip: 'Confirm (Still there)',
onPressed: () { onPressed: () {
if (alert.id.isNotEmpty) {
ref.read(swarmRoadAlertServiceProvider).confirmAlert(alert.id); ref.read(swarmRoadAlertServiceProvider).confirmAlert(alert.id);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -162,6 +169,7 @@ class DashboardAlertRegion extends ConsumerWidget {
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
}
}, },
), ),
IconButton( IconButton(
@ -170,6 +178,7 @@ class DashboardAlertRegion extends ConsumerWidget {
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Clear (No longer there)', tooltip: 'Clear (No longer there)',
onPressed: () { onPressed: () {
if (alert.id.isNotEmpty) {
ref.read(swarmRoadAlertServiceProvider).clearAlert(alert.id); ref.read(swarmRoadAlertServiceProvider).clearAlert(alert.id);
ref.read(roadAlertServiceProvider).dismissAlert(alert.id); ref.read(roadAlertServiceProvider).dismissAlert(alert.id);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -178,11 +187,12 @@ class DashboardAlertRegion extends ConsumerWidget {
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
}
}, },
), ),
], ],
IconButton( IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 18), icon: Icon(Icons.close, color: subtitleColor, size: 18),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
tooltip: 'Dismiss alert', 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/alerts/road_alert_service.dart';
import '../../../services/map/map_provider_interface.dart'; import '../../../services/map/map_provider_interface.dart';
import '../../../services/map/unified_map_view.dart'; import '../../../services/map/unified_map_view.dart';
import '../../../services/navigation/navigation_session_service.dart';
import '../../../services/telemetry_service.dart'; import '../../../services/telemetry_service.dart';
/// Embedded tactical map panel for Touring mode and responsive dashboard views. /// 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( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@ -103,7 +118,9 @@ class DashboardMapPanel extends ConsumerWidget {
key: const ValueKey('dash_map_x'), key: const ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng), center: AppLatLng(centerLat, centerLng),
zoom: zoom, zoom: zoom,
bearing: 0.0,
markers: mapMarkers, markers: mapMarkers,
polylines: polylines,
), ),
), ),
if (!isFresh && hasCoordinates) if (!isFresh && hasCoordinates)

View File

@ -257,10 +257,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
size: const Size(double.infinity, double.infinity), size: const Size(double.infinity, double.infinity),
painter: _GForcePodPainter(accelG: accelG, lateralG: lateralG), painter: _GForcePodPainter(accelG: accelG, lateralG: lateralG),
), ),
Column( Padding(
mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 18),
children: [ child: FittedBox(
Text( fit: BoxFit.scaleDown,
child: Text(
'${totalG.toStringAsFixed(2)} G', '${totalG.toStringAsFixed(2)} G',
style: const TextStyle( style: const TextStyle(
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
@ -269,10 +270,12 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
color: Colors.white, color: Colors.white,
), ),
), ),
], ),
), ),
const Positioned( const Positioned(
top: 8, top: 6,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text( child: Text(
'BRAKING', 'BRAKING',
style: TextStyle( style: TextStyle(
@ -284,8 +287,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
), ),
), ),
), ),
),
const Positioned( const Positioned(
bottom: 8, bottom: 6,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text( child: Text(
'CORNERING', 'CORNERING',
style: TextStyle( style: TextStyle(
@ -297,6 +303,7 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
), ),
), ),
), ),
),
], ],
), ),
), ),
@ -317,6 +324,10 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
maxLeft: session.maxLeanLeft, maxLeft: session.maxLeanLeft,
maxRight: session.maxLeanRight, maxRight: session.maxLeanRight,
), ),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -354,6 +365,8 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
), ),
), ),
), ),
),
),
], ],
), ),
), ),
@ -393,8 +406,57 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
width: double.infinity, width: double.infinity,
height: 52, height: 52,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () async {
if (session.state == RideState.riding) { 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 // RID-2: contain the fire-and-forget Stop action at the UI
// boundary. finishRide() keeps its Future error semantics // boundary. finishRide() keeps its Future error semantics
// for awaiting callers; only the exception class is logged, // 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)}°' : '--°'; 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() { void handleTapToCalibrate() {
final currentSpeed = sample?.speed ?? 0.0; final currentSpeed = sample?.speed ?? 0.0;
final currentLean = displayLean.abs(); final currentLean = displayLean.abs();
@ -148,6 +160,15 @@ class LeanAngleIndicator extends ConsumerWidget {
), ),
), ),
actions: [ 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( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'), child: const Text('Cancel'),
@ -193,14 +214,15 @@ class LeanAngleIndicator extends ConsumerWidget {
builder: (context, constraints) { builder: (context, constraints) {
final availableWidth = constraints.maxWidth; final availableWidth = constraints.maxWidth;
final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0); final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0);
final double gaugeHeight = compact ? 52.0 : 68.0; final double gaugeHeight = compact ? 46.0 : 60.0;
return Tooltip( return Tooltip(
message: 'Tap to Calibrate Upright (Stationary)', message: 'Tap to Calibrate | Long press for Diagnostics',
child: InkWell( child: InkWell(
key: const ValueKey('lean_angle_indicator_tap'), key: const ValueKey('lean_angle_indicator_tap'),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: handleTapToCalibrate, onTap: handleTapToCalibrate,
onLongPress: () => showDiagnosticsSheet(context),
child: Semantics( child: Semantics(
label: isAvailable label: isAvailable
? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.' ? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
@ -302,22 +324,31 @@ class LeanAngleIndicator extends ConsumerWidget {
), ),
), ),
// Preserves backward-compatible telemetry text matchers for existing tests // Instant Live Lean Angle telemetry
SizedBox( FittedBox(
width: 0, fit: BoxFit.scaleDown,
height: 0,
child: OverflowBox(
minWidth: 0,
maxWidth: 300,
minHeight: 0,
maxHeight: 50,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text('L: ${currentLeft.toStringAsFixed(0)}°'), Text(
Text('R: ${currentRight.toStringAsFixed(0)}°'), '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; 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 currentManeuver = navState.currentManeuver!;
final nextManeuver = navState.nextManeuver; final nextManeuver = navState.nextManeuver;
final colors = context.motorColors; final colors = context.motorColors;
final theme = Theme.of(context);
return Semantics( return Semantics(
label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.', 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, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Wrap( FittedBox(
spacing: 8, fit: BoxFit.scaleDown,
runSpacing: 2, alignment: Alignment.centerLeft,
crossAxisAlignment: WrapCrossAlignment.center, child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text(
navState.formattedDistanceToNext, navState.formattedDistanceToNext,
@ -96,6 +97,7 @@ class NavigationManeuverCard extends ConsumerWidget {
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
), ),
const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
@ -118,16 +120,17 @@ class NavigationManeuverCard extends ConsumerWidget {
), ),
], ],
), ),
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
currentManeuver.streetName ?? currentManeuver.instruction, currentManeuver.streetName ?? currentManeuver.instruction,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: const TextStyle(
fontFamily: 'Inter', fontFamily: 'Inter',
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface, color: Colors.white,
), ),
), ),
], ],
@ -137,6 +140,8 @@ class NavigationManeuverCard extends ConsumerWidget {
// Quick Audio Mute & End Navigation Buttons // Quick Audio Mute & End Navigation Buttons
IconButton( IconButton(
key: const ValueKey('nav_toggle_tts_button'), key: const ValueKey('nav_toggle_tts_button'),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
icon: Icon( icon: Icon(
navState.ttsEnabled ? Icons.volume_up : Icons.volume_off, navState.ttsEnabled ? Icons.volume_up : Icons.volume_off,
color: navState.ttsEnabled ? colors.cyberCyan : Colors.white54, color: navState.ttsEnabled ? colors.cyberCyan : Colors.white54,
@ -149,6 +154,8 @@ class NavigationManeuverCard extends ConsumerWidget {
), ),
IconButton( IconButton(
key: const ValueKey('nav_stop_button'), 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), icon: const Icon(Icons.close, color: Colors.white70, size: 22),
tooltip: 'Exit Navigation', tooltip: 'Exit Navigation',
onPressed: () { onPressed: () {

View File

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

View File

@ -53,12 +53,13 @@ class SpeedReadout extends ConsumerWidget {
final sample = s.value; final sample = s.value;
if (sample == null) { if (sample == null) {
// Pre-telemetry initial state (before first tick) // 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 ( return (
speed: sample.freshSpeed, speed: sample.freshSpeed,
isFresh: sample.isSpeedFresh, isFresh: sample.isSpeedFresh,
isInitial: false, isInitial: false,
rawSpeed: sample.speed,
); );
}), }),
); );
@ -70,12 +71,12 @@ class SpeedReadout extends ConsumerWidget {
final Color speedColor; final Color speedColor;
final Color unitColor; final Color unitColor;
if (speedState.isInitial) { if (speedState.isInitial || (speedState.rawSpeed == null && !speedState.isFresh)) {
speedText = '0'; speedText = '0';
speedColor = isDark ? Colors.white : AppColors.lightOnSurface; speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
unitColor = baseUnitColor; unitColor = baseUnitColor;
} else if (!speedState.isFresh) { } 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 = '--'; speedText = '--';
speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38); speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38);
unitColor = baseUnitColor.withValues(alpha: 0.5); unitColor = baseUnitColor.withValues(alpha: 0.5);

View File

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

View File

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

View File

@ -74,6 +74,8 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
double _playbackSpeed = 1.0; double _playbackSpeed = 1.0;
int _currentIndex = 0; int _currentIndex = 0;
Timer? _playbackTimer; Timer? _playbackTimer;
double _zoom = 17.0;
bool _isFollowingRider = true;
@override @override
void initState() { void initState() {
@ -325,6 +327,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final theme = Theme.of(context); final theme = Theme.of(context);
final hasLeanData = _telemetry.any((t) => t.leanAngle != null);
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
if (_telemetry.isEmpty) { if (_telemetry.isEmpty) {
@ -480,10 +483,49 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
children: [ children: [
UnifiedMapView( UnifiedMapView(
center: currentPos, center: currentPos,
zoom: 17.0, zoom: _zoom,
markers: allMarkers, markers: allMarkers,
polylines: polylines, 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 // Telemetry Overlay
Positioned( Positioned(
@ -619,7 +661,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
// Corner Entry / Apex / Exit Breakdown Table // Corner Entry / Apex / Exit Breakdown Table
Expanded( Expanded(
flex: 4, flex: _corners.isEmpty ? 3 : 4,
child: Container( child: Container(
color: theme.colorScheme.surfaceContainerLow, color: theme.colorScheme.surfaceContainerLow,
child: Column( child: Column(
@ -646,7 +688,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Tap row to seek apex', _corners.isEmpty ? '' : 'Tap row to seek apex',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
@ -659,14 +701,54 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
if (_corners.isEmpty) if (_corners.isEmpty)
Expanded( Expanded(
child: Center( child: Center(
child: Text( child: SingleChildScrollView(
'No distinct corners detected over 15° lean threshold.', 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( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6), fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
fontFamily: 'JetBrains Mono', 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 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/sync/sync_providers.dart';
import '../../services/group_rides/group_ride_providers.dart'; import '../../services/group_rides/group_ride_providers.dart';
import 'widgets/current_plan_card.dart'; import 'widgets/current_plan_card.dart';
import 'widgets/sync_status_inspector_screen.dart';
class AccountScreen extends ConsumerStatefulWidget { class AccountScreen extends ConsumerStatefulWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@ -1031,14 +1032,27 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
), ),
), ),
] else ...[ ] 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), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.4), color: colors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant), border: Border.all(color: colors.outlineVariant),
), ),
child: Row( child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildSyncStatItem( _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), const SizedBox(height: 12),
if (syncState.failedCount > 0) ...[ if (syncState.failedCount > 0) ...[
@ -1083,7 +1123,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( 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( style: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -1093,6 +1135,31 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
TextButton( TextButton(
style: TextButton.styleFrom( style: TextButton.styleFrom(
visualDensity: VisualDensity.compact, 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, foregroundColor: colors.raceRed,
), ),
onPressed: () => syncManager.retryAllPending(), onPressed: () => syncManager.retryAllPending(),

View File

@ -1238,13 +1238,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
setState(() => _isTestingGa511 = false); setState(() => _isTestingGa511 = false);
if (mounted) { 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( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(statusMsg),
status == RoadAlertProviderStatus.connected
? 'Georgia 511 feed connected!'
: 'Georgia 511 gateway status: ${status.name}',
),
), ),
); );
} }
@ -1357,8 +1367,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (mounted) { if (mounted) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar( SnackBar(
content: Text('Waze OSINT adapter configured successfully!'), 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) { if (!widget.showAppBar) {
return Container( return Container(
color: const Color(0xFF0D1117), color: Theme.of(context).colorScheme.surface,
child: content, child: content,
); );
} }
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0D1117), backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar( appBar: AppBar(
backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.95), backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
@ -232,25 +232,27 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
) { ) {
return Container( return Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
color: const Color(0xFF0D1117), color: Theme.of(context).colorScheme.surface,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Row 1: Status LED, Active metric & Search toggle // Row 1: Status LED & Search toggle
Wrap( Row(
alignment: WrapAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8,
runSpacing: 6,
children: [ children: [
// Status LED pill (Required by test suite) // Status LED pill
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF161B22), color: Theme.of(context).brightness == Brightness.light
? const Color(0xFFE0F7FA)
: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( 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( child: Row(
@ -272,48 +274,40 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( Text(
'SQUAD & CANYON FEED', 'SQUAD & CANYON FEED',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.6, 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) // Search toggle button
Container( IconButton(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), key: const ValueKey('feed_search_toggle_button'),
decoration: BoxDecoration( icon: Icon(
color: const Color(0xFF1E2530), _isSearchExpanded ? Icons.close_rounded : Icons.search_rounded,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.sensors_rounded,
size: 14,
color: AppColors.electricCyan, color: AppColors.electricCyan,
size: 20,
), ),
SizedBox(width: 6), tooltip: 'Search Feed',
Text( onPressed: () {
'24 ACTIVE', setState(() {
style: TextStyle( _isSearchExpanded = !_isSearchExpanded;
fontFamily: AppTypography.fontTelemetry, if (!_isSearchExpanded) {
fontSize: 11, _searchController.clear();
fontWeight: FontWeight.w700, ref.read(feedSearchQueryProvider.notifier).state = '';
letterSpacing: 0.6, }
color: Color(0xFF9CF0FF), });
), },
),
],
),
), ),
], ],
), ),
@ -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( SingleChildScrollView(
scrollDirection: Axis.horizontal, 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( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildFilterTab( _buildFilterTab(
tab: FeedFilter.allActivity, tab: FeedFilter.allActivity,
@ -416,18 +402,13 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
title: 'Following', title: 'Following',
isSelected: activeFilter == FeedFilter.following, isSelected: activeFilter == FeedFilter.following,
), ),
], const SizedBox(width: 8),
Container(
width: 1,
height: 20,
color: colors.outlineVariant.withValues(alpha: 0.4),
), ),
), const SizedBox(width: 8),
),
const SizedBox(height: 8),
// Riding Discipline Filter Chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildDisciplineChip( _buildDisciplineChip(
discipline: RidingDiscipline.all, discipline: RidingDiscipline.all,
label: 'All Styles', label: 'All Styles',
@ -553,7 +534,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
letterSpacing: 0.1, letterSpacing: 0.1,
color: isSelected color: isSelected
? const Color(0xFF00363D) ? 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, required bool isSelected,
}) { }) {
final chipColor = _getDisciplineColor(discipline); final chipColor = _getDisciplineColor(discipline);
final isLight = Theme.of(context).brightness == Brightness.light;
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -578,12 +562,12 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? chipColor.withValues(alpha: 0.22) ? chipColor.withValues(alpha: 0.22)
: const Color(0xFF161B22), : (isLight ? const Color(0xFFF1F5F9) : const Color(0xFF161B22)),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: isSelected color: isSelected
? chipColor.withValues(alpha: 0.7) ? chipColor.withValues(alpha: 0.7)
: const Color(0xFF263238), : (isLight ? const Color(0xFFCBD5E1) : const Color(0xFF263238)),
width: isSelected ? 1.2 : 1.0, width: isSelected ? 1.2 : 1.0,
), ),
), ),
@ -593,7 +577,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 11.5, fontSize: 11.5,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, 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 hasCustomFilters
? 'No matching ride sessions' ? 'No matching ride sessions'
: 'No posts in ${activeFilter.displayName}', : 'No posts in ${activeFilter.displayName}',
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
hasCustomFilters hasCustomFilters
? 'Try relaxing your style filters or search query to discover more community rides.' ? '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, textAlign: TextAlign.center,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, 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) ...[ if (hasCustomFilters) ...[
const SizedBox(height: 16), const SizedBox(height: 12),
OutlinedButton.icon( OutlinedButton.icon(
icon: const Icon(Icons.refresh_rounded, size: 16), icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Reset All Filters'), label: const Text('Reset All Filters'),

View File

@ -110,7 +110,11 @@ class _FeedCommentsBottomSheetState
? livePost.comments ? livePost.comments
: _localComments; : _localComments;
return Container( return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF101419), color: const Color(0xFF101419),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
@ -126,10 +130,6 @@ class _FeedCommentsBottomSheetState
padding: EdgeInsets.only(bottom: bottomInset), padding: EdgeInsets.only(bottom: bottomInset),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_typography.dart'; import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/theme_extensions.dart'; import '../../../../core/theme/theme_extensions.dart';
import '../../../../database/database.dart';
import '../../../../services/auth/auth_providers.dart'; import '../../../../services/auth/auth_providers.dart';
import '../../../../services/map/map_provider_interface.dart'; import '../../../../services/map/map_provider_interface.dart';
import '../../../../services/social/social_feed_models.dart'; import '../../../../services/social/social_feed_models.dart';
@ -29,10 +32,8 @@ class ShareSessionModal extends ConsumerStatefulWidget {
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> { class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _titleController = final _titleController = TextEditingController(text: 'Morning Canyon Carve');
TextEditingController(text: 'Morning Canyon Carve'); final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9');
final _locationController =
TextEditingController(text: 'Skyline Blvd & Highway 9');
final _bikeController = TextEditingController(); final _bikeController = TextEditingController();
final _distanceController = TextEditingController(text: '48.5'); final _distanceController = TextEditingController(text: '48.5');
final _durationController = TextEditingController(text: '52'); final _durationController = TextEditingController(text: '52');
@ -42,21 +43,96 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport; RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
bool _isSubmitting = false; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) async {
final defaultBike = ref.read(defaultMotorcycleProvider); final defaultBike = ref.read(defaultMotorcycleProvider);
if (defaultBike != null) { if (defaultBike != null) {
_bikeController.text = _bikeController.text =
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}' '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim();
.trim();
} else { } else {
_bikeController.text = '2024 Ducati Streetfighter V4'; _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 @override
void dispose() { void dispose() {
_titleController.dispose(); _titleController.dispose();
@ -107,12 +183,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
elevationFt: 1840, elevationFt: 1840,
discipline: _selectedDiscipline, discipline: _selectedDiscipline,
snapshotType: FeedSnapshotType.routeTrack, snapshotType: FeedSnapshotType.routeTrack,
routePoints: const [ routePoints: _selectedRoutePoints,
AppLatLng(37.3861, -122.2641),
AppLatLng(37.3500, -122.2300),
AppLatLng(37.3100, -122.2000),
AppLatLng(37.2800, -122.2200),
],
); );
// Reset filter to All Activity so published session is immediately displayed // 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 colors = context.motorColors;
final bottomInset = MediaQuery.of(context).viewInsets.bottom; 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( decoration: BoxDecoration(
color: const Color(0xFF101419), color: const Color(0xFF101419),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
@ -171,12 +246,15 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
padding: EdgeInsets.only(bottom: bottomInset), padding: EdgeInsets.only(bottom: bottomInset),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Drag handle // Drag handle
@ -191,7 +269,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
), ),
), ),
// Modal Header // Modal Header
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, 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 // Ride Title Field
_buildFieldLabel('SESSION / ROUTE TITLE', colors), _buildFieldLabel('SESSION / ROUTE TITLE', colors),
@ -491,11 +627,17 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
], ],
), ),
const SizedBox(height: 24), ],
),
),
),
// Publish CTA Button // Publish CTA Button (pinned at modal bottom)
SizedBox( Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox(
height: 52, height: 52,
width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan, backgroundColor: AppColors.electricCyan,
@ -536,6 +678,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
), ),
), ),
),
], ],
), ),
), ),

View File

@ -393,15 +393,15 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
TextFormField( TextFormField(
controller: _handleController, controller: _handleController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Rider Handle', labelText: 'Rider Handle',
hintText: 'e.g. canyon_runner', hintText: 'e.g. canyon_runner',
prefixText: '@', prefixText: '@',
helperText: helperText:
'Unique, 3-20 chars: letters, numbers, underscores', 'Unique, 3-20 chars: letters, numbers, underscores',
helperStyle: const TextStyle( helperStyle: TextStyle(
color: Colors.white38, fontSize: 11), color: colors.outline, fontSize: 11),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
filled: true, filled: true,
fillColor: colors.surfaceContainer, fillColor: colors.surfaceContainer,
@ -439,7 +439,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _displayNameController, controller: _displayNameController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Display Name', labelText: 'Display Name',
hintText: 'e.g. Alex Rivera', hintText: 'e.g. Alex Rivera',
@ -476,7 +476,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _regionController, controller: _regionController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Riding Region (Optional)', labelText: 'Riding Region (Optional)',
hintText: 'e.g. Blue Ridge Mountains, NC', hintText: 'e.g. Blue Ridge Mountains, NC',
@ -484,11 +484,13 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
filled: true, filled: true,
fillColor: colors.surfaceContainer, fillColor: colors.surfaceContainer,
), ),
onChanged: (_) => setState(() {}),
), ),
_buildRegionChips(colors),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _bioController, controller: _bioController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 3, maxLines: 3,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Rider Bio (Optional)', labelText: 'Rider Bio (Optional)',
@ -569,14 +571,14 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
TextFormField( TextFormField(
controller: _handleController, controller: _handleController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Rider Handle', labelText: 'Rider Handle',
hintText: 'e.g. canyon_runner', hintText: 'e.g. canyon_runner',
prefixText: '@', prefixText: '@',
helperText: 'Unique, 3-20 characters: letters, numbers, underscores', helperText: 'Unique, 3-20 characters: letters, numbers, underscores',
helperStyle: helperStyle:
const TextStyle(color: Colors.white38, fontSize: 11), TextStyle(color: colors.outline, fontSize: 11),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
filled: true, filled: true,
fillColor: colors.surfaceContainerHigh, fillColor: colors.surfaceContainerHigh,
@ -615,7 +617,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
TextFormField( TextFormField(
controller: _displayNameController, controller: _displayNameController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Display Name', labelText: 'Display Name',
hintText: 'e.g. Alex Rivera', hintText: 'e.g. Alex Rivera',
@ -629,7 +631,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
TextFormField( TextFormField(
controller: _regionController, controller: _regionController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Riding Region (Optional)', labelText: 'Riding Region (Optional)',
hintText: 'e.g. Blue Ridge Mountains, NC', hintText: 'e.g. Blue Ridge Mountains, NC',
@ -637,12 +639,14 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
filled: true, filled: true,
fillColor: colors.surfaceContainerHigh, fillColor: colors.surfaceContainerHigh,
), ),
onChanged: (_) => setState(() {}),
), ),
_buildRegionChips(colors),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _bioController, controller: _bioController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 3, maxLines: 3,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Rider Bio (Optional)', labelText: 'Rider Bio (Optional)',
@ -730,11 +734,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan, activeThumbColor: colors.cyberCyan,
title: const Text('Discoverable by Handle', title: Text('Discoverable by Handle',
style: TextStyle(fontSize: 14)), style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
subtitle: const Text( subtitle: Text(
'Allow other riders to find your profile by searching your @handle.', '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, value: _isSearchableByHandle,
onChanged: (val) => onChanged: (val) =>
@ -744,11 +748,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan, activeThumbColor: colors.cyberCyan,
title: const Text('Public Profile', title: Text('Public Profile',
style: TextStyle(fontSize: 14)), style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
subtitle: const Text( subtitle: Text(
'When off, non-friends only see your handle and avatar, hiding bio and region.', '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, value: _isProfilePublic,
onChanged: (val) => onChanged: (val) =>
@ -758,11 +762,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan, activeThumbColor: colors.cyberCyan,
title: const Text('Allow Friend Requests', title: Text('Allow Friend Requests',
style: TextStyle(fontSize: 14)), style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
subtitle: const Text( subtitle: Text(
'Allow other riders to send you friend requests.', 'Allow other riders to send you friend requests.',
style: TextStyle(fontSize: 11, color: Colors.white60), style: TextStyle(fontSize: 11, color: colors.outline),
), ),
value: _allowFriendRequests, value: _allowFriendRequests,
onChanged: (val) => 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) { Widget _buildSaveButton(MotorSignalThemeColors colors) {
return SizedBox( return SizedBox(
height: 48, height: 48,

View File

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

View File

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

View File

@ -90,4 +90,8 @@ class DefaultFirebaseOptions {
projectId: 'motoapp-d22e0', projectId: 'motoapp-d22e0',
storageBucket: 'motoapp-d22e0.firebasestorage.app', 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 { class Georgia511RoadAlertProvider implements RoadAlertProvider {
final http.Client _client; final http.Client _client;
final String backendBaseUrl; final String backendBaseUrl;
final Future<String?> Function()? getAuthToken;
Georgia511RoadAlertProvider({ Georgia511RoadAlertProvider({
http.Client? client, http.Client? client,
String? backendBaseUrl, String? backendBaseUrl,
this.getAuthToken,
}) : _client = client ?? http.Client(), }) : _client = client ?? http.Client(),
backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl(); backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl();
@ -38,13 +40,39 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
RoadAlertCategory.hazard, 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 @override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async { Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
try { try {
final headers = await _buildHeaders(apiKey: apiKey);
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health'); 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) { if (res.statusCode == 200) {
return RoadAlertProviderStatus.connected; return RoadAlertProviderStatus.connected;
} else if (res.statusCode == 401 || res.statusCode == 403) {
return RoadAlertProviderStatus.authError;
} }
return RoadAlertProviderStatus.networkError; return RoadAlertProviderStatus.networkError;
} catch (e) { } catch (e) {
@ -81,7 +109,8 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
); );
try { 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) { if (response.statusCode != 200) {
return []; return [];
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -145,6 +145,7 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
bool _ttsInitialized = false; bool _ttsInitialized = false;
bool _isRecalculating = false; bool _isRecalculating = false;
DateTime? _lastRecalculationAttempt; DateTime? _lastRecalculationAttempt;
int _lastMatchedSegment = 0;
static const double defaultOffRouteThresholdMeters = 65.0; static const double defaultOffRouteThresholdMeters = 65.0;
@ -202,6 +203,8 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
return; return;
} }
_lastMatchedSegment = 0;
final initialDist = calculationResult.estimatedDistanceMeters ?? final initialDist = calculationResult.estimatedDistanceMeters ??
calculationResult.straightLineDistanceMeters; calculationResult.straightLineDistanceMeters;
final initialDuration = calculationResult.estimatedDurationSeconds ?? final initialDuration = calculationResult.estimatedDurationSeconds ??
@ -274,11 +277,16 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) { void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
if (!state.isNavigating || state.maneuvers.isEmpty) return; if (!state.isNavigating || state.maneuvers.isEmpty) return;
// Off-route detection check against polyline points
final points = state.calculationResult?.points ?? []; final points = state.calculationResult?.points ?? [];
if (points.isNotEmpty) { if (points.isNotEmpty) {
final distToRoute = _distanceToPolylineMeters(riderPos, points); final proj = _projectPointOntoPolyline(
if (distToRoute > defaultOffRouteThresholdMeters) { riderPos,
points,
startSearchSegment: math.max(0, _lastMatchedSegment - 2),
);
// Off-route detection check against polyline
if (proj.distanceToRouteMeters > defaultOffRouteThresholdMeters) {
if (!_isRecalculating) { if (!_isRecalculating) {
final now = DateTime.now(); final now = DateTime.now();
if (_lastRecalculationAttempt == null || if (_lastRecalculationAttempt == null ||
@ -289,8 +297,79 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
} }
return; 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; final currIdx = state.currentManeuverIndex;
if (currIdx >= state.maneuvers.length) { if (currIdx >= state.maneuvers.length) {
_completeArrival(); _completeArrival();
@ -300,7 +379,6 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
final currentManeuver = state.maneuvers[currIdx]; final currentManeuver = state.maneuvers[currIdx];
final distToManeuver = _haversineMeters(riderPos, currentManeuver.location); 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) { if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) {
final nextIdx = currIdx + 1; final nextIdx = currIdx + 1;
final nextManeuver = state.maneuvers[nextIdx]; final nextManeuver = state.maneuvers[nextIdx];
@ -315,13 +393,11 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
return; return;
} }
// Recompute remaining distance along maneuvers
double remainingMeters = distToManeuver; double remainingMeters = distToManeuver;
for (int i = currIdx + 1; i < state.maneuvers.length; i++) { for (int i = currIdx + 1; i < state.maneuvers.length; i++) {
remainingMeters += state.maneuvers[i].distanceMeters; 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 effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
final remainingSec = (remainingMeters / effectiveSpeedMps).round(); final remainingSec = (remainingMeters / effectiveSpeedMps).round();
@ -383,6 +459,7 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
final initialDuration = newResult.estimatedDurationSeconds ?? final initialDuration = newResult.estimatedDurationSeconds ??
(initialDist / 15.0).round(); (initialDist / 15.0).round();
_lastMatchedSegment = 0;
state = state.copyWith( state = state.copyWith(
status: NavigationStatus.navigating, status: NavigationStatus.navigating,
calculationResult: newResult, calculationResult: newResult,
@ -421,35 +498,159 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
} catch (_) {} } catch (_) {}
} }
/// Computes shortest distance from point to polyline in meters. /// Projects a GPS point onto the route polyline with segment index tracking.
static double _distanceToPolylineMeters(AppLatLng point, List<AppLatLng> polyline) { static _PolylineProjection _projectPointOntoPolyline(
if (polyline.isEmpty) return double.infinity; AppLatLng point,
if (polyline.length == 1) return _haversineMeters(point, polyline.first); 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; double minDistance = double.infinity;
for (int i = 0; i < polyline.length - 1; i++) {
final segDist = _distanceToSegmentMeters(point, polyline[i], polyline[i + 1]); final searchStart = startSearchSegment.clamp(0, polyline.length - 2);
if (segDist < minDistance) { for (int i = searchStart; i < polyline.length - 1; i++) {
minDistance = segDist; 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); final dAB = _haversineMeters(a, b);
if (dAB == 0) return _haversineMeters(p, a); if (dAB == 0.0) return a;
final dAP = _haversineMeters(a, p); final latRad = a.latitude * (math.pi / 180.0);
final dBP = _haversineMeters(b, p); 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; final lenSq = dx * dx + dy * dy;
if (dAP * dAP >= dBP * dBP + dAB * dAB) return dBP; if (lenSq == 0.0) return a;
final s = (dAB + dAP + dBP) / 2.0; final t = ((px * dx + py * dy) / lenSq).clamp(0.0, 1.0);
final areaSq = s * (s - dAB) * (s - dAP) * (s - dBP); return AppLatLng(
if (areaSq <= 0.0) return 0.0; a.latitude + t * (b.latitude - a.latitude),
return (2.0 * math.sqrt(areaSq)) / dAB; 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) { 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. /// Global shared navigation session provider across Map and Dashboard.
final navigationSessionProvider = final navigationSessionProvider =
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>( StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(

View File

@ -64,6 +64,19 @@ class AwsLocationRouteProvider implements RouteProvider {
final seenIds = <String>{}; final seenIds = <String>{};
final combined = <PlaceSearchResult>[]; 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 // Prefer text items first because coordinates are already populated
for (final item in textItems) { for (final item in textItems) {
if (seenIds.add(item.providerId)) { if (seenIds.add(item.providerId)) {
@ -101,6 +114,9 @@ class AwsLocationRouteProvider implements RouteProvider {
body: jsonEncode({ body: jsonEncode({
'QueryText': clean, 'QueryText': clean,
'BiasPosition': bias, 'BiasPosition': bias,
'Filter': {
'IncludeCountries': ['USA'],
},
}), }),
) )
.timeout(const Duration(seconds: 8)); .timeout(const Duration(seconds: 8));
@ -142,6 +158,9 @@ class AwsLocationRouteProvider implements RouteProvider {
body: jsonEncode({ body: jsonEncode({
'QueryText': clean, 'QueryText': clean,
'BiasPosition': bias, 'BiasPosition': bias,
'Filter': {
'IncludeCountries': ['USA'],
},
}), }),
) )
.timeout(const Duration(seconds: 8)); .timeout(const Duration(seconds: 8));
@ -276,6 +295,12 @@ class AwsLocationRouteProvider implements RouteProvider {
.toList() .toList()
: null; : null;
final avoidMap = <String, dynamic>{
if (effectiveAvoidHighways) 'ControlledAccessHighways': true,
if (effectiveAvoidTolls) 'TollRoads': true,
if (effectiveAvoidFerries) 'Ferries': true,
};
final travelOptions = <String, dynamic>{ final travelOptions = <String, dynamic>{
'Car': { 'Car': {
'Avoid': { 'Avoid': {
@ -291,12 +316,15 @@ class AwsLocationRouteProvider implements RouteProvider {
'Destination': destination, 'Destination': destination,
'TravelMode': 'Car', 'TravelMode': 'Car',
'TravelModeOptions': travelOptions, 'TravelModeOptions': travelOptions,
if (avoidMap.isNotEmpty) 'Avoid': avoidMap,
'OptimizeRoutingFor': optimizeFor, 'OptimizeRoutingFor': optimizeFor,
'LegGeometryFormat': 'Simple', 'LegGeometryFormat': 'Simple',
'LegAdditionalInputs': ['TravelSteps'], 'LegAdditionalInputs': ['TravelSteps'],
'Instructions': true, 'Instructions': true,
if (options?.preference == RoutingPreference.twisty || if (options?.preference == RoutingPreference.twisty ||
options?.preference == RoutingPreference.scenic) options?.preference == RoutingPreference.scenic ||
options?.preference == RoutingPreference.fastest ||
options?.preference == RoutingPreference.shortest)
'MaxAlternatives': 2, 'MaxAlternatives': 2,
}; };
if (waypoints != null && waypoints.isNotEmpty) { 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 effectiveGeom = geom.isNotEmpty ? geom : ordered.map((s) => s.latLng).toList();
final effectiveDist = distanceM > 0 ? distanceM : straightLine; final effectiveDist = distanceM > 0 ? distanceM : straightLine;
final distMi = effectiveDist * 0.000621371; final distMi = effectiveDist * 0.000621371;
@ -393,6 +433,9 @@ class AwsLocationRouteProvider implements RouteProvider {
} else if (options?.preference == RoutingPreference.fastest) { } else if (options?.preference == RoutingPreference.fastest) {
candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds)); candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds));
selected = candidates.first; 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) { for (final rawStep in travelSteps) {
if (rawStep is! Map<String, dynamic>) continue; if (rawStep is! Map<String, dynamic>) continue;
final instruction = rawStep['Instruction']?.toString() ?? 'Continue'; final stepType = rawStep['Type']?.toString() ?? 'Continue';
final stepType = rawStep['Type']?.toString();
final turn = rawStep['Turn']?.toString();
final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0; final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0;
final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0; final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0;
final road = rawStep['NextRoad']?.toString() ?? final road = rawStep['NextRoad']?.toString() ??
rawStep['CurrentRoad']?.toString(); rawStep['CurrentRoad']?.toString();
final offset = (rawStep['GeometryOffset'] as num?)?.toInt(); 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; AppLatLng loc;
if (offset != null && offset >= 0 && offset < geometry.length) { if (offset != null && offset >= 0 && offset < geometry.length) {
loc = geometry[offset]; loc = geometry[offset];
@ -452,8 +518,8 @@ class AwsLocationRouteProvider implements RouteProvider {
maneuvers.add( maneuvers.add(
RouteManeuver( RouteManeuver(
index: maneuvers.length, index: maneuvers.length,
instruction: instruction, instruction: canonical.instruction,
type: _mapManeuverType(stepType, turn), type: canonical.type,
distanceMeters: dist, distanceMeters: dist,
durationSeconds: dur, durationSeconds: dur,
streetName: road, streetName: road,
@ -472,6 +538,202 @@ class AwsLocationRouteProvider implements RouteProvider {
return maneuvers; 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) { static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) {
final t = (typeStr ?? '').toLowerCase(); final t = (typeStr ?? '').toLowerCase();
final turn = (turnStr ?? '').toLowerCase(); final turn = (turnStr ?? '').toLowerCase();
@ -492,6 +754,15 @@ class AwsLocationRouteProvider implements RouteProvider {
if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft; if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft;
if (turn == 'keepright') return ManeuverType.keepRight; if (turn == 'keepright') return ManeuverType.keepRight;
if (turn == 'keepleft') return ManeuverType.keepLeft; 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; if (t == 'continue') return ManeuverType.continueStraight;
return ManeuverType.continueStraight; return ManeuverType.continueStraight;
} }
@ -606,6 +877,18 @@ class AwsLocationRouteProvider implements RouteProvider {
final brng = math.atan2(y, x) * (180.0 / math.pi); final brng = math.atan2(y, x) * (180.0 / math.pi);
return (brng + 360.0) % 360.0; 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 { class _CandidateRoute {

View File

@ -518,6 +518,18 @@ enum ManeuverType {
return 'CONTINUE'; 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. /// A single turn-by-turn navigation maneuver step.

View File

@ -47,6 +47,9 @@ class SensorFusionDiagnostics {
final String dashboardDirection; final String dashboardDirection;
final bool isCalibrated; final bool isCalibrated;
final String mountOrientation; final String mountOrientation;
final double eventRateHz;
final double longitudinalG;
final double lateralG;
const SensorFusionDiagnostics({ const SensorFusionDiagnostics({
required this.rawAccelX, required this.rawAccelX,
@ -64,6 +67,9 @@ class SensorFusionDiagnostics {
required this.dashboardDirection, required this.dashboardDirection,
required this.isCalibrated, required this.isCalibrated,
required this.mountOrientation, required this.mountOrientation,
this.eventRateHz = 50.0,
this.longitudinalG = 0.0,
this.lateralG = 0.0,
}); });
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
@ -78,6 +84,9 @@ class SensorFusionDiagnostics {
'dashboardDirection': dashboardDirection, 'dashboardDirection': dashboardDirection,
'isCalibrated': isCalibrated, 'isCalibrated': isCalibrated,
'mountOrientation': mountOrientation, 'mountOrientation': mountOrientation,
'eventRateHz': eventRateHz,
'longitudinalG': longitudinalG,
'lateralG': lateralG,
}; };
} }
@ -135,6 +144,7 @@ class SensorFusionService with WidgetsBindingObserver {
bool _isCalibrated = false; bool _isCalibrated = false;
bool get isCalibrated => _isCalibrated; bool get isCalibrated => _isCalibrated;
int _remountAnomalyCount = 0; int _remountAnomalyCount = 0;
int _remountReturnCount = 0;
// Stored baseline gravity vector at calibration for remount detection // Stored baseline gravity vector at calibration for remount detection
double _calGravityX = 0.0; double _calGravityX = 0.0;
@ -149,6 +159,9 @@ class SensorFusionService with WidgetsBindingObserver {
double _latestRawGyroY = 0.0; double _latestRawGyroY = 0.0;
double _latestRawGyroZ = 0.0; double _latestRawGyroZ = 0.0;
double _latestRollRateDegPerSec = 0.0; double _latestRollRateDegPerSec = 0.0;
double _eventRateHz = 50.0;
int _sensorEventCounter = 0;
DateTime? _lastRateWindow;
double get latestRawAccelX => _latestRawAccelX; double get latestRawAccelX => _latestRawAccelX;
double get latestRawAccelY => _latestRawAccelY; double get latestRawAccelY => _latestRawAccelY;
@ -157,6 +170,7 @@ class SensorFusionService with WidgetsBindingObserver {
double get latestRawGyroY => _latestRawGyroY; double get latestRawGyroY => _latestRawGyroY;
double get latestRawGyroZ => _latestRawGyroZ; double get latestRawGyroZ => _latestRawGyroZ;
double get latestRollRateDegPerSec => _latestRollRateDegPerSec; double get latestRollRateDegPerSec => _latestRollRateDegPerSec;
double get eventRateHz => _eventRateHz;
// Sensor fusion state // Sensor fusion state
double _currentRoll = 0.0; double _currentRoll = 0.0;
@ -182,6 +196,12 @@ class SensorFusionService with WidgetsBindingObserver {
MountOrientation _mountOrientation = MountOrientation.portraitUpright; MountOrientation _mountOrientation = MountOrientation.portraitUpright;
MountOrientation get mountOrientation => _mountOrientation; MountOrientation get mountOrientation => _mountOrientation;
double _speedMph = 0.0;
double get speedMph => _speedMph;
void updateVehicleSpeed(double speedMph) {
_speedMph = speedMph;
}
static const double _gravityConst = 9.80665; static const double _gravityConst = 9.80665;
// Low-pass filter for G-Force // Low-pass filter for G-Force
@ -304,13 +324,29 @@ class SensorFusionService with WidgetsBindingObserver {
_updateDisplayOrientation(); _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() { void _updateDisplayOrientation() {
try { try {
final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull; final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull;
if (view != null && view.physicalSize.height > 0) { if (view != null && view.physicalSize.height > 0) {
final size = view.physicalSize; final size = view.physicalSize;
// Only adapt automatically if display dimensions are realistic mobile dimensions if (size.width > size.height) {
if (size.width > size.height && size.width < 1500) {
_isLandscape = true; _isLandscape = true;
_updateMountOrientation(); _updateMountOrientation();
_recomputeBasisVectors(); _recomputeBasisVectors();
@ -345,7 +381,6 @@ class SensorFusionService with WidgetsBindingObserver {
} }
void _checkRemountValidity(double ax, double ay, double az) { void _checkRemountValidity(double ax, double ay, double az) {
if (!_isCalibrated) return;
final calNorm = math.sqrt(_calGravityX * _calGravityX + _calGravityY * _calGravityY + _calGravityZ * _calGravityZ); final calNorm = math.sqrt(_calGravityX * _calGravityX + _calGravityY * _calGravityY + _calGravityZ * _calGravityZ);
final curNorm = math.sqrt(ax * ax + ay * ay + az * az); final curNorm = math.sqrt(ax * ax + ay * ay + az * az);
if (calNorm < 3.0 || curNorm < 3.0) return; if (calNorm < 3.0 || curNorm < 3.0) return;
@ -355,35 +390,56 @@ class SensorFusionService with WidgetsBindingObserver {
final ny = ay / curNorm; final ny = ay / curNorm;
final nz = az / curNorm; final nz = az / curNorm;
// Project current vector onto 3D Vehicle Coordinate Frame: // Project current vector onto calibrated upright gravity axis
// vUp: component along calibrated upright axis final vUp = (nx * (_calGravityX / calNorm) + ny * (_calGravityY / calNorm) + nz * (_calGravityZ / calNorm)).clamp(-1.0, 1.0);
// 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
final totalAngleDiffDeg = math.acos(vUp) * (180.0 / math.pi); 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); final pitchTiltDeg = (math.asin(vForward)).abs() * (180.0 / math.pi);
// Motorcycle Lean & Remount Physics: // Motorcycle Lean & Remount Physics:
// 1. Roll-axis rotation (up to 65°) is normal motorcycle riding, cornering, and kickstand parking (20°-35°). // 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° // Deviations within the roll plane must NEVER invalidate calibration.
// must NEVER invalidate calibration! // 2. Cockpit mounts: Handlebar mounts (QuadLock, RAM) are commonly pitched backwards 35°-55° facing the rider.
// 2. True remount / phone removal: // 3. True remount / phone removal:
// - Placed flat on desk (screen up): totalAngleDiffDeg ~ 90°, pitchTiltDeg ~ 90° // - Placed flat on desk (screen up): totalAngleDiffDeg ~ 90°, pitchTiltDeg ~ 90°
// - Placed in pocket/bag: totalAngleDiffDeg >= 90° // - Placed in pocket/bag: totalAngleDiffDeg >= 80°
// - Mount rotated or twisted: totalAngleDiffDeg > 68° or pitchTiltDeg > 38° // - Mount rotated or twisted when stationary: totalAngleDiffDeg >= 80° or pitchTiltDeg >= 65°
final isImmediateRemount = totalAngleDiffDeg >= 75.0 || pitchTiltDeg >= 42.0; final isImmediateRemount = totalAngleDiffDeg >= 80.0 || pitchTiltDeg >= 65.0;
final isSuspectedRemount = totalAngleDiffDeg > 68.0 || pitchTiltDeg > 35.0; final isSuspectedRemount = totalAngleDiffDeg > 70.0 || pitchTiltDeg > 55.0;
if (isImmediateRemount) { if (isImmediateRemount) {
_invalidateRemount(totalAngleDiffDeg); _invalidateRemount(totalAngleDiffDeg);
} else if (isSuspectedRemount) { } else if (isSuspectedRemount) {
_remountAnomalyCount++; _remountAnomalyCount++;
// Require 5 consecutive stationary samples (100ms) to filter out transient bumps or noise // Require 50 consecutive stationary samples (1000ms) to filter out transient bumps, fork dive, or vibrations
if (_remountAnomalyCount >= 5) { if (_remountAnomalyCount >= 50) {
_invalidateRemount(totalAngleDiffDeg); _invalidateRemount(totalAngleDiffDeg);
} }
} else { } else {
@ -392,17 +448,14 @@ class SensorFusionService with WidgetsBindingObserver {
} }
void _invalidateRemount(double angleDiffDeg) { 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++; _calibrationVersion++;
_isCalibrated = false; _isCalibrated = false;
_remountAnomalyCount = 0; _remountAnomalyCount = 0;
_remountReturnCount = 0;
_rollOffset = 0.0; _rollOffset = 0.0;
_pitchOffset = 0.0; _pitchOffset = 0.0;
_calGravityX = 0.0;
_calGravityY = 0.0;
_calGravityZ = 0.0;
_recomputeBasisVectors(); _recomputeBasisVectors();
_calibrationService.clearCalibration();
_emitDiagnostics(0.0); _emitDiagnostics(0.0);
} }
@ -494,6 +547,19 @@ class SensorFusionService with WidgetsBindingObserver {
} }
void handleAccelerometerEvent(AccelerometerEvent event) { 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; _latestRawAccelX = event.x;
_latestRawAccelY = event.y; _latestRawAccelY = event.y;
_latestRawAccelZ = event.z; _latestRawAccelZ = event.z;
@ -685,6 +751,9 @@ class SensorFusionService with WidgetsBindingObserver {
dashboardDirection: direction, dashboardDirection: direction,
isCalibrated: _isCalibrated, isCalibrated: _isCalibrated,
mountOrientation: _mountOrientation.name, mountOrientation: _mountOrientation.name,
eventRateHz: _eventRateHz,
longitudinalG: _filteredLongitudinalG,
lateralG: _filteredLateralG,
); );
_latestDiagnostics = diag; _latestDiagnostics = diag;
@ -728,7 +797,13 @@ class SensorFusionService with WidgetsBindingObserver {
_isCalibrated = isCal; _isCalibrated = isCal;
_remountAnomalyCount = 0; _remountAnomalyCount = 0;
_remountReturnCount = 0;
_recomputeBasisVectors(); _recomputeBasisVectors();
if (_isCalibrated) {
_leanAngleController.add(0.0);
_pitchAngleController.add(0.0);
_emitDiagnostics(0.0);
}
} catch (_) { } catch (_) {
if (version != _calibrationVersion) return; if (version != _calibrationVersion) return;
_isCalibrated = false; _isCalibrated = false;
@ -746,6 +821,7 @@ class SensorFusionService with WidgetsBindingObserver {
_calGravityZ = _latestRawAccelZ; _calGravityZ = _latestRawAccelZ;
_isCalibrated = true; _isCalibrated = true;
_remountAnomalyCount = 0; _remountAnomalyCount = 0;
_remountReturnCount = 0;
// Auto-detect mount orientation from gravity // Auto-detect mount orientation from gravity
final ax = _calGravityX.abs(); final ax = _calGravityX.abs();
@ -793,6 +869,7 @@ class SensorFusionService with WidgetsBindingObserver {
_calibrationVersion++; _calibrationVersion++;
_isCalibrated = false; _isCalibrated = false;
_remountAnomalyCount = 0; _remountAnomalyCount = 0;
_remountReturnCount = 0;
_rollOffset = 0.0; _rollOffset = 0.0;
_pitchOffset = 0.0; _pitchOffset = 0.0;
_calGravityX = 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. /// Preset avatar options available for all riders without external image hosting.
class PresetAvatar { class PresetAvatar {
@ -6,12 +6,14 @@ class PresetAvatar {
final String label; final String label;
final int primaryColorHex; final int primaryColorHex;
final int secondaryColorHex; final int secondaryColorHex;
final IconData icon;
const PresetAvatar({ const PresetAvatar({
required this.id, required this.id,
required this.label, required this.label,
required this.primaryColorHex, required this.primaryColorHex,
required this.secondaryColorHex, required this.secondaryColorHex,
this.icon = Icons.two_wheeler,
}); });
static const List<PresetAvatar> availablePresets = [ static const List<PresetAvatar> availablePresets = [
@ -20,48 +22,56 @@ class PresetAvatar {
label: 'Cyan Apex', label: 'Cyan Apex',
primaryColorHex: 0xFF00E5FF, primaryColorHex: 0xFF00E5FF,
secondaryColorHex: 0xFF003840, secondaryColorHex: 0xFF003840,
icon: Icons.sports_motorsports,
), ),
PresetAvatar( PresetAvatar(
id: 'helmet_amber', id: 'helmet_amber',
label: 'Amber Sector', label: 'Amber Sector',
primaryColorHex: 0xFFFFB300, primaryColorHex: 0xFFFFB300,
secondaryColorHex: 0xFF4A3200, secondaryColorHex: 0xFF4A3200,
icon: Icons.two_wheeler,
), ),
PresetAvatar( PresetAvatar(
id: 'tach_neon', id: 'tach_neon',
label: 'Neon Tach', label: 'Neon Tach',
primaryColorHex: 0xFF00E676, primaryColorHex: 0xFF00E676,
secondaryColorHex: 0xFF00381B, secondaryColorHex: 0xFF00381B,
icon: Icons.speed,
), ),
PresetAvatar( PresetAvatar(
id: 'speed_violet', id: 'speed_violet',
label: 'Violet Velocity', label: 'Violet Velocity',
primaryColorHex: 0xFFD500F9, primaryColorHex: 0xFFD500F9,
secondaryColorHex: 0xFF3E004A, secondaryColorHex: 0xFF3E004A,
icon: Icons.electric_bolt,
), ),
PresetAvatar( PresetAvatar(
id: 'cruiser_red', id: 'cruiser_red',
label: 'Crimson Cruiser', label: 'Crimson Cruiser',
primaryColorHex: 0xFFFF1744, primaryColorHex: 0xFFFF1744,
secondaryColorHex: 0xFF4A0513, secondaryColorHex: 0xFF4A0513,
icon: Icons.motorcycle,
), ),
PresetAvatar( PresetAvatar(
id: 'adv_blue', id: 'adv_blue',
label: 'Adventure Azure', label: 'Adventure Azure',
primaryColorHex: 0xFF2979FF, primaryColorHex: 0xFF2979FF,
secondaryColorHex: 0xFF0A224A, secondaryColorHex: 0xFF0A224A,
icon: Icons.terrain,
), ),
PresetAvatar( PresetAvatar(
id: 'cafe_gold', id: 'cafe_gold',
label: 'Cafe Gold', label: 'Cafe Gold',
primaryColorHex: 0xFFFFD700, primaryColorHex: 0xFFFFD700,
secondaryColorHex: 0xFF4A3E00, secondaryColorHex: 0xFF4A3E00,
icon: Icons.workspace_premium,
), ),
PresetAvatar( PresetAvatar(
id: 'touring_slate', id: 'touring_slate',
label: 'Touring Slate', label: 'Touring Slate',
primaryColorHex: 0xFF90A4AE, primaryColorHex: 0xFF90A4AE,
secondaryColorHex: 0xFF263238, secondaryColorHex: 0xFF263238,
icon: Icons.navigation,
), ),
]; ];

View File

@ -138,12 +138,34 @@ class CloudRestoreController extends StateNotifier<CloudRestoreUiState> {
: result.telemetryAlreadyPresent; : result.telemetryAlreadyPresent;
parts.add('$alreadyPresent ride(s) already on this device'); 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) { 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) { if (parts.isEmpty) {
return 'No restorable MotorCloud rides were found for this account.'; 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) { _locationSub = _locationService.getFilteredPositionStream().listen((pos) {
final speedMph = pos.speed * 2.23694;
_sensorFusionService.updateVehicleSpeed(speedMph);
if (_latestPosition != null) { if (_latestPosition != null) {
final dt = pos.timestamp.difference(_latestPosition!.timestamp).inMicroseconds.abs() / 1000000.0; final dt = pos.timestamp.difference(_latestPosition!.timestamp).inMicroseconds.abs() / 1000000.0;
if (dt >= 0.05 && dt <= 3.0) { if (dt >= 0.05 && dt <= 3.0) {
@ -98,7 +101,6 @@ class TelemetryService {
final accelMps2 = (currentSpeedMps - prevSpeedMps) / dt; final accelMps2 = (currentSpeedMps - prevSpeedMps) / dt;
_gpsLongitudinalG = (accelMps2 / 9.80665).clamp(-1.5, 1.5); _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) { if (speedMph >= 2.5 && pos.heading >= 0 && _latestPosition!.heading >= 0) {
var dHeading = pos.heading - _latestPosition!.heading; var dHeading = pos.heading - _latestPosition!.heading;
while (dHeading > 180) { while (dHeading > 180) {

View File

@ -31,6 +31,7 @@ void main() {
expect(request.url.queryParameters['key'], equals('test-key')); expect(request.url.queryParameters['key'], equals('test-key'));
final body = jsonDecode(request.body); final body = jsonDecode(request.body);
expect(body['QueryText'], equals('Atlanta')); expect(body['QueryText'], equals('Atlanta'));
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
'ResultItems': [ 'ResultItems': [
@ -44,6 +45,8 @@ void main() {
200, 200,
); );
} else if (request.url.path.contains('/v2/search-text')) { } else if (request.url.path.contains('/v2/search-text')) {
final body = jsonDecode(request.body);
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
'ResultItems': [ '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)); await tester.pump(const Duration(milliseconds: 50));
expect(find.text('HAZARD'), findsOneWidget); 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); expect(find.byIcon(Icons.close), findsOneWidget);
}); });

View File

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

View File

@ -207,7 +207,7 @@ void main() {
expect(find.text('Community Feed'), findsOneWidget); expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON 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('All Activity'), findsOneWidget);
expect(find.text('Club Only'), findsOneWidget); expect(find.text('Club Only'), findsOneWidget);
expect(find.text('Nearby Packs'), 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')); 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', () { test('androidQa options strictly target demo-motorsignal-qa and non-prod credentials', () {
expect(DefaultFirebaseOptions.androidQa.projectId, equals('demo-motorsignal-qa')); expect(DefaultFirebaseOptions.androidQa.projectId, equals('demo-motorsignal-qa'));
expect(DefaultFirebaseOptions.androidQa.apiKey, equals('demo-key')); expect(DefaultFirebaseOptions.androidQa.apiKey, equals('demo-key'));

View File

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

View File

@ -593,11 +593,14 @@ void main() {
expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsNothing); expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsNothing);
final pauseFinder = find.byKey(const ValueKey('hybrid_pause_resume_ride_button')); 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')); final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button'));
expect(pauseFinder, findsOneWidget); expect(pauseFinder, findsOneWidget);
expect(hazardFinder, findsOneWidget);
expect(finishFinder, findsOneWidget); expect(finishFinder, findsOneWidget);
expect(find.text('Pause Ride'), findsOneWidget); expect(find.text('Pause Ride'), findsOneWidget);
expect(find.text('Hazard'), findsOneWidget);
expect(find.text('Finish Ride'), findsOneWidget); expect(find.text('Finish Ride'), findsOneWidget);
// Tap Pause // Tap Pause
@ -605,12 +608,49 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(fakeMachine.pauseRideCalled, isTrue); expect(fakeMachine.pauseRideCalled, isTrue);
// Tap Finish // Tap Finish & Confirm Dialog
await tester.tap(finishFinder); await tester.tap(finishFinder);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
if (find.text('End Ride').evaluate().isNotEmpty) {
await tester.tap(find.text('End Ride'));
await tester.pumpAndSettle();
}
expect(fakeMachine.finishRideCalled, isTrue); 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 { testWidgets('renders Resume button when paused and invokes resumeRide', (tester) async {
final fakeMachine = _FakeRideStateMachine( final fakeMachine = _FakeRideStateMachine(
RideSession(state: RideState.paused, startTime: DateTime.now()), RideSession(state: RideState.paused, startTime: DateTime.now()),

View File

@ -188,7 +188,12 @@ void main() {
await notifier.startNavigation(calculationResult: calcResult); 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); notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0);
expect(notifier.state.currentManeuverIndex, equals(1)); expect(notifier.state.currentManeuverIndex, equals(1));
expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm')); expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm'));
@ -280,5 +285,88 @@ void main() {
notifier.toggleTts(); notifier.toggleTts();
expect(notifier.state.ttsEnabled, isTrue); 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.tap(find.text('Stop Session'));
await tester.pumpAndSettle(); 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 // Had the rejected Future been left unhandled, flutter_test would have
// surfaced it as an unhandled async error and failed this test. // surfaced it as an unhandled async error and failed this test.
expect(fake.finishCalls, 1); expect(fake.finishCalls, 1);
final rideStopLines = final rideStopLines =
logLines.where((l) => l.contains('[RideStop]')).toList(); logLines.where((l) => l.contains('[RideStop]')).toList();
expect(rideStopLines, hasLength(1), expect(rideStopLines, hasLength(1),
@ -136,6 +140,11 @@ void main() {
await tester.tap(find.text('Stop Session')); await tester.tap(find.text('Stop Session'));
await tester.pumpAndSettle(); 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(fake.finishCalls, 1);
expect(logLines.where((l) => l.contains('[RideStop]')), isEmpty, expect(logLines.where((l) => l.contains('[RideStop]')), isEmpty,
reason: 'no diagnostic on a successful finish'); reason: 'no diagnostic on a successful finish');

View File

@ -580,6 +580,35 @@ void main() {
service.dispose(); 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', () { group('Dashboard UI: Lean Gauge Display & Availability Semantics', () {

View File

@ -79,7 +79,7 @@ void main() {
// Verify header and filter components render cleanly // Verify header and filter components render cleanly
expect(find.text('Community Feed'), findsOneWidget); expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON 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); expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
// Verify that NO false GPS Verified badge is rendered // 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; double get latestRawGyroY => 0.0;
@override @override
double get latestRawGyroZ => 0.0; double get latestRawGyroZ => 0.0;
@override
void updateVehicleSpeed(double speedMph) {}
} }
void main() { void main() {