804 lines
31 KiB
Diff
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('&', '&')
|
|
+ .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<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);
|
|
+ });
|
|
});
|
|
}
|