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