MotorSignal-App/docs/qa-2026-10-01/final-tracked.patch

1230 lines
48 KiB
Diff

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<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/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<void> show(BuildContext context) {
@@ -23,7 +24,15 @@ class QuickSwarmReportModal extends ConsumerWidget {
}
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState<QuickSwarmReportModal> createState() =>
+ _QuickSwarmReportModalState();
+}
+
+class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
+ 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<void> _handleReport(
- BuildContext context,
- WidgetRef ref,
- RoadAlertCategory category,
- ) async {
+ Future<void> _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<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/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<void> emit(double speed) async {
+ fakeTelemetry.emitSample(TelemetrySample(
+ timestamp: DateTime.now(), latitude: 34, longitude: -85,
+ speed: speed, gpsAccuracy: 3,
+ ));
+ await Future<void>.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<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);
+ });
});
}
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);
+}