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/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/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/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); + }); }); }