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:
parent
92d883ff90
commit
f9e2bd6bed
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -166,6 +166,13 @@ jobs:
|
|||
test -s "$APK"
|
||||
file "$APK"
|
||||
sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256
|
||||
if [ -n "${ANDROID_SDK_ROOT:-}" ]; then
|
||||
AAPT="$(find "$ANDROID_SDK_ROOT/build-tools" -type f -name aapt -print -quit 2>/dev/null || true)"
|
||||
if [ -x "$AAPT" ]; then
|
||||
"$AAPT" dump badging "$APK" | grep "package: name='app.motorsignal.motorcycle'"
|
||||
echo "Package identity verified: app.motorsignal.motorcycle"
|
||||
fi
|
||||
fi
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
|
|
@ -180,7 +187,7 @@ jobs:
|
|||
name: Build signed release APK and AAB
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect, analyze-and-build]
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.detect.outputs.app_changed == 'true')
|
||||
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.app_changed == 'true'))
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@
|
|||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "809431485362-0ev25bbjbjjm5tj6nitinefhg4fi7vvj.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "app.motorsignal.motorcycle",
|
||||
"certificate_hash": "4ab4d3266a5b50ef31e46a5dccb8d42f295bc635"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
|
|
|
|||
|
|
@ -4,24 +4,94 @@ import '../theme/app_spacing.dart';
|
|||
import '../theme/app_typography.dart';
|
||||
import '../theme/theme_extensions.dart';
|
||||
import '../../features/rides/ride_state_machine.dart';
|
||||
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||
|
||||
/// Pinned riding action controls for MotorSignal Hybrid v1.
|
||||
///
|
||||
/// Provides 56dp minimum height buttons for glove-friendly cockpit control.
|
||||
/// In active ride state, provides high-contrast Pause Ride (Amber) and
|
||||
/// Finish Ride (Dark/Slate with Cyan/Green accent) action controls.
|
||||
/// In active ride state, provides high-contrast Pause Ride (Amber),
|
||||
/// Report Hazard (Caution/Warning), and Finish Ride controls.
|
||||
class HybridRidingActionBar extends ConsumerWidget {
|
||||
final bool compact;
|
||||
final String? finishLabel;
|
||||
final String? logTag;
|
||||
final bool requireConfirmation;
|
||||
|
||||
const HybridRidingActionBar({
|
||||
super.key,
|
||||
this.compact = false,
|
||||
this.finishLabel,
|
||||
this.logTag,
|
||||
this.requireConfirmation = true,
|
||||
});
|
||||
|
||||
Future<void> _handleFinish(
|
||||
BuildContext context,
|
||||
RideStateMachine notifier,
|
||||
String effectiveTag,
|
||||
) async {
|
||||
if (!requireConfirmation) {
|
||||
notifier.finishRide().catchError((Object error) {
|
||||
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
key: const ValueKey('confirm_finish_ride_dialog'),
|
||||
backgroundColor: const Color(0xFF161B22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: Color(0xFF30363D)),
|
||||
),
|
||||
title: const Text(
|
||||
'End Ride?',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
content: const Text(
|
||||
'Are you sure you want to end this ride session? Telemetry recording will stop and finalize.',
|
||||
style: TextStyle(color: Color(0xFF8B949E), fontSize: 14),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const ValueKey('confirm_finish_ride_cancel_button'),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(80, 48),
|
||||
foregroundColor: const Color(0xFF8B949E),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: const ValueKey('confirm_finish_ride_confirm_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(100, 48),
|
||||
backgroundColor: const Color(0xFFFF334B),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('End Ride', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
notifier.finishRide().catchError((Object error) {
|
||||
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(
|
||||
|
|
@ -37,7 +107,7 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
if (isRiding || isPaused) {
|
||||
return Row(
|
||||
children: [
|
||||
// Pause / Resume button
|
||||
// 1. Pause / Resume button
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
|
|
@ -52,7 +122,7 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
onPressed: () {
|
||||
if (isPaused) {
|
||||
|
|
@ -63,7 +133,7 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
},
|
||||
icon: Icon(
|
||||
isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||
size: 24,
|
||||
size: 22,
|
||||
color: colors.cautionAmber,
|
||||
),
|
||||
label: FittedBox(
|
||||
|
|
@ -72,17 +142,58 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
isPaused ? 'Resume' : 'Pause Ride',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Finish Ride button
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 2. Report Hazard button (Split from pause bar for quick emergency access)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: buttonHeight,
|
||||
child: ElevatedButton.icon(
|
||||
key: const ValueKey('hybrid_report_hazard_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF9800).withValues(alpha: 0.16),
|
||||
foregroundColor: const Color(0xFFFF9800),
|
||||
side: const BorderSide(color: Color(0xFFFF9800), width: 1.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
onPressed: () => QuickSwarmReportModal.show(context),
|
||||
icon: const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 22,
|
||||
color: Color(0xFFFF9800),
|
||||
),
|
||||
label: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'Hazard',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 3. Finish Ride / Stop Session button (Guarded with confirmation dialog)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
|
|
@ -97,17 +208,15 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
onPressed: () {
|
||||
final effectiveTag = logTag ?? 'HybridRidingActionBar';
|
||||
notifier.finishRide().catchError((Object error) {
|
||||
debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
|
||||
});
|
||||
_handleFinish(context, notifier, effectiveTag);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.stop_rounded,
|
||||
size: 24,
|
||||
size: 22,
|
||||
color: colors.brandCyan,
|
||||
),
|
||||
label: FittedBox(
|
||||
|
|
@ -116,9 +225,9 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
finishLabel ?? 'Finish Ride',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -129,6 +238,7 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
// Ready / Standby state: Single prominent start button
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
|
|
|
|||
|
|
@ -82,13 +82,15 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
}
|
||||
|
||||
Widget _buildRoadAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) {
|
||||
final isSwarm = alert.providerId == 'swarm_alerts';
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSwarm = alert.providerId == 'swarm_alerts' || alert.providerId == 'swarm';
|
||||
final subtitleColor = isDark ? Colors.white70 : Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: alert.category.color.withValues(alpha: 0.2),
|
||||
color: alert.category.color.withValues(alpha: isDark ? 0.2 : 0.12),
|
||||
border: Border(bottom: BorderSide(color: alert.category.color, width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -101,9 +103,11 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
Flexible(
|
||||
child: Text(
|
||||
alert.category.displayName.toUpperCase(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
color: alert.category.color,
|
||||
|
|
@ -112,6 +116,7 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSwarm) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
|
|
@ -135,11 +140,12 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
Text(
|
||||
'${alert.distanceFromUser?.toStringAsFixed(0) ?? '0'}m Ahead • ${alert.roadName ?? alert.description}',
|
||||
_formatAlertSubtitle(alert),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
color: Colors.white,
|
||||
color: subtitleColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
|
|
@ -155,6 +161,7 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
tooltip: 'Confirm (Still there)',
|
||||
onPressed: () {
|
||||
if (alert.id.isNotEmpty) {
|
||||
ref.read(swarmRoadAlertServiceProvider).confirmAlert(alert.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
|
@ -162,6 +169,7 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
|
|
@ -170,6 +178,7 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
tooltip: 'Clear (No longer there)',
|
||||
onPressed: () {
|
||||
if (alert.id.isNotEmpty) {
|
||||
ref.read(swarmRoadAlertServiceProvider).clearAlert(alert.id);
|
||||
ref.read(roadAlertServiceProvider).dismissAlert(alert.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -178,11 +187,12 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
|
||||
icon: Icon(Icons.close, color: subtitleColor, size: 18),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
tooltip: 'Dismiss alert',
|
||||
|
|
@ -256,4 +266,30 @@ class DashboardAlertRegion extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatAlertSubtitle(RoadAlert alert) {
|
||||
final distText = _formatAlertDistance(alert.distanceFromUser);
|
||||
final detail = alert.roadName ?? alert.description;
|
||||
if (distText.isEmpty) {
|
||||
return detail.isNotEmpty ? detail : 'Road Alert';
|
||||
}
|
||||
if (detail.isEmpty) {
|
||||
return distText;
|
||||
}
|
||||
return '$distText • $detail';
|
||||
}
|
||||
|
||||
String _formatAlertDistance(double? meters) {
|
||||
if (meters == null) return '';
|
||||
if (meters < 15.0) {
|
||||
return 'Nearby';
|
||||
}
|
||||
final miles = meters / 1609.344;
|
||||
if (miles < 0.1) {
|
||||
final feet = ((meters * 3.28084) / 10).round() * 10;
|
||||
return '${feet > 0 ? feet : 50} ft Ahead';
|
||||
} else {
|
||||
return '${miles.toStringAsFixed(1)} mi Ahead';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../../../services/alerts/road_alert_models.dart';
|
|||
import '../../../services/alerts/road_alert_service.dart';
|
||||
import '../../../services/map/map_provider_interface.dart';
|
||||
import '../../../services/map/unified_map_view.dart';
|
||||
import '../../../services/navigation/navigation_session_service.dart';
|
||||
import '../../../services/telemetry_service.dart';
|
||||
|
||||
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
|
||||
|
|
@ -80,6 +81,20 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
final navSession = ref.watch(navigationSessionProvider);
|
||||
final polylines = <AppPolyline>[];
|
||||
if (navSession.isNavigating &&
|
||||
navSession.calculationResult?.geometry.isNotEmpty == true) {
|
||||
polylines.add(
|
||||
AppPolyline(
|
||||
id: 'dash_active_nav_route',
|
||||
points: navSession.calculationResult!.geometry,
|
||||
color: const Color(0xFF00E5FF),
|
||||
width: 5.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
|
|
@ -103,7 +118,9 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
key: const ValueKey('dash_map_x'),
|
||||
center: AppLatLng(centerLat, centerLng),
|
||||
zoom: zoom,
|
||||
bearing: 0.0,
|
||||
markers: mapMarkers,
|
||||
polylines: polylines,
|
||||
),
|
||||
),
|
||||
if (!isFresh && hasCoordinates)
|
||||
|
|
|
|||
|
|
@ -257,10 +257,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
size: const Size(double.infinity, double.infinity),
|
||||
painter: _GForcePodPainter(accelG: accelG, lateralG: lateralG),
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 18),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${totalG.toStringAsFixed(2)} G',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
|
|
@ -269,10 +270,12 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 8,
|
||||
top: 6,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'BRAKING',
|
||||
style: TextStyle(
|
||||
|
|
@ -284,8 +287,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
bottom: 8,
|
||||
bottom: 6,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'CORNERING',
|
||||
style: TextStyle(
|
||||
|
|
@ -297,6 +303,7 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -317,6 +324,10 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
maxLeft: session.maxLeanLeft,
|
||||
maxRight: session.maxLeanRight,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
@ -354,6 +365,8 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -393,8 +406,57 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
width: double.infinity,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
if (session.state == RideState.riding) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
key: const ValueKey('confirm_stop_session_dialog'),
|
||||
backgroundColor: const Color(0xFF161B22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: Color(0xFF30363D)),
|
||||
),
|
||||
title: const Text(
|
||||
'End Ride?',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
content: const Text(
|
||||
'Are you sure you want to end this ride session? Telemetry recording will stop and finalize.',
|
||||
style: TextStyle(color: Color(0xFF8B949E), fontSize: 14),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const ValueKey('confirm_stop_session_cancel_button'),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(80, 48),
|
||||
foregroundColor: const Color(0xFF8B949E),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: const ValueKey('confirm_stop_session_confirm_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(100, 48),
|
||||
backgroundColor: const Color(0xFFFF334B),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('End Ride', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
// RID-2: contain the fire-and-forget Stop action at the UI
|
||||
// boundary. finishRide() keeps its Future error semantics
|
||||
// for awaiting callers; only the exception class is logged,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,18 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
|
||||
final String angleText = isAvailable ? '${displayLean.abs().toStringAsFixed(0)}°' : '--°';
|
||||
|
||||
void showDiagnosticsSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: const Color(0xFF14171A),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => const _SensorDiagnosticsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
void handleTapToCalibrate() {
|
||||
final currentSpeed = sample?.speed ?? 0.0;
|
||||
final currentLean = displayLean.abs();
|
||||
|
|
@ -148,6 +160,15 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('lean_calibrate_diagnostics_button'),
|
||||
icon: const Icon(Icons.analytics_outlined, size: 16),
|
||||
label: const Text('Diagnostics'),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
showDiagnosticsSheet(context);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
|
|
@ -193,14 +214,15 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.maxWidth;
|
||||
final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0);
|
||||
final double gaugeHeight = compact ? 52.0 : 68.0;
|
||||
final double gaugeHeight = compact ? 46.0 : 60.0;
|
||||
|
||||
return Tooltip(
|
||||
message: 'Tap to Calibrate Upright (Stationary)',
|
||||
message: 'Tap to Calibrate | Long press for Diagnostics',
|
||||
child: InkWell(
|
||||
key: const ValueKey('lean_angle_indicator_tap'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: handleTapToCalibrate,
|
||||
onLongPress: () => showDiagnosticsSheet(context),
|
||||
child: Semantics(
|
||||
label: isAvailable
|
||||
? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
|
||||
|
|
@ -302,22 +324,31 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
|
||||
// Preserves backward-compatible telemetry text matchers for existing tests
|
||||
SizedBox(
|
||||
width: 0,
|
||||
height: 0,
|
||||
child: OverflowBox(
|
||||
minWidth: 0,
|
||||
maxWidth: 300,
|
||||
minHeight: 0,
|
||||
maxHeight: 50,
|
||||
// Instant Live Lean Angle telemetry
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('L: ${currentLeft.toStringAsFixed(0)}°'),
|
||||
Text('R: ${currentRight.toStringAsFixed(0)}°'),
|
||||
],
|
||||
Text(
|
||||
'L: ${currentLeft.toStringAsFixed(0)}°',
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 8 : 9.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.textMuted,
|
||||
),
|
||||
),
|
||||
SizedBox(width: compact ? 8 : 12),
|
||||
Text(
|
||||
'R: ${currentRight.toStringAsFixed(0)}°',
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 8 : 9.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -492,3 +523,131 @@ class _MotorcycleLeanPainter extends CustomPainter {
|
|||
oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorDiagnosticsSheet extends ConsumerWidget {
|
||||
const _SensorDiagnosticsSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final diagAsync = ref.watch(sensorDiagnosticsStreamProvider);
|
||||
final fallbackDiag = ref.read(sensorFusionServiceProvider).latestDiagnostics;
|
||||
final diag = diagAsync.value ?? fallbackDiag;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF14171A),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.sensors, color: Color(0xFF00E5FF), size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'IMU Sensor Diagnostics',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: (diag?.isCalibrated ?? false)
|
||||
? const Color(0xFF10B981).withValues(alpha: 0.2)
|
||||
: const Color(0xFFEF4444).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: (diag?.isCalibrated ?? false)
|
||||
? const Color(0xFF10B981)
|
||||
: const Color(0xFFEF4444),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
(diag?.isCalibrated ?? false) ? 'CALIBRATED' : 'UNCALIBRATED',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: (diag?.isCalibrated ?? false)
|
||||
? const Color(0xFF10B981)
|
||||
: const Color(0xFFEF4444),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (diag == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Awaiting IMU sensor data...',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
_buildDiagRow('Event Rate', '${diag.eventRateHz.toStringAsFixed(1)} Hz (target 50 Hz)'),
|
||||
_buildDiagRow('Mount Mode', diag.mountOrientation),
|
||||
_buildDiagRow('Fused Roll (Lean)', '${diag.finalLeanAngle.toStringAsFixed(1)}° (${diag.dashboardDirection})'),
|
||||
_buildDiagRow('Fused Pitch', '${diag.transformedPitch.toStringAsFixed(1)}° (Offset: ${diag.pitchOffset.toStringAsFixed(1)}°)'),
|
||||
_buildDiagRow('Longitudinal G', '${diag.longitudinalG >= 0 ? "+" : ""}${diag.longitudinalG.toStringAsFixed(2)} G'),
|
||||
_buildDiagRow('Lateral G', '${diag.lateralG >= 0 ? "+" : ""}${diag.lateralG.toStringAsFixed(2)} G'),
|
||||
_buildDiagRow('Raw Accel [X, Y, Z]', '${diag.rawAccelX.toStringAsFixed(2)}, ${diag.rawAccelY.toStringAsFixed(2)}, ${diag.rawAccelZ.toStringAsFixed(2)} m/s²'),
|
||||
_buildDiagRow('Raw Gyro [X, Y, Z]', '${diag.rawGyroX.toStringAsFixed(3)}, ${diag.rawGyroY.toStringAsFixed(3)}, ${diag.rawGyroZ.toStringAsFixed(3)} rad/s'),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('close_sensor_diagnostics_button'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF222831),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close Diagnostics', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiagRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 13, color: Colors.white70)),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF00E5FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ class NavigationManeuverCard extends ConsumerWidget {
|
|||
final currentManeuver = navState.currentManeuver!;
|
||||
final nextManeuver = navState.nextManeuver;
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Semantics(
|
||||
label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.',
|
||||
|
|
@ -81,10 +80,12 @@ class NavigationManeuverCard extends ConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 2,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
navState.formattedDistanceToNext,
|
||||
|
|
@ -96,6 +97,7 @@ class NavigationManeuverCard extends ConsumerWidget {
|
|||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
|
|
@ -118,16 +120,17 @@ class NavigationManeuverCard extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentManeuver.streetName ?? currentManeuver.instruction,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -137,6 +140,8 @@ class NavigationManeuverCard extends ConsumerWidget {
|
|||
// Quick Audio Mute & End Navigation Buttons
|
||||
IconButton(
|
||||
key: const ValueKey('nav_toggle_tts_button'),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
navState.ttsEnabled ? Icons.volume_up : Icons.volume_off,
|
||||
color: navState.ttsEnabled ? colors.cyberCyan : Colors.white54,
|
||||
|
|
@ -149,6 +154,8 @@ class NavigationManeuverCard extends ConsumerWidget {
|
|||
),
|
||||
IconButton(
|
||||
key: const ValueKey('nav_stop_button'),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.close, color: Colors.white70, size: 22),
|
||||
tooltip: 'Exit Navigation',
|
||||
onPressed: () {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Group-ride alerts reduce the usable height above this layout.
|
||||
final isShortPortrait = constraints.maxHeight < 760;
|
||||
final isShortPortrait = constraints.maxHeight < 880;
|
||||
final isUltraShortPortrait = constraints.maxHeight < 500;
|
||||
|
||||
return Column(
|
||||
|
|
@ -200,8 +200,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
child: Center(
|
||||
child: MetricTile(
|
||||
label: 'ELEV',
|
||||
value: session.elevationGain.toStringAsFixed(0),
|
||||
unit: 'M',
|
||||
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
|
||||
unit: 'FT',
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
|
|
@ -229,13 +229,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
if (!isShortPortrait) const Spacer(flex: 1),
|
||||
Flexible(
|
||||
flex: isShortPortrait ? 3 : 4,
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12),
|
||||
child: const Center(
|
||||
child: SpeedReadout(size: SpeedReadoutSize.hero),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isShortPortrait) const Spacer(flex: 1),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
|
|
@ -351,8 +348,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
),
|
||||
MetricTile(
|
||||
label: 'ELEV',
|
||||
value: session.elevationGain.toStringAsFixed(0),
|
||||
unit: 'M',
|
||||
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
|
||||
unit: 'FT',
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
|
|
@ -559,8 +556,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
),
|
||||
MetricTile(
|
||||
label: 'ELEV GAIN',
|
||||
value: session.elevationGain.toStringAsFixed(0),
|
||||
unit: 'M',
|
||||
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
|
||||
unit: 'FT',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -53,12 +53,13 @@ class SpeedReadout extends ConsumerWidget {
|
|||
final sample = s.value;
|
||||
if (sample == null) {
|
||||
// Pre-telemetry initial state (before first tick)
|
||||
return (speed: 0.0, isFresh: true, isInitial: true);
|
||||
return (speed: 0.0, isFresh: true, isInitial: true, rawSpeed: null);
|
||||
}
|
||||
return (
|
||||
speed: sample.freshSpeed,
|
||||
isFresh: sample.isSpeedFresh,
|
||||
isInitial: false,
|
||||
rawSpeed: sample.speed,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -70,12 +71,12 @@ class SpeedReadout extends ConsumerWidget {
|
|||
final Color speedColor;
|
||||
final Color unitColor;
|
||||
|
||||
if (speedState.isInitial) {
|
||||
if (speedState.isInitial || (speedState.rawSpeed == null && !speedState.isFresh)) {
|
||||
speedText = '0';
|
||||
speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
|
||||
unitColor = baseUnitColor;
|
||||
} else if (!speedState.isFresh) {
|
||||
// Stale or unavailable GNSS fix: display '--' to reflect truthful uncertainty
|
||||
// Stale or unavailable GNSS fix during active motion: display '--' to reflect truthful uncertainty
|
||||
speedText = '--';
|
||||
speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38);
|
||||
unitColor = baseUnitColor.withValues(alpha: 0.5);
|
||||
|
|
|
|||
|
|
@ -137,8 +137,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
.where((p) => p.id == ProductCatalog.proLifetime)
|
||||
.firstOrNull;
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: isDark ? const Color(0xFF0B0D0F) : Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
|
|
@ -359,9 +360,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
: (entitlement.expiresAt != null
|
||||
? 'Subscription active. Renews ${entitlement.expiresAt!.toLocal().toString().split(' ')[0]}.'
|
||||
: 'Subscription active.'),
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white70,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -373,7 +374,8 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
}
|
||||
|
||||
Widget _buildPitchHeader() {
|
||||
return const Column(
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -382,17 +384,17 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
fontFamily: 'Sora',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
color: theme.colorScheme.onSurface,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unlock 20Hz deep telemetry analysis, multiple motorcycle profiles, unlimited cloud sync, and forensic data exports.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white70,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
|
@ -426,7 +428,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
title: 'PRO LIFETIME',
|
||||
subtitle: 'One-time purchase • Permanent access',
|
||||
priceDisplay: lifetimeProd?.price ?? 'Unavailable',
|
||||
badgeText: 'BEST VALUE',
|
||||
badgeText: lifetimeProd != null ? 'BEST VALUE' : null,
|
||||
isHighlighted: true,
|
||||
isLoading: _currentStep == ProPurchaseStep.purchasing,
|
||||
isCurrentPlan:
|
||||
|
|
@ -528,12 +530,12 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
}
|
||||
|
||||
Widget _buildDisclaimer() {
|
||||
return const Text(
|
||||
return Text(
|
||||
'Basic ride recording, cockpit instruments, offline history, local SQLite storage, and crash detection remain 100% free forever. MotorSignal Pro subscriptions can be managed or canceled anytime in Google Play Store settings.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white38,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
height: 1.4,
|
||||
),
|
||||
);
|
||||
|
|
@ -557,10 +559,10 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
Text(
|
||||
'Cannot connect to Google Play. Please check your internet connection or Google Play Store service status.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.white60),
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton(
|
||||
|
|
@ -619,11 +621,11 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
|
|
@ -675,9 +677,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white60,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -700,18 +702,22 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
fontWeight: FontWeight.w900,
|
||||
color: isHighlighted
|
||||
? colors.cyberCyan
|
||||
: Colors.white,
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isCurrentPlan ? 'Active' : 'Select',
|
||||
isCurrentPlan
|
||||
? 'Active'
|
||||
: (onTap == null ? 'Unavailable' : 'Select'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isHighlighted
|
||||
? colors.cyberCyan
|
||||
: Colors.white54,
|
||||
: (onTap == null
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
|
@ -751,18 +757,18 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
desc,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white60,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
List<PlaceSearchResult> _searchResults = [];
|
||||
ResolvedPlace? _selectedDestination;
|
||||
RouteCalculationResult? _previewRouteResult;
|
||||
bool _isAddressExpanded = false;
|
||||
bool _isCalculatingRoute = false;
|
||||
int _routeCalculationRequestId = 0;
|
||||
RoutingOptions _routingOptions = const RoutingOptions();
|
||||
|
||||
static const double _hardAccelThresholdG = 0.35;
|
||||
|
|
@ -119,8 +121,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
AppLatLng currentPos,
|
||||
) async {
|
||||
_searchDebounce?.cancel();
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_isAddressExpanded = false;
|
||||
_searchController.text = place.primaryText;
|
||||
_destinationQuery = place.primaryText;
|
||||
});
|
||||
|
|
@ -148,6 +152,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
ResolvedPlace dest,
|
||||
AppLatLng currentPos,
|
||||
) async {
|
||||
final reqId = ++_routeCalculationRequestId;
|
||||
setState(() => _isCalculatingRoute = true);
|
||||
|
||||
try {
|
||||
|
|
@ -170,14 +175,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
.read(activeRouteCalculationProvider)
|
||||
.calculateRoute([origin, destinationStop], options: _routingOptions);
|
||||
|
||||
if (mounted) {
|
||||
if (mounted && reqId == _routeCalculationRequestId) {
|
||||
setState(() {
|
||||
_previewRouteResult = result;
|
||||
_isCalculatingRoute = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
if (mounted && reqId == _routeCalculationRequestId) {
|
||||
setState(() => _isCalculatingRoute = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Route calculation error: $e')),
|
||||
|
|
@ -194,19 +199,25 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
_searchResults = [];
|
||||
_selectedDestination = null;
|
||||
_previewRouteResult = null;
|
||||
_isAddressExpanded = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _startTurnByTurnNavigation() {
|
||||
if (_previewRouteResult == null || _selectedDestination == null) return;
|
||||
final preview = _previewRouteResult!;
|
||||
final dest = _selectedDestination!;
|
||||
setState(() {
|
||||
_previewRouteResult = null;
|
||||
});
|
||||
ref.read(navigationSessionProvider.notifier).startNavigation(
|
||||
calculationResult: _previewRouteResult!,
|
||||
destination: _selectedDestination,
|
||||
calculationResult: preview,
|
||||
destination: dest,
|
||||
options: _routingOptions,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Navigation started to ${_selectedDestination!.displayName}'),
|
||||
content: Text('Navigation started to ${dest.displayName}'),
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
),
|
||||
);
|
||||
|
|
@ -880,6 +891,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
if (_isLoadingHistory) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
ref.listen<NavigationSessionState>(navigationSessionProvider, (prev, next) {
|
||||
if (prev?.isNavigating == true && !next.isNavigating && mounted) {
|
||||
setState(() {
|
||||
_previewRouteResult = null;
|
||||
_selectedDestination = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||
final layerSettings = ref.watch(mapLayerSettingsProvider);
|
||||
final colors = context.motorColors;
|
||||
|
|
@ -1158,6 +1178,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
key: const ValueKey('live_map_view'),
|
||||
center: currentPos,
|
||||
zoom: _currentZoom,
|
||||
bearing: _isNorthUp ? 0.0 : currentHeading,
|
||||
markers: mapMarkers,
|
||||
polylines: livePolylines,
|
||||
),
|
||||
|
|
@ -1288,10 +1309,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
),
|
||||
|
||||
// Bottom-left Quick Swarm Report Button
|
||||
if (_previewRouteResult == null && !navSession.isNavigating)
|
||||
// Bottom-left Quick Swarm Report Button (Repositioned to prevent card collision and hidden during search/typing)
|
||||
if (MediaQuery.of(context).viewInsets.bottom == 0 && _searchResults.isEmpty)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
bottom: _previewRouteResult != null ? 295 : 24,
|
||||
left: 20,
|
||||
child: FloatingActionButton.extended(
|
||||
key: const ValueKey('quick_swarm_report_button'),
|
||||
|
|
@ -1502,7 +1523,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
child: Row(
|
||||
children: [
|
||||
_buildQuickDestinationChip(
|
||||
label: 'Gas & Fuel',
|
||||
label: 'Gas Stations',
|
||||
customKey: 'chip_gas_and_fuel',
|
||||
icon: Icons.local_gas_station,
|
||||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
|
|
@ -1542,10 +1564,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
required IconData icon,
|
||||
required MotorSignalThemeColors colors,
|
||||
required AppLatLng currentPos,
|
||||
String? customKey,
|
||||
}) {
|
||||
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
|
||||
final keyString = customKey ??
|
||||
'chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}';
|
||||
return InkWell(
|
||||
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
|
||||
key: ValueKey(keyString),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {
|
||||
if (isSelected) {
|
||||
|
|
@ -1575,7 +1600,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
Icon(
|
||||
icon,
|
||||
size: 13,
|
||||
color: isSelected ? colors.cyberCyan : Colors.white70,
|
||||
color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
|
|
@ -1584,7 +1609,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? colors.cyberCyan : Colors.white70,
|
||||
color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -1597,15 +1622,17 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
MotorSignalThemeColors colors,
|
||||
AppLatLng currentPos,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
return Card(
|
||||
key: const ValueKey('search_results_overlay'),
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black87,
|
||||
color: colors.surfaceContainer.withValues(alpha: 0.98),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.4),
|
||||
color: colors.cyberCyan.withValues(alpha: isDark ? 0.4 : 0.6),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
|
|
@ -1617,7 +1644,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
itemCount: _searchResults.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _searchResults[index];
|
||||
|
|
@ -1637,11 +1664,11 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
title: Text(
|
||||
item.primaryText,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: item.secondaryText.isNotEmpty
|
||||
|
|
@ -1649,13 +1676,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
item.secondaryText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
color: Colors.white70,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
selectedTileColor: colors.cyberCyan.withValues(alpha: 0.12),
|
||||
hoverColor: colors.cyberCyan.withValues(alpha: 0.08),
|
||||
onTap: () => _onSelectPlace(item, currentPos),
|
||||
);
|
||||
},
|
||||
|
|
@ -1670,6 +1699,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
) {
|
||||
final preview = _previewRouteResult!;
|
||||
final dest = _selectedDestination;
|
||||
final confirmedPref = preview.options?.preference ?? _routingOptions.preference;
|
||||
|
||||
return Card(
|
||||
key: const ValueKey('route_preview_card'),
|
||||
|
|
@ -1694,18 +1724,36 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
InkWell(
|
||||
key: const ValueKey('destination_address_expand_toggle'),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => setState(() => _isAddressExpanded = !_isAddressExpanded),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dest?.displayName ?? 'Destination Route',
|
||||
maxLines: 1,
|
||||
maxLines: _isAddressExpanded ? 4 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isAddressExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
|
|
@ -1720,6 +1768,70 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: colors.cyberCyan,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isCalculatingRoute) ...[
|
||||
SizedBox(
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
confirmedPref.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (preview.options?.avoidHighways == true)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF59E0B),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'NO HIGHWAYS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (preview.curvatureMetric != null && preview.curvatureMetric! >= 25.0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
|
|
@ -1734,7 +1846,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
),
|
||||
child: Text(
|
||||
preview.twistinessRating,
|
||||
'CURVES: ${preview.twistinessRating}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9.5,
|
||||
|
|
@ -1750,7 +1862,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
IconButton(
|
||||
key: const ValueKey('clear_route_button'),
|
||||
icon: const Icon(Icons.close, color: Colors.white70),
|
||||
icon: Icon(Icons.close, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
onPressed: _clearDestination,
|
||||
tooltip: 'Cancel Destination',
|
||||
),
|
||||
|
|
@ -1790,6 +1902,28 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
colors: colors,
|
||||
currentPos: currentPos,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
key: const ValueKey('preference_avoid_highways'),
|
||||
label: const Text('Avoid Highways'),
|
||||
selected: _routingOptions.avoidHighways,
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: _routingOptions.avoidHighways ? FontWeight.bold : FontWeight.normal,
|
||||
color: _routingOptions.avoidHighways ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
selectedColor: colors.cyberCyan,
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_routingOptions = _routingOptions.copyWith(avoidHighways: selected);
|
||||
});
|
||||
if (_selectedDestination != null) {
|
||||
_calculateRouteToDestination(_selectedDestination!, currentPos);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1860,14 +1994,16 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
required AppLatLng currentPos,
|
||||
}) {
|
||||
final isSelected = _routingOptions.preference == preference;
|
||||
final onVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return ChoiceChip(
|
||||
key: ValueKey('preference_${preference.name}'),
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? Colors.black : Colors.white70,
|
||||
color: isSelected ? Colors.black : onVariant,
|
||||
),
|
||||
selectedColor: colors.cyberCyan,
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
|
|
@ -1908,8 +2044,10 @@ class _HudIconButton extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final defaultBg = isDark ? colors.surfaceContainer : const Color(0xFF1E293B);
|
||||
final button = Material(
|
||||
color: backgroundColor ?? colors.surfaceContainer.withValues(alpha: 0.9),
|
||||
color: backgroundColor ?? defaultBg.withValues(alpha: 0.9),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
double _playbackSpeed = 1.0;
|
||||
int _currentIndex = 0;
|
||||
Timer? _playbackTimer;
|
||||
double _zoom = 17.0;
|
||||
bool _isFollowingRider = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -325,6 +327,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final hasLeanData = _telemetry.any((t) => t.leanAngle != null);
|
||||
|
||||
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
if (_telemetry.isEmpty) {
|
||||
|
|
@ -480,10 +483,49 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
children: [
|
||||
UnifiedMapView(
|
||||
center: currentPos,
|
||||
zoom: 17.0,
|
||||
zoom: _zoom,
|
||||
markers: allMarkers,
|
||||
polylines: polylines,
|
||||
isFollowModeOverride: true,
|
||||
isFollowModeOverride: _isFollowingRider,
|
||||
onUserInteraction: () {
|
||||
if (_isFollowingRider) {
|
||||
setState(() => _isFollowingRider = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
||||
Positioned(
|
||||
right: 14,
|
||||
bottom: 14,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_isFollowingRider) ...[
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_recenter_button'),
|
||||
icon: Icons.my_location,
|
||||
tooltip: 'Recenter on Rider',
|
||||
color: colors.cyberCyan,
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
||||
onTap: () => setState(() => _isFollowingRider = true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_zoom_in_button'),
|
||||
icon: Icons.add,
|
||||
tooltip: 'Zoom In',
|
||||
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_zoom_out_button'),
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Zoom Out',
|
||||
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Telemetry Overlay
|
||||
Positioned(
|
||||
|
|
@ -619,7 +661,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
|
||||
// Corner Entry / Apex / Exit Breakdown Table
|
||||
Expanded(
|
||||
flex: 4,
|
||||
flex: _corners.isEmpty ? 3 : 4,
|
||||
child: Container(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
|
|
@ -646,7 +688,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Tap row to seek apex',
|
||||
_corners.isEmpty ? '' : 'Tap row to seek apex',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
|
|
@ -659,14 +701,54 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
if (_corners.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No distinct corners detected over 15° lean threshold.',
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
hasLeanData
|
||||
? Icons.straighten_rounded
|
||||
: Icons.sensors_off_rounded,
|
||||
size: 26,
|
||||
color: hasLeanData
|
||||
? colors.cyberCyan.withValues(alpha: 0.7)
|
||||
: colors.warningAmber.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
hasLeanData
|
||||
? 'No distinct corners detected over 15° lean threshold.'
|
||||
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(
|
||||
hasLeanData
|
||||
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
|
||||
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
|
|
@ -861,3 +943,48 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReplayMapButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
final Color? color;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const _ReplayMapButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: backgroundColor ?? Colors.black87,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: (color ?? Colors.white).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color ?? Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../../services/sync/cloud_restore_controller.dart';
|
|||
import '../../services/sync/sync_providers.dart';
|
||||
import '../../services/group_rides/group_ride_providers.dart';
|
||||
import 'widgets/current_plan_card.dart';
|
||||
import 'widgets/sync_status_inspector_screen.dart';
|
||||
|
||||
class AccountScreen extends ConsumerStatefulWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
|
@ -1031,14 +1032,27 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
),
|
||||
),
|
||||
] else ...[
|
||||
Container(
|
||||
InkWell(
|
||||
key: const ValueKey('open_sync_inspector_button'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SyncStatusInspectorScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildSyncStatItem(
|
||||
|
|
@ -1058,6 +1072,32 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.list_alt_rounded, size: 12, color: colors.cyberCyan),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Inspect Queue',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(Icons.chevron_right, size: 12, color: colors.cyberCyan),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (syncState.failedCount > 0) ...[
|
||||
|
|
@ -1083,7 +1123,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${syncState.failedCount} ride(s) need attention. Tap Retry to process.',
|
||||
'${syncState.failedCount} ride(s) need attention.',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -1093,6 +1135,31 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: const Size(40, 28),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SyncStatusInspectorScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'INSPECT',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: const Size(40, 28),
|
||||
foregroundColor: colors.raceRed,
|
||||
),
|
||||
onPressed: () => syncManager.retryAllPending(),
|
||||
|
|
|
|||
|
|
@ -1238,13 +1238,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
setState(() => _isTestingGa511 = false);
|
||||
|
||||
if (mounted) {
|
||||
String statusMsg;
|
||||
switch (status) {
|
||||
case RoadAlertProviderStatus.connected:
|
||||
statusMsg = 'Georgia 511 feed connected!';
|
||||
break;
|
||||
case RoadAlertProviderStatus.authError:
|
||||
statusMsg = 'Georgia 511 gateway: Authentication required (Sign in to MotorSignal)';
|
||||
break;
|
||||
case RoadAlertProviderStatus.networkError:
|
||||
statusMsg = 'Georgia 511 gateway: Unreachable (Network error)';
|
||||
break;
|
||||
default:
|
||||
statusMsg = 'Georgia 511 gateway status: ${status.name}';
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
status == RoadAlertProviderStatus.connected
|
||||
? 'Georgia 511 feed connected!'
|
||||
: 'Georgia 511 gateway status: ${status.name}',
|
||||
),
|
||||
content: Text(statusMsg),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1357,8 +1367,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Waze OSINT adapter configured successfully!'),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
status == RoadAlertProviderStatus.connected
|
||||
? 'Waze OSINT feed connected!'
|
||||
: 'Waze OSINT adapter configured locally (live upstream queries disabled by capability gate)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -122,15 +122,15 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
|
||||
if (!widget.showAppBar) {
|
||||
return Container(
|
||||
color: const Color(0xFF0D1117),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0D1117),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.95),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
|
||||
|
|
@ -232,25 +232,27 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
|
||||
color: const Color(0xFF0D1117),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Row 1: Status LED, Active metric & Search toggle
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
// Row 1: Status LED & Search toggle
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Status LED pill (Required by test suite)
|
||||
// Status LED pill
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161B22),
|
||||
color: Theme.of(context).brightness == Brightness.light
|
||||
? const Color(0xFFE0F7FA)
|
||||
: const Color(0xFF161B22),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.25),
|
||||
color: AppColors.electricCyan.withValues(
|
||||
alpha: Theme.of(context).brightness == Brightness.light ? 0.6 : 0.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -272,48 +274,40 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'SQUAD & CANYON FEED',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: Color(0xFF9CF0FF),
|
||||
color: Theme.of(context).brightness == Brightness.light
|
||||
? const Color(0xFF006064)
|
||||
: const Color(0xFF9CF0FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Live sensor metric pill (Required by test suite)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E2530),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.sensors_rounded,
|
||||
size: 14,
|
||||
// Search toggle button
|
||||
IconButton(
|
||||
key: const ValueKey('feed_search_toggle_button'),
|
||||
icon: Icon(
|
||||
_isSearchExpanded ? Icons.close_rounded : Icons.search_rounded,
|
||||
color: AppColors.electricCyan,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'24 ACTIVE',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: Color(0xFF9CF0FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: 'Search Feed',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSearchExpanded = !_isSearchExpanded;
|
||||
if (!_isSearchExpanded) {
|
||||
_searchController.clear();
|
||||
ref.read(feedSearchQueryProvider.notifier).state = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -372,20 +366,12 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Stream Selector (Scrollable horizontally to guarantee zero RenderFlex overflow)
|
||||
// Unified Single Streamlined Filter Row (Streams + Disciplines)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0B0E13),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF1E2633)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildFilterTab(
|
||||
tab: FeedFilter.allActivity,
|
||||
|
|
@ -416,18 +402,13 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
title: 'Following',
|
||||
isSelected: activeFilter == FeedFilter.following,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Riding Discipline Filter Chips
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_buildDisciplineChip(
|
||||
discipline: RidingDiscipline.all,
|
||||
label: 'All Styles',
|
||||
|
|
@ -553,7 +534,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
letterSpacing: 0.1,
|
||||
color: isSelected
|
||||
? const Color(0xFF00363D)
|
||||
: const Color(0xFFCBD5E1),
|
||||
: (Theme.of(context).brightness == Brightness.light
|
||||
? const Color(0xFF334155)
|
||||
: const Color(0xFFCBD5E1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -566,6 +549,7 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
required bool isSelected,
|
||||
}) {
|
||||
final chipColor = _getDisciplineColor(discipline);
|
||||
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -578,12 +562,12 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? chipColor.withValues(alpha: 0.22)
|
||||
: const Color(0xFF161B22),
|
||||
: (isLight ? const Color(0xFFF1F5F9) : const Color(0xFF161B22)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? chipColor.withValues(alpha: 0.7)
|
||||
: const Color(0xFF263238),
|
||||
: (isLight ? const Color(0xFFCBD5E1) : const Color(0xFF263238)),
|
||||
width: isSelected ? 1.2 : 1.0,
|
||||
),
|
||||
),
|
||||
|
|
@ -593,7 +577,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 11.5,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected ? Colors.white : const Color(0xFF94A3B8),
|
||||
color: isSelected
|
||||
? (isLight ? const Color(0xFF0F172A) : Colors.white)
|
||||
: (isLight ? const Color(0xFF475569) : const Color(0xFF94A3B8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -626,27 +612,71 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|||
hasCustomFilters
|
||||
? 'No matching ride sessions'
|
||||
: 'No posts in ${activeFilter.displayName}',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
hasCustomFilters
|
||||
? 'Try relaxing your style filters or search query to discover more community rides.'
|
||||
: 'Hit "Share Recent Session" below to publish telemetry from your latest ride.',
|
||||
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
key: const ValueKey('empty_feed_group_rides_button'),
|
||||
icon: const Icon(Icons.two_wheeler_rounded, size: 16),
|
||||
label: const Text('Group Rides'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.electricCyan,
|
||||
side: BorderSide(
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
key: const ValueKey('empty_feed_clubs_button'),
|
||||
icon: const Icon(Icons.groups_rounded, size: 16),
|
||||
label: const Text('Clubs'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.electricCyan,
|
||||
side: BorderSide(
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/social/clubs'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
key: const ValueKey('empty_feed_friends_button'),
|
||||
icon: const Icon(Icons.person_add_rounded, size: 16),
|
||||
label: const Text('Friends'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.electricCyan,
|
||||
side: BorderSide(
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/social/friends'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasCustomFilters) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||
label: const Text('Reset All Filters'),
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ class _FeedCommentsBottomSheetState
|
|||
? livePost.comments
|
||||
: _localComments;
|
||||
|
||||
return Container(
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF101419),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
|
|
@ -126,10 +130,6 @@ class _FeedCommentsBottomSheetState
|
|||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../core/theme/theme_extensions.dart';
|
||||
import '../../../../database/database.dart';
|
||||
import '../../../../services/auth/auth_providers.dart';
|
||||
import '../../../../services/map/map_provider_interface.dart';
|
||||
import '../../../../services/social/social_feed_models.dart';
|
||||
|
|
@ -29,10 +32,8 @@ class ShareSessionModal extends ConsumerStatefulWidget {
|
|||
|
||||
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController =
|
||||
TextEditingController(text: 'Morning Canyon Carve');
|
||||
final _locationController =
|
||||
TextEditingController(text: 'Skyline Blvd & Highway 9');
|
||||
final _titleController = TextEditingController(text: 'Morning Canyon Carve');
|
||||
final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9');
|
||||
final _bikeController = TextEditingController();
|
||||
final _distanceController = TextEditingController(text: '48.5');
|
||||
final _durationController = TextEditingController(text: '52');
|
||||
|
|
@ -42,21 +43,96 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
List<Ride> _recentRides = [];
|
||||
Ride? _selectedRide;
|
||||
List<AppLatLng> _selectedRoutePoints = const [
|
||||
AppLatLng(37.3861, -122.2641),
|
||||
AppLatLng(37.3500, -122.2300),
|
||||
AppLatLng(37.3100, -122.2000),
|
||||
AppLatLng(37.2800, -122.2200),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final defaultBike = ref.read(defaultMotorcycleProvider);
|
||||
if (defaultBike != null) {
|
||||
_bikeController.text =
|
||||
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'
|
||||
.trim();
|
||||
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim();
|
||||
} else {
|
||||
_bikeController.text = '2024 Ducati Streetfighter V4';
|
||||
}
|
||||
|
||||
await _loadRecentRides();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadRecentRides() async {
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
final rides = await (db.select(db.rides)
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.id, mode: drift.OrderingMode.desc)])
|
||||
..limit(10))
|
||||
.get();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recentRides = rides;
|
||||
if (rides.isNotEmpty) {
|
||||
_selectRide(rides.first);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore database retrieval errors on rides
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectRide(Ride ride) async {
|
||||
_selectedRide = ride;
|
||||
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
|
||||
final durMins = ride.movingTime > 0
|
||||
? (ride.movingTime / 60).round()
|
||||
: (ride.endTime?.difference(ride.startTime).inMinutes ?? 35).clamp(1, 999);
|
||||
final maxLean = math.max(ride.maxLeanLeft, ride.maxLeanRight);
|
||||
final side = ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT';
|
||||
|
||||
_titleController.text = 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride';
|
||||
_distanceController.text = dist > 0 ? dist.toStringAsFixed(1) : '24.5';
|
||||
_durationController.text = durMins.toString();
|
||||
_maxLeanController.text = maxLean > 0 ? maxLean.toStringAsFixed(0) : '38';
|
||||
_selectedLeanSide = side;
|
||||
|
||||
if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) {
|
||||
_bikeController.text =
|
||||
'${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
final samples = await (db.select(db.telemetrySamples)
|
||||
..where((t) => t.rideId.equals(ride.id))
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.id)]))
|
||||
.get();
|
||||
|
||||
final validSamples = samples.where((s) => s.latitude != null && s.longitude != null).toList();
|
||||
if (validSamples.isNotEmpty) {
|
||||
final step = (validSamples.length / 25).ceil().clamp(1, validSamples.length);
|
||||
final downsampled = [
|
||||
for (int i = 0; i < validSamples.length; i += step)
|
||||
AppLatLng(validSamples[i].latitude!, validSamples[i].longitude!)
|
||||
];
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedRoutePoints = downsampled;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
|
|
@ -107,12 +183,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
elevationFt: 1840,
|
||||
discipline: _selectedDiscipline,
|
||||
snapshotType: FeedSnapshotType.routeTrack,
|
||||
routePoints: const [
|
||||
AppLatLng(37.3861, -122.2641),
|
||||
AppLatLng(37.3500, -122.2300),
|
||||
AppLatLng(37.3100, -122.2000),
|
||||
AppLatLng(37.2800, -122.2200),
|
||||
],
|
||||
routePoints: _selectedRoutePoints,
|
||||
);
|
||||
|
||||
// Reset filter to All Activity so published session is immediately displayed
|
||||
|
|
@ -155,7 +226,11 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
final colors = context.motorColors;
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return Container(
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF101419),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
|
|
@ -171,12 +246,15 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Modal Header & Handle
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
|
|
@ -191,7 +269,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Modal Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -248,7 +325,66 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
// Recent Database Rides Selector
|
||||
if (_recentRides.isNotEmpty) ...[
|
||||
_buildFieldLabel('SELECT RECORDED SESSION', colors),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161B22),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Ride>(
|
||||
key: const ValueKey('share_session_ride_picker'),
|
||||
value: _selectedRide,
|
||||
isExpanded: true,
|
||||
dropdownColor: const Color(0xFF161B22),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
items: _recentRides.map((ride) {
|
||||
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
|
||||
final maxL = math.max(ride.maxLeanLeft, ride.maxLeanRight);
|
||||
return DropdownMenuItem<Ride>(
|
||||
value: ride,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.two_wheeler_rounded, size: 16, color: AppColors.electricCyan),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ride #${ride.id} • ${dist.toStringAsFixed(1)} mi • ${maxL.toStringAsFixed(0)}° lean • ${ride.startTime.month}/${ride.startTime.day}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) _selectRide(val);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Ride Title Field
|
||||
_buildFieldLabel('SESSION / ROUTE TITLE', colors),
|
||||
|
|
@ -491,11 +627,17 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Publish CTA Button
|
||||
SizedBox(
|
||||
// Publish CTA Button (pinned at modal bottom)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.electricCyan,
|
||||
|
|
@ -536,13 +678,14 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) {
|
||||
return Padding(
|
||||
|
|
|
|||
|
|
@ -393,15 +393,15 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _handleController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Rider Handle',
|
||||
hintText: 'e.g. canyon_runner',
|
||||
prefixText: '@',
|
||||
helperText:
|
||||
'Unique, 3-20 chars: letters, numbers, underscores',
|
||||
helperStyle: const TextStyle(
|
||||
color: Colors.white38, fontSize: 11),
|
||||
helperStyle: TextStyle(
|
||||
color: colors.outline, fontSize: 11),
|
||||
border: const OutlineInputBorder(),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
|
|
@ -439,7 +439,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _displayNameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Display Name',
|
||||
hintText: 'e.g. Alex Rivera',
|
||||
|
|
@ -476,7 +476,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
children: [
|
||||
TextFormField(
|
||||
controller: _regionController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Riding Region (Optional)',
|
||||
hintText: 'e.g. Blue Ridge Mountains, NC',
|
||||
|
|
@ -484,11 +484,13 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
_buildRegionChips(colors),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _bioController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Rider Bio (Optional)',
|
||||
|
|
@ -569,14 +571,14 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
|
||||
TextFormField(
|
||||
controller: _handleController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Rider Handle',
|
||||
hintText: 'e.g. canyon_runner',
|
||||
prefixText: '@',
|
||||
helperText: 'Unique, 3-20 characters: letters, numbers, underscores',
|
||||
helperStyle:
|
||||
const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
TextStyle(color: colors.outline, fontSize: 11),
|
||||
border: const OutlineInputBorder(),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
|
|
@ -615,7 +617,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
|
||||
TextFormField(
|
||||
controller: _displayNameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Display Name',
|
||||
hintText: 'e.g. Alex Rivera',
|
||||
|
|
@ -629,7 +631,7 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
|
||||
TextFormField(
|
||||
controller: _regionController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Riding Region (Optional)',
|
||||
hintText: 'e.g. Blue Ridge Mountains, NC',
|
||||
|
|
@ -637,12 +639,14 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
_buildRegionChips(colors),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _bioController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Rider Bio (Optional)',
|
||||
|
|
@ -730,11 +734,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeThumbColor: colors.cyberCyan,
|
||||
title: const Text('Discoverable by Handle',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text(
|
||||
title: Text('Discoverable by Handle',
|
||||
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
|
||||
subtitle: Text(
|
||||
'Allow other riders to find your profile by searching your @handle.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white60),
|
||||
style: TextStyle(fontSize: 11, color: colors.outline),
|
||||
),
|
||||
value: _isSearchableByHandle,
|
||||
onChanged: (val) =>
|
||||
|
|
@ -744,11 +748,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeThumbColor: colors.cyberCyan,
|
||||
title: const Text('Public Profile',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text(
|
||||
title: Text('Public Profile',
|
||||
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
|
||||
subtitle: Text(
|
||||
'When off, non-friends only see your handle and avatar, hiding bio and region.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white60),
|
||||
style: TextStyle(fontSize: 11, color: colors.outline),
|
||||
),
|
||||
value: _isProfilePublic,
|
||||
onChanged: (val) =>
|
||||
|
|
@ -758,11 +762,11 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeThumbColor: colors.cyberCyan,
|
||||
title: const Text('Allow Friend Requests',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text(
|
||||
title: Text('Allow Friend Requests',
|
||||
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)),
|
||||
subtitle: Text(
|
||||
'Allow other riders to send you friend requests.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white60),
|
||||
style: TextStyle(fontSize: 11, color: colors.outline),
|
||||
),
|
||||
value: _allowFriendRequests,
|
||||
onChanged: (val) =>
|
||||
|
|
@ -774,6 +778,68 @@ class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
static const List<String> _popularRegions = [
|
||||
'Tail of the Dragon',
|
||||
'Bay Area & NorCal Canyons',
|
||||
'SoCal Canyons (ACH / PCH)',
|
||||
'Blue Ridge Parkway',
|
||||
'Twisted Sisters (TX)',
|
||||
'Smoky Mountains',
|
||||
'Colorado Rockies',
|
||||
'Ozark Twisties',
|
||||
];
|
||||
|
||||
Widget _buildRegionChips(MotorSignalThemeColors colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'POPULAR RIDING REGIONS',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _popularRegions.map((region) {
|
||||
final isSelected = _regionController.text.trim().toLowerCase() == region.toLowerCase();
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
region,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected ? const Color(0xFF003840) : Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedColor: colors.cyberCyan,
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
checkmarkColor: const Color(0xFF003840),
|
||||
side: BorderSide(
|
||||
color: isSelected ? colors.cyberCyan : colors.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_regionController.text = region;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton(MotorSignalThemeColors colors) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../services/social/rider_profile_models.dart';
|
||||
|
||||
/// Renders a rider's avatar using their preset styling or custom photo URL.
|
||||
|
|
@ -35,10 +34,6 @@ class AvatarBadge extends StatelessWidget {
|
|||
backgroundImage: NetworkImage(photoUrl!),
|
||||
);
|
||||
} else {
|
||||
final initial = displayName.trim().isNotEmpty
|
||||
? displayName.trim()[0].toUpperCase()
|
||||
: 'R';
|
||||
|
||||
inner = CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: secondaryColor,
|
||||
|
|
@ -51,14 +46,10 @@ class AvatarBadge extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
child: Icon(
|
||||
preset.icon,
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: radius * 0.9,
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
),
|
||||
size: radius * 1.15,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ class AvatarSelector extends StatelessWidget {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'SELECT AVATAR PRESET',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.8,
|
||||
|
|
|
|||
|
|
@ -90,4 +90,8 @@ class DefaultFirebaseOptions {
|
|||
projectId: 'motoapp-d22e0',
|
||||
storageBucket: 'motoapp-d22e0.firebasestorage.app',
|
||||
);
|
||||
|
||||
/// Authoritative Web OAuth client ID for Google Sign-In on Android/Web.
|
||||
static const String webClientId =
|
||||
'809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import 'road_alert_provider_interface.dart';
|
|||
class Georgia511RoadAlertProvider implements RoadAlertProvider {
|
||||
final http.Client _client;
|
||||
final String backendBaseUrl;
|
||||
final Future<String?> Function()? getAuthToken;
|
||||
|
||||
Georgia511RoadAlertProvider({
|
||||
http.Client? client,
|
||||
String? backendBaseUrl,
|
||||
this.getAuthToken,
|
||||
}) : _client = client ?? http.Client(),
|
||||
backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl();
|
||||
|
||||
|
|
@ -38,13 +40,39 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
|
|||
RoadAlertCategory.hazard,
|
||||
};
|
||||
|
||||
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
|
||||
final headers = <String, String>{
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (getAuthToken != null) {
|
||||
try {
|
||||
final token = await getAuthToken!();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
return headers;
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Georgia511RoadAlertProvider] Error resolving auth token: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $apiKey';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
try {
|
||||
final headers = await _buildHeaders(apiKey: apiKey);
|
||||
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
||||
final res = await _client.get(uri).timeout(const Duration(seconds: 4));
|
||||
final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
|
||||
if (res.statusCode == 200) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
} else if (res.statusCode == 401 || res.statusCode == 403) {
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
} catch (e) {
|
||||
|
|
@ -81,7 +109,8 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
|
|||
);
|
||||
|
||||
try {
|
||||
final response = await _client.get(uri).timeout(const Duration(seconds: 5));
|
||||
final headers = await _buildHeaders(apiKey: apiKey);
|
||||
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode != 200) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) {
|
|||
});
|
||||
|
||||
final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) {
|
||||
return Georgia511RoadAlertProvider();
|
||||
final authService = ref.watch(authServiceProvider);
|
||||
return Georgia511RoadAlertProvider(
|
||||
getAuthToken: () => authService.getIdToken(),
|
||||
);
|
||||
});
|
||||
|
||||
final wazeProviderInstance = Provider<WazeRoadAlertProvider>((ref) {
|
||||
|
|
@ -522,7 +525,7 @@ final ga511AlertsEnabledProvider =
|
|||
|
||||
final ga511StatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||
final enabled = ref.watch(ga511AlertsEnabledProvider);
|
||||
return enabled ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured;
|
||||
return enabled ? RoadAlertProviderStatus.configured : RoadAlertProviderStatus.notConfigured;
|
||||
});
|
||||
|
||||
// Active Providers List
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import '../../firebase_options.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'auth_user.dart';
|
||||
|
||||
|
|
@ -44,7 +45,10 @@ class FirebaseAuthService implements AuthService {
|
|||
fb.FirebaseAuth? firebaseAuth,
|
||||
GoogleSignIn? googleSignIn,
|
||||
}) : _firebaseAuth = firebaseAuth ?? _resolveFirebaseAuth(),
|
||||
_googleSignIn = googleSignIn ?? GoogleSignIn() {
|
||||
_googleSignIn = googleSignIn ??
|
||||
GoogleSignIn(
|
||||
serverClientId: DefaultFirebaseOptions.webClientId,
|
||||
) {
|
||||
_init();
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +278,10 @@ class FirebaseAuthService implements AuthService {
|
|||
));
|
||||
|
||||
try {
|
||||
final googleSignIn = _googleSignIn ?? GoogleSignIn();
|
||||
final googleSignIn = _googleSignIn ??
|
||||
GoogleSignIn(
|
||||
serverClientId: DefaultFirebaseOptions.webClientId,
|
||||
);
|
||||
final GoogleSignInAccount? googleUser = await googleSignIn.signIn();
|
||||
|
||||
if (googleUser == null) {
|
||||
|
|
@ -442,7 +449,11 @@ class FirebaseAuthService implements AuthService {
|
|||
if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.');
|
||||
credential = fb.EmailAuthProvider.credential(email: user.email!, password: password);
|
||||
} else if (providers.contains('google.com')) {
|
||||
final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn();
|
||||
final googleUser = await (_googleSignIn ??
|
||||
GoogleSignIn(
|
||||
serverClientId: DefaultFirebaseOptions.webClientId,
|
||||
))
|
||||
.signIn();
|
||||
if (googleUser == null) throw StateError('Google reauthentication was cancelled.');
|
||||
final googleAuth = await googleUser.authentication;
|
||||
credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class AwsMapProvider implements AppMapProvider {
|
|||
required BuildContext context,
|
||||
required AppLatLng center,
|
||||
required double zoom,
|
||||
double bearing = 0.0,
|
||||
required List<AppMapMarker> markers,
|
||||
required List<AppPolyline> polylines,
|
||||
required bool isFollowMode,
|
||||
|
|
@ -59,6 +60,7 @@ class AwsMapProvider implements AppMapProvider {
|
|||
return _AwsMapView(
|
||||
center: center,
|
||||
zoom: zoom,
|
||||
bearing: bearing,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
isFollowMode: isFollowMode,
|
||||
|
|
@ -72,6 +74,7 @@ class AwsMapProvider implements AppMapProvider {
|
|||
class _AwsMapView extends StatefulWidget {
|
||||
final AppLatLng center;
|
||||
final double zoom;
|
||||
final double bearing;
|
||||
final List<AppMapMarker> markers;
|
||||
final List<AppPolyline> polylines;
|
||||
final bool isFollowMode;
|
||||
|
|
@ -82,6 +85,7 @@ class _AwsMapView extends StatefulWidget {
|
|||
const _AwsMapView({
|
||||
required this.center,
|
||||
required this.zoom,
|
||||
this.bearing = 0.0,
|
||||
required this.markers,
|
||||
required this.polylines,
|
||||
required this.isFollowMode,
|
||||
|
|
@ -108,16 +112,22 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
@override
|
||||
void didUpdateWidget(covariant _AwsMapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isFollowMode) {
|
||||
final centerLl =
|
||||
ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
|
||||
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
|
||||
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
|
||||
|
||||
if (widget.isFollowMode) {
|
||||
final now = DateTime.now();
|
||||
final posDelta = _lastFollowPos == null
|
||||
? 1.0
|
||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||
|
||||
final shouldMove = _lastFollowPos == null ||
|
||||
final shouldMove = justBecameFollowMode ||
|
||||
_lastFollowPos == null ||
|
||||
zoomChanged ||
|
||||
(posDelta > 0.00005 &&
|
||||
(_lastFollowTime == null ||
|
||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
||||
|
|
@ -133,6 +143,24 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
}
|
||||
});
|
||||
}
|
||||
} else if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.rotate(widget.bearing);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +184,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
options: MapOptions(
|
||||
initialCenter: centerLl,
|
||||
initialZoom: widget.zoom,
|
||||
initialRotation: widget.bearing,
|
||||
minZoom: widget.config.minZoom,
|
||||
maxZoom: widget.config.maxZoom,
|
||||
backgroundColor: const Color(0xFF0A0F14),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class GoogleMapProvider implements AppMapProvider {
|
|||
required BuildContext context,
|
||||
required AppLatLng center,
|
||||
required double zoom,
|
||||
double bearing = 0.0,
|
||||
required List<AppMapMarker> markers,
|
||||
required List<AppPolyline> polylines,
|
||||
required bool isFollowMode,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ abstract class AppMapProvider {
|
|||
required BuildContext context,
|
||||
required AppLatLng center,
|
||||
required double zoom,
|
||||
double bearing = 0.0,
|
||||
required List<AppMapMarker> markers,
|
||||
required List<AppPolyline> polylines,
|
||||
required bool isFollowMode,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class OpenStreetMapProvider implements AppMapProvider {
|
|||
required BuildContext context,
|
||||
required AppLatLng center,
|
||||
required double zoom,
|
||||
double bearing = 0.0,
|
||||
required List<AppMapMarker> markers,
|
||||
required List<AppPolyline> polylines,
|
||||
required bool isFollowMode,
|
||||
|
|
@ -26,6 +27,7 @@ class OpenStreetMapProvider implements AppMapProvider {
|
|||
return _OsmMapView(
|
||||
center: center,
|
||||
zoom: zoom,
|
||||
bearing: bearing,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
isFollowMode: isFollowMode,
|
||||
|
|
@ -39,6 +41,7 @@ class OpenStreetMapProvider implements AppMapProvider {
|
|||
class _OsmMapView extends StatefulWidget {
|
||||
final AppLatLng center;
|
||||
final double zoom;
|
||||
final double bearing;
|
||||
final List<AppMapMarker> markers;
|
||||
final List<AppPolyline> polylines;
|
||||
final bool isFollowMode;
|
||||
|
|
@ -49,6 +52,7 @@ class _OsmMapView extends StatefulWidget {
|
|||
const _OsmMapView({
|
||||
required this.center,
|
||||
required this.zoom,
|
||||
this.bearing = 0.0,
|
||||
required this.markers,
|
||||
required this.polylines,
|
||||
required this.isFollowMode,
|
||||
|
|
@ -75,16 +79,24 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
@override
|
||||
void didUpdateWidget(covariant _OsmMapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isFollowMode) {
|
||||
final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
|
||||
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
|
||||
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
|
||||
|
||||
if (widget.isFollowMode) {
|
||||
final now = DateTime.now();
|
||||
final posDelta = _lastFollowPos == null
|
||||
? 1.0
|
||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||
|
||||
final shouldMove = _lastFollowPos == null ||
|
||||
(posDelta > 0.00005 && (_lastFollowTime == null || now.difference(_lastFollowTime!).inMilliseconds > 500));
|
||||
final shouldMove = justBecameFollowMode ||
|
||||
_lastFollowPos == null ||
|
||||
zoomChanged ||
|
||||
(posDelta > 0.00005 &&
|
||||
(_lastFollowTime == null ||
|
||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
||||
|
||||
if (shouldMove) {
|
||||
_lastFollowPos = centerLl;
|
||||
|
|
@ -97,6 +109,24 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
}
|
||||
});
|
||||
}
|
||||
} else if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.rotate(widget.bearing);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,9 +148,12 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
options: MapOptions(
|
||||
initialCenter: centerLl,
|
||||
initialZoom: widget.zoom,
|
||||
initialRotation: widget.bearing,
|
||||
minZoom: widget.config.minZoom,
|
||||
maxZoom: widget.config.maxZoom,
|
||||
backgroundColor: const Color(0xFF0F0F0F),
|
||||
backgroundColor: widget.config.isDarkMode
|
||||
? const Color(0xFF0F0F0F)
|
||||
: const Color(0xFFE5E5E5),
|
||||
onPositionChanged: (position, hasGesture) {
|
||||
if (hasGesture) {
|
||||
widget.onUserInteraction?.call();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'map_providers.dart';
|
|||
class UnifiedMapView extends ConsumerWidget {
|
||||
final AppLatLng center;
|
||||
final double zoom;
|
||||
final double bearing;
|
||||
final List<AppMapMarker> markers;
|
||||
final List<AppPolyline> polylines;
|
||||
final ValueChanged<AppLatLng>? onPositionChanged;
|
||||
|
|
@ -18,6 +19,7 @@ class UnifiedMapView extends ConsumerWidget {
|
|||
super.key,
|
||||
required this.center,
|
||||
this.zoom = 16.0,
|
||||
this.bearing = 0.0,
|
||||
this.markers = const [],
|
||||
this.polylines = const [],
|
||||
this.onPositionChanged,
|
||||
|
|
@ -42,7 +44,13 @@ class UnifiedMapView extends ConsumerWidget {
|
|||
switch (layerSettings.style) {
|
||||
case TacticalMapStyle.tacticalDark:
|
||||
provider = ref.watch(openStreetMapProviderInstance);
|
||||
activeConfig = MapConfig.tacticalDark(offlineOnly: layerSettings.offlineOnly);
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
activeConfig = isDarkTheme
|
||||
? MapConfig.tacticalDark(offlineOnly: layerSettings.offlineOnly)
|
||||
: MapConfig(
|
||||
isDarkMode: false,
|
||||
offlineOnly: layerSettings.offlineOnly,
|
||||
);
|
||||
break;
|
||||
case TacticalMapStyle.satellite4K:
|
||||
final aws = ref.watch(awsMapProviderInstance);
|
||||
|
|
@ -75,6 +83,7 @@ class UnifiedMapView extends ConsumerWidget {
|
|||
context: context,
|
||||
center: center,
|
||||
zoom: zoom,
|
||||
bearing: bearing,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
isFollowMode: isFollowMode,
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
bool _ttsInitialized = false;
|
||||
bool _isRecalculating = false;
|
||||
DateTime? _lastRecalculationAttempt;
|
||||
int _lastMatchedSegment = 0;
|
||||
|
||||
static const double defaultOffRouteThresholdMeters = 65.0;
|
||||
|
||||
|
|
@ -202,6 +203,8 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
return;
|
||||
}
|
||||
|
||||
_lastMatchedSegment = 0;
|
||||
|
||||
final initialDist = calculationResult.estimatedDistanceMeters ??
|
||||
calculationResult.straightLineDistanceMeters;
|
||||
final initialDuration = calculationResult.estimatedDurationSeconds ??
|
||||
|
|
@ -274,11 +277,16 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
|
||||
if (!state.isNavigating || state.maneuvers.isEmpty) return;
|
||||
|
||||
// Off-route detection check against polyline points
|
||||
final points = state.calculationResult?.points ?? [];
|
||||
if (points.isNotEmpty) {
|
||||
final distToRoute = _distanceToPolylineMeters(riderPos, points);
|
||||
if (distToRoute > defaultOffRouteThresholdMeters) {
|
||||
final proj = _projectPointOntoPolyline(
|
||||
riderPos,
|
||||
points,
|
||||
startSearchSegment: math.max(0, _lastMatchedSegment - 2),
|
||||
);
|
||||
|
||||
// Off-route detection check against polyline
|
||||
if (proj.distanceToRouteMeters > defaultOffRouteThresholdMeters) {
|
||||
if (!_isRecalculating) {
|
||||
final now = DateTime.now();
|
||||
if (_lastRecalculationAttempt == null ||
|
||||
|
|
@ -289,8 +297,79 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_lastMatchedSegment = proj.segmentIndex;
|
||||
|
||||
// Check arrival at final destination stop
|
||||
final lastManeuver = state.maneuvers.last;
|
||||
final distToDest = _haversineMeters(riderPos, lastManeuver.location);
|
||||
if (distToDest <= 25.0 &&
|
||||
(proj.segmentIndex >= points.length - 2 || state.currentManeuverIndex >= state.maneuvers.length - 1)) {
|
||||
_completeArrival();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remaining distance strictly counts down along route polyline
|
||||
final remainingMeters = _remainingDistanceAlongPolyline(
|
||||
points,
|
||||
proj.segmentIndex,
|
||||
proj.projectedPoint,
|
||||
);
|
||||
|
||||
// Maneuver advancement based on polyline progression
|
||||
final offsets = _getManeuverOffsets(state.maneuvers, points);
|
||||
int currIdx = state.currentManeuverIndex;
|
||||
|
||||
// If at step 0 (Depart), advance only once rider has genuinely departed (moving > 3.0 mph or past start point)
|
||||
if (currIdx == 0 && state.maneuvers.first.type == ManeuverType.depart && state.maneuvers.length > 1) {
|
||||
final distFromStart = _haversineMeters(riderPos, state.maneuvers.first.location);
|
||||
if (speedMph > 3.0 || distFromStart > 35.0 || proj.segmentIndex > 0) {
|
||||
currIdx = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance through completed subsequent maneuvers along the route (step 1+)
|
||||
while (currIdx > 0 && currIdx < state.maneuvers.length - 1) {
|
||||
final targetOffset = offsets[currIdx];
|
||||
final targetLoc = state.maneuvers[currIdx].location;
|
||||
final distToTarget = _haversineMeters(riderPos, targetLoc);
|
||||
|
||||
// Advance if rider reached within 25m of maneuver point or passed its polyline index
|
||||
if (distToTarget <= 25.0 || (proj.segmentIndex >= targetOffset && targetOffset > 0)) {
|
||||
currIdx++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Announce updated maneuver if advanced
|
||||
if (currIdx != state.currentManeuverIndex && currIdx < state.maneuvers.length) {
|
||||
_speakInstruction(state.maneuvers[currIdx].instruction);
|
||||
}
|
||||
|
||||
// Distance to next active maneuver along polyline
|
||||
final targetOffset = offsets[currIdx];
|
||||
final distToNext = _distanceToOffsetAlongPolyline(
|
||||
points,
|
||||
proj.segmentIndex,
|
||||
proj.projectedPoint,
|
||||
targetOffset,
|
||||
targetLocation: state.maneuvers[currIdx].location,
|
||||
);
|
||||
|
||||
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
|
||||
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
|
||||
|
||||
state = state.copyWith(
|
||||
currentManeuverIndex: currIdx,
|
||||
distanceToNextManeuverMeters: distToNext,
|
||||
remainingDistanceMeters: remainingMeters,
|
||||
remainingDurationSeconds: remainingSec,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback when polyline points are not available
|
||||
final currIdx = state.currentManeuverIndex;
|
||||
if (currIdx >= state.maneuvers.length) {
|
||||
_completeArrival();
|
||||
|
|
@ -300,7 +379,6 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
final currentManeuver = state.maneuvers[currIdx];
|
||||
final distToManeuver = _haversineMeters(riderPos, currentManeuver.location);
|
||||
|
||||
// If within 25 meters of maneuver point, advance to next maneuver
|
||||
if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) {
|
||||
final nextIdx = currIdx + 1;
|
||||
final nextManeuver = state.maneuvers[nextIdx];
|
||||
|
|
@ -315,13 +393,11 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Recompute remaining distance along maneuvers
|
||||
double remainingMeters = distToManeuver;
|
||||
for (int i = currIdx + 1; i < state.maneuvers.length; i++) {
|
||||
remainingMeters += state.maneuvers[i].distanceMeters;
|
||||
}
|
||||
|
||||
// Dynamic duration based on speed (or default 30 mph if stationary)
|
||||
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
|
||||
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
|
||||
|
||||
|
|
@ -383,6 +459,7 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
final initialDuration = newResult.estimatedDurationSeconds ??
|
||||
(initialDist / 15.0).round();
|
||||
|
||||
_lastMatchedSegment = 0;
|
||||
state = state.copyWith(
|
||||
status: NavigationStatus.navigating,
|
||||
calculationResult: newResult,
|
||||
|
|
@ -421,35 +498,159 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Computes shortest distance from point to polyline in meters.
|
||||
static double _distanceToPolylineMeters(AppLatLng point, List<AppLatLng> polyline) {
|
||||
if (polyline.isEmpty) return double.infinity;
|
||||
if (polyline.length == 1) return _haversineMeters(point, polyline.first);
|
||||
/// Projects a GPS point onto the route polyline with segment index tracking.
|
||||
static _PolylineProjection _projectPointOntoPolyline(
|
||||
AppLatLng point,
|
||||
List<AppLatLng> polyline, {
|
||||
int startSearchSegment = 0,
|
||||
}) {
|
||||
if (polyline.isEmpty) {
|
||||
return _PolylineProjection(
|
||||
segmentIndex: 0,
|
||||
projectedPoint: point,
|
||||
distanceToRouteMeters: 0.0,
|
||||
);
|
||||
}
|
||||
if (polyline.length == 1) {
|
||||
return _PolylineProjection(
|
||||
segmentIndex: 0,
|
||||
projectedPoint: polyline.first,
|
||||
distanceToRouteMeters: _haversineMeters(point, polyline.first),
|
||||
);
|
||||
}
|
||||
|
||||
int bestSegment = 0;
|
||||
AppLatLng bestProj = polyline.first;
|
||||
double minDistance = double.infinity;
|
||||
for (int i = 0; i < polyline.length - 1; i++) {
|
||||
final segDist = _distanceToSegmentMeters(point, polyline[i], polyline[i + 1]);
|
||||
if (segDist < minDistance) {
|
||||
minDistance = segDist;
|
||||
|
||||
final searchStart = startSearchSegment.clamp(0, polyline.length - 2);
|
||||
for (int i = searchStart; i < polyline.length - 1; i++) {
|
||||
final a = polyline[i];
|
||||
final b = polyline[i + 1];
|
||||
final proj = _projectPointOntoSegment(point, a, b);
|
||||
final dist = _haversineMeters(point, proj);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
bestSegment = i;
|
||||
bestProj = proj;
|
||||
}
|
||||
}
|
||||
return minDistance;
|
||||
}
|
||||
|
||||
static double _distanceToSegmentMeters(AppLatLng p, AppLatLng a, AppLatLng b) {
|
||||
// If searching forward yielded an unacceptable match (> threshold)
|
||||
// and search started midway, search earlier segments in case rider turned around
|
||||
if (minDistance > defaultOffRouteThresholdMeters && searchStart > 0) {
|
||||
for (int i = 0; i < searchStart; i++) {
|
||||
final a = polyline[i];
|
||||
final b = polyline[i + 1];
|
||||
final proj = _projectPointOntoSegment(point, a, b);
|
||||
final dist = _haversineMeters(point, proj);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
bestSegment = i;
|
||||
bestProj = proj;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _PolylineProjection(
|
||||
segmentIndex: bestSegment,
|
||||
projectedPoint: bestProj,
|
||||
distanceToRouteMeters: minDistance,
|
||||
);
|
||||
}
|
||||
|
||||
static AppLatLng _projectPointOntoSegment(AppLatLng p, AppLatLng a, AppLatLng b) {
|
||||
final dAB = _haversineMeters(a, b);
|
||||
if (dAB == 0) return _haversineMeters(p, a);
|
||||
if (dAB == 0.0) return a;
|
||||
|
||||
final dAP = _haversineMeters(a, p);
|
||||
final dBP = _haversineMeters(b, p);
|
||||
final latRad = a.latitude * (math.pi / 180.0);
|
||||
final cosLat = math.cos(latRad);
|
||||
final dx = (b.longitude - a.longitude) * cosLat;
|
||||
final dy = b.latitude - a.latitude;
|
||||
final px = (p.longitude - a.longitude) * cosLat;
|
||||
final py = p.latitude - a.latitude;
|
||||
|
||||
if (dBP * dBP >= dAP * dAP + dAB * dAB) return dAP;
|
||||
if (dAP * dAP >= dBP * dBP + dAB * dAB) return dBP;
|
||||
final lenSq = dx * dx + dy * dy;
|
||||
if (lenSq == 0.0) return a;
|
||||
|
||||
final s = (dAB + dAP + dBP) / 2.0;
|
||||
final areaSq = s * (s - dAB) * (s - dAP) * (s - dBP);
|
||||
if (areaSq <= 0.0) return 0.0;
|
||||
return (2.0 * math.sqrt(areaSq)) / dAB;
|
||||
final t = ((px * dx + py * dy) / lenSq).clamp(0.0, 1.0);
|
||||
return AppLatLng(
|
||||
a.latitude + t * (b.latitude - a.latitude),
|
||||
a.longitude + t * (b.longitude - a.longitude),
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculates strictly decreasing remaining distance along the polyline.
|
||||
static double _remainingDistanceAlongPolyline(
|
||||
List<AppLatLng> polyline,
|
||||
int segmentIndex,
|
||||
AppLatLng projectedPoint,
|
||||
) {
|
||||
if (polyline.isEmpty) return 0.0;
|
||||
if (polyline.length == 1) return 0.0;
|
||||
if (segmentIndex >= polyline.length - 1) {
|
||||
return _haversineMeters(projectedPoint, polyline.last);
|
||||
}
|
||||
|
||||
double remaining = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]);
|
||||
for (int i = segmentIndex + 1; i < polyline.length - 1; i++) {
|
||||
remaining += _haversineMeters(polyline[i], polyline[i + 1]);
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
/// Calculates distance along polyline from projected point to a maneuver offset.
|
||||
static double _distanceToOffsetAlongPolyline(
|
||||
List<AppLatLng> polyline,
|
||||
int segmentIndex,
|
||||
AppLatLng projectedPoint,
|
||||
int targetOffset, {
|
||||
required AppLatLng targetLocation,
|
||||
}) {
|
||||
if (polyline.isEmpty) return _haversineMeters(projectedPoint, targetLocation);
|
||||
if (segmentIndex >= targetOffset) {
|
||||
return _haversineMeters(projectedPoint, targetLocation);
|
||||
}
|
||||
|
||||
double dist = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]);
|
||||
final limit = math.min(targetOffset, polyline.length - 1);
|
||||
for (int i = segmentIndex + 1; i < limit; i++) {
|
||||
dist += _haversineMeters(polyline[i], polyline[i + 1]);
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
/// Resolves monotonically increasing polyline indices for each maneuver.
|
||||
static List<int> _getManeuverOffsets(
|
||||
List<RouteManeuver> maneuvers,
|
||||
List<AppLatLng> points,
|
||||
) {
|
||||
final offsets = <int>[];
|
||||
for (int i = 0; i < maneuvers.length; i++) {
|
||||
final m = maneuvers[i];
|
||||
if (m.geometryOffset != null &&
|
||||
m.geometryOffset! >= 0 &&
|
||||
m.geometryOffset! < points.length) {
|
||||
offsets.add(m.geometryOffset!);
|
||||
} else {
|
||||
int bestIdx = 0;
|
||||
double bestDist = double.infinity;
|
||||
for (int j = 0; j < points.length; j++) {
|
||||
final d = _haversineMeters(points[j], m.location);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = j;
|
||||
}
|
||||
}
|
||||
offsets.add(bestIdx);
|
||||
}
|
||||
}
|
||||
for (int i = 1; i < offsets.length; i++) {
|
||||
if (offsets[i] < offsets[i - 1]) {
|
||||
offsets[i] = offsets[i - 1];
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
|
||||
|
|
@ -469,6 +670,18 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
|||
}
|
||||
}
|
||||
|
||||
class _PolylineProjection {
|
||||
final int segmentIndex;
|
||||
final AppLatLng projectedPoint;
|
||||
final double distanceToRouteMeters;
|
||||
|
||||
const _PolylineProjection({
|
||||
required this.segmentIndex,
|
||||
required this.projectedPoint,
|
||||
required this.distanceToRouteMeters,
|
||||
});
|
||||
}
|
||||
|
||||
/// Global shared navigation session provider across Map and Dashboard.
|
||||
final navigationSessionProvider =
|
||||
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
final seenIds = <String>{};
|
||||
final combined = <PlaceSearchResult>[];
|
||||
|
||||
// Sort text results with coordinates by physical proximity to rider
|
||||
if (nearLocation != null) {
|
||||
textItems.sort((a, b) {
|
||||
final da = (a.latitude != null && a.longitude != null)
|
||||
? _distanceMeters(nearLocation, AppLatLng(a.latitude!, a.longitude!))
|
||||
: double.infinity;
|
||||
final db = (b.latitude != null && b.longitude != null)
|
||||
? _distanceMeters(nearLocation, AppLatLng(b.latitude!, b.longitude!))
|
||||
: double.infinity;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer text items first because coordinates are already populated
|
||||
for (final item in textItems) {
|
||||
if (seenIds.add(item.providerId)) {
|
||||
|
|
@ -101,6 +114,9 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
body: jsonEncode({
|
||||
'QueryText': clean,
|
||||
'BiasPosition': bias,
|
||||
'Filter': {
|
||||
'IncludeCountries': ['USA'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
|
@ -142,6 +158,9 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
body: jsonEncode({
|
||||
'QueryText': clean,
|
||||
'BiasPosition': bias,
|
||||
'Filter': {
|
||||
'IncludeCountries': ['USA'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
|
@ -276,6 +295,12 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
.toList()
|
||||
: null;
|
||||
|
||||
final avoidMap = <String, dynamic>{
|
||||
if (effectiveAvoidHighways) 'ControlledAccessHighways': true,
|
||||
if (effectiveAvoidTolls) 'TollRoads': true,
|
||||
if (effectiveAvoidFerries) 'Ferries': true,
|
||||
};
|
||||
|
||||
final travelOptions = <String, dynamic>{
|
||||
'Car': {
|
||||
'Avoid': {
|
||||
|
|
@ -291,12 +316,15 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
'Destination': destination,
|
||||
'TravelMode': 'Car',
|
||||
'TravelModeOptions': travelOptions,
|
||||
if (avoidMap.isNotEmpty) 'Avoid': avoidMap,
|
||||
'OptimizeRoutingFor': optimizeFor,
|
||||
'LegGeometryFormat': 'Simple',
|
||||
'LegAdditionalInputs': ['TravelSteps'],
|
||||
'Instructions': true,
|
||||
if (options?.preference == RoutingPreference.twisty ||
|
||||
options?.preference == RoutingPreference.scenic)
|
||||
options?.preference == RoutingPreference.scenic ||
|
||||
options?.preference == RoutingPreference.fastest ||
|
||||
options?.preference == RoutingPreference.shortest)
|
||||
'MaxAlternatives': 2,
|
||||
};
|
||||
if (waypoints != null && waypoints.isNotEmpty) {
|
||||
|
|
@ -358,6 +386,18 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
}
|
||||
}
|
||||
|
||||
if (geom.isEmpty) {
|
||||
final rGMap = r['Geometry'] as Map<String, dynamic>?;
|
||||
final rLineString = rGMap?['LineString'] as List<dynamic>?;
|
||||
if (rLineString != null) {
|
||||
for (final pt in rLineString) {
|
||||
if (pt is List && pt.length >= 2) {
|
||||
geom.add(AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final effectiveGeom = geom.isNotEmpty ? geom : ordered.map((s) => s.latLng).toList();
|
||||
final effectiveDist = distanceM > 0 ? distanceM : straightLine;
|
||||
final distMi = effectiveDist * 0.000621371;
|
||||
|
|
@ -393,6 +433,9 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
} else if (options?.preference == RoutingPreference.fastest) {
|
||||
candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds));
|
||||
selected = candidates.first;
|
||||
} else if (options?.preference == RoutingPreference.scenic) {
|
||||
candidates.sort((a, b) => b.curvature.compareTo(a.curvature));
|
||||
selected = candidates.first;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,15 +474,38 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
|
||||
for (final rawStep in travelSteps) {
|
||||
if (rawStep is! Map<String, dynamic>) continue;
|
||||
final instruction = rawStep['Instruction']?.toString() ?? 'Continue';
|
||||
final stepType = rawStep['Type']?.toString();
|
||||
final turn = rawStep['Turn']?.toString();
|
||||
final stepType = rawStep['Type']?.toString() ?? 'Continue';
|
||||
final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0;
|
||||
final road = rawStep['NextRoad']?.toString() ??
|
||||
rawStep['CurrentRoad']?.toString();
|
||||
final offset = (rawStep['GeometryOffset'] as num?)?.toInt();
|
||||
|
||||
// Extract steering direction from AWS Location Routes V2 details objects
|
||||
final turnStep = rawStep['TurnStepDetails'] as Map<String, dynamic>?;
|
||||
final rampStep = rawStep['RampStepDetails'] as Map<String, dynamic>?;
|
||||
final keepStep = rawStep['KeepStepDetails'] as Map<String, dynamic>?;
|
||||
final uTurnStep = rawStep['UTurnStepDetails'] as Map<String, dynamic>?;
|
||||
final roundaboutStep = rawStep['RoundaboutStepDetails'] as Map<String, dynamic>?;
|
||||
|
||||
final turn = turnStep?['SteeringDirection']?.toString() ??
|
||||
rampStep?['SteeringDirection']?.toString() ??
|
||||
keepStep?['SteeringDirection']?.toString() ??
|
||||
uTurnStep?['SteeringDirection']?.toString() ??
|
||||
roundaboutStep?['SteeringDirection']?.toString() ??
|
||||
rawStep['Turn']?.toString();
|
||||
|
||||
final initialType = _mapManeuverType(stepType, turn);
|
||||
final canonical = _canonicalizeManeuver(
|
||||
stepType: stepType,
|
||||
turn: turn,
|
||||
road: road,
|
||||
rawInstruction: rawStep['Instruction']?.toString(),
|
||||
initialType: initialType,
|
||||
offset: offset,
|
||||
geometry: geometry,
|
||||
);
|
||||
|
||||
AppLatLng loc;
|
||||
if (offset != null && offset >= 0 && offset < geometry.length) {
|
||||
loc = geometry[offset];
|
||||
|
|
@ -452,8 +518,8 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
maneuvers.add(
|
||||
RouteManeuver(
|
||||
index: maneuvers.length,
|
||||
instruction: instruction,
|
||||
type: _mapManeuverType(stepType, turn),
|
||||
instruction: canonical.instruction,
|
||||
type: canonical.type,
|
||||
distanceMeters: dist,
|
||||
durationSeconds: dur,
|
||||
streetName: road,
|
||||
|
|
@ -472,6 +538,202 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
return maneuvers;
|
||||
}
|
||||
|
||||
static ({ManeuverType type, String instruction}) _canonicalizeManeuver({
|
||||
required String stepType,
|
||||
required String? turn,
|
||||
required String? road,
|
||||
required String? rawInstruction,
|
||||
required ManeuverType initialType,
|
||||
required int? offset,
|
||||
required List<AppLatLng> geometry,
|
||||
}) {
|
||||
ManeuverType resolvedType = initialType;
|
||||
String resolvedInstruction = _buildManeuverInstruction(
|
||||
stepType: stepType,
|
||||
turn: turn,
|
||||
road: road,
|
||||
rawInstruction: rawInstruction,
|
||||
maneuverType: initialType,
|
||||
);
|
||||
|
||||
// Calculate polyline bearing delta at offset if geometry is available
|
||||
double? bearingDelta;
|
||||
if (geometry.length >= 2 && offset != null && offset >= 0 && offset < geometry.length - 1) {
|
||||
final prevIdx = (offset > 0) ? offset - 1 : 0;
|
||||
final nextIdx = (offset + 1 < geometry.length) ? offset + 1 : offset;
|
||||
if (prevIdx != offset && offset != nextIdx) {
|
||||
final b1 = _bearing(geometry[prevIdx], geometry[offset]);
|
||||
final b2 = _bearing(geometry[offset], geometry[nextIdx]);
|
||||
var d = b2 - b1;
|
||||
while (d < -180.0) {
|
||||
d += 360.0;
|
||||
}
|
||||
while (d > 180.0) {
|
||||
d -= 360.0;
|
||||
}
|
||||
bearingDelta = d;
|
||||
}
|
||||
}
|
||||
|
||||
final rawLower = (rawInstruction ?? '').toLowerCase().trim();
|
||||
final bool rawHasLeft = rawLower.contains('left');
|
||||
final bool rawHasRight = rawLower.contains('right');
|
||||
final bool rawHasUturn = rawLower.contains('u-turn') || rawLower.contains('uturn');
|
||||
|
||||
// 1. If raw instruction explicitly states U-turn
|
||||
if (rawHasUturn) {
|
||||
resolvedType = ManeuverType.uTurn;
|
||||
}
|
||||
// 2. If raw instruction specifies turn direction but resolvedType is continueStraight
|
||||
else if (resolvedType == ManeuverType.continueStraight) {
|
||||
if (rawHasLeft) {
|
||||
if (rawLower.contains('sharp')) {
|
||||
resolvedType = ManeuverType.turnSharpLeft;
|
||||
} else if (rawLower.contains('slight') || rawLower.contains('bear')) {
|
||||
resolvedType = ManeuverType.turnSlightLeft;
|
||||
} else if (rawLower.contains('keep')) {
|
||||
resolvedType = ManeuverType.keepLeft;
|
||||
} else {
|
||||
resolvedType = ManeuverType.turnLeft;
|
||||
}
|
||||
} else if (rawHasRight) {
|
||||
if (rawLower.contains('sharp')) {
|
||||
resolvedType = ManeuverType.turnSharpRight;
|
||||
} else if (rawLower.contains('slight') || rawLower.contains('bear')) {
|
||||
resolvedType = ManeuverType.turnSlightRight;
|
||||
} else if (rawLower.contains('keep')) {
|
||||
resolvedType = ManeuverType.keepRight;
|
||||
} else {
|
||||
resolvedType = ManeuverType.turnRight;
|
||||
}
|
||||
} else if (stepType.toLowerCase() == 'turn' &&
|
||||
bearingDelta != null &&
|
||||
bearingDelta.abs() >= 20.0) {
|
||||
// Only convert to turn if step was an explicit Turn step lacking steering direction.
|
||||
// Pure road bends (Type: 'Continue' / 'Follow') along the existing road remain continueStraight.
|
||||
if (bearingDelta > 120.0) {
|
||||
resolvedType = ManeuverType.turnSharpRight;
|
||||
} else if (bearingDelta > 45.0) {
|
||||
resolvedType = ManeuverType.turnRight;
|
||||
} else if (bearingDelta > 20.0) {
|
||||
resolvedType = ManeuverType.turnSlightRight;
|
||||
} else if (bearingDelta < -120.0) {
|
||||
resolvedType = ManeuverType.turnSharpLeft;
|
||||
} else if (bearingDelta < -45.0) {
|
||||
resolvedType = ManeuverType.turnLeft;
|
||||
} else if (bearingDelta < -20.0) {
|
||||
resolvedType = ManeuverType.turnSlightLeft;
|
||||
}
|
||||
resolvedInstruction = _buildManeuverInstruction(
|
||||
stepType: stepType,
|
||||
turn: resolvedType.name,
|
||||
road: road,
|
||||
rawInstruction: null,
|
||||
maneuverType: resolvedType,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 3. Direct conflict resolution: instruction says left but type says right (or vice versa)
|
||||
else if (resolvedType.isLeft && rawHasRight && !rawHasLeft) {
|
||||
if (bearingDelta != null && bearingDelta > 20.0) {
|
||||
resolvedType = ManeuverType.turnRight;
|
||||
} else if (bearingDelta != null && bearingDelta < -20.0) {
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
resolvedInstruction = 'Turn left$roadSuffix';
|
||||
} else {
|
||||
resolvedType = ManeuverType.turnRight;
|
||||
}
|
||||
} else if (resolvedType.isRight && rawHasLeft && !rawHasRight) {
|
||||
if (bearingDelta != null && bearingDelta < -20.0) {
|
||||
resolvedType = ManeuverType.turnLeft;
|
||||
} else if (bearingDelta != null && bearingDelta > 20.0) {
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
resolvedInstruction = 'Turn right$roadSuffix';
|
||||
} else {
|
||||
resolvedType = ManeuverType.turnLeft;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Authoritative geometry override: only for explicit Turn steps where provider claimed wrong direction
|
||||
if (bearingDelta != null && (stepType.toLowerCase() == 'turn' || rawHasLeft || rawHasRight)) {
|
||||
if (resolvedType.isRight && bearingDelta < -35.0) {
|
||||
resolvedType = bearingDelta < -120.0
|
||||
? ManeuverType.turnSharpLeft
|
||||
: (bearingDelta < -45.0 ? ManeuverType.turnLeft : ManeuverType.turnSlightLeft);
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
resolvedInstruction = 'Turn left$roadSuffix';
|
||||
} else if (resolvedType.isLeft && bearingDelta > 35.0) {
|
||||
resolvedType = bearingDelta > 120.0
|
||||
? ManeuverType.turnSharpRight
|
||||
: (bearingDelta > 45.0 ? ManeuverType.turnRight : ManeuverType.turnSlightRight);
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
resolvedInstruction = 'Turn right$roadSuffix';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-verify that resolved instruction and resolved type agree on direction
|
||||
if (resolvedType.isLeft && resolvedInstruction.toLowerCase().contains('right')) {
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
resolvedInstruction = 'Turn left$roadSuffix';
|
||||
} else if (resolvedType.isRight && resolvedInstruction.toLowerCase().contains('left')) {
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
resolvedInstruction = 'Turn right$roadSuffix';
|
||||
}
|
||||
|
||||
return (type: resolvedType, instruction: resolvedInstruction);
|
||||
}
|
||||
|
||||
static String _buildManeuverInstruction({
|
||||
required String stepType,
|
||||
required String? turn,
|
||||
required String? road,
|
||||
required String? rawInstruction,
|
||||
required ManeuverType maneuverType,
|
||||
}) {
|
||||
if (rawInstruction != null &&
|
||||
rawInstruction.trim().isNotEmpty &&
|
||||
rawInstruction.trim().toLowerCase() != 'continue') {
|
||||
return rawInstruction.trim();
|
||||
}
|
||||
|
||||
final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : '';
|
||||
|
||||
switch (maneuverType) {
|
||||
case ManeuverType.depart:
|
||||
return roadSuffix.isNotEmpty ? 'Head out$roadSuffix' : 'Head towards destination';
|
||||
case ManeuverType.arrive:
|
||||
return 'Arrive at destination';
|
||||
case ManeuverType.turnRight:
|
||||
return 'Turn right$roadSuffix';
|
||||
case ManeuverType.turnLeft:
|
||||
return 'Turn left$roadSuffix';
|
||||
case ManeuverType.turnSharpRight:
|
||||
return 'Turn sharp right$roadSuffix';
|
||||
case ManeuverType.turnSharpLeft:
|
||||
return 'Turn sharp left$roadSuffix';
|
||||
case ManeuverType.turnSlightRight:
|
||||
return 'Turn slight right$roadSuffix';
|
||||
case ManeuverType.turnSlightLeft:
|
||||
return 'Turn slight left$roadSuffix';
|
||||
case ManeuverType.uTurn:
|
||||
return 'Make a U-turn$roadSuffix';
|
||||
case ManeuverType.ramp:
|
||||
final side = (turn?.toLowerCase() == 'left') ? ' on the left' : ' on the right';
|
||||
return 'Take the ramp$side$roadSuffix';
|
||||
case ManeuverType.keepRight:
|
||||
return 'Keep right$roadSuffix';
|
||||
case ManeuverType.keepLeft:
|
||||
return 'Keep left$roadSuffix';
|
||||
case ManeuverType.enterRoundabout:
|
||||
return 'Enter roundabout$roadSuffix';
|
||||
case ManeuverType.exitRoundabout:
|
||||
return 'Exit roundabout$roadSuffix';
|
||||
case ManeuverType.continueStraight:
|
||||
default:
|
||||
return roadSuffix.isNotEmpty ? 'Continue$roadSuffix' : 'Continue straight';
|
||||
}
|
||||
}
|
||||
|
||||
static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) {
|
||||
final t = (typeStr ?? '').toLowerCase();
|
||||
final turn = (turnStr ?? '').toLowerCase();
|
||||
|
|
@ -492,6 +754,15 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft;
|
||||
if (turn == 'keepright') return ManeuverType.keepRight;
|
||||
if (turn == 'keepleft') return ManeuverType.keepLeft;
|
||||
if (t == 'keep') {
|
||||
if (turn.contains('left')) return ManeuverType.keepLeft;
|
||||
if (turn.contains('right')) return ManeuverType.keepRight;
|
||||
return ManeuverType.continueStraight;
|
||||
}
|
||||
if (t == 'exit') {
|
||||
if (turn.contains('left')) return ManeuverType.turnLeft;
|
||||
return ManeuverType.turnRight;
|
||||
}
|
||||
if (t == 'continue') return ManeuverType.continueStraight;
|
||||
return ManeuverType.continueStraight;
|
||||
}
|
||||
|
|
@ -606,6 +877,18 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
final brng = math.atan2(y, x) * (180.0 / math.pi);
|
||||
return (brng + 360.0) % 360.0;
|
||||
}
|
||||
|
||||
static double _distanceMeters(AppLatLng p1, AppLatLng p2) {
|
||||
const double r = 6371000.0;
|
||||
final lat1 = p1.latitude * (math.pi / 180.0);
|
||||
final lat2 = p2.latitude * (math.pi / 180.0);
|
||||
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
|
||||
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
|
||||
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(lat1) * math.cos(lat2) * math.sin(dLon / 2) * math.sin(dLon / 2);
|
||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
return r * c;
|
||||
}
|
||||
}
|
||||
|
||||
class _CandidateRoute {
|
||||
|
|
|
|||
|
|
@ -518,6 +518,18 @@ enum ManeuverType {
|
|||
return 'CONTINUE';
|
||||
}
|
||||
}
|
||||
|
||||
bool get isLeft =>
|
||||
this == ManeuverType.turnLeft ||
|
||||
this == ManeuverType.turnSharpLeft ||
|
||||
this == ManeuverType.turnSlightLeft ||
|
||||
this == ManeuverType.keepLeft;
|
||||
|
||||
bool get isRight =>
|
||||
this == ManeuverType.turnRight ||
|
||||
this == ManeuverType.turnSharpRight ||
|
||||
this == ManeuverType.turnSlightRight ||
|
||||
this == ManeuverType.keepRight;
|
||||
}
|
||||
|
||||
/// A single turn-by-turn navigation maneuver step.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ class SensorFusionDiagnostics {
|
|||
final String dashboardDirection;
|
||||
final bool isCalibrated;
|
||||
final String mountOrientation;
|
||||
final double eventRateHz;
|
||||
final double longitudinalG;
|
||||
final double lateralG;
|
||||
|
||||
const SensorFusionDiagnostics({
|
||||
required this.rawAccelX,
|
||||
|
|
@ -64,6 +67,9 @@ class SensorFusionDiagnostics {
|
|||
required this.dashboardDirection,
|
||||
required this.isCalibrated,
|
||||
required this.mountOrientation,
|
||||
this.eventRateHz = 50.0,
|
||||
this.longitudinalG = 0.0,
|
||||
this.lateralG = 0.0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
|
|
@ -78,6 +84,9 @@ class SensorFusionDiagnostics {
|
|||
'dashboardDirection': dashboardDirection,
|
||||
'isCalibrated': isCalibrated,
|
||||
'mountOrientation': mountOrientation,
|
||||
'eventRateHz': eventRateHz,
|
||||
'longitudinalG': longitudinalG,
|
||||
'lateralG': lateralG,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +144,7 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
bool _isCalibrated = false;
|
||||
bool get isCalibrated => _isCalibrated;
|
||||
int _remountAnomalyCount = 0;
|
||||
int _remountReturnCount = 0;
|
||||
|
||||
// Stored baseline gravity vector at calibration for remount detection
|
||||
double _calGravityX = 0.0;
|
||||
|
|
@ -149,6 +159,9 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
double _latestRawGyroY = 0.0;
|
||||
double _latestRawGyroZ = 0.0;
|
||||
double _latestRollRateDegPerSec = 0.0;
|
||||
double _eventRateHz = 50.0;
|
||||
int _sensorEventCounter = 0;
|
||||
DateTime? _lastRateWindow;
|
||||
|
||||
double get latestRawAccelX => _latestRawAccelX;
|
||||
double get latestRawAccelY => _latestRawAccelY;
|
||||
|
|
@ -157,6 +170,7 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
double get latestRawGyroY => _latestRawGyroY;
|
||||
double get latestRawGyroZ => _latestRawGyroZ;
|
||||
double get latestRollRateDegPerSec => _latestRollRateDegPerSec;
|
||||
double get eventRateHz => _eventRateHz;
|
||||
|
||||
// Sensor fusion state
|
||||
double _currentRoll = 0.0;
|
||||
|
|
@ -182,6 +196,12 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
MountOrientation _mountOrientation = MountOrientation.portraitUpright;
|
||||
MountOrientation get mountOrientation => _mountOrientation;
|
||||
|
||||
double _speedMph = 0.0;
|
||||
double get speedMph => _speedMph;
|
||||
void updateVehicleSpeed(double speedMph) {
|
||||
_speedMph = speedMph;
|
||||
}
|
||||
|
||||
static const double _gravityConst = 9.80665;
|
||||
|
||||
// Low-pass filter for G-Force
|
||||
|
|
@ -304,13 +324,29 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
_updateDisplayOrientation();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_updateDisplayOrientation();
|
||||
_ensureSensorsListening();
|
||||
if (!_isCalibrated) {
|
||||
loadCalibrationForMotorcycle(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureSensorsListening() {
|
||||
if (_accelSub == null || _gyroSub == null) {
|
||||
_startSensors();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateDisplayOrientation() {
|
||||
try {
|
||||
final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull;
|
||||
if (view != null && view.physicalSize.height > 0) {
|
||||
final size = view.physicalSize;
|
||||
// Only adapt automatically if display dimensions are realistic mobile dimensions
|
||||
if (size.width > size.height && size.width < 1500) {
|
||||
if (size.width > size.height) {
|
||||
_isLandscape = true;
|
||||
_updateMountOrientation();
|
||||
_recomputeBasisVectors();
|
||||
|
|
@ -345,7 +381,6 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
}
|
||||
|
||||
void _checkRemountValidity(double ax, double ay, double az) {
|
||||
if (!_isCalibrated) return;
|
||||
final calNorm = math.sqrt(_calGravityX * _calGravityX + _calGravityY * _calGravityY + _calGravityZ * _calGravityZ);
|
||||
final curNorm = math.sqrt(ax * ax + ay * ay + az * az);
|
||||
if (calNorm < 3.0 || curNorm < 3.0) return;
|
||||
|
|
@ -355,35 +390,56 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
final ny = ay / curNorm;
|
||||
final nz = az / curNorm;
|
||||
|
||||
// Project current vector onto 3D Vehicle Coordinate Frame:
|
||||
// vUp: component along calibrated upright axis
|
||||
// vForward: component along motorcycle longitudinal/forward axis
|
||||
final vUp = (nx * _upVector[0] + ny * _upVector[1] + nz * _upVector[2]).clamp(-1.0, 1.0);
|
||||
final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0);
|
||||
|
||||
// Total 3D angular deviation from calibrated upright gravity
|
||||
// Project current vector onto calibrated upright gravity axis
|
||||
final vUp = (nx * (_calGravityX / calNorm) + ny * (_calGravityY / calNorm) + nz * (_calGravityZ / calNorm)).clamp(-1.0, 1.0);
|
||||
final totalAngleDiffDeg = math.acos(vUp) * (180.0 / math.pi);
|
||||
|
||||
// Pitch tilt (tilt out of motorcycle roll plane)
|
||||
if (!_isCalibrated) {
|
||||
// Automatic re-lock: if the device returns to the motorcycle mount (within 25° of calibrated gravity)
|
||||
if (totalAngleDiffDeg < 25.0) {
|
||||
_remountReturnCount++;
|
||||
if (_remountReturnCount >= 15) {
|
||||
_isCalibrated = true;
|
||||
_remountAnomalyCount = 0;
|
||||
_remountReturnCount = 0;
|
||||
_recomputeBasisVectors();
|
||||
_emitDiagnostics(0.0);
|
||||
}
|
||||
} else {
|
||||
_remountReturnCount = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_remountReturnCount = 0;
|
||||
|
||||
// Do not invalidate calibration while the vehicle is actively in motion or experiencing dynamic rotation
|
||||
if (_speedMph > 2.0 || _latestRollRateDegPerSec.abs() > 3.0) {
|
||||
_remountAnomalyCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Project current vector onto 3D Vehicle Coordinate Frame:
|
||||
final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0);
|
||||
final pitchTiltDeg = (math.asin(vForward)).abs() * (180.0 / math.pi);
|
||||
|
||||
// Motorcycle Lean & Remount Physics:
|
||||
// 1. Roll-axis rotation (up to 65°) is normal motorcycle riding, cornering, and kickstand parking (20°-35°).
|
||||
// Deviations within the roll plane with pitchTiltDeg <= 35° and totalAngleDiffDeg <= 68°
|
||||
// must NEVER invalidate calibration!
|
||||
// 2. True remount / phone removal:
|
||||
// Deviations within the roll plane must NEVER invalidate calibration.
|
||||
// 2. Cockpit mounts: Handlebar mounts (QuadLock, RAM) are commonly pitched backwards 35°-55° facing the rider.
|
||||
// 3. True remount / phone removal:
|
||||
// - Placed flat on desk (screen up): totalAngleDiffDeg ~ 90°, pitchTiltDeg ~ 90°
|
||||
// - Placed in pocket/bag: totalAngleDiffDeg >= 90°
|
||||
// - Mount rotated or twisted: totalAngleDiffDeg > 68° or pitchTiltDeg > 38°
|
||||
final isImmediateRemount = totalAngleDiffDeg >= 75.0 || pitchTiltDeg >= 42.0;
|
||||
final isSuspectedRemount = totalAngleDiffDeg > 68.0 || pitchTiltDeg > 35.0;
|
||||
// - Placed in pocket/bag: totalAngleDiffDeg >= 80°
|
||||
// - Mount rotated or twisted when stationary: totalAngleDiffDeg >= 80° or pitchTiltDeg >= 65°
|
||||
final isImmediateRemount = totalAngleDiffDeg >= 80.0 || pitchTiltDeg >= 65.0;
|
||||
final isSuspectedRemount = totalAngleDiffDeg > 70.0 || pitchTiltDeg > 55.0;
|
||||
|
||||
if (isImmediateRemount) {
|
||||
_invalidateRemount(totalAngleDiffDeg);
|
||||
} else if (isSuspectedRemount) {
|
||||
_remountAnomalyCount++;
|
||||
// Require 5 consecutive stationary samples (100ms) to filter out transient bumps or noise
|
||||
if (_remountAnomalyCount >= 5) {
|
||||
// Require 50 consecutive stationary samples (1000ms) to filter out transient bumps, fork dive, or vibrations
|
||||
if (_remountAnomalyCount >= 50) {
|
||||
_invalidateRemount(totalAngleDiffDeg);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -392,17 +448,14 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
}
|
||||
|
||||
void _invalidateRemount(double angleDiffDeg) {
|
||||
debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating calibration.');
|
||||
debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating runtime calibration.');
|
||||
_calibrationVersion++;
|
||||
_isCalibrated = false;
|
||||
_remountAnomalyCount = 0;
|
||||
_remountReturnCount = 0;
|
||||
_rollOffset = 0.0;
|
||||
_pitchOffset = 0.0;
|
||||
_calGravityX = 0.0;
|
||||
_calGravityY = 0.0;
|
||||
_calGravityZ = 0.0;
|
||||
_recomputeBasisVectors();
|
||||
_calibrationService.clearCalibration();
|
||||
_emitDiagnostics(0.0);
|
||||
}
|
||||
|
||||
|
|
@ -494,6 +547,19 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
}
|
||||
|
||||
void handleAccelerometerEvent(AccelerometerEvent event) {
|
||||
_sensorEventCounter++;
|
||||
final now = DateTime.now();
|
||||
if (_lastRateWindow == null) {
|
||||
_lastRateWindow = now;
|
||||
} else {
|
||||
final elapsedMs = now.difference(_lastRateWindow!).inMilliseconds;
|
||||
if (elapsedMs >= 1000) {
|
||||
_eventRateHz = (_sensorEventCounter * 1000.0) / elapsedMs;
|
||||
_sensorEventCounter = 0;
|
||||
_lastRateWindow = now;
|
||||
}
|
||||
}
|
||||
|
||||
_latestRawAccelX = event.x;
|
||||
_latestRawAccelY = event.y;
|
||||
_latestRawAccelZ = event.z;
|
||||
|
|
@ -685,6 +751,9 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
dashboardDirection: direction,
|
||||
isCalibrated: _isCalibrated,
|
||||
mountOrientation: _mountOrientation.name,
|
||||
eventRateHz: _eventRateHz,
|
||||
longitudinalG: _filteredLongitudinalG,
|
||||
lateralG: _filteredLateralG,
|
||||
);
|
||||
|
||||
_latestDiagnostics = diag;
|
||||
|
|
@ -728,7 +797,13 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
|
||||
_isCalibrated = isCal;
|
||||
_remountAnomalyCount = 0;
|
||||
_remountReturnCount = 0;
|
||||
_recomputeBasisVectors();
|
||||
if (_isCalibrated) {
|
||||
_leanAngleController.add(0.0);
|
||||
_pitchAngleController.add(0.0);
|
||||
_emitDiagnostics(0.0);
|
||||
}
|
||||
} catch (_) {
|
||||
if (version != _calibrationVersion) return;
|
||||
_isCalibrated = false;
|
||||
|
|
@ -746,6 +821,7 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
_calGravityZ = _latestRawAccelZ;
|
||||
_isCalibrated = true;
|
||||
_remountAnomalyCount = 0;
|
||||
_remountReturnCount = 0;
|
||||
|
||||
// Auto-detect mount orientation from gravity
|
||||
final ax = _calGravityX.abs();
|
||||
|
|
@ -793,6 +869,7 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
_calibrationVersion++;
|
||||
_isCalibrated = false;
|
||||
_remountAnomalyCount = 0;
|
||||
_remountReturnCount = 0;
|
||||
_rollOffset = 0.0;
|
||||
_pitchOffset = 0.0;
|
||||
_calGravityX = 0.0;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Preset avatar options available for all riders without external image hosting.
|
||||
class PresetAvatar {
|
||||
|
|
@ -6,12 +6,14 @@ class PresetAvatar {
|
|||
final String label;
|
||||
final int primaryColorHex;
|
||||
final int secondaryColorHex;
|
||||
final IconData icon;
|
||||
|
||||
const PresetAvatar({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.primaryColorHex,
|
||||
required this.secondaryColorHex,
|
||||
this.icon = Icons.two_wheeler,
|
||||
});
|
||||
|
||||
static const List<PresetAvatar> availablePresets = [
|
||||
|
|
@ -20,48 +22,56 @@ class PresetAvatar {
|
|||
label: 'Cyan Apex',
|
||||
primaryColorHex: 0xFF00E5FF,
|
||||
secondaryColorHex: 0xFF003840,
|
||||
icon: Icons.sports_motorsports,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'helmet_amber',
|
||||
label: 'Amber Sector',
|
||||
primaryColorHex: 0xFFFFB300,
|
||||
secondaryColorHex: 0xFF4A3200,
|
||||
icon: Icons.two_wheeler,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'tach_neon',
|
||||
label: 'Neon Tach',
|
||||
primaryColorHex: 0xFF00E676,
|
||||
secondaryColorHex: 0xFF00381B,
|
||||
icon: Icons.speed,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'speed_violet',
|
||||
label: 'Violet Velocity',
|
||||
primaryColorHex: 0xFFD500F9,
|
||||
secondaryColorHex: 0xFF3E004A,
|
||||
icon: Icons.electric_bolt,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'cruiser_red',
|
||||
label: 'Crimson Cruiser',
|
||||
primaryColorHex: 0xFFFF1744,
|
||||
secondaryColorHex: 0xFF4A0513,
|
||||
icon: Icons.motorcycle,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'adv_blue',
|
||||
label: 'Adventure Azure',
|
||||
primaryColorHex: 0xFF2979FF,
|
||||
secondaryColorHex: 0xFF0A224A,
|
||||
icon: Icons.terrain,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'cafe_gold',
|
||||
label: 'Cafe Gold',
|
||||
primaryColorHex: 0xFFFFD700,
|
||||
secondaryColorHex: 0xFF4A3E00,
|
||||
icon: Icons.workspace_premium,
|
||||
),
|
||||
PresetAvatar(
|
||||
id: 'touring_slate',
|
||||
label: 'Touring Slate',
|
||||
primaryColorHex: 0xFF90A4AE,
|
||||
secondaryColorHex: 0xFF263238,
|
||||
icon: Icons.navigation,
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -138,12 +138,34 @@ class CloudRestoreController extends StateNotifier<CloudRestoreUiState> {
|
|||
: result.telemetryAlreadyPresent;
|
||||
parts.add('$alreadyPresent ride(s) already on this device');
|
||||
}
|
||||
final failureDetails = <String>[];
|
||||
if (result.telemetryFailed > 0) {
|
||||
failureDetails.add(
|
||||
'${result.telemetryFailed} ride(s) pending telemetry upload from source device',
|
||||
);
|
||||
}
|
||||
if (result.telemetryAuthFailures > 0) {
|
||||
failureDetails.add('${result.telemetryAuthFailures} ride(s) require re-authentication');
|
||||
}
|
||||
if (result.metadata.conflicts > 0) {
|
||||
failureDetails.add('${result.metadata.conflicts} conflict(s)');
|
||||
}
|
||||
if (result.metadata.skipped > 0) {
|
||||
failureDetails.add('${result.metadata.skipped} skipped');
|
||||
}
|
||||
|
||||
if (result.failures > 0) {
|
||||
parts.add('${result.failures} item(s) need attention');
|
||||
final explanation = failureDetails.isNotEmpty
|
||||
? failureDetails.join('; ')
|
||||
: '${result.failures} item(s) need attention';
|
||||
parts.add('Unresolved: $explanation');
|
||||
}
|
||||
if (parts.isEmpty) {
|
||||
return 'No restorable MotorCloud rides were found for this account.';
|
||||
}
|
||||
return 'MotorCloud restore complete: ${parts.join(', ')}.';
|
||||
final prefix = result.failures > 0
|
||||
? 'MotorCloud restore partially completed'
|
||||
: 'MotorCloud restore complete';
|
||||
return '$prefix: ${parts.join(', ')}.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ class TelemetryService {
|
|||
};
|
||||
|
||||
_locationSub = _locationService.getFilteredPositionStream().listen((pos) {
|
||||
final speedMph = pos.speed * 2.23694;
|
||||
_sensorFusionService.updateVehicleSpeed(speedMph);
|
||||
|
||||
if (_latestPosition != null) {
|
||||
final dt = pos.timestamp.difference(_latestPosition!.timestamp).inMicroseconds.abs() / 1000000.0;
|
||||
if (dt >= 0.05 && dt <= 3.0) {
|
||||
|
|
@ -98,7 +101,6 @@ class TelemetryService {
|
|||
final accelMps2 = (currentSpeedMps - prevSpeedMps) / dt;
|
||||
_gpsLongitudinalG = (accelMps2 / 9.80665).clamp(-1.5, 1.5);
|
||||
|
||||
final speedMph = pos.speed * 2.23694;
|
||||
if (speedMph >= 2.5 && pos.heading >= 0 && _latestPosition!.heading >= 0) {
|
||||
var dHeading = pos.heading - _latestPosition!.heading;
|
||||
while (dHeading > 180) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ void main() {
|
|||
expect(request.url.queryParameters['key'], equals('test-key'));
|
||||
final body = jsonDecode(request.body);
|
||||
expect(body['QueryText'], equals('Atlanta'));
|
||||
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'ResultItems': [
|
||||
|
|
@ -44,6 +45,8 @@ void main() {
|
|||
200,
|
||||
);
|
||||
} else if (request.url.path.contains('/v2/search-text')) {
|
||||
final body = jsonDecode(request.body);
|
||||
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'ResultItems': [
|
||||
|
|
@ -423,5 +426,392 @@ void main() {
|
|||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('calculateRoute parses real AWS Routes V2 TurnStepDetails and ControlledAccessHighways', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
final body = jsonDecode(request.body);
|
||||
expect(body['Avoid']['ControlledAccessHighways'], isTrue);
|
||||
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Routes': [
|
||||
{
|
||||
'Summary': {'Distance': 8500.0, 'Duration': 540},
|
||||
'Legs': [
|
||||
{
|
||||
'Geometry': {
|
||||
'LineString': [
|
||||
[-84.0, 34.0],
|
||||
[-84.02, 34.02],
|
||||
[-84.05, 34.05],
|
||||
[-84.08, 34.08],
|
||||
],
|
||||
},
|
||||
'VehicleLegDetails': {
|
||||
'TravelSteps': [
|
||||
{
|
||||
'Type': 'Depart',
|
||||
'Distance': 100.0,
|
||||
'Duration': 15,
|
||||
'GeometryOffset': 0,
|
||||
'CurrentRoad': 'Main St',
|
||||
},
|
||||
{
|
||||
'Type': 'Turn',
|
||||
'Distance': 2400.0,
|
||||
'Duration': 180,
|
||||
'GeometryOffset': 1,
|
||||
'NextRoad': 'Canyon Blvd',
|
||||
'TurnStepDetails': {
|
||||
'SteeringDirection': 'Right',
|
||||
'TurnIntensity': 'Typical',
|
||||
},
|
||||
},
|
||||
{
|
||||
'Type': 'Ramp',
|
||||
'Distance': 3000.0,
|
||||
'Duration': 200,
|
||||
'GeometryOffset': 2,
|
||||
'NextRoad': 'Mountain Pkwy',
|
||||
'RampStepDetails': {
|
||||
'SteeringDirection': 'Left',
|
||||
},
|
||||
},
|
||||
{
|
||||
'Type': 'Arrive',
|
||||
'Distance': 0.0,
|
||||
'Duration': 0,
|
||||
'GeometryOffset': 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final stops = [
|
||||
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
|
||||
const RouteStop(id: '2', displayName: 'End', latitude: 34.08, longitude: -84.08),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(
|
||||
stops,
|
||||
options: const RoutingOptions(
|
||||
avoidHighways: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.maneuvers.length, equals(4));
|
||||
expect(result.maneuvers[0].type, equals(ManeuverType.depart));
|
||||
expect(result.maneuvers[0].instruction, equals('Head out onto Main St'));
|
||||
expect(result.maneuvers[1].type, equals(ManeuverType.turnRight));
|
||||
expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Blvd'));
|
||||
expect(result.maneuvers[2].type, equals(ManeuverType.ramp));
|
||||
expect(result.maneuvers[2].instruction, equals('Take the ramp on the left onto Mountain Pkwy'));
|
||||
expect(result.maneuvers[3].type, equals(ManeuverType.arrive));
|
||||
expect(result.maneuvers[3].instruction, equals('Arrive at destination'));
|
||||
});
|
||||
|
||||
test('calculateRoute canonicalizes conflicting maneuver instructions and types', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Routes': [
|
||||
{
|
||||
'Distance': 5000.0,
|
||||
'Duration': 300,
|
||||
'Geometry': {
|
||||
'LineString': [
|
||||
[-84.00, 34.00],
|
||||
[-84.00, 34.01],
|
||||
[-84.02, 34.01], // Turns left
|
||||
],
|
||||
},
|
||||
'Legs': [
|
||||
{
|
||||
'VehicleLegDetails': {
|
||||
'TravelSteps': [
|
||||
{
|
||||
'Type': 'Depart',
|
||||
'Instruction': 'Head out',
|
||||
'NextRoad': 'Main St',
|
||||
'Distance': 1000.0,
|
||||
'Duration': 60,
|
||||
'GeometryOffset': 0,
|
||||
},
|
||||
{
|
||||
// Raw instruction says left, but Type is generic Continue and no turn detail
|
||||
'Type': 'Continue',
|
||||
'Instruction': 'Turn left onto Peachtree St',
|
||||
'NextRoad': 'Peachtree St',
|
||||
'Distance': 2000.0,
|
||||
'Duration': 120,
|
||||
'GeometryOffset': 1,
|
||||
},
|
||||
{
|
||||
'Type': 'Arrive',
|
||||
'Instruction': 'Arrive at destination',
|
||||
'Distance': 0.0,
|
||||
'Duration': 0,
|
||||
'GeometryOffset': 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final stops = [
|
||||
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
|
||||
const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(stops);
|
||||
|
||||
expect(result.maneuvers.length, equals(3));
|
||||
// Step 1 MUST be canonicalized to turnLeft matching its text instruction and left geometry
|
||||
expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft));
|
||||
expect(result.maneuvers[1].instruction, equals('Turn left onto Peachtree St'));
|
||||
});
|
||||
|
||||
test('calculateRoute handles bearing wraparound across North 0/360 degrees', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Routes': [
|
||||
{
|
||||
'Distance': 3000.0,
|
||||
'Duration': 180,
|
||||
'Geometry': {
|
||||
'LineString': [
|
||||
[-84.00000, 34.00000], // Start
|
||||
[-84.00176, 34.01000], // Heading ~350 degrees NNW
|
||||
[-83.99824, 34.02000], // Turns to heading ~20 degrees NNE (delta = +30 deg right)
|
||||
],
|
||||
},
|
||||
'Legs': [
|
||||
{
|
||||
'VehicleLegDetails': {
|
||||
'TravelSteps': [
|
||||
{
|
||||
'Type': 'Depart',
|
||||
'Instruction': 'Head out',
|
||||
'Distance': 1000.0,
|
||||
'Duration': 60,
|
||||
'GeometryOffset': 0,
|
||||
},
|
||||
{
|
||||
'Type': 'Turn',
|
||||
'Instruction': 'Turn onto Mountain Pass Rd',
|
||||
'NextRoad': 'Mountain Pass Rd',
|
||||
'Distance': 1000.0,
|
||||
'Duration': 60,
|
||||
'GeometryOffset': 1,
|
||||
},
|
||||
{
|
||||
'Type': 'Arrive',
|
||||
'Instruction': 'Arrive at summit',
|
||||
'Distance': 0.0,
|
||||
'Duration': 0,
|
||||
'GeometryOffset': 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final stops = [
|
||||
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
|
||||
const RouteStop(id: '2', displayName: 'Summit', latitude: 34.02, longitude: -83.99824),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(stops);
|
||||
|
||||
expect(result.maneuvers.length, equals(3));
|
||||
// Wrapped delta from 350 deg to 20 deg is +30 deg, which resolves to slight right turn
|
||||
expect(result.maneuvers[1].type.isRight, isTrue);
|
||||
expect(result.maneuvers[1].instruction.toLowerCase(), contains('right'));
|
||||
});
|
||||
|
||||
test('calculateRoute resolves provider text vs geometry mismatch using geometry truth', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Routes': [
|
||||
{
|
||||
'Distance': 4000.0,
|
||||
'Duration': 240,
|
||||
'Geometry': {
|
||||
'LineString': [
|
||||
[-84.00, 34.00],
|
||||
[-84.00, 34.01], // Heading 0 degrees North
|
||||
[-84.02, 34.01], // Heading 270 degrees West (delta = -90 deg Left turn)
|
||||
],
|
||||
},
|
||||
'Legs': [
|
||||
{
|
||||
'VehicleLegDetails': {
|
||||
'TravelSteps': [
|
||||
{
|
||||
'Type': 'Depart',
|
||||
'Instruction': 'Head out',
|
||||
'Distance': 1000.0,
|
||||
'Duration': 60,
|
||||
'GeometryOffset': 0,
|
||||
},
|
||||
{
|
||||
// Bug in raw provider: says Turn right, but geometry is 90 degree left turn!
|
||||
'Type': 'Turn',
|
||||
'TurnDetail': {'SteeringDirection': 'Right'},
|
||||
'Instruction': 'Turn right onto West Pkwy',
|
||||
'NextRoad': 'West Pkwy',
|
||||
'Distance': 2000.0,
|
||||
'Duration': 120,
|
||||
'GeometryOffset': 1,
|
||||
},
|
||||
{
|
||||
'Type': 'Arrive',
|
||||
'Instruction': 'Arrive at destination',
|
||||
'Distance': 0.0,
|
||||
'Duration': 0,
|
||||
'GeometryOffset': 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final stops = [
|
||||
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
|
||||
const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(stops);
|
||||
|
||||
expect(result.maneuvers.length, equals(3));
|
||||
// Step 1 MUST be corrected to turnLeft because geometry bearing delta is -90 degrees
|
||||
expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft));
|
||||
expect(result.maneuvers[1].instruction, equals('Turn left onto West Pkwy'));
|
||||
expect(result.maneuvers[1].type.isLeft, isTrue);
|
||||
expect(result.maneuvers[1].type.isRight, isFalse);
|
||||
});
|
||||
|
||||
test('calculateRoute preserves continueStraight for pure road bends without generating false turn announcements', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Routes': [
|
||||
{
|
||||
'Distance': 6000.0,
|
||||
'Duration': 360,
|
||||
'Geometry': {
|
||||
'LineString': [
|
||||
[-84.00, 34.00],
|
||||
[-84.00, 34.01],
|
||||
[-84.02, 34.01], // Curving road bend
|
||||
[-84.02, 34.02],
|
||||
],
|
||||
},
|
||||
'Legs': [
|
||||
{
|
||||
'VehicleLegDetails': {
|
||||
'TravelSteps': [
|
||||
{
|
||||
'Type': 'Depart',
|
||||
'Instruction': 'Head out onto US-129',
|
||||
'NextRoad': 'US-129',
|
||||
'Distance': 1000.0,
|
||||
'Duration': 60,
|
||||
'GeometryOffset': 0,
|
||||
},
|
||||
{
|
||||
// Pure road bend on US-129 - Must NOT be announced as a turn!
|
||||
'Type': 'Continue',
|
||||
'Instruction': 'Continue onto US-129',
|
||||
'NextRoad': 'US-129',
|
||||
'Distance': 4000.0,
|
||||
'Duration': 240,
|
||||
'GeometryOffset': 1,
|
||||
},
|
||||
{
|
||||
'Type': 'Arrive',
|
||||
'Instruction': 'Arrive at Deals Gap',
|
||||
'Distance': 0.0,
|
||||
'Duration': 0,
|
||||
'GeometryOffset': 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final stops = [
|
||||
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
|
||||
const RouteStop(id: '2', displayName: 'Deals Gap', latitude: 34.02, longitude: -84.02),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(stops);
|
||||
|
||||
expect(result.maneuvers.length, equals(3));
|
||||
// Step 1 MUST remain continueStraight, NOT turn left or right
|
||||
expect(result.maneuvers[1].type, equals(ManeuverType.continueStraight));
|
||||
expect(result.maneuvers[1].instruction, equals('Continue onto US-129'));
|
||||
expect(result.maneuvers[1].type.isLeft, isFalse);
|
||||
expect(result.maneuvers[1].type.isRight, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ void main() {
|
|||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.text('HAZARD'), findsOneWidget);
|
||||
expect(find.text('250m Ahead • Skyline Blvd'), findsOneWidget);
|
||||
expect(find.text('0.2 mi Ahead • Skyline Blvd'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ class _FakeMapProvider implements AppMapProvider {
|
|||
required BuildContext context,
|
||||
required AppLatLng center,
|
||||
required double zoom,
|
||||
double bearing = 0.0,
|
||||
required List<AppMapMarker> markers,
|
||||
required List<AppPolyline> polylines,
|
||||
required bool isFollowMode,
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ void main() {
|
|||
|
||||
expect(find.text('Community Feed'), findsOneWidget);
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
expect(find.text('24 ACTIVE'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
|
||||
expect(find.text('All Activity'), findsOneWidget);
|
||||
expect(find.text('Club Only'), findsOneWidget);
|
||||
expect(find.text('Nearby Packs'), findsOneWidget);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -13,6 +13,13 @@ void main() {
|
|||
expect(DefaultFirebaseOptions.ios.projectId, equals('motoapp-d22e0'));
|
||||
});
|
||||
|
||||
test('DefaultFirebaseOptions exposes authoritative webClientId for Google Sign-In', () {
|
||||
expect(
|
||||
DefaultFirebaseOptions.webClientId,
|
||||
equals('809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com'),
|
||||
);
|
||||
});
|
||||
|
||||
test('androidQa options strictly target demo-motorsignal-qa and non-prod credentials', () {
|
||||
expect(DefaultFirebaseOptions.androidQa.projectId, equals('demo-motorsignal-qa'));
|
||||
expect(DefaultFirebaseOptions.androidQa.apiKey, equals('demo-key'));
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ void main() {
|
|||
);
|
||||
|
||||
rideStreamController.add(rideDoc);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
fakeService.getParticipantController('user_accepted').add(
|
||||
GroupRideParticipant(
|
||||
|
|
@ -224,7 +224,7 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final presenceAlice = LiveGroupPresence(
|
||||
userId: 'user_accepted',
|
||||
|
|
@ -238,7 +238,7 @@ void main() {
|
|||
);
|
||||
fakeService.getPresenceController('user_accepted').add(presenceAlice);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(states.isNotEmpty, isTrue);
|
||||
expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue);
|
||||
|
|
@ -282,7 +282,7 @@ void main() {
|
|||
);
|
||||
|
||||
rideStreamController.add(rideDoc);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
fakeService.getParticipantController('user_alice').add(
|
||||
GroupRideParticipant(
|
||||
|
|
@ -307,7 +307,7 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final presenceAlice = LiveGroupPresence(
|
||||
userId: 'user_alice',
|
||||
|
|
@ -322,7 +322,7 @@ void main() {
|
|||
fakeService.getPresenceController('user_alice').add(presenceAlice);
|
||||
fakeService.getPresenceController('user_bob').addError('permission-denied');
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(states.isNotEmpty, isTrue);
|
||||
expect(states.last.length, 1);
|
||||
|
|
@ -365,7 +365,7 @@ void main() {
|
|||
);
|
||||
|
||||
rideStreamController.add(rideDoc);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
fakeService.getParticipantController('user_alice').add(
|
||||
GroupRideParticipant(
|
||||
|
|
@ -390,7 +390,7 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(states.isNotEmpty, isTrue);
|
||||
expect(states.last.length, 2);
|
||||
|
|
|
|||
|
|
@ -593,11 +593,14 @@ void main() {
|
|||
expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsNothing);
|
||||
|
||||
final pauseFinder = find.byKey(const ValueKey('hybrid_pause_resume_ride_button'));
|
||||
final hazardFinder = find.byKey(const ValueKey('hybrid_report_hazard_button'));
|
||||
final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button'));
|
||||
|
||||
expect(pauseFinder, findsOneWidget);
|
||||
expect(hazardFinder, findsOneWidget);
|
||||
expect(finishFinder, findsOneWidget);
|
||||
expect(find.text('Pause Ride'), findsOneWidget);
|
||||
expect(find.text('Hazard'), findsOneWidget);
|
||||
expect(find.text('Finish Ride'), findsOneWidget);
|
||||
|
||||
// Tap Pause
|
||||
|
|
@ -605,12 +608,49 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(fakeMachine.pauseRideCalled, isTrue);
|
||||
|
||||
// Tap Finish
|
||||
// Tap Finish & Confirm Dialog
|
||||
await tester.tap(finishFinder);
|
||||
await tester.pumpAndSettle();
|
||||
if (find.text('End Ride').evaluate().isNotEmpty) {
|
||||
await tester.tap(find.text('End Ride'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(fakeMachine.finishRideCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('cancelling End Ride confirmation dialog preserves active ride session', (tester) async {
|
||||
final fakeMachine = _FakeRideStateMachine(
|
||||
RideSession(state: RideState.riding, startTime: DateTime.now()),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
rideStateMachineProvider.overrideWith((ref) => fakeMachine),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.darkTheme,
|
||||
home: const Scaffold(
|
||||
body: HybridRidingActionBar(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button'));
|
||||
await tester.tap(finishFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('End Ride?'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fakeMachine.finishRideCalled, isFalse);
|
||||
expect(find.text('End Ride?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders Resume button when paused and invokes resumeRide', (tester) async {
|
||||
final fakeMachine = _FakeRideStateMachine(
|
||||
RideSession(state: RideState.paused, startTime: DateTime.now()),
|
||||
|
|
|
|||
|
|
@ -188,7 +188,12 @@ void main() {
|
|||
|
||||
await notifier.startNavigation(calculationResult: calcResult);
|
||||
|
||||
// Rider reaches within 10 meters of maneuver 0 point along the route
|
||||
// Stationary rider at departure does NOT prematurely advance to step 1
|
||||
notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 0.0);
|
||||
expect(notifier.state.currentManeuverIndex, equals(0));
|
||||
expect(notifier.state.currentManeuver?.instruction, equals('Head north on Main'));
|
||||
|
||||
// Rider departs and is moving (> 3.0 mph), advancing to step 1
|
||||
notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0);
|
||||
expect(notifier.state.currentManeuverIndex, equals(1));
|
||||
expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm'));
|
||||
|
|
@ -280,5 +285,88 @@ void main() {
|
|||
notifier.toggleTts();
|
||||
expect(notifier.state.ttsEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('remaining distance strictly counts down and maneuvers advance as rider travels', () async {
|
||||
final notifier = NavigationSessionNotifier();
|
||||
// Polyline with 5 points, ~1000m total
|
||||
const p0 = AppLatLng(33.7500, -84.3880);
|
||||
const p1 = AppLatLng(33.7525, -84.3880); // ~278m north
|
||||
const p2 = AppLatLng(33.7550, -84.3880); // ~278m north (turn right)
|
||||
const p3 = AppLatLng(33.7550, -84.3850); // ~278m east
|
||||
const p4 = AppLatLng(33.7550, -84.3820); // ~278m east (destination)
|
||||
|
||||
final maneuvers = <RouteManeuver>[
|
||||
const RouteManeuver(
|
||||
index: 0,
|
||||
instruction: 'Depart north on Main St',
|
||||
type: ManeuverType.depart,
|
||||
distanceMeters: 556.0,
|
||||
durationSeconds: 60,
|
||||
location: p0,
|
||||
geometryOffset: 0,
|
||||
),
|
||||
const RouteManeuver(
|
||||
index: 1,
|
||||
instruction: 'Turn right on Oak St',
|
||||
type: ManeuverType.turnRight,
|
||||
distanceMeters: 556.0,
|
||||
durationSeconds: 60,
|
||||
location: p2,
|
||||
geometryOffset: 2,
|
||||
),
|
||||
const RouteManeuver(
|
||||
index: 2,
|
||||
instruction: 'Arrive at destination',
|
||||
type: ManeuverType.arrive,
|
||||
distanceMeters: 0.0,
|
||||
durationSeconds: 0,
|
||||
location: p4,
|
||||
geometryOffset: 4,
|
||||
),
|
||||
];
|
||||
|
||||
final calcResult = RouteCalculationResult(
|
||||
straightLineDistanceMeters: 1000.0,
|
||||
estimatedDistanceMeters: 1112.0,
|
||||
estimatedDurationSeconds: 120,
|
||||
geometry: const [p0, p1, p2, p3, p4],
|
||||
isStraightLine: false,
|
||||
providerName: 'AWS Location',
|
||||
maneuvers: maneuvers,
|
||||
);
|
||||
|
||||
await notifier.startNavigation(calculationResult: calcResult);
|
||||
final initialRemaining = notifier.state.remainingDistanceMeters;
|
||||
expect(initialRemaining, greaterThan(1000.0));
|
||||
|
||||
// Rider moves to p1 (halfway to turn)
|
||||
notifier.updateLocation(p1, speedMph: 30.0);
|
||||
final remainingAtP1 = notifier.state.remainingDistanceMeters;
|
||||
// Remaining distance MUST decrease (never count up!)
|
||||
expect(remainingAtP1, lessThan(initialRemaining));
|
||||
expect(notifier.state.currentManeuverIndex, equals(1));
|
||||
expect(notifier.state.currentManeuver?.instruction, equals('Turn right on Oak St'));
|
||||
final distToTurnP1 = notifier.state.distanceToNextManeuverMeters;
|
||||
expect(distToTurnP1, greaterThan(200.0));
|
||||
expect(distToTurnP1, lessThan(350.0));
|
||||
|
||||
// Rider approaches turn at p2 (within 10m of p2)
|
||||
notifier.updateLocation(p2, speedMph: 20.0);
|
||||
final remainingAtP2 = notifier.state.remainingDistanceMeters;
|
||||
expect(remainingAtP2, lessThan(remainingAtP1));
|
||||
// Maneuver advances to arrival!
|
||||
expect(notifier.state.currentManeuverIndex, equals(2));
|
||||
expect(notifier.state.currentManeuver?.instruction, equals('Arrive at destination'));
|
||||
|
||||
// Rider continues to p3
|
||||
notifier.updateLocation(p3, speedMph: 25.0);
|
||||
final remainingAtP3 = notifier.state.remainingDistanceMeters;
|
||||
expect(remainingAtP3, lessThan(remainingAtP2));
|
||||
|
||||
// Rider arrives at destination p4
|
||||
notifier.updateLocation(p4, speedMph: 5.0);
|
||||
expect(notifier.state.status, equals(NavigationStatus.arrived));
|
||||
expect(notifier.state.remainingDistanceMeters, equals(0.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,10 +101,14 @@ void main() {
|
|||
await tester.tap(find.text('Stop Session'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
if (find.text('End Ride').evaluate().isNotEmpty) {
|
||||
await tester.tap(find.text('End Ride'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Had the rejected Future been left unhandled, flutter_test would have
|
||||
// surfaced it as an unhandled async error and failed this test.
|
||||
expect(fake.finishCalls, 1);
|
||||
|
||||
final rideStopLines =
|
||||
logLines.where((l) => l.contains('[RideStop]')).toList();
|
||||
expect(rideStopLines, hasLength(1),
|
||||
|
|
@ -136,6 +140,11 @@ void main() {
|
|||
await tester.tap(find.text('Stop Session'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
if (find.text('End Ride').evaluate().isNotEmpty) {
|
||||
await tester.tap(find.text('End Ride'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
expect(fake.finishCalls, 1);
|
||||
expect(logLines.where((l) => l.contains('[RideStop]')), isEmpty,
|
||||
reason: 'no diagnostic on a successful finish');
|
||||
|
|
|
|||
|
|
@ -580,6 +580,35 @@ void main() {
|
|||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test('18. Vehicle in motion (>2.0 mph) preserves calibration even with high pitch tilt during hard braking', () async {
|
||||
final calibrationService = CalibrationService();
|
||||
final service = SensorFusionService(calibrationService, autoStartSensors: false);
|
||||
|
||||
service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now()));
|
||||
await service.calibrateUpright();
|
||||
expect(service.isCalibrated, isTrue);
|
||||
|
||||
// Simulate riding at speed (35 mph)
|
||||
service.updateVehicleSpeed(35.0);
|
||||
|
||||
// Simulate hard braking pitch tilt / hill climb (>65° apparent gravity shift due to deceleration)
|
||||
final hardBrakeAngleRad = 70.0 * (math.pi / 180.0);
|
||||
for (int i = 0; i < 60; i++) {
|
||||
service.handleAccelerometerEvent(AccelerometerEvent(
|
||||
0.0,
|
||||
g * math.cos(hardBrakeAngleRad),
|
||||
g * math.sin(hardBrakeAngleRad),
|
||||
DateTime.now(),
|
||||
));
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 15));
|
||||
|
||||
// Calibration must NOT be wiped while actively riding
|
||||
expect(service.isCalibrated, isTrue);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('Dashboard UI: Lean Gauge Display & Availability Semantics', () {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ void main() {
|
|||
// Verify header and filter components render cleanly
|
||||
expect(find.text('Community Feed'), findsOneWidget);
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
expect(find.text('24 ACTIVE'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
|
||||
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
|
||||
|
||||
// Verify that NO false GPS Verified badge is rendered
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ class _NullSensorFusionService extends Fake implements SensorFusionService {
|
|||
double get latestRawGyroY => 0.0;
|
||||
@override
|
||||
double get latestRawGyroZ => 0.0;
|
||||
@override
|
||||
void updateVehicleSpeed(double speedMph) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue