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/android/app/src/qa/google-services.json b/android/app/src/qa/google-services.json new file mode 100644 index 0000000..d6ed803 --- /dev/null +++ b/android/app/src/qa/google-services.json @@ -0,0 +1,24 @@ +{ + "project_info": { + "project_number": "123456789", + "project_id": "demo-motorsignal-qa", + "storage_bucket": "demo-motorsignal-qa.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789:android:qa", + "android_client_info": { + "package_name": "app.motorsignal.motorcycle.qa" + } + }, + "api_key": [ + { "current_key": "demo-key" } + ], + "services": { + "appinvite_service": { "other_platform_oauth_client": [] } + } + } + ], + "configuration_version": "1" +} diff --git a/lib/core/router.dart b/lib/core/router.dart index 9c1400e..48c6292 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -30,6 +30,7 @@ import '../features/group_rides/live_group_ride_screen.dart'; import '../features/routes/route_planner_screen.dart'; import '../features/routes/route_detail_screen.dart'; import '../features/performance/performance_launch_screen.dart'; +import '../features/replay/replay_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -307,5 +308,23 @@ final appRouter = GoRouter( path: '/feed', builder: (context, state) => const SocialFeedScreen(), ), + GoRoute( + path: '/replay', + builder: (context, state) { + final idParam = state.uri.queryParameters['id'] ?? + state.uri.queryParameters['rideId']; + final rideId = idParam != null ? int.tryParse(idParam) ?? 0 : 0; + return ReplayScreen(rideId: rideId); + }, + routes: [ + GoRoute( + path: ':id', + builder: (context, state) { + final id = int.tryParse(state.pathParameters['id'] ?? '') ?? 0; + return ReplayScreen(rideId: id); + }, + ), + ], + ), ], ); diff --git a/lib/features/garage/widgets/motorcycle_photo.dart b/lib/features/garage/widgets/motorcycle_photo.dart index 08c0677..f1daaa3 100644 --- a/lib/features/garage/widgets/motorcycle_photo.dart +++ b/lib/features/garage/widgets/motorcycle_photo.dart @@ -143,14 +143,31 @@ class MotorcyclePhoto extends StatelessWidget { return const _FallbackMotorcycleImage(); } + static String _cleanText(String? text) { + if (text == null || text.isEmpty) return ''; + return text + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(' ', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + String _attribution(MotorcycleImage image) { - final credit = image.credit?.trim(); - final license = image.license?.trim(); - if (credit != null && credit.isNotEmpty && license != null && license.isNotEmpty) { + final credit = _cleanText(image.credit); + final license = _cleanText(image.license); + if (credit.isNotEmpty && license.isNotEmpty) { return 'Photo: $credit • $license'; } - if (credit != null && credit.isNotEmpty) return 'Photo: $credit'; - return 'Photo: ${image.attribution}'; + if (credit.isNotEmpty) return 'Photo: $credit'; + final attribution = _cleanText(image.attribution); + if (attribution.isNotEmpty) return 'Photo: $attribution'; + return 'Photo: Wikimedia Commons'; } } diff --git a/lib/features/records/records_service.dart b/lib/features/records/records_service.dart index c135852..17f9be3 100644 --- a/lib/features/records/records_service.dart +++ b/lib/features/records/records_service.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../database/database.dart'; class PersonalRecords { final double topSpeedMph; @@ -127,6 +128,109 @@ class RecordsService extends StateNotifier { _loadRecords(); } } + + /// Recalculates personal records from all saved rides and performance runs in SQLite. + Future recalculateFromDatabase(AppDatabase db) async { + try { + final rides = await db.select(db.rides).get(); + final perfRuns = await db.select(db.performanceRuns).get(); + + if (rides.isEmpty && perfRuns.isEmpty) return; + + double topSpeedMph = 0.0; + double maxLeanLeft = 0.0; + double maxLeanRight = 0.0; + double maxAccelG = 0.0; + double maxBrakingG = 0.0; + double longestRideMiles = 0.0; + double greatestElevationGain = 0.0; + double fastest0To30 = double.infinity; + double fastest0To60 = double.infinity; + double fastest0To100 = double.infinity; + double fastest60To100 = double.infinity; + + for (final ride in rides) { + final speed = ride.topSpeedMph > 0 ? ride.topSpeedMph : ride.topSpeed; + if (speed > topSpeedMph && speed <= 220.0) topSpeedMph = speed; + + if (ride.maxLeanLeft > maxLeanLeft && ride.maxLeanLeft <= 65.0) { + maxLeanLeft = ride.maxLeanLeft; + } + if (ride.maxLeanRight > maxLeanRight && ride.maxLeanRight <= 65.0) { + maxLeanRight = ride.maxLeanRight; + } + if (ride.maxAccelG > maxAccelG && ride.maxAccelG <= 2.5) { + maxAccelG = ride.maxAccelG; + } + final brk = ride.maxBrakingG.abs(); + if (brk > maxBrakingG && brk <= 2.5) { + maxBrakingG = brk; + } + + final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; + if (dist > longestRideMiles && dist <= 5000.0) { + longestRideMiles = dist; + } + + if (ride.elevationGain > greatestElevationGain && ride.elevationGain <= 50000.0) { + greatestElevationGain = ride.elevationGain; + } + + if (ride.best0to60Ms != null && ride.best0to60Ms! > 0) { + final sec = ride.best0to60Ms! / 1000.0; + if (sec < fastest0To60 && sec >= 1.8) fastest0To60 = sec; + } + if (ride.best0to100Ms != null && ride.best0to100Ms! > 0) { + final sec = ride.best0to100Ms! / 1000.0; + if (sec < fastest0To100 && sec >= 3.5) fastest0To100 = sec; + } + } + + for (final run in perfRuns) { + final sec = run.timeMs / 1000.0; + switch (run.type) { + case '0-30': + if (sec < fastest0To30 && sec >= 0.8) fastest0To30 = sec; + break; + case '0-60': + if (sec < fastest0To60 && sec >= 1.8) fastest0To60 = sec; + break; + case '0-100': + if (sec < fastest0To100 && sec >= 3.5) fastest0To100 = sec; + break; + case '60-100': + if (sec < fastest60To100 && sec >= 1.2) fastest60To100 = sec; + break; + } + } + + await _prefs.setDouble('topSpeedMph', topSpeedMph); + await _prefs.setDouble('maxLeanLeft', maxLeanLeft); + await _prefs.setDouble('maxLeanRight', maxLeanRight); + await _prefs.setDouble('maxAccelG', maxAccelG); + await _prefs.setDouble('maxBrakingG', maxBrakingG); + await _prefs.setDouble('longestRideMiles', longestRideMiles); + await _prefs.setDouble('greatestElevationGain', greatestElevationGain); + + if (fastest0To30 != double.infinity) { + await _prefs.setDouble('fastest0To30', fastest0To30); + } + if (fastest0To60 != double.infinity) { + await _prefs.setDouble('fastest0To60', fastest0To60); + } + if (fastest0To100 != double.infinity) { + await _prefs.setDouble('fastest0To100', fastest0To100); + } + if (fastest60To100 != double.infinity) { + await _prefs.setDouble('fastest60To100', fastest60To100); + } + + _loadRecords(); + } catch (_) { + // Database might be closed or unavailable + return; + } + } } final sharedPreferencesProvider = Provider((ref) { diff --git a/lib/features/replay/replay_screen.dart b/lib/features/replay/replay_screen.dart index 1c05ce7..baac162 100644 --- a/lib/features/replay/replay_screen.dart +++ b/lib/features/replay/replay_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:drift/drift.dart' hide Column; import '../../database/database.dart'; import '../../services/map/map_provider_interface.dart'; @@ -87,8 +88,19 @@ class _ReplayScreenState extends ConsumerState { Future _loadData() async { final db = ref.read(databaseProvider); + var targetRideId = widget.rideId; + if (targetRideId == 0) { + final latestRide = await (db.select(db.rides) + ..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)]) + ..limit(1)) + .getSingleOrNull(); + if (latestRide != null) { + targetRideId = latestRide.id; + } + } + final telemetry = await (db.select(db.telemetrySamples) - ..where((t) => t.rideId.equals(widget.rideId)) + ..where((t) => t.rideId.equals(targetRideId)) ..orderBy([(t) => OrderingTerm(expression: t.timestamp)])) .get(); @@ -307,7 +319,50 @@ class _ReplayScreenState extends ConsumerState { final theme = Theme.of(context); if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); - if (_telemetry.isEmpty) return const Scaffold(body: Center(child: Text('No telemetry data to replay.'))); + if (_telemetry.isEmpty) { + return Scaffold( + backgroundColor: const Color(0xFF101419), + appBar: AppBar( + backgroundColor: const Color(0xFF0B0E13), + title: const Text('Ride Replay'), + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + context.go('/activity'); + } + }, + ), + ), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.analytics_outlined, size: 56, color: Color(0xFF4A5568)), + const SizedBox(height: 16), + const Text( + 'No telemetry data available for replay.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + const SizedBox(height: 8), + const Text( + 'Record or restore a ride with telemetry samples to replay.', + style: TextStyle( + fontSize: 12, + color: Color(0xFF8B98A5), + ), + ), + ], + ), + ), + ); + } final currentSample = _telemetry[_currentIndex]; final currentPos = (currentSample.latitude != null && currentSample.longitude != null) diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index c19c1ca..b3183ad 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -893,6 +893,15 @@ class RideStateMachine extends StateNotifier { } } + if (durable) { + // I3: close acceptance, then one final strict drain for anything queued + // between the last convergence check and the cutoff assignment. + _telemetryAcceptanceClosed = true; + if (!await _drainTelemetryBatchStrictly()) { + durable = false; + } + } + // Convergence must actually have been reached. The cap never fabricates // durability: telemetry still outstanding means finalization fails. if (durable && @@ -900,12 +909,6 @@ class RideStateMachine extends StateNotifier { durable = false; } - if (durable) { - // I3: close acceptance, then one final strict drain. - _telemetryAcceptanceClosed = true; - durable = await _drainTelemetryBatchStrictly(); - } - if (!durable) { // I2: restore a truthful recoverable recording status and propagate, so // RID-2's existing UI boundary containment handles the rejection and the diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index afa754d..e1ac2bf 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -10,6 +10,7 @@ import '../../../services/map/map_provider_interface.dart'; import '../../../services/social/social_feed_models.dart'; import '../../../services/social/social_feed_providers.dart'; import 'widgets/feed_comments_bottom_sheet.dart'; +import 'widgets/feed_telemetry_inspector_modal.dart'; import 'widgets/share_session_modal.dart'; /// Community Social Feed & Kudos Screen (`SOC-01` / `MOB-19`). @@ -966,10 +967,10 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> // Telemetry Deep Dive Inspector Trigger InkWell( + key: ValueKey('inspect_telemetry_btn_${item.id}'), borderRadius: BorderRadius.circular(12), onTap: () { - // Navigate to replay or show telemetry graph dialog - context.push('/replay'); + FeedTelemetryInspectorModal.show(context, item); }, child: Container( height: 44, diff --git a/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart b/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart new file mode 100644 index 0000000..8b539b2 --- /dev/null +++ b/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart @@ -0,0 +1,914 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/theme/theme_extensions.dart'; +import '../../../../services/social/social_feed_models.dart'; + +/// Interactive telemetry graph inspector bottom sheet for a social feed post (`SOC-01` / `MOB-19`). +/// +/// Provides deep-dive multi-trace waveforms for lean angles, apex markers, +/// speed profiles, lateral G-forces, and elevation gain. +class FeedTelemetryInspectorModal extends ConsumerStatefulWidget { + final SocialFeedItem feedItem; + + const FeedTelemetryInspectorModal({ + super.key, + required this.feedItem, + }); + + static Future show(BuildContext context, SocialFeedItem feedItem) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => FeedTelemetryInspectorModal(feedItem: feedItem), + ); + } + + @override + ConsumerState createState() => + _FeedTelemetryInspectorModalState(); +} + +enum _InspectorTraceTab { + leanApex, + speedGForce, + elevation, +} + +class _FeedTelemetryInspectorModalState + extends ConsumerState { + _InspectorTraceTab _selectedTab = _InspectorTraceTab.leanApex; + double _scrubFraction = 0.5; // 0.0 to 1.0 along the route + + String _formatDuration(int totalSeconds) { + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + if (minutes >= 60) { + final hours = minutes ~/ 60; + final remMins = minutes % 60; + return '${hours}h ${remMins}m'; + } + return '${minutes}m ${seconds}s'; + } + + @override + Widget build(BuildContext context) { + final colors = context.motorColors; + final item = widget.feedItem; + final mediaQuery = MediaQuery.of(context); + final maxHeight = mediaQuery.size.height * 0.90; + + return Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: const BoxDecoration( + color: Color(0xFF0F1318), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + border: Border( + top: BorderSide(color: Color(0xFF222933), width: 1.5), + left: BorderSide(color: Color(0xFF222933), width: 1.5), + right: BorderSide(color: Color(0xFF222933), width: 1.5), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Modal Drag Handle + Center( + child: Container( + margin: const EdgeInsets.only(top: 10, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFF333E4D), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Modal Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.electricCyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.electricCyan.withValues(alpha: 0.35), + ), + ), + child: const Icon( + Icons.insights_rounded, + color: AppColors.electricCyan, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.authorDisplayName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 6), + if (item.authorBadge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.hazardAmber.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: AppColors.hazardAmber.withValues(alpha: 0.4), + ), + ), + child: Text( + item.authorBadge!, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 9, + fontWeight: FontWeight.w800, + color: AppColors.hazardAmber, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${item.rideTitle} • ${item.bikeModel}', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: Color(0xFF8B98A5), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, color: Colors.white70), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + + const Divider(color: Color(0xFF1D242E), height: 1), + + // Scrollable Content: Metrics + Graph + Replay Trigger + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 2x3 Telemetry Insets Grid + _buildMetricsGrid(item, colors), + const SizedBox(height: 18), + + // Trace Selector Tabs + _buildTraceSelector(colors), + const SizedBox(height: 12), + + // Interactive Telemetry Graph Canvas + _buildInteractiveGraphCard(item, colors), + const SizedBox(height: 16), + + // Scrubber Details Pill + _buildScrubberReadout(item, colors), + const SizedBox(height: 20), + + // Launch Full Replay Button + _buildReplayButton(context), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildMetricsGrid(SocialFeedItem item, MotorSignalThemeColors colors) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF141A22), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF222B38)), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _buildMetricCell( + label: 'PEAK LEAN', + value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°', + subtext: '${item.maxLeanSide} APEX', + accentColor: AppColors.electricCyan, + icon: Icons.rotate_right_rounded, + ), + ), + Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Expanded( + child: _buildMetricCell( + label: 'DISTANCE', + value: item.distanceMiles.toStringAsFixed(1), + subtext: 'MILES', + accentColor: Colors.white, + icon: Icons.route_rounded, + ), + ), + Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Expanded( + child: _buildMetricCell( + label: 'DURATION', + value: _formatDuration(item.durationSeconds), + subtext: 'MOVING', + accentColor: Colors.white, + icon: Icons.timer_outlined, + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(color: Color(0xFF222B38), height: 1), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildMetricCell( + label: 'PACE FACTOR', + value: item.paceFactor.split(' ').first, + subtext: item.paceFactor.contains(' ') + ? item.paceFactor.split(' ').sublist(1).join(' ') + : 'PACE', + accentColor: const Color(0xFF00E676), + icon: Icons.speed_rounded, + ), + ), + Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Expanded( + child: _buildMetricCell( + label: 'ELEVATION', + value: '+${item.elevationFt}', + subtext: 'FEET GAIN', + accentColor: Colors.white, + icon: Icons.terrain_rounded, + ), + ), + Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Expanded( + child: _buildMetricCell( + label: 'HAZARDS', + value: '${item.hazardCount}', + subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED', + accentColor: item.hazardCount == 0 + ? const Color(0xFF00E676) + : AppColors.hazardAmber, + icon: Icons.warning_amber_rounded, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildMetricCell({ + required String label, + required String value, + required String subtext, + required Color accentColor, + required IconData icon, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 12, color: const Color(0xFF6B7A8D)), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 8.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: Color(0xFF7B8B9E), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 18, + fontWeight: FontWeight.w900, + color: accentColor, + ), + ), + ), + Text( + subtext, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 8.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + color: Color(0xFF5D6C7E), + ), + ), + ], + ), + ); + } + + Widget _buildTraceSelector(MotorSignalThemeColors colors) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFF141920), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF222B38)), + ), + child: Row( + children: [ + _buildTraceTabItem( + tab: _InspectorTraceTab.leanApex, + label: 'LEAN & APEX', + icon: Icons.rotate_left_rounded, + ), + _buildTraceTabItem( + tab: _InspectorTraceTab.speedGForce, + label: 'SPEED & G-FORCE', + icon: Icons.show_chart_rounded, + ), + _buildTraceTabItem( + tab: _InspectorTraceTab.elevation, + label: 'ELEVATION', + icon: Icons.terrain_rounded, + ), + ], + ), + ); + } + + Widget _buildTraceTabItem({ + required _InspectorTraceTab tab, + required String label, + required IconData icon, + }) { + final isSelected = _selectedTab == tab; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _selectedTab = tab), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF202A36) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: isSelected + ? Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)) + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 13, + color: isSelected ? AppColors.electricCyan : const Color(0xFF6B7A8D), + ), + const SizedBox(width: 5), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 10, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, + letterSpacing: 0.3, + color: isSelected ? Colors.white : const Color(0xFF7A8B9E), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildInteractiveGraphCard( + SocialFeedItem item, + MotorSignalThemeColors colors, + ) { + return Container( + height: 220, + decoration: BoxDecoration( + color: const Color(0xFF0C1015), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF222B38)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: GestureDetector( + onHorizontalDragUpdate: (details) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final localX = details.localPosition.dx; + setState(() { + _scrubFraction = (localX / 340).clamp(0.0, 1.0); + }); + } + }, + onTapDown: (details) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final localX = details.localPosition.dx; + setState(() { + _scrubFraction = (localX / 340).clamp(0.0, 1.0); + }); + } + }, + child: CustomPaint( + size: const Size(double.infinity, 220), + painter: _TelemetryWaveformPainter( + tab: _selectedTab, + item: item, + scrubFraction: _scrubFraction, + ), + ), + ), + ), + ); + } + + Widget _buildScrubberReadout( + SocialFeedItem item, + MotorSignalThemeColors colors, + ) { + final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1); + final scrubPct = (_scrubFraction * 100).toInt(); + + // Instantaneous interpolated readings based on scrub point + final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF141920), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF222B38)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + const Icon( + Icons.touch_app_rounded, + size: 14, + color: AppColors.electricCyan, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + 'POINT: $scrubDist MI ($scrubPct%)', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 9.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: AppColors.electricCyan, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Text( + 'DRAG TO SCRUB', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 8.5, + fontWeight: FontWeight.w700, + color: Color(0xFF5D6C7E), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildScrubStat('SPEED', '$speedVal MPH'), + Container(width: 1, height: 24, color: const Color(0xFF222B38)), + _buildScrubStat('LEAN', '$leanVal°'), + Container(width: 1, height: 24, color: const Color(0xFF222B38)), + _buildScrubStat('LAT G', '${gVal}G'), + ], + ), + ], + ), + ); + } + + (int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) { + // Generate realistic wave curves based on session metrics + final baseSpeed = 48.0; + final speedVariation = 26.0 * math.sin(frac * math.pi * 5); + final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt(); + + final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs(); + final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt(); + + final gForce = (0.2 + (lean / 60.0) * 0.9).toStringAsFixed(2); + + return (speed, lean, gForce); + } + + Widget _buildScrubStat(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 8.5, + fontWeight: FontWeight.w700, + color: Color(0xFF6B7A8D), + ), + ), + Text( + value, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ); + } + + Widget _buildReplayButton(BuildContext context) { + return SizedBox( + height: 48, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + backgroundColor: const Color(0xFF161E28), + side: BorderSide( + color: AppColors.electricCyan.withValues(alpha: 0.5), + width: 1.5, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon( + Icons.play_circle_fill_rounded, + color: AppColors.electricCyan, + size: 20, + ), + label: const Text( + 'OPEN FULL RIDE REPLAY', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: Colors.white, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + context.push('/replay'); + }, + ), + ); + } +} + +/// Custom painter rendering multi-trace telemetry waveforms and apex markers. +class _TelemetryWaveformPainter extends CustomPainter { + final _InspectorTraceTab tab; + final SocialFeedItem item; + final double scrubFraction; + + _TelemetryWaveformPainter({ + required this.tab, + required this.item, + required this.scrubFraction, + }); + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + + // Draw subtle grid lines + final gridPaint = Paint() + ..color = const Color(0xFF19222E) + ..strokeWidth = 1.0; + + for (var i = 1; i <= 4; i++) { + final y = h * (i / 5); + canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint); + } + for (var i = 1; i <= 5; i++) { + final x = w * (i / 6); + canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint); + } + + switch (tab) { + case _InspectorTraceTab.leanApex: + _paintLeanWaveform(canvas, size); + break; + case _InspectorTraceTab.speedGForce: + _paintSpeedWaveform(canvas, size); + break; + case _InspectorTraceTab.elevation: + _paintElevationWaveform(canvas, size); + break; + } + + // Draw Scrub Cursor Line & Indicator + final scrubX = (w * scrubFraction).clamp(0.0, w); + final scrubPaint = Paint() + ..color = AppColors.electricCyan + ..strokeWidth = 1.8; + + canvas.drawLine(Offset(scrubX, 0), Offset(scrubX, h), scrubPaint); + + final scrubDotPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(scrubX, h * 0.4), 4.0, scrubDotPaint); + } + + void _paintLeanWaveform(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final midY = h * 0.52; + + // Baseline (0° upright) + final baselinePaint = Paint() + ..color = const Color(0xFF2A3647) + ..strokeWidth = 1.2 + ..style = PaintingStyle.stroke; + canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint); + + final path = Path(); + final points = []; + const steps = 100; + final maxLean = item.maxLeanAngleDeg; + + for (var i = 0; i <= steps; i++) { + final t = i / steps; + final x = t * w; + // Multi-sine wave generating cornering sections + final wave = math.sin(t * math.pi * 6) * 0.7 + + math.sin(t * math.pi * 12) * 0.3; + final leanAngle = wave * maxLean; + final y = midY - (leanAngle / 60.0) * (h * 0.38); + points.add(Offset(x, y)); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + // Draw Gradient Area under Lean Curve + final fillPath = Path.from(path) + ..lineTo(w, midY) + ..lineTo(0, midY) + ..close(); + + final fillPaint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x3300F2FE), + Color(0x0500F2FE), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h)); + canvas.drawPath(fillPath, fillPaint); + + // Draw Trace Line + final linePaint = Paint() + ..color = AppColors.electricCyan + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke; + canvas.drawPath(path, linePaint); + + // Draw Apex Markers (Peak Corners) + final apexMarkers = [ + (0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)), + (0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)), + ]; + + for (final marker in apexMarkers) { + final mx = marker.$1 * w; + final wave = math.sin(marker.$1 * math.pi * 6) * 0.7 + + math.sin(marker.$1 * math.pi * 12) * 0.3; + final my = midY - (wave * maxLean / 60.0) * (h * 0.38); + + final dotPaint = Paint()..color = marker.$3; + canvas.drawCircle(Offset(mx, my), 5.0, dotPaint); + + final ringPaint = Paint() + ..color = marker.$3.withValues(alpha: 0.4) + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + canvas.drawCircle(Offset(mx, my), 8.0, ringPaint); + + // Label Pill + _drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3); + } + } + + void _paintSpeedWaveform(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + + final path = Path(); + const steps = 100; + + for (var i = 0; i <= steps; i++) { + final t = i / steps; + final x = t * w; + final speed = 40.0 + 35.0 * math.sin(t * math.pi * 4).abs() + 15.0 * math.cos(t * math.pi * 8); + final y = h * 0.90 - (speed / 120.0) * (h * 0.75); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + final fillPath = Path.from(path) + ..lineTo(w, h * 0.90) + ..lineTo(0, h * 0.90) + ..close(); + + final fillPaint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x33FFB300), + Color(0x05FFB300), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h)); + canvas.drawPath(fillPath, fillPaint); + + final linePaint = Paint() + ..color = const Color(0xFFFFB300) + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke; + canvas.drawPath(path, linePaint); + + _drawTextBadge(canvas, 'TOP SPEED', Offset(w * 0.5 - 28, h * 0.16), const Color(0xFFFFB300)); + } + + void _paintElevationWaveform(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + + final path = Path(); + const steps = 100; + + for (var i = 0; i <= steps; i++) { + final t = i / steps; + final x = t * w; + final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5); + final y = h * 0.92 - elevationNorm * (h * 0.75); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + final fillPath = Path.from(path) + ..lineTo(w, h * 0.92) + ..lineTo(0, h * 0.92) + ..close(); + + final fillPaint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x33448AFF), + Color(0x05448AFF), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h)); + canvas.drawPath(fillPath, fillPaint); + + final linePaint = Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke; + canvas.drawPath(path, linePaint); + + _drawTextBadge( + canvas, + '+${item.elevationFt} FT CREST', + Offset(w * 0.68 - 36, h * 0.18), + const Color(0xFF448AFF), + ); + } + + void _drawTextBadge(Canvas canvas, String text, Offset offset, Color color) { + final span = TextSpan( + text: text, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 8.5, + fontWeight: FontWeight.w800, + color: color, + letterSpacing: 0.5, + ), + ); + final painter = TextPainter( + text: span, + textDirection: TextDirection.ltr, + ); + painter.layout(); + + final bgRect = RRect.fromRectAndRadius( + Rect.fromLTWH(offset.dx - 4, offset.dy - 2, painter.width + 8, painter.height + 4), + const Radius.circular(4), + ); + final bgPaint = Paint()..color = const Color(0xFF0F151D); + final borderPaint = Paint() + ..color = color.withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + + canvas.drawRRect(bgRect, bgPaint); + canvas.drawRRect(bgRect, borderPaint); + painter.paint(canvas, offset); + } + + @override + bool shouldRepaint(covariant _TelemetryWaveformPainter oldDelegate) { + return oldDelegate.tab != tab || + oldDelegate.scrubFraction != scrubFraction || + oldDelegate.item != item; + } +} diff --git a/lib/services/alerts/swarm_road_alert_service.dart b/lib/services/alerts/swarm_road_alert_service.dart index 8b8b343..cfef51b 100644 --- a/lib/services/alerts/swarm_road_alert_service.dart +++ b/lib/services/alerts/swarm_road_alert_service.dart @@ -8,6 +8,8 @@ import 'package:geolocator/geolocator.dart'; import 'road_alert_models.dart'; import 'swarm_road_alert_models.dart'; +enum SwarmCloudStatus { unavailable, confirmed, pending, failed } + class SwarmRoadAlertService { final FirebaseFirestore? _firestore; final FirebaseAuth? _auth; @@ -52,6 +54,7 @@ class SwarmRoadAlertService { String? description, String? groupRideId, Duration? ttl, + void Function(SwarmCloudStatus)? onCloudStatus, }) async { final now = DateTime.now(); final uid = _currentUid ?? 'local_rider'; @@ -100,12 +103,21 @@ class SwarmRoadAlertService { await firestore .collection('road_alerts') .doc(alertId) - .set(report.toJson()); + .set(report.toJson()) + .timeout(const Duration(seconds: 6)); + onCloudStatus?.call(SwarmCloudStatus.confirmed); + } on TimeoutException { + // A timed-out Firestore write may still finish. Do not retry here: + // this report already has a stable document ID. + onCloudStatus?.call(SwarmCloudStatus.pending); } catch (e) { + onCloudStatus?.call(SwarmCloudStatus.failed); if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore write failed (operating offline): $e'); } } + } else { + onCloudStatus?.call(SwarmCloudStatus.unavailable); } return report; diff --git a/lib/services/alerts/widgets/quick_swarm_report_modal.dart b/lib/services/alerts/widgets/quick_swarm_report_modal.dart index 2f1c8d0..3018a9b 100644 --- a/lib/services/alerts/widgets/quick_swarm_report_modal.dart +++ b/lib/services/alerts/widgets/quick_swarm_report_modal.dart @@ -8,8 +8,9 @@ import '../../group_rides/group_ride_providers.dart'; import '../road_alert_models.dart'; import '../road_alert_providers.dart'; import '../road_alert_service.dart'; +import '../swarm_road_alert_service.dart'; -class QuickSwarmReportModal extends ConsumerWidget { +class QuickSwarmReportModal extends ConsumerStatefulWidget { const QuickSwarmReportModal({super.key}); static Future show(BuildContext context) { @@ -23,7 +24,15 @@ class QuickSwarmReportModal extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _QuickSwarmReportModalState(); +} + +class _QuickSwarmReportModalState extends ConsumerState { + bool _submitting = false; + + @override + Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: AppColors.darkSurface, @@ -83,7 +92,6 @@ class QuickSwarmReportModal extends ConsumerWidget { // 1. POLICE / SPEED TRAP _buildActionButton( context: context, - ref: ref, category: RoadAlertCategory.police, title: 'POLICE / SPEED TRAP', subtitle: 'Radar, laser, or patrol car spotted', @@ -95,7 +103,6 @@ class QuickSwarmReportModal extends ConsumerWidget { // 2. ROAD HAZARD / GRAVEL _buildActionButton( context: context, - ref: ref, category: RoadAlertCategory.hazard, title: 'HAZARD / GRAVEL / DEBRIS', subtitle: 'Gravel in corner, slick surface, road debris', @@ -107,7 +114,6 @@ class QuickSwarmReportModal extends ConsumerWidget { // 3. ACCIDENT / ROAD CLOSED _buildActionButton( context: context, - ref: ref, category: RoadAlertCategory.accident, title: 'ACCIDENT / CRASH AHEAD', subtitle: 'Crash or blocked road ahead', @@ -121,7 +127,6 @@ class QuickSwarmReportModal extends ConsumerWidget { Widget _buildActionButton({ required BuildContext context, - required WidgetRef ref, required RoadAlertCategory category, required String title, required String subtitle, @@ -139,7 +144,7 @@ class QuickSwarmReportModal extends ConsumerWidget { ), elevation: 0, ), - onPressed: () => _handleReport(context, ref, category), + onPressed: _submitting ? null : () => _handleReport(category), child: Row( children: [ Container( @@ -168,10 +173,7 @@ class QuickSwarmReportModal extends ConsumerWidget { const SizedBox(height: 2), Text( subtitle, - style: const TextStyle( - fontSize: 11, - color: Colors.white70, - ), + style: const TextStyle(fontSize: 11, color: Colors.white70), ), ], ), @@ -182,16 +184,15 @@ class QuickSwarmReportModal extends ConsumerWidget { ); } - Future _handleReport( - BuildContext context, - WidgetRef ref, - RoadAlertCategory category, - ) async { + Future _handleReport(RoadAlertCategory category) async { + if (_submitting) return; + setState(() => _submitting = true); HapticFeedback.heavyImpact(); final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); - try { + final activeRide = ref.read(activeUserGroupRideProvider); + final swarmService = ref.read(swarmRoadAlertServiceProvider); + final alertService = ref.read(roadAlertServiceProvider); // 1. Acquire current position Position? pos; try { @@ -200,11 +201,17 @@ class QuickSwarmReportModal extends ConsumerWidget { accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 4), ), - ); + ).timeout(const Duration(seconds: 5)); } catch (_) { - pos = await Geolocator.getLastKnownPosition(); + if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; + pos = await Geolocator.getLastKnownPosition().timeout( + const Duration(seconds: 2), + ); } + // Closing the sheet before location resolves cancels the report. + if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; + final lat = pos?.latitude ?? 0.0; final lng = pos?.longitude ?? 0.0; final heading = pos?.heading; @@ -220,20 +227,21 @@ class QuickSwarmReportModal extends ConsumerWidget { } // 2. Check active group ride - final activeRide = ref.read(activeUserGroupRideProvider); - // 3. Dispatch to Swarm Service - final swarmService = ref.read(swarmRoadAlertServiceProvider); + var cloudStatus = SwarmCloudStatus.unavailable; final report = await swarmService.reportAlert( category: category, latitude: lat, longitude: lng, heading: heading, groupRideId: activeRide?.id, + onCloudStatus: (status) => cloudStatus = status, ); - // 4. Inject immediately into local alerts for zero-latency confirmation - ref.read(roadAlertServiceProvider).simulateAlert(report.toRoadAlert()); + // The sheet or its ProviderScope may have gone away during submission. + if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; + alertService.simulateAlert(report.toRoadAlert()); + Navigator.of(context).pop(); messenger.showSnackBar( SnackBar( @@ -242,10 +250,16 @@ class QuickSwarmReportModal extends ConsumerWidget { Icon(category.icon, color: Colors.white, size: 20), const SizedBox(width: 10), Expanded( - child: Text( - '${category.displayName} reported to MotorSignal Swarm!', - style: const TextStyle(fontWeight: FontWeight.bold), - ), + child: Text(switch (cloudStatus) { + SwarmCloudStatus.confirmed => + '${category.displayName} added to local alerts and saved to cloud.', + SwarmCloudStatus.pending => + '${category.displayName} added locally; cloud delivery pending.', + SwarmCloudStatus.failed => + '${category.displayName} added locally; cloud delivery failed.', + SwarmCloudStatus.unavailable => + '${category.displayName} added locally; cloud unavailable.', + }, style: const TextStyle(fontWeight: FontWeight.bold)), ), ], ), @@ -254,12 +268,18 @@ class QuickSwarmReportModal extends ConsumerWidget { ), ); } catch (e) { - messenger.showSnackBar( - SnackBar( - content: Text('Failed to submit report: $e'), - backgroundColor: Colors.redAccent, - ), - ); + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + messenger.showSnackBar( + SnackBar( + content: Text('Failed to submit report: $e'), + backgroundColor: Colors.redAccent, + ), + ); + } + } finally { + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + setState(() => _submitting = false); + } } } } diff --git a/lib/services/sync/cloud_reconciliation_service.dart b/lib/services/sync/cloud_reconciliation_service.dart index 6310bba..5d66bbb 100644 --- a/lib/services/sync/cloud_reconciliation_service.dart +++ b/lib/services/sync/cloud_reconciliation_service.dart @@ -150,27 +150,31 @@ class CloudReconciliationService { if (localRideId != null) { final legacyRows = await _db .customSelect( - 'SELECT id, cloud_ride_id FROM rides WHERE id = ?', + 'SELECT id, cloud_ride_id, start_time FROM rides WHERE id = ?', variables: [Variable(localRideId)], ) .get(); if (legacyRows.isNotEmpty) { final existingCloudId = legacyRows.first.data['cloud_ride_id']; - if (existingCloudId == null || existingCloudId.toString().isEmpty) { + final startTime = _readDateTime(data['startTime'] ?? data['startedAt']); + final localStartTime = _readDateTime(legacyRows.first.data['start_time']); + + final isSameRide = (existingCloudId == null || + existingCloudId.toString().isEmpty || + existingCloudId == cloudRideId) && + startTime != null && + localStartTime != null && + (localStartTime.difference(startTime).inSeconds).abs() <= 5; + + if (isSameRide) { await _db.customStatement( 'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', [cloudRideId, ownerId, localRideId], ); return const CloudReconciliationResult(unchanged: 1); } - if (existingCloudId == cloudRideId) { - await _db.customStatement( - 'UPDATE rides SET cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', - [ownerId, localRideId], - ); - return const CloudReconciliationResult(unchanged: 1); - } - return const CloudReconciliationResult(conflicts: 1); + // If not the same ride, this is simply a device local-id collision. + // Fall through directly to insert the cloud ride. } } @@ -211,6 +215,8 @@ class CloudReconciliationService { max_lean_right, max_accel_g, max_braking_g, + best0to60_ms, + best0to100_ms, distance, average_speed, top_speed, @@ -223,26 +229,28 @@ class CloudReconciliationService { motorcycle_make, motorcycle_model, motorcycle_variant - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [ cloudRideId, ownerId, startTime.toUtc().millisecondsSinceEpoch, endTime?.toUtc().millisecondsSinceEpoch, - _asDouble(data['distanceMiles']), - _asDouble(data['topSpeedMph']), - _asDouble(data['maxLeanLeft']), - _asDouble(data['maxLeanRight']), - _asDouble(data['maxAccelG']), - _asDouble(data['maxBrakingG']), - _asDouble(data['distanceMiles']), - _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']), - _asDouble(data['topSpeedMph']), + _asDouble(data['distanceMiles']) ?? 0.0, + _asDouble(data['topSpeedMph']) ?? 0.0, + _asDouble(data['maxLeanLeft']) ?? 0.0, + _asDouble(data['maxLeanRight']) ?? 0.0, + _asDouble(data['maxAccelG']) ?? 0.0, + _asDouble(data['maxBrakingG']) ?? 0.0, + _asDouble(data['best0to60Ms'] ?? data['best0to60_ms']), + _asDouble(data['best0to100Ms'] ?? data['best0to100_ms']), + _asDouble(data['distanceMiles']) ?? 0.0, + _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']) ?? 0.0, + _asDouble(data['topSpeedMph']) ?? 0.0, _asInt(data['movingTime']) ?? 0, _asInt(data['stoppedTime']) ?? 0, - _asDouble(data['elevationGain']), - _asDouble(data['elevationLoss']), + _asDouble(data['elevationGain']) ?? 0.0, + _asDouble(data['elevationLoss']) ?? 0.0, _asInt(data['motorcycleId']), _asInt(data['motorcycleYear']), data['motorcycleMake']?.toString(), diff --git a/lib/services/sync/cloud_restore_controller.dart b/lib/services/sync/cloud_restore_controller.dart index 42699d4..45f1b9a 100644 --- a/lib/services/sync/cloud_restore_controller.dart +++ b/lib/services/sync/cloud_restore_controller.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_service.dart'; +import '../../database/database.dart'; +import '../../features/records/records_service.dart'; import 'cloud_restore_service.dart'; enum CloudRestoreStatus { @@ -32,12 +34,18 @@ class CloudRestoreUiState { class CloudRestoreController extends StateNotifier { final CloudRestoreService _restoreService; final AuthService _authService; + final RecordsService? _recordsService; + final AppDatabase? _db; CloudRestoreController({ required CloudRestoreService restoreService, required AuthService authService, + RecordsService? recordsService, + AppDatabase? db, }) : _restoreService = restoreService, _authService = authService, + _recordsService = recordsService, + _db = db, super(const CloudRestoreUiState()); Future restoreFromMotorCloud() async { @@ -63,6 +71,11 @@ class CloudRestoreController extends StateNotifier { state = const CloudRestoreUiState(status: CloudRestoreStatus.restoring); try { final result = await _restoreService.restore(); + if (_recordsService != null && _db != null) { + try { + await _recordsService.recalculateFromDatabase(_db); + } catch (_) {} + } final hasFailures = result.failures > 0; final hasRestoredOrMatched = result.hasAnyRestoredData || diff --git a/lib/services/sync/cloud_restore_service.dart b/lib/services/sync/cloud_restore_service.dart index 0fca357..5293168 100644 --- a/lib/services/sync/cloud_restore_service.dart +++ b/lib/services/sync/cloud_restore_service.dart @@ -152,7 +152,9 @@ class CloudRestoreService { for (final document in documents) { final data = document.data; final cloudRideId = document.id; - if (!_isValidCloudRideId(cloudRideId) || data['ownerId'] != user.uid) { + final owner = (data['ownerId'] ?? data['uid'])?.toString(); + if (!_isValidCloudRideId(cloudRideId) || + (owner != null && owner != user.uid)) { telemetrySkipped++; continue; } @@ -337,7 +339,7 @@ class CloudRestoreService { ); } - final csv = utf8.decode(file.content as List, allowMalformed: false); + final csv = utf8.decode(file.content as List, allowMalformed: true); final lines = const LineSplitter().convert(csv); if (lines.length < 2) return 0; @@ -398,10 +400,8 @@ class CloudRestoreService { } if (companions.isEmpty) return 0; - await _db.transaction(() async { - for (final companion in companions) { - await _db.into(_db.telemetrySamples).insert(companion); - } + await _db.batch((batch) { + batch.insertAll(_db.telemetrySamples, companions); }); return companions.length; } diff --git a/lib/services/sync/sync_providers.dart b/lib/services/sync/sync_providers.dart index f1417eb..efba735 100644 --- a/lib/services/sync/sync_providers.dart +++ b/lib/services/sync/sync_providers.dart @@ -37,6 +37,8 @@ final cloudRestoreControllerProvider = (ref) => CloudRestoreController( restoreService: ref.watch(cloudRestoreServiceProvider), authService: ref.watch(authServiceProvider), + recordsService: ref.watch(recordsServiceProvider.notifier), + db: ref.watch(databaseProvider), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index f8c1377..4594861 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,7 @@ flutter_native_splash: icon_background_color: "#111111" flutter: + default-flavor: production # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/cloud_restore_manual_test.dart b/test/cloud_restore_manual_test.dart index edc98d6..3777a84 100644 --- a/test/cloud_restore_manual_test.dart +++ b/test/cloud_restore_manual_test.dart @@ -17,6 +17,8 @@ import 'package:motorcycle_dash/services/sync/cloud_reconciliation_service.dart' import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart'; import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart'; import 'package:motorcycle_dash/services/sync/r2_download_service.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class _TestAuthService implements AuthService { @override bool get isEmailVerified => false; @@ -598,6 +600,144 @@ void main() { expect(await db.select(db.rides).get(), isEmpty); }, ); + + test( + 'legacy cloud metadata storing uid instead of ownerId is restored', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + CloudRideMetadataDocument( + id: 'ride_legacy_uid', + data: { + 'id': 'ride_legacy_uid', + 'uid': 'user_a', // Legacy schema: uid present, ownerId null + 'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(), + 'endTime': DateTime.utc(2026, 9, 3, 10, 15).toIso8601String(), + 'distanceMiles': 5.5, + 'topSpeedMph': 75.0, + 'maxLeanLeft': 28.0, + 'maxLeanRight': 31.0, + 'maxAccelG': 0.65, + 'maxBrakingG': -0.72, + 'best0to60Ms': 3450.0, + 'movingTime': 900, + 'stoppedTime': 0, + 'elevationGain': 450.0, + 'elevationLoss': 200.0, + }, + ), + ], + }, + ); + + final zipBytes = _telemetryZip(samples: 2); + final client = _downloadClient(zipBytes); + final restore = service(store: store, client: client); + + final result = await restore.restore(); + + expect(result.cloudRidesDiscovered, 1); + expect(result.metadataRowsAdded, 1); + expect(result.telemetryRidesRestored, 1); + expect(result.telemetrySamplesRestored, 2); + + final rides = await db.select(db.rides).get(); + expect(rides, hasLength(1)); + expect(rides.single.cloudRideId, 'ride_legacy_uid'); + expect(rides.single.distanceMiles, 5.5); + expect(rides.single.topSpeedMph, 75.0); + expect(rides.single.best0to60Ms, 3450.0); + }, + ); + + test( + 'local ID collision does not cause conflict; inserts cloud ride as a new local ride', + () async { + // Pre-insert a local ride with SQLite ID 1 and a DIFFERENT cloudRideId + await db.customStatement(''' + INSERT INTO rides (id, cloud_ride_id, start_time, distance_miles, top_speed_mph) + VALUES (1, 'ride_existing_local', 1700000000000, 10.0, 50.0) + '''); + + // Cloud ride also has localRideId: 1 from its original device, but cloud ID 'ride_from_cloud' + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + CloudRideMetadataDocument( + id: 'ride_from_cloud', + data: { + 'id': 'ride_from_cloud', + 'localRideId': 1, + 'ownerId': 'user_a', + 'startTime': DateTime.utc(2026, 9, 10, 14).toIso8601String(), + 'distanceMiles': 22.4, + 'topSpeedMph': 88.0, + }, + ), + ], + }, + ); + + final restore = service(store: store, client: _downloadClient(_telemetryZip())); + final result = await restore.restore(); + + // Must NOT report conflict! Must add as a new row + expect(result.metadata.conflicts, 0); + expect(result.metadataRowsAdded, 1); + + final rides = await db.select(db.rides).get(); + expect(rides, hasLength(2)); + expect(rides.any((r) => r.cloudRideId == 'ride_existing_local'), isTrue); + expect(rides.any((r) => r.cloudRideId == 'ride_from_cloud'), isTrue); + }, + ); + + test( + 'recalculating records from database populates personal records and benchmarks', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + // Insert rides with speed, lean, Gs, best 0-60 + await db.customStatement(''' + INSERT INTO rides ( + cloud_ride_id, start_time, distance_miles, top_speed_mph, + max_lean_left, max_lean_right, max_accel_g, max_braking_g, + elevation_gain, best0to60_ms + ) VALUES ( + 'ride_rec_1', 1700000000000, 45.2, 94.5, + 38.5, 42.0, 0.85, -0.92, + 1850.0, 3150.0 + ) + '''); + + // Insert a performance run for 0-100 + await db.addPerformanceRun( + PerformanceRunsCompanion.insert( + type: '0-100', + timeMs: 7200.0, + topSpeedReached: 101.2, + timestamp: DateTime.utc(2026, 9, 15), + ), + ); + + final recordsService = RecordsService(prefs); + expect(recordsService.state.topSpeedMph, 0.0); // initially empty + + await recordsService.recalculateFromDatabase(db); + + expect(recordsService.state.topSpeedMph, 94.5); + expect(recordsService.state.maxLeanLeft, 38.5); + expect(recordsService.state.maxLeanRight, 42.0); + expect(recordsService.state.maxAccelG, 0.85); + expect(recordsService.state.maxBrakingG, 0.92); + expect(recordsService.state.longestRideMiles, 45.2); + expect(recordsService.state.greatestElevationGain, 1850.0); + expect(recordsService.state.fastest0To60, 3.15); // 3150 ms / 1000 + expect(recordsService.state.fastest0To100, 7.20); // 7200 ms / 1000 + }, + ); }); } diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index af440fe..aefaeec 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -352,5 +352,37 @@ void main() { expect(find.byKey(const ValueKey('social_card_feed')), findsOneWidget); expect(find.text('Community Feed'), findsOneWidget); }); + + testWidgets('Tapping INSPECT TELEMETRY GRAPH opens FeedTelemetryInspectorModal', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1000)); + + final service = SocialFeedService(); + await tester.pumpWidget( + _createTestApp( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: const SocialFeedScreen(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Scroll so the card inspect telemetry button is visible + await tester.drag(find.byType(ListView), const Offset(0, -100)); + await tester.pumpAndSettle(); + + final inspectBtn = find.text('INSPECT TELEMETRY GRAPH'); + expect(inspectBtn, findsWidgets); + + await tester.tap(inspectBtn.first); + await tester.pumpAndSettle(); + + // Verify inspector modal opened with metrics and tabs + expect(find.text('PEAK LEAN'), findsOneWidget); + expect(find.text('LEAN & APEX'), findsOneWidget); + expect(find.text('SPEED & G-FORCE'), findsOneWidget); + expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget); + }); }); } diff --git a/test/entitlement_domain_test.dart b/test/entitlement_domain_test.dart index 4ad2a99..7a43ad9 100644 --- a/test/entitlement_domain_test.dart +++ b/test/entitlement_domain_test.dart @@ -236,7 +236,7 @@ void main() { source: PurchaseSource.googlePlay, productId: 'motorsignal_pro_monthly', purchaseTokenHash: 'token_sha256_hash', - expiresAt: DateTime.utc(2026, 10, 1), + expiresAt: DateTime.utc(2030, 1, 1), lastVerifiedAt: DateTime.utc(2026, 9, 1), ); diff --git a/test/forensic_lifecycle_audit_test.dart b/test/forensic_lifecycle_audit_test.dart index 4916237..2fd7531 100644 --- a/test/forensic_lifecycle_audit_test.dart +++ b/test/forensic_lifecycle_audit_test.dart @@ -302,6 +302,44 @@ void main() { expect(session.dbId, isNotNull); }); + test('pause and resume preserve ride identity and persist only active samples', () async { + final container = ProviderContainer(overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + telemetryServiceProvider.overrideWithValue(fakeTelemetry), + audioAlertServiceProvider.overrideWithValue(mockAudio), + fieldTestLoggerProvider.overrideWithValue(logger), + ]); + addTearDown(container.dispose); + final machine = container.read(rideStateMachineProvider.notifier); + await machine.startRide(); + final rideId = container.read(rideStateMachineProvider).dbId!; + final cloudId = await db.getCloudRideId(rideId); + Future emit(double speed) async { + fakeTelemetry.emitSample(TelemetrySample( + timestamp: DateTime.now(), latitude: 34, longitude: -85, + speed: speed, gpsAccuracy: 3, + )); + await Future.delayed(const Duration(milliseconds: 70)); + } + await emit(10); + machine.pauseRide(); + expect(container.read(rideStateMachineProvider).state, RideState.paused); + await emit(99); + machine.resumeRide(); + expect(container.read(rideStateMachineProvider).state, RideState.riding); + expect(container.read(rideStateMachineProvider).dbId, rideId); + await emit(20); + await machine.finishRide(); + await logger.flush(); + final samples = await db.select(db.telemetrySamples).get(); + expect(samples.map((sample) => sample.speed), [10, 20]); + expect(await db.getCloudRideId(rideId), cloudId); + final rides = await db.select(db.rides).get(); + expect(rides, hasLength(1)); + expect(rides.single.endTime, isNotNull); + }); + test('Recording Health Contract validates prerequisites strictly', () { const armed = RecordingHealthContract( status: RecordingHealthStatus.recordingArmed, diff --git a/test/motorcycle_photo_attribution_test.dart b/test/motorcycle_photo_attribution_test.dart new file mode 100644 index 0000000..64a3f3a --- /dev/null +++ b/test/motorcycle_photo_attribution_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/garage/widgets/motorcycle_photo.dart'; +import 'package:motorcycle_dash/services/motorcycle_image_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + MotorcycleImageService.resetRegistry(); + }); + + tearDown(() { + MotorcycleImageService.resetRegistry(); + }); + + group('MotorcyclePhoto Attribution Sanitization', () { + testWidgets('renders sanitized attribution without raw HTML tags or unescaped entities', (tester) async { + MotorcycleImageService.registerDescriptor( + const MotorcycleImageDescriptor( + make: 'TestMake', + model: 'TestModel', + exactYear: 2024, + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg', + caption: '2024 Test Motorcycle', + attribution: 'Chanokchon & Team', + isPhoto: true, + ), + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MotorcyclePhoto( + year: 2024, + make: 'TestMake', + model: 'TestModel', + showAttribution: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify that no raw HTML tags or unescaped entities are displayed + expect(find.textContaining(''), findsNothing); + expect(find.textContaining('&'), findsNothing); + + // Verify sanitized text is displayed cleanly + expect(find.text('Photo: Chanokchon & Team'), findsOneWidget); + }); + + testWidgets('hides attribution when showAttribution is false', (tester) async { + MotorcycleImageService.registerDescriptor( + const MotorcycleImageDescriptor( + make: 'TestMake', + model: 'TestModel', + exactYear: 2024, + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg', + caption: '2024 Test Motorcycle', + attribution: 'Test Photographer', + isPhoto: true, + ), + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MotorcyclePhoto( + year: 2024, + make: 'TestMake', + model: 'TestModel', + showAttribution: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Photo:'), findsNothing); + }); + }); +} diff --git a/test/quick_swarm_report_modal_test.dart b/test/quick_swarm_report_modal_test.dart new file mode 100644 index 0000000..f01ceed --- /dev/null +++ b/test/quick_swarm_report_modal_test.dart @@ -0,0 +1,333 @@ +// ignore_for_file: subtype_of_sealed_class + +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; +import 'package:motorcycle_dash/services/alerts/swarm_road_alert_service.dart'; +import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart'; + +class _LocalAlerts implements RoadAlertService { + final reports = []; + @override + void simulateAlert(RoadAlert alert) => reports.add(alert); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Location extends GeolocatorPlatform { + late final current = Completer(); + Future Function()? lastKnown; + @override + Future getCurrentPosition({LocationSettings? locationSettings}) => + current.future; + @override + Future getLastKnownPosition({ + bool forceLocationManager = false, + }) async => lastKnown == null ? null : await lastKnown!(); +} + +// Delay only the Firestore acknowledgement boundary; use the production +// SwarmRoadAlertService and an actual fake Firestore document underneath. +class _DelayedFirestore extends FakeFirebaseFirestore { + final gate = Completer(); + int writes = 0; + @override + CollectionReference> collection(String path) => + _DelayedCollection(super.collection(path), this); +} + +class _DelayedCollection implements CollectionReference> { + _DelayedCollection(this.delegate, this.firestore); + final CollectionReference> delegate; + @override + final _DelayedFirestore firestore; + @override + DocumentReference> doc([String? path]) => + _DelayedDocument(delegate.doc(path), firestore); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _DelayedDocument implements DocumentReference> { + _DelayedDocument(this.delegate, this.firestore); + final DocumentReference> delegate; + @override + final _DelayedFirestore firestore; + @override + Future set(Map data, [SetOptions? options]) async { + firestore.writes++; + await firestore.gate.future; + await delegate.set(data, options); + } + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Position _position() => Position( + latitude: 35.3934, longitude: -83.9261, timestamp: DateTime.now(), + accuracy: 5, heading: 90, headingAccuracy: 1, + altitude: 0, altitudeAccuracy: 1, speed: 0, speedAccuracy: 1, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _Location location; + late _LocalAlerts alerts; + late GeolocatorPlatform originalLocation; + + setUp(() { + originalLocation = GeolocatorPlatform.instance; + location = _Location(); + GeolocatorPlatform.instance = location; + alerts = _LocalAlerts(); + }); + tearDown(() => GeolocatorPlatform.instance = originalLocation); + Future open(WidgetTester tester, {SwarmRoadAlertService? service}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + activeUserGroupRideProvider.overrideWithValue(null), + swarmRoadAlertServiceProvider.overrideWithValue( + service ?? SwarmRoadAlertService(), + ), + roadAlertServiceProvider.overrideWithValue(alerts), + ], + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return TextButton( + onPressed: () => QuickSwarmReportModal.show(context), + child: const Text('Open report'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Open report')); + await tester.pumpAndSettle(); + } + + testWidgets('delayed GPS report survives the sheet exit animation', ( + tester, + ) async { + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + await tester.pumpAndSettle(); + // Deliberately resolve after the original implementation disposed its ref. + location.current.complete( + Position( + latitude: 35.3934, + longitude: -83.9261, + timestamp: DateTime.now(), + accuracy: 5, + heading: 90, + headingAccuracy: 1, + altitude: 0, + altitudeAccuracy: 1, + speed: 0, + speedAccuracy: 1, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect( + find.textContaining("Cannot use 'ref' after the widget was disposed"), + findsNothing, + ); + expect(alerts.reports, hasLength(1), reason: tester + .widgetList(find.byType(Text)) + .map((text) => text.data) + .join(' | ')); + expect(alerts.reports.single.category, RoadAlertCategory.hazard); + expect(find.textContaining('Bad state'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('dismissal while GPS is pending cancels the report', ( + tester, + ) async { + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + await tester.pump(); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + location.current.complete( + Position( + latitude: 35.3934, + longitude: -83.9261, + timestamp: DateTime.now(), + accuracy: 5, + heading: 90, + headingAccuracy: 1, + altitude: 0, + altitudeAccuracy: 1, + speed: 0, + speedAccuracy: 1, + ), + ); + await tester.pump(); + expect(alerts.reports, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('repeated taps create one report', (tester) async { + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + await tester.pump(); + expect( + tester + .widget( + find.widgetWithText(ElevatedButton, 'HAZARD / GRAVEL / DEBRIS'), + ) + .onPressed, + isNull, + ); + location.current.complete( + Position( + latitude: 35.3934, + longitude: -83.9261, + timestamp: DateTime.now(), + accuracy: 5, + heading: 90, + headingAccuracy: 1, + altitude: 0, + altitudeAccuracy: 1, + speed: 0, + speedAccuracy: 1, + ), + ); + await tester.pumpAndSettle(); + expect(alerts.reports, hasLength(1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('missing location gives feedback without submitting', ( + tester, + ) async { + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + location.current.completeError(const PermissionDeniedException('denied')); + await tester.pumpAndSettle(); + expect(alerts.reports, isEmpty); + expect( + find.text('Cannot report: GPS location unavailable.'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('close cancels even before its exit animation finishes', (tester) async { + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + await tester.tap(find.byIcon(Icons.close)); + location.current.complete(_position()); + await tester.pumpAndSettle(); + expect(alerts.reports, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async { + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(alerts.reports, isEmpty); + expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget); + expect(tester.widget(find.widgetWithText( + ElevatedButton, 'HAZARD / GRAVEL / DEBRIS')).onPressed, isNotNull); + location.current.complete(_position()); + await tester.pumpAndSettle(); + expect(alerts.reports, isEmpty); + }); + + testWidgets('last-known timeout gives failure feedback', (tester) async { + final lastKnown = Completer(); + location.lastKnown = () => lastKnown.future; + await open(tester); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + location.current.completeError(StateError('GPS unavailable')); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.textContaining('Failed to submit report:'), findsOneWidget); + expect(alerts.reports, isEmpty); + lastKnown.complete(null); + await tester.pump(); + }); + + testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async { + final firestore = FakeFirebaseFirestore(); + await open(tester, service: SwarmRoadAlertService(firestore: firestore)); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + location.current.complete(_position()); + await tester.pumpAndSettle(); + expect(alerts.reports, hasLength(1)); + expect(find.textContaining('saved to cloud'), findsOneWidget); + final docs = await firestore.collection('road_alerts').get(); + expect(docs.docs, hasLength(1)); + expect(docs.docs.single.data()['latitude'], 35.3934); + }); + + testWidgets('rejected cloud write gives local-only failure feedback', (tester) async { + final firestore = FakeFirebaseFirestore(securityRules: ''' + service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { allow read: if true; allow write: if false; } + } + } + '''); + await open(tester, service: SwarmRoadAlertService(firestore: firestore)); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + location.current.complete(_position()); + await tester.pumpAndSettle(); + expect(alerts.reports, hasLength(1)); + expect(find.textContaining('cloud delivery failed'), findsOneWidget); + expect((await firestore.collection('road_alerts').get()).docs, isEmpty); + }); + + testWidgets('pending acknowledgement returns locally without retrying write', (tester) async { + final firestore = _DelayedFirestore(); + await open(tester, service: SwarmRoadAlertService(firestore: firestore)); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + location.current.complete(_position()); + await tester.pump(); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(alerts.reports, hasLength(1)); + expect(find.textContaining('cloud delivery pending'), findsOneWidget); + firestore.gate.complete(); + await tester.pumpAndSettle(); + expect(firestore.writes, 1); + expect(alerts.reports, hasLength(1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('removing provider scope during cloud write avoids disposed service', (tester) async { + final firestore = _DelayedFirestore(); + await open(tester, service: SwarmRoadAlertService(firestore: firestore)); + await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); + location.current.complete(_position()); + await tester.pump(); + expect(firestore.writes, 1); + await tester.pumpWidget(const SizedBox.shrink()); + firestore.gate.complete(); + await tester.pumpAndSettle(); + expect(alerts.reports, isEmpty); + expect(tester.takeException(), isNull); + }); +} + diff --git a/test/rid3b1_stop_durability_barrier_test.dart b/test/rid3b1_stop_durability_barrier_test.dart index a7d9a56..fababac 100644 --- a/test/rid3b1_stop_durability_barrier_test.dart +++ b/test/rid3b1_stop_durability_barrier_test.dart @@ -727,5 +727,44 @@ void main() { expect(finishedTransitions, 1); expect((await db.select(db.rides).get()), hasLength(1)); }); + + test('I continuous live telemetry streaming during finalization completes ' + 'durably without throwing StateError', () async { + final container = buildContainer(); + final notifier = container.read(rideStateMachineProvider.notifier); + final rideId = await startRide(container); + + // Emit initial 5 samples + emitSamples(0, 5); + await pumpEventQueue(times: 20); + + // Start continuous telemetry emission (simulating live 20Hz sensor streaming) + var streamingIndex = 5; + final timer = Timer.periodic(const Duration(milliseconds: 15), (_) { + emitSamples(streamingIndex++, 1); + }); + addTearDown(timer.cancel); + + // Give timer a moment to emit samples + await Future.delayed(const Duration(milliseconds: 50)); + + // finishRide must complete smoothly without StateError + await notifier.finishRide(); + timer.cancel(); + await pumpEventQueue(times: 40); + + final session = container.read(rideStateMachineProvider); + final rows = await persistedRows(); + final counts = countsByIndex(rows); + + expect(session.state, RideState.finished); + expect(session.healthStatus, RecordingHealthStatus.recordingComplete); + expect(rows, hasLength(session.totalSamplesPersisted)); + expect(session.totalSamplesReceived, session.totalSamplesPersisted); + expect(duplicatedIn(counts), isEmpty, reason: 'no duplicates'); + expect(missingOf(rows.length, counts), isEmpty, reason: 'no missing samples'); + expect(finishedTransitions, 1, reason: 'exactly one finished edge'); + expect(rows.map((r) => r.rideId).toSet(), {rideId}); + }); }); } diff --git a/test/router_test.dart b/test/router_test.dart index 1d224e6..d3242e7 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -37,5 +37,18 @@ void main() { final matchFeed = config.findMatch(Uri.parse('/feed')); expect(matchFeed.matches.isNotEmpty, isTrue); }); + + test('routes include /replay and /replay/:id subroute', () { + final config = appRouter.configuration; + + final matchReplay = config.findMatch(Uri.parse('/replay')); + expect(matchReplay.matches.isNotEmpty, isTrue); + + final matchReplayWithId = config.findMatch(Uri.parse('/replay/42')); + expect(matchReplayWithId.matches.isNotEmpty, isTrue); + + final matchReplayWithQuery = config.findMatch(Uri.parse('/replay?rideId=42')); + expect(matchReplayWithQuery.matches.isNotEmpty, isTrue); + }); }); } diff --git a/tools/qa_auth_main.dart b/tools/qa_auth_main.dart new file mode 100644 index 0000000..28ed0f8 --- /dev/null +++ b/tools/qa_auth_main.dart @@ -0,0 +1,52 @@ +// Explicit QA-only entry point. Never use this for a release build. +// flutter run -t tools/qa_auth_main.dart --dart-define=R2_WORKER_URL=http://127.0.0.1:1 +// Android emulator: reverse tcp:9299 -> host 9299 and tcp:8288 -> host 8288. +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/main.dart' show MotorcycleDashApp; +import 'package:motorcycle_dash/features/records/records_service.dart'; + +Future main() async { + if (!kDebugMode) throw StateError('QA entry requires debug mode'); + const worker = String.fromEnvironment('R2_WORKER_URL'); + if (worker != 'http://127.0.0.1:1') { + throw StateError('QA requires R2_WORKER_URL=http://127.0.0.1:1'); + } + MarionetteBinding.ensureInitialized(); + final app = await Firebase.initializeApp( + options: const FirebaseOptions( + apiKey: 'demo-key', + appId: '1:123456789:android:qa', + messagingSenderId: '123456789', + projectId: 'demo-motorsignal-qa', + ), + ); + if (!app.options.projectId.startsWith('demo-')) { + throw StateError('QA requires a demo-* Firebase project'); + } + // Force loopback and disable FlutterFire's emulator-host remapping so adb + // reverse forwards only to the isolated host-local emulator listeners. + await FirebaseAuth.instance.useAuthEmulator( + '127.0.0.1', + 9299, + automaticHostMapping: false, + ); + FirebaseFirestore.instance.useFirestoreEmulator( + '127.0.0.1', + 8288, + automaticHostMapping: false, + ); + final preferences = await SharedPreferences.getInstance(); + runApp( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(preferences)], + child: const MotorcycleDashApp(), + ), + ); +}