fix(mobile): apply qa defect remediation for stop barrier, photo attribution, and alert dismissal

This commit is contained in:
Developer 2026-10-02 12:40:08 -04:00
parent c956ffd7bd
commit 8fd337c672
25 changed files with 2015 additions and 80 deletions

View File

@ -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

View File

@ -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"
}

View File

@ -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);
},
),
],
),
],
);

View File

@ -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';
}
}

View File

@ -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) {

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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<void> show(BuildContext context, SocialFeedItem feedItem) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => FeedTelemetryInspectorModal(feedItem: feedItem),
);
}
@override
ConsumerState<FeedTelemetryInspectorModal> createState() =>
_FeedTelemetryInspectorModalState();
}
enum _InspectorTraceTab {
leanApex,
speedGForce,
elevation,
}
class _FeedTelemetryInspectorModalState
extends ConsumerState<FeedTelemetryInspectorModal> {
_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 = <Offset>[];
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;
}
}

View File

@ -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;

View File

@ -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);
}
}
}
}

View File

@ -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(),

View File

@ -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 ||

View File

@ -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;
}

View File

@ -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),
),
);

View File

@ -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

View File

@ -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
},
);
});
}

View File

@ -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);
});
});
}

View File

@ -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),
);

View File

@ -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,

View File

@ -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: '<a href="//commons.wikimedia.org/wiki/User:Chanokchon">Chanokchon</a> &amp; 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('<a href='), findsNothing);
expect(find.textContaining('</a>'), findsNothing);
expect(find.textContaining('&amp;'), 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);
});
});
}

View File

@ -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 = <RoadAlert>[];
@override
void simulateAlert(RoadAlert alert) => reports.add(alert);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Location extends GeolocatorPlatform {
late final current = Completer<Position>();
Future<Position?> Function()? lastKnown;
@override
Future<Position> getCurrentPosition({LocationSettings? locationSettings}) =>
current.future;
@override
Future<Position?> 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<void>();
int writes = 0;
@override
CollectionReference<Map<String, dynamic>> collection(String path) =>
_DelayedCollection(super.collection(path), this);
}
class _DelayedCollection implements CollectionReference<Map<String, dynamic>> {
_DelayedCollection(this.delegate, this.firestore);
final CollectionReference<Map<String, dynamic>> delegate;
@override
final _DelayedFirestore firestore;
@override
DocumentReference<Map<String, dynamic>> doc([String? path]) =>
_DelayedDocument(delegate.doc(path), firestore);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _DelayedDocument implements DocumentReference<Map<String, dynamic>> {
_DelayedDocument(this.delegate, this.firestore);
final DocumentReference<Map<String, dynamic>> delegate;
@override
final _DelayedFirestore firestore;
@override
Future<void> set(Map<String, dynamic> 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<void> 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<Text>(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<ElevatedButton>(
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<ElevatedButton>(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<Position?>();
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);
});
}

View File

@ -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});
});
});
}

View File

@ -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);
});
});
}

52
tools/qa_auth_main.dart Normal file
View File

@ -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<void> 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(),
),
);
}