MotorSignal-App/docs/qa-2026-10-01/baseline.patch

804 lines
31 KiB
Diff

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<NavigatorState>();
@@ -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('&amp;', '&')
+ .replaceAll('&lt;', '<')
+ .replaceAll('&gt;', '>')
+ .replaceAll('&quot;', '"')
+ .replaceAll('&#039;', "'")
+ .replaceAll('&apos;', "'")
+ .replaceAll('&nbsp;', ' ')
+ .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<PersonalRecords> {
_loadRecords();
}
}
+
+ /// Recalculates personal records from all saved rides and performance runs in SQLite.
+ Future<void> 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<SharedPreferences>((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<ReplayScreen> {
Future<void> _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<ReplayScreen> {
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<RideSession> {
}
}
+ 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<RideSession> {
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<int>(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<CloudRestoreUiState> {
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<CloudRestoreResult?> restoreFromMotorCloud() async {
@@ -63,6 +71,11 @@ class CloudRestoreController extends StateNotifier<CloudRestoreUiState> {
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<int>, allowMalformed: false);
+ final csv = utf8.decode(file.content as List<int>, 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<void>.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);
+ });
});
}