diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 615cacc..c8a0414 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,6 +31,17 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + flavorDimensions += "environment" + productFlavors { + create("production") { + dimension = "environment" + } + create("qa") { + dimension = "environment" + applicationIdSuffix = ".qa" + } + } + defaultConfig { applicationId = "app.motorsignal.motorcycle" minSdk = flutter.minSdkVersion diff --git a/lib/core/router.dart b/lib/core/router.dart index 9c1400e..48c6292 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -30,6 +30,7 @@ import '../features/group_rides/live_group_ride_screen.dart'; import '../features/routes/route_planner_screen.dart'; import '../features/routes/route_detail_screen.dart'; import '../features/performance/performance_launch_screen.dart'; +import '../features/replay/replay_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -307,5 +308,23 @@ final appRouter = GoRouter( path: '/feed', builder: (context, state) => const SocialFeedScreen(), ), + GoRoute( + path: '/replay', + builder: (context, state) { + final idParam = state.uri.queryParameters['id'] ?? + state.uri.queryParameters['rideId']; + final rideId = idParam != null ? int.tryParse(idParam) ?? 0 : 0; + return ReplayScreen(rideId: rideId); + }, + routes: [ + GoRoute( + path: ':id', + builder: (context, state) { + final id = int.tryParse(state.pathParameters['id'] ?? '') ?? 0; + return ReplayScreen(rideId: id); + }, + ), + ], + ), ], ); diff --git a/lib/features/garage/widgets/motorcycle_photo.dart b/lib/features/garage/widgets/motorcycle_photo.dart index 08c0677..f1daaa3 100644 --- a/lib/features/garage/widgets/motorcycle_photo.dart +++ b/lib/features/garage/widgets/motorcycle_photo.dart @@ -143,14 +143,31 @@ class MotorcyclePhoto extends StatelessWidget { return const _FallbackMotorcycleImage(); } + static String _cleanText(String? text) { + if (text == null || text.isEmpty) return ''; + return text + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(' ', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + String _attribution(MotorcycleImage image) { - final credit = image.credit?.trim(); - final license = image.license?.trim(); - if (credit != null && credit.isNotEmpty && license != null && license.isNotEmpty) { + final credit = _cleanText(image.credit); + final license = _cleanText(image.license); + if (credit.isNotEmpty && license.isNotEmpty) { return 'Photo: $credit • $license'; } - if (credit != null && credit.isNotEmpty) return 'Photo: $credit'; - return 'Photo: ${image.attribution}'; + if (credit.isNotEmpty) return 'Photo: $credit'; + final attribution = _cleanText(image.attribution); + if (attribution.isNotEmpty) return 'Photo: $attribution'; + return 'Photo: Wikimedia Commons'; } } diff --git a/lib/features/records/records_service.dart b/lib/features/records/records_service.dart index c135852..17f9be3 100644 --- a/lib/features/records/records_service.dart +++ b/lib/features/records/records_service.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../database/database.dart'; class PersonalRecords { final double topSpeedMph; @@ -127,6 +128,109 @@ class RecordsService extends StateNotifier { _loadRecords(); } } + + /// Recalculates personal records from all saved rides and performance runs in SQLite. + Future recalculateFromDatabase(AppDatabase db) async { + try { + final rides = await db.select(db.rides).get(); + final perfRuns = await db.select(db.performanceRuns).get(); + + if (rides.isEmpty && perfRuns.isEmpty) return; + + double topSpeedMph = 0.0; + double maxLeanLeft = 0.0; + double maxLeanRight = 0.0; + double maxAccelG = 0.0; + double maxBrakingG = 0.0; + double longestRideMiles = 0.0; + double greatestElevationGain = 0.0; + double fastest0To30 = double.infinity; + double fastest0To60 = double.infinity; + double fastest0To100 = double.infinity; + double fastest60To100 = double.infinity; + + for (final ride in rides) { + final speed = ride.topSpeedMph > 0 ? ride.topSpeedMph : ride.topSpeed; + if (speed > topSpeedMph && speed <= 220.0) topSpeedMph = speed; + + if (ride.maxLeanLeft > maxLeanLeft && ride.maxLeanLeft <= 65.0) { + maxLeanLeft = ride.maxLeanLeft; + } + if (ride.maxLeanRight > maxLeanRight && ride.maxLeanRight <= 65.0) { + maxLeanRight = ride.maxLeanRight; + } + if (ride.maxAccelG > maxAccelG && ride.maxAccelG <= 2.5) { + maxAccelG = ride.maxAccelG; + } + final brk = ride.maxBrakingG.abs(); + if (brk > maxBrakingG && brk <= 2.5) { + maxBrakingG = brk; + } + + final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; + if (dist > longestRideMiles && dist <= 5000.0) { + longestRideMiles = dist; + } + + if (ride.elevationGain > greatestElevationGain && ride.elevationGain <= 50000.0) { + greatestElevationGain = ride.elevationGain; + } + + if (ride.best0to60Ms != null && ride.best0to60Ms! > 0) { + final sec = ride.best0to60Ms! / 1000.0; + if (sec < fastest0To60 && sec >= 1.8) fastest0To60 = sec; + } + if (ride.best0to100Ms != null && ride.best0to100Ms! > 0) { + final sec = ride.best0to100Ms! / 1000.0; + if (sec < fastest0To100 && sec >= 3.5) fastest0To100 = sec; + } + } + + for (final run in perfRuns) { + final sec = run.timeMs / 1000.0; + switch (run.type) { + case '0-30': + if (sec < fastest0To30 && sec >= 0.8) fastest0To30 = sec; + break; + case '0-60': + if (sec < fastest0To60 && sec >= 1.8) fastest0To60 = sec; + break; + case '0-100': + if (sec < fastest0To100 && sec >= 3.5) fastest0To100 = sec; + break; + case '60-100': + if (sec < fastest60To100 && sec >= 1.2) fastest60To100 = sec; + break; + } + } + + await _prefs.setDouble('topSpeedMph', topSpeedMph); + await _prefs.setDouble('maxLeanLeft', maxLeanLeft); + await _prefs.setDouble('maxLeanRight', maxLeanRight); + await _prefs.setDouble('maxAccelG', maxAccelG); + await _prefs.setDouble('maxBrakingG', maxBrakingG); + await _prefs.setDouble('longestRideMiles', longestRideMiles); + await _prefs.setDouble('greatestElevationGain', greatestElevationGain); + + if (fastest0To30 != double.infinity) { + await _prefs.setDouble('fastest0To30', fastest0To30); + } + if (fastest0To60 != double.infinity) { + await _prefs.setDouble('fastest0To60', fastest0To60); + } + if (fastest0To100 != double.infinity) { + await _prefs.setDouble('fastest0To100', fastest0To100); + } + if (fastest60To100 != double.infinity) { + await _prefs.setDouble('fastest60To100', fastest60To100); + } + + _loadRecords(); + } catch (_) { + // Database might be closed or unavailable + return; + } + } } final sharedPreferencesProvider = Provider((ref) { diff --git a/lib/features/replay/replay_screen.dart b/lib/features/replay/replay_screen.dart index 1c05ce7..baac162 100644 --- a/lib/features/replay/replay_screen.dart +++ b/lib/features/replay/replay_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:drift/drift.dart' hide Column; import '../../database/database.dart'; import '../../services/map/map_provider_interface.dart'; @@ -87,8 +88,19 @@ class _ReplayScreenState extends ConsumerState { Future _loadData() async { final db = ref.read(databaseProvider); + var targetRideId = widget.rideId; + if (targetRideId == 0) { + final latestRide = await (db.select(db.rides) + ..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)]) + ..limit(1)) + .getSingleOrNull(); + if (latestRide != null) { + targetRideId = latestRide.id; + } + } + final telemetry = await (db.select(db.telemetrySamples) - ..where((t) => t.rideId.equals(widget.rideId)) + ..where((t) => t.rideId.equals(targetRideId)) ..orderBy([(t) => OrderingTerm(expression: t.timestamp)])) .get(); @@ -307,7 +319,50 @@ class _ReplayScreenState extends ConsumerState { final theme = Theme.of(context); if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); - if (_telemetry.isEmpty) return const Scaffold(body: Center(child: Text('No telemetry data to replay.'))); + if (_telemetry.isEmpty) { + return Scaffold( + backgroundColor: const Color(0xFF101419), + appBar: AppBar( + backgroundColor: const Color(0xFF0B0E13), + title: const Text('Ride Replay'), + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + context.go('/activity'); + } + }, + ), + ), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.analytics_outlined, size: 56, color: Color(0xFF4A5568)), + const SizedBox(height: 16), + const Text( + 'No telemetry data available for replay.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + const SizedBox(height: 8), + const Text( + 'Record or restore a ride with telemetry samples to replay.', + style: TextStyle( + fontSize: 12, + color: Color(0xFF8B98A5), + ), + ), + ], + ), + ), + ); + } final currentSample = _telemetry[_currentIndex]; final currentPos = (currentSample.latitude != null && currentSample.longitude != null) diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index c19c1ca..b3183ad 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -893,6 +893,15 @@ class RideStateMachine extends StateNotifier { } } + if (durable) { + // I3: close acceptance, then one final strict drain for anything queued + // between the last convergence check and the cutoff assignment. + _telemetryAcceptanceClosed = true; + if (!await _drainTelemetryBatchStrictly()) { + durable = false; + } + } + // Convergence must actually have been reached. The cap never fabricates // durability: telemetry still outstanding means finalization fails. if (durable && @@ -900,12 +909,6 @@ class RideStateMachine extends StateNotifier { durable = false; } - if (durable) { - // I3: close acceptance, then one final strict drain. - _telemetryAcceptanceClosed = true; - durable = await _drainTelemetryBatchStrictly(); - } - if (!durable) { // I2: restore a truthful recoverable recording status and propagate, so // RID-2's existing UI boundary containment handles the rejection and the diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index afa754d..e1ac2bf 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -10,6 +10,7 @@ import '../../../services/map/map_provider_interface.dart'; import '../../../services/social/social_feed_models.dart'; import '../../../services/social/social_feed_providers.dart'; import 'widgets/feed_comments_bottom_sheet.dart'; +import 'widgets/feed_telemetry_inspector_modal.dart'; import 'widgets/share_session_modal.dart'; /// Community Social Feed & Kudos Screen (`SOC-01` / `MOB-19`). @@ -966,10 +967,10 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> // Telemetry Deep Dive Inspector Trigger InkWell( + key: ValueKey('inspect_telemetry_btn_${item.id}'), borderRadius: BorderRadius.circular(12), onTap: () { - // Navigate to replay or show telemetry graph dialog - context.push('/replay'); + FeedTelemetryInspectorModal.show(context, item); }, child: Container( height: 44, diff --git a/lib/services/alerts/swarm_road_alert_service.dart b/lib/services/alerts/swarm_road_alert_service.dart index 8b8b343..cfef51b 100644 --- a/lib/services/alerts/swarm_road_alert_service.dart +++ b/lib/services/alerts/swarm_road_alert_service.dart @@ -8,6 +8,8 @@ import 'package:geolocator/geolocator.dart'; import 'road_alert_models.dart'; import 'swarm_road_alert_models.dart'; +enum SwarmCloudStatus { unavailable, confirmed, pending, failed } + class SwarmRoadAlertService { final FirebaseFirestore? _firestore; final FirebaseAuth? _auth; @@ -52,6 +54,7 @@ class SwarmRoadAlertService { String? description, String? groupRideId, Duration? ttl, + void Function(SwarmCloudStatus)? onCloudStatus, }) async { final now = DateTime.now(); final uid = _currentUid ?? 'local_rider'; @@ -100,12 +103,21 @@ class SwarmRoadAlertService { await firestore .collection('road_alerts') .doc(alertId) - .set(report.toJson()); + .set(report.toJson()) + .timeout(const Duration(seconds: 6)); + onCloudStatus?.call(SwarmCloudStatus.confirmed); + } on TimeoutException { + // A timed-out Firestore write may still finish. Do not retry here: + // this report already has a stable document ID. + onCloudStatus?.call(SwarmCloudStatus.pending); } catch (e) { + onCloudStatus?.call(SwarmCloudStatus.failed); if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore write failed (operating offline): $e'); } } + } else { + onCloudStatus?.call(SwarmCloudStatus.unavailable); } return report; diff --git a/lib/services/alerts/widgets/quick_swarm_report_modal.dart b/lib/services/alerts/widgets/quick_swarm_report_modal.dart index 2f1c8d0..3018a9b 100644 --- a/lib/services/alerts/widgets/quick_swarm_report_modal.dart +++ b/lib/services/alerts/widgets/quick_swarm_report_modal.dart @@ -8,8 +8,9 @@ import '../../group_rides/group_ride_providers.dart'; import '../road_alert_models.dart'; import '../road_alert_providers.dart'; import '../road_alert_service.dart'; +import '../swarm_road_alert_service.dart'; -class QuickSwarmReportModal extends ConsumerWidget { +class QuickSwarmReportModal extends ConsumerStatefulWidget { const QuickSwarmReportModal({super.key}); static Future show(BuildContext context) { @@ -23,7 +24,15 @@ class QuickSwarmReportModal extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _QuickSwarmReportModalState(); +} + +class _QuickSwarmReportModalState extends ConsumerState { + bool _submitting = false; + + @override + Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: AppColors.darkSurface, @@ -83,7 +92,6 @@ class QuickSwarmReportModal extends ConsumerWidget { // 1. POLICE / SPEED TRAP _buildActionButton( context: context, - ref: ref, category: RoadAlertCategory.police, title: 'POLICE / SPEED TRAP', subtitle: 'Radar, laser, or patrol car spotted', @@ -95,7 +103,6 @@ class QuickSwarmReportModal extends ConsumerWidget { // 2. ROAD HAZARD / GRAVEL _buildActionButton( context: context, - ref: ref, category: RoadAlertCategory.hazard, title: 'HAZARD / GRAVEL / DEBRIS', subtitle: 'Gravel in corner, slick surface, road debris', @@ -107,7 +114,6 @@ class QuickSwarmReportModal extends ConsumerWidget { // 3. ACCIDENT / ROAD CLOSED _buildActionButton( context: context, - ref: ref, category: RoadAlertCategory.accident, title: 'ACCIDENT / CRASH AHEAD', subtitle: 'Crash or blocked road ahead', @@ -121,7 +127,6 @@ class QuickSwarmReportModal extends ConsumerWidget { Widget _buildActionButton({ required BuildContext context, - required WidgetRef ref, required RoadAlertCategory category, required String title, required String subtitle, @@ -139,7 +144,7 @@ class QuickSwarmReportModal extends ConsumerWidget { ), elevation: 0, ), - onPressed: () => _handleReport(context, ref, category), + onPressed: _submitting ? null : () => _handleReport(category), child: Row( children: [ Container( @@ -168,10 +173,7 @@ class QuickSwarmReportModal extends ConsumerWidget { const SizedBox(height: 2), Text( subtitle, - style: const TextStyle( - fontSize: 11, - color: Colors.white70, - ), + style: const TextStyle(fontSize: 11, color: Colors.white70), ), ], ), @@ -182,16 +184,15 @@ class QuickSwarmReportModal extends ConsumerWidget { ); } - Future _handleReport( - BuildContext context, - WidgetRef ref, - RoadAlertCategory category, - ) async { + Future _handleReport(RoadAlertCategory category) async { + if (_submitting) return; + setState(() => _submitting = true); HapticFeedback.heavyImpact(); final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); - try { + final activeRide = ref.read(activeUserGroupRideProvider); + final swarmService = ref.read(swarmRoadAlertServiceProvider); + final alertService = ref.read(roadAlertServiceProvider); // 1. Acquire current position Position? pos; try { @@ -200,11 +201,17 @@ class QuickSwarmReportModal extends ConsumerWidget { accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 4), ), - ); + ).timeout(const Duration(seconds: 5)); } catch (_) { - pos = await Geolocator.getLastKnownPosition(); + if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; + pos = await Geolocator.getLastKnownPosition().timeout( + const Duration(seconds: 2), + ); } + // Closing the sheet before location resolves cancels the report. + if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; + final lat = pos?.latitude ?? 0.0; final lng = pos?.longitude ?? 0.0; final heading = pos?.heading; @@ -220,20 +227,21 @@ class QuickSwarmReportModal extends ConsumerWidget { } // 2. Check active group ride - final activeRide = ref.read(activeUserGroupRideProvider); - // 3. Dispatch to Swarm Service - final swarmService = ref.read(swarmRoadAlertServiceProvider); + var cloudStatus = SwarmCloudStatus.unavailable; final report = await swarmService.reportAlert( category: category, latitude: lat, longitude: lng, heading: heading, groupRideId: activeRide?.id, + onCloudStatus: (status) => cloudStatus = status, ); - // 4. Inject immediately into local alerts for zero-latency confirmation - ref.read(roadAlertServiceProvider).simulateAlert(report.toRoadAlert()); + // The sheet or its ProviderScope may have gone away during submission. + if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; + alertService.simulateAlert(report.toRoadAlert()); + Navigator.of(context).pop(); messenger.showSnackBar( SnackBar( @@ -242,10 +250,16 @@ class QuickSwarmReportModal extends ConsumerWidget { Icon(category.icon, color: Colors.white, size: 20), const SizedBox(width: 10), Expanded( - child: Text( - '${category.displayName} reported to MotorSignal Swarm!', - style: const TextStyle(fontWeight: FontWeight.bold), - ), + child: Text(switch (cloudStatus) { + SwarmCloudStatus.confirmed => + '${category.displayName} added to local alerts and saved to cloud.', + SwarmCloudStatus.pending => + '${category.displayName} added locally; cloud delivery pending.', + SwarmCloudStatus.failed => + '${category.displayName} added locally; cloud delivery failed.', + SwarmCloudStatus.unavailable => + '${category.displayName} added locally; cloud unavailable.', + }, style: const TextStyle(fontWeight: FontWeight.bold)), ), ], ), @@ -254,12 +268,18 @@ class QuickSwarmReportModal extends ConsumerWidget { ), ); } catch (e) { - messenger.showSnackBar( - SnackBar( - content: Text('Failed to submit report: $e'), - backgroundColor: Colors.redAccent, - ), - ); + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + messenger.showSnackBar( + SnackBar( + content: Text('Failed to submit report: $e'), + backgroundColor: Colors.redAccent, + ), + ); + } + } finally { + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + setState(() => _submitting = false); + } } } } diff --git a/lib/services/sync/cloud_reconciliation_service.dart b/lib/services/sync/cloud_reconciliation_service.dart index 6310bba..5d66bbb 100644 --- a/lib/services/sync/cloud_reconciliation_service.dart +++ b/lib/services/sync/cloud_reconciliation_service.dart @@ -150,27 +150,31 @@ class CloudReconciliationService { if (localRideId != null) { final legacyRows = await _db .customSelect( - 'SELECT id, cloud_ride_id FROM rides WHERE id = ?', + 'SELECT id, cloud_ride_id, start_time FROM rides WHERE id = ?', variables: [Variable(localRideId)], ) .get(); if (legacyRows.isNotEmpty) { final existingCloudId = legacyRows.first.data['cloud_ride_id']; - if (existingCloudId == null || existingCloudId.toString().isEmpty) { + final startTime = _readDateTime(data['startTime'] ?? data['startedAt']); + final localStartTime = _readDateTime(legacyRows.first.data['start_time']); + + final isSameRide = (existingCloudId == null || + existingCloudId.toString().isEmpty || + existingCloudId == cloudRideId) && + startTime != null && + localStartTime != null && + (localStartTime.difference(startTime).inSeconds).abs() <= 5; + + if (isSameRide) { await _db.customStatement( 'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', [cloudRideId, ownerId, localRideId], ); return const CloudReconciliationResult(unchanged: 1); } - if (existingCloudId == cloudRideId) { - await _db.customStatement( - 'UPDATE rides SET cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', - [ownerId, localRideId], - ); - return const CloudReconciliationResult(unchanged: 1); - } - return const CloudReconciliationResult(conflicts: 1); + // If not the same ride, this is simply a device local-id collision. + // Fall through directly to insert the cloud ride. } } @@ -211,6 +215,8 @@ class CloudReconciliationService { max_lean_right, max_accel_g, max_braking_g, + best0to60_ms, + best0to100_ms, distance, average_speed, top_speed, @@ -223,26 +229,28 @@ class CloudReconciliationService { motorcycle_make, motorcycle_model, motorcycle_variant - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [ cloudRideId, ownerId, startTime.toUtc().millisecondsSinceEpoch, endTime?.toUtc().millisecondsSinceEpoch, - _asDouble(data['distanceMiles']), - _asDouble(data['topSpeedMph']), - _asDouble(data['maxLeanLeft']), - _asDouble(data['maxLeanRight']), - _asDouble(data['maxAccelG']), - _asDouble(data['maxBrakingG']), - _asDouble(data['distanceMiles']), - _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']), - _asDouble(data['topSpeedMph']), + _asDouble(data['distanceMiles']) ?? 0.0, + _asDouble(data['topSpeedMph']) ?? 0.0, + _asDouble(data['maxLeanLeft']) ?? 0.0, + _asDouble(data['maxLeanRight']) ?? 0.0, + _asDouble(data['maxAccelG']) ?? 0.0, + _asDouble(data['maxBrakingG']) ?? 0.0, + _asDouble(data['best0to60Ms'] ?? data['best0to60_ms']), + _asDouble(data['best0to100Ms'] ?? data['best0to100_ms']), + _asDouble(data['distanceMiles']) ?? 0.0, + _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']) ?? 0.0, + _asDouble(data['topSpeedMph']) ?? 0.0, _asInt(data['movingTime']) ?? 0, _asInt(data['stoppedTime']) ?? 0, - _asDouble(data['elevationGain']), - _asDouble(data['elevationLoss']), + _asDouble(data['elevationGain']) ?? 0.0, + _asDouble(data['elevationLoss']) ?? 0.0, _asInt(data['motorcycleId']), _asInt(data['motorcycleYear']), data['motorcycleMake']?.toString(), diff --git a/lib/services/sync/cloud_restore_controller.dart b/lib/services/sync/cloud_restore_controller.dart index 42699d4..45f1b9a 100644 --- a/lib/services/sync/cloud_restore_controller.dart +++ b/lib/services/sync/cloud_restore_controller.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_service.dart'; +import '../../database/database.dart'; +import '../../features/records/records_service.dart'; import 'cloud_restore_service.dart'; enum CloudRestoreStatus { @@ -32,12 +34,18 @@ class CloudRestoreUiState { class CloudRestoreController extends StateNotifier { final CloudRestoreService _restoreService; final AuthService _authService; + final RecordsService? _recordsService; + final AppDatabase? _db; CloudRestoreController({ required CloudRestoreService restoreService, required AuthService authService, + RecordsService? recordsService, + AppDatabase? db, }) : _restoreService = restoreService, _authService = authService, + _recordsService = recordsService, + _db = db, super(const CloudRestoreUiState()); Future restoreFromMotorCloud() async { @@ -63,6 +71,11 @@ class CloudRestoreController extends StateNotifier { state = const CloudRestoreUiState(status: CloudRestoreStatus.restoring); try { final result = await _restoreService.restore(); + if (_recordsService != null && _db != null) { + try { + await _recordsService.recalculateFromDatabase(_db); + } catch (_) {} + } final hasFailures = result.failures > 0; final hasRestoredOrMatched = result.hasAnyRestoredData || diff --git a/lib/services/sync/cloud_restore_service.dart b/lib/services/sync/cloud_restore_service.dart index 0fca357..5293168 100644 --- a/lib/services/sync/cloud_restore_service.dart +++ b/lib/services/sync/cloud_restore_service.dart @@ -152,7 +152,9 @@ class CloudRestoreService { for (final document in documents) { final data = document.data; final cloudRideId = document.id; - if (!_isValidCloudRideId(cloudRideId) || data['ownerId'] != user.uid) { + final owner = (data['ownerId'] ?? data['uid'])?.toString(); + if (!_isValidCloudRideId(cloudRideId) || + (owner != null && owner != user.uid)) { telemetrySkipped++; continue; } @@ -337,7 +339,7 @@ class CloudRestoreService { ); } - final csv = utf8.decode(file.content as List, allowMalformed: false); + final csv = utf8.decode(file.content as List, allowMalformed: true); final lines = const LineSplitter().convert(csv); if (lines.length < 2) return 0; @@ -398,10 +400,8 @@ class CloudRestoreService { } if (companions.isEmpty) return 0; - await _db.transaction(() async { - for (final companion in companions) { - await _db.into(_db.telemetrySamples).insert(companion); - } + await _db.batch((batch) { + batch.insertAll(_db.telemetrySamples, companions); }); return companions.length; } diff --git a/lib/services/sync/sync_providers.dart b/lib/services/sync/sync_providers.dart index f1417eb..efba735 100644 --- a/lib/services/sync/sync_providers.dart +++ b/lib/services/sync/sync_providers.dart @@ -37,6 +37,8 @@ final cloudRestoreControllerProvider = (ref) => CloudRestoreController( restoreService: ref.watch(cloudRestoreServiceProvider), authService: ref.watch(authServiceProvider), + recordsService: ref.watch(recordsServiceProvider.notifier), + db: ref.watch(databaseProvider), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index f8c1377..4594861 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,7 @@ flutter_native_splash: icon_background_color: "#111111" flutter: + default-flavor: production # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/cloud_restore_manual_test.dart b/test/cloud_restore_manual_test.dart index edc98d6..3777a84 100644 --- a/test/cloud_restore_manual_test.dart +++ b/test/cloud_restore_manual_test.dart @@ -17,6 +17,8 @@ import 'package:motorcycle_dash/services/sync/cloud_reconciliation_service.dart' import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart'; import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart'; import 'package:motorcycle_dash/services/sync/r2_download_service.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class _TestAuthService implements AuthService { @override bool get isEmailVerified => false; @@ -598,6 +600,144 @@ void main() { expect(await db.select(db.rides).get(), isEmpty); }, ); + + test( + 'legacy cloud metadata storing uid instead of ownerId is restored', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + CloudRideMetadataDocument( + id: 'ride_legacy_uid', + data: { + 'id': 'ride_legacy_uid', + 'uid': 'user_a', // Legacy schema: uid present, ownerId null + 'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(), + 'endTime': DateTime.utc(2026, 9, 3, 10, 15).toIso8601String(), + 'distanceMiles': 5.5, + 'topSpeedMph': 75.0, + 'maxLeanLeft': 28.0, + 'maxLeanRight': 31.0, + 'maxAccelG': 0.65, + 'maxBrakingG': -0.72, + 'best0to60Ms': 3450.0, + 'movingTime': 900, + 'stoppedTime': 0, + 'elevationGain': 450.0, + 'elevationLoss': 200.0, + }, + ), + ], + }, + ); + + final zipBytes = _telemetryZip(samples: 2); + final client = _downloadClient(zipBytes); + final restore = service(store: store, client: client); + + final result = await restore.restore(); + + expect(result.cloudRidesDiscovered, 1); + expect(result.metadataRowsAdded, 1); + expect(result.telemetryRidesRestored, 1); + expect(result.telemetrySamplesRestored, 2); + + final rides = await db.select(db.rides).get(); + expect(rides, hasLength(1)); + expect(rides.single.cloudRideId, 'ride_legacy_uid'); + expect(rides.single.distanceMiles, 5.5); + expect(rides.single.topSpeedMph, 75.0); + expect(rides.single.best0to60Ms, 3450.0); + }, + ); + + test( + 'local ID collision does not cause conflict; inserts cloud ride as a new local ride', + () async { + // Pre-insert a local ride with SQLite ID 1 and a DIFFERENT cloudRideId + await db.customStatement(''' + INSERT INTO rides (id, cloud_ride_id, start_time, distance_miles, top_speed_mph) + VALUES (1, 'ride_existing_local', 1700000000000, 10.0, 50.0) + '''); + + // Cloud ride also has localRideId: 1 from its original device, but cloud ID 'ride_from_cloud' + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + CloudRideMetadataDocument( + id: 'ride_from_cloud', + data: { + 'id': 'ride_from_cloud', + 'localRideId': 1, + 'ownerId': 'user_a', + 'startTime': DateTime.utc(2026, 9, 10, 14).toIso8601String(), + 'distanceMiles': 22.4, + 'topSpeedMph': 88.0, + }, + ), + ], + }, + ); + + final restore = service(store: store, client: _downloadClient(_telemetryZip())); + final result = await restore.restore(); + + // Must NOT report conflict! Must add as a new row + expect(result.metadata.conflicts, 0); + expect(result.metadataRowsAdded, 1); + + final rides = await db.select(db.rides).get(); + expect(rides, hasLength(2)); + expect(rides.any((r) => r.cloudRideId == 'ride_existing_local'), isTrue); + expect(rides.any((r) => r.cloudRideId == 'ride_from_cloud'), isTrue); + }, + ); + + test( + 'recalculating records from database populates personal records and benchmarks', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + // Insert rides with speed, lean, Gs, best 0-60 + await db.customStatement(''' + INSERT INTO rides ( + cloud_ride_id, start_time, distance_miles, top_speed_mph, + max_lean_left, max_lean_right, max_accel_g, max_braking_g, + elevation_gain, best0to60_ms + ) VALUES ( + 'ride_rec_1', 1700000000000, 45.2, 94.5, + 38.5, 42.0, 0.85, -0.92, + 1850.0, 3150.0 + ) + '''); + + // Insert a performance run for 0-100 + await db.addPerformanceRun( + PerformanceRunsCompanion.insert( + type: '0-100', + timeMs: 7200.0, + topSpeedReached: 101.2, + timestamp: DateTime.utc(2026, 9, 15), + ), + ); + + final recordsService = RecordsService(prefs); + expect(recordsService.state.topSpeedMph, 0.0); // initially empty + + await recordsService.recalculateFromDatabase(db); + + expect(recordsService.state.topSpeedMph, 94.5); + expect(recordsService.state.maxLeanLeft, 38.5); + expect(recordsService.state.maxLeanRight, 42.0); + expect(recordsService.state.maxAccelG, 0.85); + expect(recordsService.state.maxBrakingG, 0.92); + expect(recordsService.state.longestRideMiles, 45.2); + expect(recordsService.state.greatestElevationGain, 1850.0); + expect(recordsService.state.fastest0To60, 3.15); // 3150 ms / 1000 + expect(recordsService.state.fastest0To100, 7.20); // 7200 ms / 1000 + }, + ); }); } diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index af440fe..aefaeec 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -352,5 +352,37 @@ void main() { expect(find.byKey(const ValueKey('social_card_feed')), findsOneWidget); expect(find.text('Community Feed'), findsOneWidget); }); + + testWidgets('Tapping INSPECT TELEMETRY GRAPH opens FeedTelemetryInspectorModal', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1000)); + + final service = SocialFeedService(); + await tester.pumpWidget( + _createTestApp( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: const SocialFeedScreen(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Scroll so the card inspect telemetry button is visible + await tester.drag(find.byType(ListView), const Offset(0, -100)); + await tester.pumpAndSettle(); + + final inspectBtn = find.text('INSPECT TELEMETRY GRAPH'); + expect(inspectBtn, findsWidgets); + + await tester.tap(inspectBtn.first); + await tester.pumpAndSettle(); + + // Verify inspector modal opened with metrics and tabs + expect(find.text('PEAK LEAN'), findsOneWidget); + expect(find.text('LEAN & APEX'), findsOneWidget); + expect(find.text('SPEED & G-FORCE'), findsOneWidget); + expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget); + }); }); } diff --git a/test/entitlement_domain_test.dart b/test/entitlement_domain_test.dart index 4ad2a99..7a43ad9 100644 --- a/test/entitlement_domain_test.dart +++ b/test/entitlement_domain_test.dart @@ -236,7 +236,7 @@ void main() { source: PurchaseSource.googlePlay, productId: 'motorsignal_pro_monthly', purchaseTokenHash: 'token_sha256_hash', - expiresAt: DateTime.utc(2026, 10, 1), + expiresAt: DateTime.utc(2030, 1, 1), lastVerifiedAt: DateTime.utc(2026, 9, 1), ); diff --git a/test/forensic_lifecycle_audit_test.dart b/test/forensic_lifecycle_audit_test.dart index 4916237..2fd7531 100644 --- a/test/forensic_lifecycle_audit_test.dart +++ b/test/forensic_lifecycle_audit_test.dart @@ -302,6 +302,44 @@ void main() { expect(session.dbId, isNotNull); }); + test('pause and resume preserve ride identity and persist only active samples', () async { + final container = ProviderContainer(overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + telemetryServiceProvider.overrideWithValue(fakeTelemetry), + audioAlertServiceProvider.overrideWithValue(mockAudio), + fieldTestLoggerProvider.overrideWithValue(logger), + ]); + addTearDown(container.dispose); + final machine = container.read(rideStateMachineProvider.notifier); + await machine.startRide(); + final rideId = container.read(rideStateMachineProvider).dbId!; + final cloudId = await db.getCloudRideId(rideId); + Future emit(double speed) async { + fakeTelemetry.emitSample(TelemetrySample( + timestamp: DateTime.now(), latitude: 34, longitude: -85, + speed: speed, gpsAccuracy: 3, + )); + await Future.delayed(const Duration(milliseconds: 70)); + } + await emit(10); + machine.pauseRide(); + expect(container.read(rideStateMachineProvider).state, RideState.paused); + await emit(99); + machine.resumeRide(); + expect(container.read(rideStateMachineProvider).state, RideState.riding); + expect(container.read(rideStateMachineProvider).dbId, rideId); + await emit(20); + await machine.finishRide(); + await logger.flush(); + final samples = await db.select(db.telemetrySamples).get(); + expect(samples.map((sample) => sample.speed), [10, 20]); + expect(await db.getCloudRideId(rideId), cloudId); + final rides = await db.select(db.rides).get(); + expect(rides, hasLength(1)); + expect(rides.single.endTime, isNotNull); + }); + test('Recording Health Contract validates prerequisites strictly', () { const armed = RecordingHealthContract( status: RecordingHealthStatus.recordingArmed, diff --git a/test/rid3b1_stop_durability_barrier_test.dart b/test/rid3b1_stop_durability_barrier_test.dart index a7d9a56..fababac 100644 --- a/test/rid3b1_stop_durability_barrier_test.dart +++ b/test/rid3b1_stop_durability_barrier_test.dart @@ -727,5 +727,44 @@ void main() { expect(finishedTransitions, 1); expect((await db.select(db.rides).get()), hasLength(1)); }); + + test('I continuous live telemetry streaming during finalization completes ' + 'durably without throwing StateError', () async { + final container = buildContainer(); + final notifier = container.read(rideStateMachineProvider.notifier); + final rideId = await startRide(container); + + // Emit initial 5 samples + emitSamples(0, 5); + await pumpEventQueue(times: 20); + + // Start continuous telemetry emission (simulating live 20Hz sensor streaming) + var streamingIndex = 5; + final timer = Timer.periodic(const Duration(milliseconds: 15), (_) { + emitSamples(streamingIndex++, 1); + }); + addTearDown(timer.cancel); + + // Give timer a moment to emit samples + await Future.delayed(const Duration(milliseconds: 50)); + + // finishRide must complete smoothly without StateError + await notifier.finishRide(); + timer.cancel(); + await pumpEventQueue(times: 40); + + final session = container.read(rideStateMachineProvider); + final rows = await persistedRows(); + final counts = countsByIndex(rows); + + expect(session.state, RideState.finished); + expect(session.healthStatus, RecordingHealthStatus.recordingComplete); + expect(rows, hasLength(session.totalSamplesPersisted)); + expect(session.totalSamplesReceived, session.totalSamplesPersisted); + expect(duplicatedIn(counts), isEmpty, reason: 'no duplicates'); + expect(missingOf(rows.length, counts), isEmpty, reason: 'no missing samples'); + expect(finishedTransitions, 1, reason: 'exactly one finished edge'); + expect(rows.map((r) => r.rideId).toSet(), {rideId}); + }); }); } diff --git a/test/router_test.dart b/test/router_test.dart index 1d224e6..d3242e7 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -37,5 +37,18 @@ void main() { final matchFeed = config.findMatch(Uri.parse('/feed')); expect(matchFeed.matches.isNotEmpty, isTrue); }); + + test('routes include /replay and /replay/:id subroute', () { + final config = appRouter.configuration; + + final matchReplay = config.findMatch(Uri.parse('/replay')); + expect(matchReplay.matches.isNotEmpty, isTrue); + + final matchReplayWithId = config.findMatch(Uri.parse('/replay/42')); + expect(matchReplayWithId.matches.isNotEmpty, isTrue); + + final matchReplayWithQuery = config.findMatch(Uri.parse('/replay?rideId=42')); + expect(matchReplayWithQuery.matches.isNotEmpty, isTrue); + }); }); } diff --git a/website/components/auth-form.tsx b/website/components/auth-form.tsx index 18a34fe..91c51f0 100644 --- a/website/components/auth-form.tsx +++ b/website/components/auth-form.tsx @@ -21,24 +21,28 @@ export function AuthForm({ mode }: { mode: AuthMode }) { const credential = await signInWithPopup(firebaseAuth, googleProvider); const idToken = await credential.user.getIdToken(); // Exchange firebase idToken with backend to set server session & ops cookies + let response: Response | undefined; try { - await fetch('/api/auth/firebase-login', { + response = await fetch('/api/auth/firebase-login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id_token: idToken }), }); } catch { // Fallback to legacy link if available - try { - await fetch('/api/account/firebase-link', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id_token: idToken }), - }); - } catch { - // Backend link optional; Firebase auth is sufficient for client cloud data + const legacyResponse = await fetch('/api/account/firebase-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id_token: idToken }), + }); + if (!legacyResponse.ok) { + throw new Error('Unable to establish a secure session. Please try again.'); } } + if (response && !response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail ?? 'Unable to establish a secure session. Please try again.'); + } const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account'; window.location.assign(destination); } catch (error) { diff --git a/website/lib/firebase-client.ts b/website/lib/firebase-client.ts index 9b43fba..0c298a7 100644 --- a/website/lib/firebase-client.ts +++ b/website/lib/firebase-client.ts @@ -1,8 +1,26 @@ import { getApp, getApps, initializeApp } from 'firebase/app'; -import { getAuth, GoogleAuthProvider } from 'firebase/auth'; -import { getFirestore } from 'firebase/firestore'; +import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth'; +import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore'; -const app = getApps().length ? getApp() : initializeApp({ +// Explicit local QA opt-in. A separate app/project prevents reusing a rider's +// production session, and loopback endpoints cannot send fixtures to production. +const useEmulators = process.env.NEXT_PUBLIC_FIREBASE_EMULATORS === 'true'; +const qaProject = process.env.NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID; +const authEmulatorPort = Number(process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT ?? 9099); +const firestoreEmulatorPort = Number(process.env.NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT ?? 8088); +if (useEmulators && (!qaProject?.startsWith('demo-') || + (typeof window !== 'undefined' && !['localhost', '127.0.0.1', '[::1]'].includes(window.location.hostname)))) { + throw new Error('Firebase QA requires a demo- project and a loopback website origin.'); +} +if (useEmulators && (![authEmulatorPort, firestoreEmulatorPort].every((port) => + Number.isInteger(port) && port >= 1 && port <= 65535))) { + throw new Error('Firebase QA emulator ports must be valid TCP ports.'); +} +const existingQaApp = getApps().find((candidate) => candidate.name === 'motorsignal-local-qa'); + +const app = useEmulators + ? existingQaApp ?? initializeApp({ apiKey: 'demo-key', projectId: qaProject, authDomain: 'localhost' }, 'motorsignal-local-qa') + : getApps().some((candidate) => candidate.name === '[DEFAULT]') ? getApp() : initializeApp({ apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg', authDomain: 'motoapp-d22e0.firebaseapp.com', projectId: 'motoapp-d22e0', @@ -14,3 +32,8 @@ const app = getApps().length ? getApp() : initializeApp({ export const firebaseAuth = getAuth(app); export const googleProvider = new GoogleAuthProvider(); export const firestore = getFirestore(app); + +if (useEmulators && !existingQaApp) { + connectAuthEmulator(firebaseAuth, `http://127.0.0.1:${authEmulatorPort}`); + connectFirestoreEmulator(firestore, '127.0.0.1', firestoreEmulatorPort); +}