fix(mobile): apply qa defect remediation for stop barrier, photo attribution, and alert dismissal
This commit is contained in:
parent
c956ffd7bd
commit
8fd337c672
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,6 +268,7 @@ class QuickSwarmReportModal extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to submit report: $e'),
|
||||
|
|
@ -261,6 +276,11 @@ class QuickSwarmReportModal extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> & 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('&'), 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue