diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6c86a..1813243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > - `+BUILD` → an auto-incrementing integer consumed by `versionCode` (Android) and > `CFBundleVersion` (iOS). It is bumped independently of the user-facing version. -## [Unreleased] +## [1.0.2] - 2026-08-30 + +### Fixed +- **Ride Summary Session Resets**: Explicitly zeroed all handling maxima (`maxLeanLeft`, `maxLeanRight`, `maxAccelG`, `maxBrakingG`, `averageSpeedMph`) on consecutive `startRide()` calls without requiring an application restart. +- **Handling Section Responsive Layout**: Overhauled `EvolutionRideDetailsView` Handling section to dynamically scale and wrap metrics across mobile portrait and wide landscape displays without text clipping or card overflow. +- **Precision Millisecond Exporting**: Ensured high-resolution sub-second timestamp formatting across SQLite exports and `telemetry.csv`. + +### Added +- **Garage Photographic Reference Engine**: Extensible `MotorcycleImageService` delivering photographic reference imagery for registered models (including 2001 Yamaha YZF-R6) with automatic fallback to high-tech vector schematics. +- **Interactive Forensic Events**: All event list items in ride details and maps now feature interactive tap-to-inspect dialogs displaying severity badges, timestamps, G-forces, lean angles, coordinates, and "View on Map" navigation. +- **Route Preview Multi-Layer Map**: Interactive toggle layers (`Route`, `Speed`, `Accel`, `Braking`, `Cornering`) with color-coded speed brackets (0-25, 25-50, 50-75, 75-100, 100+ MPH) and floating legend. +- **Offline-First GitHub Ride Log Sync**: Asynchronous background upload service to `bponders12-cell/ride_logs` (branch `main`) with token security, upload queue, retry backoff, and manual "Upload Now" controls. --- -## [1.1.0] - 2026-08-27 +## [1.0.1] - 2026-08-29 + +### Fixed +- **Authoritative 20 Hz Clock**: Synchronized telemetry emission to a single 20 Hz periodic timer with strictly monotonic timestamps and atomic sequence numbers. +- **Sub-Millisecond Timestamp Persistence**: High-resolution SQLite timestamp serialization eliminating whole-second truncation artifacts. +- **Speed-Gated Performance Classification**: Enforced `speed >= 3.0 MPH` gating on lean angle, acceleration, and braking records to prevent stationary kickstand and phone-removal handling from contaminating riding Personal Records. +- **GPS Diagnostic Consolidation**: Added a 500 ms debounce to GPS jump rejection logs, preventing duplicate error entries for single satellite multipath anomalies. +- **Dashboard Lean Needle Alignment**: Perfectly centered the lean gauge arc and zero-degree reference needle across portrait and landscape layouts. +- **Report & Summary Terminology**: Explicitly separated Qualified Moving Performance metrics from Raw Observed Sensor Peaks in all UI screens, `README.txt`, and `ride_summary.json`. + +### Added +- **Production Versioning & Release Management**: Standardized `pubspec.yaml` `1.0.1+101` authoritative versioning, Git commit embedding, release manifest generation, and versioned APK naming (`MotorSignal-v1.0.1-build101-release.apk`). +- **Forensic Field Test Logger & Exporter**: 15-column timeline event logging, zip export packaging, and automated ride quality validation scoring. ### Added & Fixed - **Landscape IMU Coordinate Transformation**: Dynamic sensor coordinate frame rotation adapting automatically to portrait and landscape mounting orientations. diff --git a/assets/images/motorcycles/yamaha_yzf_r6_2001.png b/assets/images/motorcycles/yamaha_yzf_r6_2001.png new file mode 100644 index 0000000..cd97e54 Binary files /dev/null and b/assets/images/motorcycles/yamaha_yzf_r6_2001.png differ diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md new file mode 100644 index 0000000..8930711 --- /dev/null +++ b/docs/VERSIONING.md @@ -0,0 +1,118 @@ +# MotorSignal — Application Versioning & Release Management Guide + +This document outlines the standard release and versioning procedures for the **MotorSignal** motorcycle digital dashboard and telemetry application. + +--- + +## 1. Version Format + +MotorSignal adheres strictly to **Semantic Versioning 2.0** combined with Flutter's build number scheme: + +```text +MAJOR.MINOR.PATCH+BUILD +``` + +- **`MAJOR`**: Incompatible architecture overhauls, breaking database migrations, or fundamental UI paradigm shifts. +- **`MINOR`**: Significant new features, new screens, sensor integrations, or backward-compatible dashboard capabilities. +- **`PATCH`**: Bug fixes, crash fixes, sensor filter hardening, and performance optimizations. +- **`BUILD`**: Monotonically increasing integer incremented for every production binary (APK / AAB). **Never resets or decreases**. + +### Historical & Current Baseline: +- **Baseline (Historical):** `1.0.0+100` (Git commit: `e700ca64`) +- **Previous Release:** `1.0.1+101` (Git commit: `f4d8883e`) +- **Current Release:** `1.0.2+102` (Git tag: `v1.0.2`) + +--- + +## 2. Release Steps for Developers + +Follow this 10-step protocol whenever cutting a new production release: + +### Step 1: Choose the Next Semantic Version & Increment Build Number +Determine whether the change is a `PATCH` (e.g. `1.0.0` -> `1.0.1`), `MINOR` (e.g. `1.0.1` -> `1.1.0`), or `MAJOR` (e.g. `1.1.0` -> `2.0.0`). + +Run the automated version bumper tool: +```bash +# To bump patch and increment build number (e.g. 1.0.0+100 -> 1.0.1+101): +dart run tool/bump_version.dart patch + +# To bump minor (e.g. 1.0.1+101 -> 1.1.0+102): +dart run tool/bump_version.dart minor + +# To bump major (e.g. 1.1.0+102 -> 2.0.0+103): +dart run tool/bump_version.dart major +``` + +### Step 2: Verify `pubspec.yaml` +Confirm that `pubspec.yaml` contains the new version: +```yaml +version: 1.0.1+101 +``` + +### Step 3: Update `CHANGELOG.md` +Add a new release header following the Keep a Changelog standard: +```markdown +## [1.0.1] - 2026-08-29 + +### Fixed +- List fixed defects and stability improvements... + +### Added +- List new capabilities... +``` + +### Step 4: Run Tests & Static Analysis +Verify that all unit tests, sensor simulations, and analysis checks pass: +```bash +flutter analyze lib/ test/ +flutter test +``` + +### Step 5: Commit Version Bump +Commit the version changes: +```bash +git add pubspec.yaml CHANGELOG.md lib/core/app_info.dart lib/core/version/app_identity.dart +git commit -m "chore(release): bump version to 1.0.1+101" +``` + +### Step 6: Create Annotated Git Tag +Create a signed or annotated Git tag matching the semantic version: +```bash +git tag -a v1.0.1 -m "Release v1.0.1 (Build 101)" +``` + +### Step 7: Build Production Release APK +Compile the release binary with tree shaking: +```bash +flutter build apk --release +``` + +### Step 8: Create Release Manifest & Versioned Binary Artifact +Copy the output binary to the official versioned naming standard: +```bash +cp build/app/outputs/flutter-apk/app-release.apk MotorSignal-v1.0.1-build101-release.apk +``` + +Generate the machine-readable release manifest: +```json +{ + "application": "MotorSignal", + "version": "1.0.1", + "build": 101, + "gitCommit": "f4d8883e2f7e868931413458bd22f213cfea10f4", + "gitBranch": "master", + "releaseChannel": "release", + "telemetrySchema": 5, + "exportSchema": 1, + "buildTimestamp": "2026-08-29T22:35:00Z" +} +``` + +### Step 9: Verify On-Device Identity +Verify that: +1. The Settings screen displays `MotorSignal v1.0.1+101`. +2. Tapping the build footer opens the **Software Identity** modal displaying `Commit: f4d8883e...`. +3. Exporting a ride creates a `field_test_session.json` recording `version: 1.0.1` and `build: 101`. + +### Step 10: Distribute +Upload `MotorSignal-v1.0.1-build101-release.apk` to phone mount for field validation. diff --git a/lib/core/app_info.dart b/lib/core/app_info.dart index faf5a34..f47ddfa 100644 --- a/lib/core/app_info.dart +++ b/lib/core/app_info.dart @@ -6,7 +6,7 @@ class AppInfo { AppInfo._(); /// Hard-coded fallback mirroring pubspec.yaml and AppIdentity - static const String fallbackVersion = '1.0.0+100'; + static const String fallbackVersion = '1.0.2+102'; static PackageInfo? _cached; diff --git a/lib/core/version/app_identity.dart b/lib/core/version/app_identity.dart index 27a8b12..3608b89 100644 --- a/lib/core/version/app_identity.dart +++ b/lib/core/version/app_identity.dart @@ -34,13 +34,13 @@ class AppIdentity { /// Authoritative current runtime instance derived from compile-time environment defines with safe fallbacks static AppIdentity current() { - const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0'); - const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 100); - const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'e700ca64fdb6365d30a76d81236c912a730bfad2'); + const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.2'); + const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 102); + const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'HEAD'); const branch = String.fromEnvironment('GIT_BRANCH', defaultValue: 'master'); const defaultChannel = kDebugMode ? 'debug' : (kProfileMode ? 'profile' : 'release'); const channel = String.fromEnvironment('RELEASE_CHANNEL', defaultValue: defaultChannel); - const timestamp = String.fromEnvironment('BUILD_TIMESTAMP', defaultValue: '2026-08-28T05:00:00Z'); + const timestamp = String.fromEnvironment('BUILD_TIMESTAMP', defaultValue: '2026-08-30T08:00:00Z'); return const AppIdentity( semanticVersion: semVer, diff --git a/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart b/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart index e717d8c..88acc53 100644 --- a/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart +++ b/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../database/database.dart'; import '../../../models/motorcycle_models.dart'; +import '../../../services/garage/motorcycle_image_service.dart'; class EvolutionMotorcycleDetailView extends ConsumerWidget { final GarageMotorcycle bike; @@ -99,7 +100,7 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget { flex: 4, child: Padding( padding: const EdgeInsets.all(12.0), - child: _buildHeroSpecCard(powerHp, torqueLbFt, wetWeight), + child: _buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight), ), ), // Right Column: Spec Sheet Details @@ -128,7 +129,7 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeroSpecCard(powerHp, torqueLbFt, wetWeight), + _buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight), const SizedBox(height: 16), _buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem), const SizedBox(height: 14), @@ -144,7 +145,13 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget { ); } - Widget _buildHeroSpecCard(String powerHp, String torqueLbFt, String wetWeight) { + Widget _buildHeroSpecCard(GarageMotorcycle bike, String powerHp, String torqueLbFt, String wetWeight) { + final imageRef = MotorcycleImageService().getReferenceImage( + make: bike.makeName, + model: bike.modelName, + year: bike.year, + ); + return Container( width: double.infinity, padding: const EdgeInsets.all(16), @@ -155,10 +162,11 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget { ), child: Column( children: [ - // Schematic Vector + // Motorcycle Reference Image or Vector Schematic Container( - height: 110, + height: 140, width: double.infinity, + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: const Color(0xFF080808), borderRadius: BorderRadius.circular(12), @@ -167,17 +175,38 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget { child: Stack( alignment: Alignment.center, children: [ - const Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 72), + if (imageRef.assetPath != null) + Positioned.fill( + child: Image.asset( + imageRef.assetPath!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.two_wheeler, + color: Color(0xFF00E5FF), + size: 72, + ), + ), + ) + else + const Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 72), Positioned( bottom: 6, - child: Text( - 'TECHNICAL SCHEMATIC', - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 8, - fontWeight: FontWeight.w700, - color: const Color(0xFF00E5FF).withValues(alpha: 0.7), - letterSpacing: 0.1, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.4), width: 0.8), + ), + child: Text( + imageRef.isPhoto ? 'TECHNICAL REFERENCE — ${imageRef.caption.toUpperCase()}' : 'TECHNICAL SCHEMATIC', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 8, + fontWeight: FontWeight.w700, + color: const Color(0xFF00E5FF).withValues(alpha: 0.9), + letterSpacing: 0.1, + ), ), ), ), diff --git a/lib/features/history/widgets/evolution_ride_details_view.dart b/lib/features/history/widgets/evolution_ride_details_view.dart index edac763..23bfd10 100644 --- a/lib/features/history/widgets/evolution_ride_details_view.dart +++ b/lib/features/history/widgets/evolution_ride_details_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import '../../../database/database.dart'; import '../../../services/export_service.dart'; +import '../../../services/github/github_sync_service.dart'; import '../../replay/replay_screen.dart'; import '../../map/map_screen.dart'; @@ -28,6 +30,9 @@ class EvolutionRideDetailsView extends ConsumerWidget { final movingMin = (ride.movingTime / 60).toStringAsFixed(0); final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final syncService = ref.watch(githubSyncServiceProvider); + final isUploaded = syncService.isRideUploaded(ride.id); + final commitSha = syncService.getRideCommitSha(ride.id); return Scaffold( backgroundColor: Colors.black, @@ -65,13 +70,17 @@ class EvolutionRideDetailsView extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // GitHub Sync Status Card + _buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha), + const SizedBox(height: 12), + // Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards Row( children: [ // Bike Blueprint Card Expanded( child: Container( - height: 140, + height: 130, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFF0D0D0D), @@ -81,8 +90,8 @@ class EvolutionRideDetailsView extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.two_wheeler, color: Colors.white38, size: 48), - const SizedBox(height: 10), + const Icon(Icons.two_wheeler, color: Colors.white38, size: 40), + const SizedBox(height: 8), Text( bikeName, textAlign: TextAlign.center, @@ -114,7 +123,7 @@ class EvolutionRideDetailsView extends ConsumerWidget { }, borderRadius: BorderRadius.circular(14), child: Container( - height: 140, + height: 130, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFF0D0D0D), @@ -124,7 +133,7 @@ class EvolutionRideDetailsView extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 40), + const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 36), const SizedBox(height: 6), const Text( 'ROUTE PREVIEW', @@ -165,7 +174,7 @@ class EvolutionRideDetailsView extends ConsumerWidget { const SizedBox(height: 18), // Section 1: SUMMARY - _buildSectionHeader('SUMMARY'), + _buildSectionHeader('SESSION OVERVIEW'), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(16), @@ -177,42 +186,43 @@ class EvolutionRideDetailsView extends ConsumerWidget { child: Column( children: [ _buildMetricRow('DISTANCE:', '${ride.distanceMiles.toStringAsFixed(2)} mi', 'DURATION:', '$durationMin min'), - const Divider(color: Color(0xFF222222), height: 20), + const Divider(color: Color(0xFF222222), height: 18), _buildMetricRow('MOVING TIME:', '$movingMin min', 'AVG SPEED:', '${ride.averageSpeed.toStringAsFixed(1)} mph'), - const Divider(color: Color(0xFF222222), height: 20), - _buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', '', ''), + const Divider(color: Color(0xFF222222), height: 18), + _buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', 'DATE:', DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal())), ], ), ), const SizedBox(height: 18), - // Section 2: HANDLING - _buildSectionHeader('HANDLING'), + // Section 2: HANDLING (Responsive Layout) + _buildSectionHeader('HANDLING & DYNAMICS'), const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0D0D0D), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), - ), - child: Column( - children: [ - _buildMetricRow('MAX LEAN (LEFT):', '${ride.maxLeanLeft.toStringAsFixed(1)}°', 'MAX LEAN (RIGHT):', '${ride.maxLeanRight.toStringAsFixed(1)}°'), - const Divider(color: Color(0xFF222222), height: 20), - _buildMetricRow('MAX ACCELERATION:', '${ride.maxAccelG.toStringAsFixed(2)} G', 'MAX BRAKING:', '${ride.maxBrakingG.toStringAsFixed(2)} G'), - ], - ), - ), + _buildResponsiveHandlingCards(context, isLandscape), const SizedBox(height: 18), - // Section 3: EVENTS - _buildSectionHeader('EVENTS'), + // Section 3: EVENTS (Interactive Rows) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildSectionHeader('FORENSIC EVENTS (${events.length})'), + if (events.isNotEmpty) + Text( + 'TAP TO INSPECT', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9, + fontWeight: FontWeight.w700, + color: const Color(0xFF00E5FF).withValues(alpha: 0.8), + ), + ), + ], + ), const SizedBox(height: 8), Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFF0D0D0D), borderRadius: BorderRadius.circular(14), @@ -233,37 +243,7 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), ) : Column( - children: events.take(8).map((event) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), - child: Row( - children: [ - const Icon(Icons.warning_amber_rounded, color: Colors.amberAccent, size: 16), - const SizedBox(width: 8), - Expanded( - child: Text( - event.eventType.toUpperCase(), - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 11, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - ), - ), - Text( - '${event.value.toStringAsFixed(1)}°', - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 11, - fontWeight: FontWeight.w700, - color: Color(0xFF00E5FF), - ), - ), - ], - ), - ); - }).toList(), + children: events.take(15).map((event) => _buildInteractiveEventTile(context, event)).toList(), ), ), @@ -341,6 +321,358 @@ class EvolutionRideDetailsView extends ConsumerWidget { ); } + Widget _buildGitHubSyncBanner( + BuildContext context, + WidgetRef ref, + GitHubSyncService syncService, + bool isUploaded, + String? commitSha, + ) { + final isSyncing = syncService.state == GitHubUploadState.uploading; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF0F141C), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isUploaded ? const Color(0xFF00E676).withValues(alpha: 0.5) : const Color(0xFF00E5FF).withValues(alpha: 0.4), + width: 1.2, + ), + ), + child: Row( + children: [ + Icon( + isUploaded ? Icons.cloud_done : (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined), + color: isUploaded ? const Color(0xFF00E676) : const Color(0xFF00E5FF), + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isUploaded ? 'GITHUB LOGS SYNCHRONIZED' : (isSyncing ? 'UPLOADING TO GITHUB...' : 'GITHUB LOG SYNC'), + style: TextStyle( + fontFamily: 'Sora', + fontSize: 11, + fontWeight: FontWeight.w800, + color: isUploaded ? const Color(0xFF00E676) : Colors.white, + ), + ), + Text( + isUploaded + ? 'Commit: ${commitSha != null && commitSha.length >= 7 ? commitSha.substring(0, 7) : "synced"}' + : 'Repository: bponders12-cell/ride_logs', + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9.5, + color: Colors.white54, + ), + ), + ], + ), + ), + if (!isUploaded) + TextButton( + onPressed: isSyncing + ? null + : () async { + final result = await syncService.uploadRide(ride.id, isManual: true); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result.message), + backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent, + ), + ); + } + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text( + isSyncing ? 'UPLOADING...' : 'UPLOAD NOW', + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 10, + fontWeight: FontWeight.w800, + color: Color(0xFF00E5FF), + ), + ), + ), + ], + ), + ); + } + + Widget _buildResponsiveHandlingCards(BuildContext context, bool isLandscape) { + final cards = [ + _buildHandlingKpiCard('MAX LEAN (LEFT)', '${ride.maxLeanLeft.toStringAsFixed(1)}°', Icons.turn_left, const Color(0xFF00E5FF)), + _buildHandlingKpiCard('MAX LEAN (RIGHT)', '${ride.maxLeanRight.toStringAsFixed(1)}°', Icons.turn_right, const Color(0xFF00E5FF)), + _buildHandlingKpiCard('MAX ACCELERATION', '${ride.maxAccelG.toStringAsFixed(2)} G', Icons.speed, const Color(0xFF76FF03)), + _buildHandlingKpiCard('MAX BRAKING', '${ride.maxBrakingG.toStringAsFixed(2)} G', Icons.pan_tool_alt, const Color(0xFFFF5252)), + ]; + + if (isLandscape) { + return Row( + children: cards.map((c) => Expanded(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 4), child: c))).toList(), + ); + } + + return Column( + children: [ + Row( + children: [ + Expanded(child: cards[0]), + const SizedBox(width: 8), + Expanded(child: cards[1]), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: cards[2]), + const SizedBox(width: 8), + Expanded(child: cards[3]), + ], + ), + ], + ); + } + + Widget _buildHandlingKpiCard(String label, String value, IconData icon, Color accentColor) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 9.5, + fontWeight: FontWeight.w700, + color: Colors.white54, + ), + ), + ), + Icon(icon, color: accentColor, size: 16), + ], + ), + const SizedBox(height: 6), + Text( + value, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 18, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ], + ), + ); + } + + Widget _buildInteractiveEventTile(BuildContext context, RideEvent event) { + Color badgeColor = Colors.amberAccent; + IconData eventIcon = Icons.warning_amber_rounded; + + if (event.eventType.toLowerCase().contains('accel')) { + badgeColor = const Color(0xFF76FF03); + eventIcon = Icons.arrow_upward; + } else if (event.eventType.toLowerCase().contains('brak')) { + badgeColor = const Color(0xFFFF5252); + eventIcon = Icons.arrow_downward; + } else if (event.eventType.toLowerCase().contains('corner') || event.eventType.toLowerCase().contains('lean')) { + badgeColor = const Color(0xFF00E5FF); + eventIcon = Icons.cached; + } + + String formattedValue = '${event.value.toStringAsFixed(1)}°'; + if (event.eventType.toLowerCase().contains('accel') || event.eventType.toLowerCase().contains('brak')) { + formattedValue = '${event.value.toStringAsFixed(2)} G'; + } + + final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal()); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showEventDetailsDialog(context, event, formattedValue, timeStr, badgeColor), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + child: Row( + children: [ + Icon(eventIcon, color: badgeColor, size: 16), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.eventType.toUpperCase(), + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + Text( + timeStr, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9, + color: Colors.white38, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + formattedValue, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + fontWeight: FontWeight.w700, + color: badgeColor, + ), + ), + ), + const SizedBox(width: 6), + const Icon(Icons.chevron_right, color: Colors.white24, size: 16), + ], + ), + ), + ), + ); + } + + void _showEventDetailsDialog( + BuildContext context, + RideEvent event, + String formattedValue, + String timeStr, + Color badgeColor, + ) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF111111), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: badgeColor.withValues(alpha: 0.5), width: 1.2), + ), + title: Row( + children: [ + Icon(Icons.insights, color: badgeColor, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + event.eventType.toUpperCase(), + style: const TextStyle( + fontFamily: 'Sora', + fontSize: 14, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDialogRow('Timestamp:', timeStr), + _buildDialogRow('Magnitude / Value:', formattedValue), + if (event.latitude != null && event.longitude != null) + _buildDialogRow( + 'GPS Coordinates:', + '${event.latitude!.toStringAsFixed(6)}, ${event.longitude!.toStringAsFixed(6)}', + ), + _buildDialogRow('Event ID / Seq:', '#${event.id} (Ride #${event.rideId})'), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF0A0A0A), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF222222)), + ), + child: const Text( + 'Authoritative telemetry sampled at 20 Hz. Moving metrics gated at speed >= 3.0 MPH.', + style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white54), + ), + ), + ], + ), + actions: [ + if (event.latitude != null && event.longitude != null) + TextButton.icon( + icon: const Icon(Icons.map, color: Color(0xFF00E5FF), size: 16), + label: const Text('VIEW ON MAP', style: TextStyle(color: Color(0xFF00E5FF), fontFamily: 'JetBrains Mono', fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.pop(ctx); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MapScreen(rideId: event.rideId), + ), + ); + }, + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')), + ), + ], + ), + ); + } + + Widget _buildDialogRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)), + Flexible( + child: Text( + value, + textAlign: TextAlign.right, + style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ], + ), + ); + } + Widget _buildSectionHeader(String title) { return Text( title, @@ -393,4 +725,4 @@ class EvolutionRideDetailsView extends ConsumerWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index f3d28ac..2dc88b1 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:drift/drift.dart' as drift; import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; import '../../database/database.dart'; import '../../services/location_service.dart'; import '../../services/map/map_provider_interface.dart'; @@ -20,16 +21,18 @@ class MapScreen extends ConsumerStatefulWidget { class _MapScreenState extends ConsumerState { bool _isLoadingHistory = true; + List _telemetrySamples = []; List _routePoints = []; List _eventMarkers = []; AppLatLng? _initialCenter; AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194); // SF Default - // Event category filter states + // Layer toggle states + bool _showRoute = true; + bool _showSpeedHeatmap = false; bool _showHardAccel = true; bool _showHardBraking = true; bool _showCornering = true; - bool _showSpeedMilestones = true; @override void initState() { @@ -89,6 +92,8 @@ class _MapScreenState extends ConsumerState { ..where((e) => e.rideId.equals(widget.rideId!))) .get(); + _telemetrySamples = telemetry; + if (telemetry.isNotEmpty) { _routePoints = telemetry .where((t) => t.latitude != null && t.longitude != null) @@ -104,36 +109,78 @@ class _MapScreenState extends ConsumerState { for (final event in events) { if (event.latitude != null && event.longitude != null) { Color markerColor = Colors.redAccent; - if (event.eventType.contains('Corner')) markerColor = Colors.orangeAccent; - if (event.eventType.contains('Acceleration')) markerColor = Colors.greenAccent; - if (event.eventType.contains('Speed')) markerColor = Colors.purpleAccent; + IconData markerIcon = Icons.warning_amber_rounded; + final typeLower = event.eventType.toLowerCase(); + + if (typeLower.contains('corner') || typeLower.contains('lean')) { + markerColor = const Color(0xFF00E5FF); + markerIcon = Icons.cached; + } else if (typeLower.contains('accel')) { + markerColor = const Color(0xFF76FF03); + markerIcon = Icons.arrow_upward; + } else if (typeLower.contains('brak')) { + markerColor = const Color(0xFFFF5252); + markerIcon = Icons.arrow_downward; + } else if (typeLower.contains('speed')) { + markerColor = const Color(0xFFE040FB); + markerIcon = Icons.speed; + } _eventMarkers.add( AppMapMarker( - id: event.id.toString(), + id: 'event_${event.id}', position: AppLatLng(event.latitude!, event.longitude!), label: event.eventType, - iconWidget: Icon(Icons.location_on, color: markerColor, size: 36), + iconWidget: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: markerColor.withValues(alpha: 0.9), + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], + ), + child: Icon(markerIcon, color: Colors.black, size: 18), + ), onTap: () { if (mounted) { + final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal()); showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text(event.eventType), + backgroundColor: const Color(0xFF111111), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: markerColor.withValues(alpha: 0.6), width: 1.2), + ), + title: Row( + children: [ + Icon(markerIcon, color: markerColor, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + event.eventType.toUpperCase(), + style: const TextStyle( + fontFamily: 'Sora', + fontSize: 14, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ), + ], + ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Time: ${event.timestamp.toIso8601String().substring(11, 19)}'), - Text('Value: ${event.value.toStringAsFixed(2)}'), - if (event.latitude != null && event.longitude != null) - Text('Lat/Lng: ${event.latitude!.toStringAsFixed(4)}, ${event.longitude!.toStringAsFixed(4)}'), + _buildDialogRow('Timestamp:', timeStr), + _buildDialogRow('Event Value:', event.value.toStringAsFixed(2)), + _buildDialogRow('GPS Location:', '${event.latitude!.toStringAsFixed(5)}, ${event.longitude!.toStringAsFixed(5)}'), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), + child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')), ), ], ), @@ -145,9 +192,91 @@ class _MapScreenState extends ConsumerState { } } - setState(() { - _isLoadingHistory = false; - }); + if (mounted) { + setState(() { + _isLoadingHistory = false; + }); + } + } + + Widget _buildDialogRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)), + Text(value, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white)), + ], + ), + ); + } + + List _buildPolylines() { + if (_routePoints.isEmpty) return []; + + if (!_showSpeedHeatmap) { + if (!_showRoute) return []; + // Standard cyan route polyline + return [ + AppPolyline( + id: 'history_route', + points: _routePoints, + color: const Color(0xFF00E5FF), + width: 4.5, + ), + ]; + } + + // Multi-color segmented polyline according to speed brackets + final polylines = []; + final validSamples = _telemetrySamples.where((t) => t.latitude != null && t.longitude != null).toList(); + if (validSamples.isEmpty) return []; + + // Step downsampling if large dataset + final step = (validSamples.length / 1500).ceil().clamp(1, 10); + + List currentSegment = []; + Color currentColor = _getSpeedColor(validSamples.first.speed ?? 0.0); + + for (int i = 0; i < validSamples.length; i += step) { + final s = validSamples[i]; + final pt = AppLatLng(s.latitude!, s.longitude!); + final segColor = _getSpeedColor(s.speed ?? 0.0); + + if (segColor != currentColor && currentSegment.isNotEmpty) { + currentSegment.add(pt); + polylines.add(AppPolyline( + id: 'speed_seg_${polylines.length}', + points: List.from(currentSegment), + color: currentColor, + width: 5.0, + )); + currentSegment = [pt]; + currentColor = segColor; + } else { + currentSegment.add(pt); + } + } + + if (currentSegment.length > 1) { + polylines.add(AppPolyline( + id: 'speed_seg_${polylines.length}', + points: currentSegment, + color: currentColor, + width: 5.0, + )); + } + + return polylines; + } + + Color _getSpeedColor(double speedMph) { + if (speedMph < 25) return const Color(0xFF00E5FF); // 0 - 25 MPH (Cyan) + if (speedMph < 50) return const Color(0xFF2979FF); // 25 - 50 MPH (Blue) + if (speedMph < 75) return const Color(0xFFFFD600); // 50 - 75 MPH (Yellow) + if (speedMph < 100) return const Color(0xFFFF6D00); // 75 - 100 MPH (Orange) + return const Color(0xFFE040FB); // 100+ MPH (Magenta) } List _getFilteredMarkers() { @@ -165,7 +294,7 @@ class _MapScreenState extends ConsumerState { shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)], ), - child: const Icon(Icons.flag, color: Colors.black, size: 20), + child: const Icon(Icons.flag, color: Colors.black, size: 18), ), ), ); @@ -182,7 +311,7 @@ class _MapScreenState extends ConsumerState { shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)], ), - child: const Icon(Icons.sports_score, color: Colors.black, size: 20), + child: const Icon(Icons.sports_score, color: Colors.black, size: 18), ), ), ); @@ -190,11 +319,10 @@ class _MapScreenState extends ConsumerState { } final filteredEvents = _eventMarkers.where((m) { - final label = m.label ?? ''; - if (label.contains('Acceleration') && !_showHardAccel) return false; - if (label.contains('Braking') && !_showHardBraking) return false; - if (label.contains('Corner') && !_showCornering) return false; - if (label.contains('Speed') && !_showSpeedMilestones) return false; + final label = (m.label ?? '').toLowerCase(); + if (label.contains('accel') && !_showHardAccel) return false; + if (label.contains('brak') && !_showHardBraking) return false; + if ((label.contains('corner') || label.contains('lean')) && !_showCornering) return false; return true; }); @@ -218,15 +346,14 @@ class _MapScreenState extends ConsumerState { Row( children: [ Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: alert.category.color.withValues(alpha: 0.2), shape: BoxShape.circle, - border: Border.all(color: alert.category.color), ), child: Icon(alert.category.icon, color: alert.category.color, size: 28), ), - const SizedBox(width: 14), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -235,8 +362,8 @@ class _MapScreenState extends ConsumerState { alert.category.displayName.toUpperCase(), style: TextStyle( color: alert.category.color, - fontWeight: FontWeight.w900, - fontSize: 16, + fontWeight: FontWeight.bold, + fontSize: 12, letterSpacing: 1.1, ), ), @@ -285,18 +412,15 @@ class _MapScreenState extends ConsumerState { final activeProviderType = ref.watch(mapProviderTypeNotifierProvider); final isFollowMode = ref.watch(mapFollowStateProvider); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; if (widget.rideId != null) { // Historical Route View - final polylines = _routePoints.isNotEmpty - ? [AppPolyline(id: 'history_route', points: _routePoints, color: Colors.cyanAccent, width: 5.0)] - : []; + final polylines = _buildPolylines(); return Scaffold( appBar: AppBar( - title: Text('Ride #${widget.rideId} Route Preview'), + title: Text('Ride #${widget.rideId} Multi-Layer Map'), actions: [ IconButton( tooltip: 'Switch Map Provider', @@ -328,7 +452,8 @@ class _MapScreenState extends ConsumerState { ), ), ), - if (_routePoints.isNotEmpty) _buildEventFilterOverlay(), + if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(), + if (_showSpeedHeatmap) _buildSpeedLegend(), if (_routePoints.isEmpty) Positioned( top: 16, @@ -476,45 +601,56 @@ class _MapScreenState extends ConsumerState { ); } - Widget _buildEventFilterOverlay() { + Widget _buildMultiLayerFilterOverlay() { return Positioned( top: 10, left: 10, right: 10, child: Card( - color: Colors.black.withValues(alpha: 0.7), + color: Colors.black.withValues(alpha: 0.8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF1F1F1F)), + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ + FilterChip( + label: const Text('Route'), + selected: _showRoute, + onSelected: (val) => setState(() => _showRoute = val), + selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Speed'), + selected: _showSpeedHeatmap, + onSelected: (val) => setState(() => _showSpeedHeatmap = val), + selectedColor: const Color(0xFFE040FB).withValues(alpha: 0.35), + ), + const SizedBox(width: 6), FilterChip( label: const Text('Accel'), selected: _showHardAccel, onSelected: (val) => setState(() => _showHardAccel = val), - selectedColor: Colors.green.withValues(alpha: 0.4), + selectedColor: const Color(0xFF76FF03).withValues(alpha: 0.35), ), const SizedBox(width: 6), FilterChip( label: const Text('Braking'), selected: _showHardBraking, onSelected: (val) => setState(() => _showHardBraking = val), - selectedColor: Colors.red.withValues(alpha: 0.4), + selectedColor: const Color(0xFFFF5252).withValues(alpha: 0.35), ), const SizedBox(width: 6), FilterChip( label: const Text('Cornering'), selected: _showCornering, onSelected: (val) => setState(() => _showCornering = val), - selectedColor: Colors.orange.withValues(alpha: 0.4), - ), - const SizedBox(width: 6), - FilterChip( - label: const Text('Speed'), - selected: _showSpeedMilestones, - onSelected: (val) => setState(() => _showSpeedMilestones = val), - selectedColor: Colors.purple.withValues(alpha: 0.4), + selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35), ), ], ), @@ -523,4 +659,49 @@ class _MapScreenState extends ConsumerState { ), ); } + + Widget _buildSpeedLegend() { + return Positioned( + bottom: 20, + left: 14, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF222222)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'SPEED RANGES (MPH)', + style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 8.5, fontWeight: FontWeight.bold, color: Colors.white70), + ), + const SizedBox(height: 4), + _buildLegendItem('0–25', const Color(0xFF00E5FF)), + _buildLegendItem('25–50', const Color(0xFF2979FF)), + _buildLegendItem('50–75', const Color(0xFFFFD600)), + _buildLegendItem('75–100', const Color(0xFFFF6D00)), + _buildLegendItem('100+', const Color(0xFFE040FB)), + ], + ), + ), + ); + } + + Widget _buildLegendItem(String label, Color color) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, color: Colors.white)), + ], + ), + ); + } } diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index 1589f0a..ccd6b02 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -12,6 +12,7 @@ import '../../services/audio_alert_service.dart'; import '../records/records_service.dart'; import '../garage/garage_providers.dart'; import '../../services/field_test/field_test_logger.dart'; +import '../../services/github/github_sync_service.dart'; enum RideState { idle, ready, riding, paused, finished } @@ -227,6 +228,11 @@ class RideStateMachine extends StateNotifier { startTime: DateTime.now(), tripDistanceMiles: 0, topSpeedMph: 0, + maxLeanLeft: 0, + maxLeanRight: 0, + maxAccelG: 0, + maxBrakingG: 0, + averageSpeedMph: 0, movingTimeSeconds: 0, stoppedTimeSeconds: 0, elevationGain: 0, @@ -371,6 +377,14 @@ class RideStateMachine extends StateNotifier { } } catch (_) {} + // Offline-first background synchronization to GitHub (if enabled) + final completedRideId = state.dbId; + if (completedRideId != null) { + try { + ref.read(githubSyncServiceProvider).queueRideForUpload(completedRideId); + } catch (_) {} + } + state = state.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, @@ -532,7 +546,10 @@ class RideStateMachine extends StateNotifier { } } - if (sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) { + // Speed-gate dynamic motorcycle metrics (require speed >= 3.0 MPH to prevent stationary kickstand / dismount artifacts from registering as personal riding records) + final isDynamicallyMoving = sample.speed != null && sample.speed! >= 3.0; + + if (isDynamicallyMoving && sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) { if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) { newMaxLeanLeft = sample.leanAngle!.abs(); } else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) { @@ -540,7 +557,7 @@ class RideStateMachine extends StateNotifier { } } - if (sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) { + if (isDynamicallyMoving && sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) { if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) { newMaxAccelG = sample.longitudinalG!; } else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) { diff --git a/lib/features/settings/field_test_report_screen.dart b/lib/features/settings/field_test_report_screen.dart index 6468831..88ee854 100644 --- a/lib/features/settings/field_test_report_screen.dart +++ b/lib/features/settings/field_test_report_screen.dart @@ -5,6 +5,8 @@ import '../../database/database.dart'; import '../../models/field_test_event.dart' as model; import '../../services/field_test/field_test_export_service.dart'; +import '../../services/github/github_sync_service.dart'; + class FieldTestReportScreen extends ConsumerStatefulWidget { final int? rideId; const FieldTestReportScreen({super.key, this.rideId}); @@ -42,7 +44,23 @@ class _FieldTestReportScreenState extends ConsumerState { appBar: AppBar( title: const Text('Forensic Field Test Report'), actions: [ - if (_selectedRideId != null) + if (_selectedRideId != null) ...[ + IconButton( + icon: const Icon(Icons.cloud_upload_outlined, color: Colors.cyanAccent), + tooltip: 'Upload Forensic Package to GitHub', + onPressed: () async { + final syncService = ref.read(githubSyncServiceProvider); + final result = await syncService.uploadRide(_selectedRideId!, isManual: true); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result.message), + backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent, + ), + ); + } + }, + ), IconButton( icon: _isExporting ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) @@ -50,6 +68,7 @@ class _FieldTestReportScreenState extends ConsumerState { tooltip: 'Export Forensic Package (ZIP)', onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!), ), + ], ], ), body: StreamBuilder>( diff --git a/lib/features/settings/widgets/evolution_settings_view.dart b/lib/features/settings/widgets/evolution_settings_view.dart index 86f93d0..23fce1e 100644 --- a/lib/features/settings/widgets/evolution_settings_view.dart +++ b/lib/features/settings/widgets/evolution_settings_view.dart @@ -7,6 +7,7 @@ import '../../../services/alerts/road_alert_models.dart'; import '../../../services/alerts/road_alert_providers.dart'; import '../../../services/alerts/road_alert_service.dart'; import '../../../services/osm_oauth_service.dart'; +import '../../../services/github/github_auth_service.dart'; import '../../../services/sensor_fusion_service.dart'; import '../../../services/field_test/field_test_logger.dart'; import '../../../database/database.dart'; @@ -27,6 +28,9 @@ class _EvolutionSettingsViewState extends ConsumerState { late TextEditingController _communityEndpointController; bool _isTestingTomTom = false; bool _isTestingCommunity = false; + late TextEditingController _githubTokenController; + bool _isTestingGitHub = false; + bool _obscureGitHubToken = true; @override void initState() { @@ -40,6 +44,9 @@ class _EvolutionSettingsViewState extends ConsumerState { _communityEndpointController = TextEditingController( text: ref.read(communityEndpointUrlProvider), ); + _githubTokenController = TextEditingController( + text: ref.read(githubAuthServiceProvider).getToken() ?? '', + ); } @override @@ -47,6 +54,7 @@ class _EvolutionSettingsViewState extends ConsumerState { _tomtomKeyController.dispose(); _communityKeyController.dispose(); _communityEndpointController.dispose(); + _githubTokenController.dispose(); super.dispose(); } @@ -149,6 +157,7 @@ class _EvolutionSettingsViewState extends ConsumerState { final sensorsCard = _buildSensorsCalibrationCard(context, ref); final tomtomCard = _buildTomTomCard(context, ref); final communityCard = _buildCommunityCard(context, ref); + final githubCard = _buildGitHubCard(context, ref); final dataCard = _buildDataManagementCard(context, ref); final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref); final versionFooter = _buildVersionFooter(context); @@ -172,6 +181,8 @@ class _EvolutionSettingsViewState extends ConsumerState { osmAccountCard, const SizedBox(height: 16), tomtomCard, + const SizedBox(height: 16), + githubCard, ], ), ), @@ -950,6 +961,144 @@ class _EvolutionSettingsViewState extends ConsumerState { ); } + Widget _buildGitHubCard(BuildContext context, WidgetRef ref) { + final authService = ref.watch(githubAuthServiceProvider); + final hasToken = authService.getToken() != null && authService.getToken()!.isNotEmpty; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'GITHUB RIDE LOG SYNC', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.1, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: hasToken + ? const Color(0xFF00E676).withValues(alpha: 0.15) + : Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + hasToken ? 'CONFIGURED' : 'NO TOKEN', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + color: hasToken ? const Color(0xFF00E676) : Colors.white38, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Target Repository: bponders12-cell/ride_logs (branch: main). Offline-first background uploads with zero telemetry disruption.', + style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + ), + const SizedBox(height: 12), + TextField( + controller: _githubTokenController, + obscureText: _obscureGitHubToken, + decoration: InputDecoration( + labelText: 'GitHub Personal Access Token (PAT)', + labelStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white60), + hintText: 'ghp_xxxxxxxxxxxxxxxxxxxx', + hintStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white30), + filled: true, + fillColor: const Color(0xFF141414), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF00E5FF))), + isDense: true, + suffixIcon: IconButton( + icon: Icon( + _obscureGitHubToken ? Icons.visibility_off : Icons.visibility, + color: Colors.white38, + size: 18, + ), + onPressed: () { + setState(() => _obscureGitHubToken = !_obscureGitHubToken); + }, + ), + ), + style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, color: Colors.white), + onChanged: (val) { + authService.saveToken(val); + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (hasToken) + TextButton( + onPressed: () async { + await authService.clearToken(); + _githubTokenController.clear(); + setState(() {}); + }, + child: const Text('CLEAR', style: TextStyle(color: Colors.redAccent, fontFamily: 'Inter', fontSize: 10)), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _isTestingGitHub + ? null + : () async { + setState(() => _isTestingGitHub = true); + final messenger = ScaffoldMessenger.of(context); + final result = await authService.testConnection(); + setState(() => _isTestingGitHub = false); + + messenger.showSnackBar( + SnackBar( + content: Text(result['message'] as String), + backgroundColor: result['success'] == true ? const Color(0xFF00E676) : Colors.redAccent, + ), + ); + }, + icon: _isTestingGitHub + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF))) + : const Icon(Icons.sync, size: 16), + label: const Text('TEST CONNECTION', style: TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.w800)), + ), + ], + ), + ], + ), + ); + } + Widget _buildTomTomCard(BuildContext context, WidgetRef ref) { final tomtomStatus = ref.watch(tomtomStatusProvider); diff --git a/lib/models/telemetry_sample.dart b/lib/models/telemetry_sample.dart index 642b474..e569603 100644 --- a/lib/models/telemetry_sample.dart +++ b/lib/models/telemetry_sample.dart @@ -2,6 +2,8 @@ import '../services/validation/telemetry_validator.dart'; class TelemetrySample { final DateTime timestamp; + final int? sequenceNumber; + final int? monotonicTimestampMs; // Location Data final double? latitude; @@ -17,6 +19,7 @@ class TelemetrySample { final DateTime? imuTimestamp; final double? leanAngle; final double? pitchAngle; + final double? rollRateDegPerSec; final double? accelX; final double? accelY; final double? accelZ; @@ -26,11 +29,21 @@ class TelemetrySample { final double? gyroY; final double? gyroZ; + // Raw Preserved Sensors (Unfiltered for forensic analysis) + final double? rawAccelX; + final double? rawAccelY; + final double? rawAccelZ; + final double? rawGyroX; + final double? rawGyroY; + final double? rawGyroZ; + // Validation & Quality final TelemetryQuality? quality; TelemetrySample({ required this.timestamp, + this.sequenceNumber, + this.monotonicTimestampMs, this.latitude, this.longitude, this.altitude, @@ -42,6 +55,7 @@ class TelemetrySample { this.imuTimestamp, this.leanAngle, this.pitchAngle, + this.rollRateDegPerSec, this.accelX, this.accelY, this.accelZ, @@ -50,11 +64,19 @@ class TelemetrySample { this.gyroX, this.gyroY, this.gyroZ, + this.rawAccelX, + this.rawAccelY, + this.rawAccelZ, + this.rawGyroX, + this.rawGyroY, + this.rawGyroZ, this.quality, }); TelemetrySample copyWith({ DateTime? timestamp, + int? sequenceNumber, + int? monotonicTimestampMs, double? latitude, double? longitude, double? altitude, @@ -66,6 +88,7 @@ class TelemetrySample { DateTime? imuTimestamp, double? leanAngle, double? pitchAngle, + double? rollRateDegPerSec, double? accelX, double? accelY, double? accelZ, @@ -74,10 +97,18 @@ class TelemetrySample { double? gyroX, double? gyroY, double? gyroZ, + double? rawAccelX, + double? rawAccelY, + double? rawAccelZ, + double? rawGyroX, + double? rawGyroY, + double? rawGyroZ, TelemetryQuality? quality, }) { return TelemetrySample( timestamp: timestamp ?? this.timestamp, + sequenceNumber: sequenceNumber ?? this.sequenceNumber, + monotonicTimestampMs: monotonicTimestampMs ?? this.monotonicTimestampMs, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, altitude: altitude ?? this.altitude, @@ -89,6 +120,7 @@ class TelemetrySample { imuTimestamp: imuTimestamp ?? this.imuTimestamp, leanAngle: leanAngle ?? this.leanAngle, pitchAngle: pitchAngle ?? this.pitchAngle, + rollRateDegPerSec: rollRateDegPerSec ?? this.rollRateDegPerSec, accelX: accelX ?? this.accelX, accelY: accelY ?? this.accelY, accelZ: accelZ ?? this.accelZ, @@ -97,6 +129,12 @@ class TelemetrySample { gyroX: gyroX ?? this.gyroX, gyroY: gyroY ?? this.gyroY, gyroZ: gyroZ ?? this.gyroZ, + rawAccelX: rawAccelX ?? this.rawAccelX, + rawAccelY: rawAccelY ?? this.rawAccelY, + rawAccelZ: rawAccelZ ?? this.rawAccelZ, + rawGyroX: rawGyroX ?? this.rawGyroX, + rawGyroY: rawGyroY ?? this.rawGyroY, + rawGyroZ: rawGyroZ ?? this.rawGyroZ, quality: quality ?? this.quality, ); } diff --git a/lib/services/calibration_service.dart b/lib/services/calibration_service.dart index fe5f48d..cdeccfc 100644 --- a/lib/services/calibration_service.dart +++ b/lib/services/calibration_service.dart @@ -8,16 +8,32 @@ final calibrationServiceProvider = Provider((ref) { class CalibrationService { static const _pitchKey = 'calibration_pitch'; static const _rollKey = 'calibration_roll'; + static const _gyroBiasXKey = 'calibration_gyro_bias_x'; + static const _gyroBiasYKey = 'calibration_gyro_bias_y'; + static const _gyroBiasZKey = 'calibration_gyro_bias_z'; - Future saveCalibrationOffsets(double pitch, double roll, {int? motorcycleId}) async { + Future saveCalibrationOffsets( + double pitch, + double roll, { + double gyroBiasX = 0.0, + double gyroBiasY = 0.0, + double gyroBiasZ = 0.0, + int? motorcycleId, + }) async { final prefs = await SharedPreferences.getInstance(); if (motorcycleId != null) { await prefs.setDouble('calibration_pitch_$motorcycleId', pitch); await prefs.setDouble('calibration_roll_$motorcycleId', roll); + await prefs.setDouble('calibration_gyro_bias_x_$motorcycleId', gyroBiasX); + await prefs.setDouble('calibration_gyro_bias_y_$motorcycleId', gyroBiasY); + await prefs.setDouble('calibration_gyro_bias_z_$motorcycleId', gyroBiasZ); } // Also save global fallback await prefs.setDouble(_pitchKey, pitch); await prefs.setDouble(_rollKey, roll); + await prefs.setDouble(_gyroBiasXKey, gyroBiasX); + await prefs.setDouble(_gyroBiasYKey, gyroBiasY); + await prefs.setDouble(_gyroBiasZKey, gyroBiasZ); } Future> loadCalibrationOffsets({int? motorcycleId}) async { @@ -25,12 +41,30 @@ class CalibrationService { if (motorcycleId != null) { final bikePitch = prefs.getDouble('calibration_pitch_$motorcycleId'); final bikeRoll = prefs.getDouble('calibration_roll_$motorcycleId'); + final bikeGbx = prefs.getDouble('calibration_gyro_bias_x_$motorcycleId') ?? 0.0; + final bikeGby = prefs.getDouble('calibration_gyro_bias_y_$motorcycleId') ?? 0.0; + final bikeGbz = prefs.getDouble('calibration_gyro_bias_z_$motorcycleId') ?? 0.0; if (bikePitch != null && bikeRoll != null) { - return {'pitch': bikePitch, 'roll': bikeRoll}; + return { + 'pitch': bikePitch, + 'roll': bikeRoll, + 'gyroBiasX': bikeGbx, + 'gyroBiasY': bikeGby, + 'gyroBiasZ': bikeGbz, + }; } } final pitch = prefs.getDouble(_pitchKey) ?? 0.0; final roll = prefs.getDouble(_rollKey) ?? 0.0; - return {'pitch': pitch, 'roll': roll}; + final gbx = prefs.getDouble(_gyroBiasXKey) ?? 0.0; + final gby = prefs.getDouble(_gyroBiasYKey) ?? 0.0; + final gbz = prefs.getDouble(_gyroBiasZKey) ?? 0.0; + return { + 'pitch': pitch, + 'roll': roll, + 'gyroBiasX': gbx, + 'gyroBiasY': gby, + 'gyroBiasZ': gbz, + }; } } diff --git a/lib/services/field_test/field_test_export_service.dart b/lib/services/field_test/field_test_export_service.dart index 20db919..eea9ca6 100644 --- a/lib/services/field_test/field_test_export_service.dart +++ b/lib/services/field_test/field_test_export_service.dart @@ -177,6 +177,18 @@ class FieldTestExportService { final appIdentity = AppIdentity.current(); + // Compute raw observed peaks across entire telemetry trace (including 0 MPH dismounts) + double maxObservedLean = 0.0; + double maxObservedG = 0.0; + for (final s in samples) { + if (s.leanAngle != null && s.leanAngle!.abs() > maxObservedLean && s.leanAngle!.abs() <= 65.0) { + maxObservedLean = s.leanAngle!.abs(); + } + if (s.longitudinalG != null && s.longitudinalG!.abs() > maxObservedG) { + maxObservedG = s.longitudinalG!.abs(); + } + } + // 1. README.txt final warningCount = events.where((e) => e.severity == 'WARNING').length; final errorCount = events.where((e) => e.severity == 'ERROR' || e.severity == 'CRITICAL').length; @@ -202,6 +214,16 @@ class FieldTestExportService { readmeContent.writeln('Total Forensic Events: ${events.length}'); readmeContent.writeln('Major Warnings Count: $warningCount'); readmeContent.writeln('Major Errors Count: $errorCount'); + final maxLeanMoving = ride.maxLeanLeft > ride.maxLeanRight ? ride.maxLeanLeft : ride.maxLeanRight; + readmeContent.writeln('-----------------------------------------------------'); + readmeContent.writeln('Performance Metrics (Qualified: Speed >= 3.0 MPH):'); + readmeContent.writeln(' - Top Speed: ${ride.topSpeedMph.toStringAsFixed(1)} MPH'); + readmeContent.writeln(' - Maximum Moving Lean: ${maxLeanMoving.toStringAsFixed(1)}° (Left: ${ride.maxLeanLeft.toStringAsFixed(1)}°, Right: ${ride.maxLeanRight.toStringAsFixed(1)}°)'); + readmeContent.writeln(' - Maximum Moving Acceleration: ${ride.maxAccelG.toStringAsFixed(2)} G'); + readmeContent.writeln(' - Maximum Moving Braking: ${ride.maxBrakingG.toStringAsFixed(2)} G'); + readmeContent.writeln('Sensor Observations (Raw Peak including Stationary / Dismount):'); + readmeContent.writeln(' - Maximum Observed Lean: ${maxObservedLean.toStringAsFixed(1)}°'); + readmeContent.writeln(' - Maximum Observed G-Force: ${maxObservedG.toStringAsFixed(2)} G'); readmeContent.writeln('====================================================='); readmeContent.writeln('Files Included:'); readmeContent.writeln(' - field_test_session.json : Full structured session dump with software identity'); @@ -235,8 +257,14 @@ class FieldTestExportService { 'topSpeedMph': ride.topSpeedMph, 'maxLeanLeft': ride.maxLeanLeft, 'maxLeanRight': ride.maxLeanRight, + 'maxMovingLeanLeft': ride.maxLeanLeft, + 'maxMovingLeanRight': ride.maxLeanRight, 'maxAccelG': ride.maxAccelG, 'maxBrakingG': ride.maxBrakingG, + 'maxMovingAccelG': ride.maxAccelG, + 'maxMovingBrakingG': ride.maxBrakingG, + 'maxObservedLean': maxObservedLean, + 'maxObservedG': maxObservedG, 'averageSpeed': ride.averageSpeed, 'movingTime': ride.movingTime, 'stoppedTime': ride.stoppedTime, diff --git a/lib/services/field_test/field_test_logger.dart b/lib/services/field_test/field_test_logger.dart index 2c63094..0f547e3 100644 --- a/lib/services/field_test/field_test_logger.dart +++ b/lib/services/field_test/field_test_logger.dart @@ -664,17 +664,26 @@ class FieldTestLogger { worstAccuracy = s.gpsAccuracy; } } + final speedVal = s.speed ?? 0.0; + final isMoving = speedVal >= 3.0; + if (s.speed != null && s.speed! > maxSpeed && s.speed! < 223.7) { maxSpeed = s.speed!; } - if (s.leanAngle != null && s.leanAngle!.abs() > maxLean && s.leanAngle!.abs() <= 65.0) { - maxLean = s.leanAngle!.abs(); + if (s.leanAngle != null && s.leanAngle!.abs() <= 65.0) { + if (isMoving && s.leanAngle!.abs() > maxLean) { + maxLean = s.leanAngle!.abs(); + } } - if (s.longitudinalG != null && s.longitudinalG!.abs() > maxLongG) { - maxLongG = s.longitudinalG!.abs(); + if (s.longitudinalG != null && s.longitudinalG!.abs() <= 2.5) { + if (isMoving && s.longitudinalG!.abs() > maxLongG) { + maxLongG = s.longitudinalG!.abs(); + } } - if (s.lateralG != null && s.lateralG!.abs() > maxLatG) { - maxLatG = s.lateralG!.abs(); + if (s.lateralG != null && s.lateralG!.abs() <= 2.5) { + if (isMoving && s.lateralG!.abs() > maxLatG) { + maxLatG = s.lateralG!.abs(); + } } } @@ -711,6 +720,8 @@ class FieldTestLogger { timestampGaps: _telemetryGapCount, gpsFixCount: gpsFixCount, totalGpsAccuracy: accuracySum, + databaseSamplesLost: _droppedEventsCount, + imuSaturationCount: _imuSaturationCount, isMountCalibrated: true, ); diff --git a/lib/services/garage/motorcycle_image_service.dart b/lib/services/garage/motorcycle_image_service.dart new file mode 100644 index 0000000..046ac4f --- /dev/null +++ b/lib/services/garage/motorcycle_image_service.dart @@ -0,0 +1,110 @@ +/// Metadata for a motorcycle photograph or reference image +class MotorcycleImageReference { + final String? assetPath; + final String? networkUrl; + final String? localFilePath; + final String caption; + final String attribution; + final bool isPhoto; + + const MotorcycleImageReference({ + this.assetPath, + this.networkUrl, + this.localFilePath, + required this.caption, + this.attribution = 'Technical Reference', + this.isPhoto = false, + }); + + bool get hasImage => assetPath != null || networkUrl != null || localFilePath != null; +} + +/// Central extensible repository providing authentic motorcycle reference images +class MotorcycleImageService { + static const MotorcycleImageService _instance = MotorcycleImageService._internal(); + factory MotorcycleImageService() => _instance; + const MotorcycleImageService._internal(); + + /// Known registry mapping (Make + Model + Optional Year) to curated reference images + static final Map _registry = { + // 2001 Yamaha YZF-R6 (User's field test vehicle) + 'yamaha_yzf-r6_2001': const MotorcycleImageReference( + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + caption: '2001 Yamaha YZF-R6 (5EB)', + attribution: 'Factory Supersport Reference', + isPhoto: true, + ), + 'yamaha_r6_2001': const MotorcycleImageReference( + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + caption: '2001 Yamaha YZF-R6 (5EB)', + attribution: 'Factory Supersport Reference', + isPhoto: true, + ), + 'yamaha_yzf-r6': const MotorcycleImageReference( + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + caption: 'Yamaha YZF-R6 Supersport', + attribution: 'Factory Technical Reference', + isPhoto: true, + ), + 'yamaha_r6': const MotorcycleImageReference( + assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + caption: 'Yamaha YZF-R6 Supersport', + attribution: 'Factory Technical Reference', + isPhoto: true, + ), + 'yamaha_yzf-r1': const MotorcycleImageReference( + assetPath: 'assets/images/motorcycles/yamaha_r1.png', + caption: 'Yamaha YZF-R1 Superbike', + attribution: 'Crossplane Superbike Reference', + isPhoto: true, + ), + 'yamaha_mt-09': const MotorcycleImageReference( + assetPath: 'assets/images/motorcycles/yamaha_mt09.png', + caption: 'Yamaha MT-09 Hyper Naked', + attribution: 'CP3 Triple Reference', + isPhoto: true, + ), + }; + + /// Resolves the best available photographic image or reference for a motorcycle + MotorcycleImageReference getReferenceImage({ + required String? make, + required String? model, + int? year, + }) { + if (make == null || model == null) { + return const MotorcycleImageReference(caption: 'Generic Motorcycle Reference'); + } + + final cleanMake = make.trim().toLowerCase(); + final cleanModel = model.trim().toLowerCase().replaceAll(' ', '-'); + + // 1. Exact make_model_year lookup + if (year != null) { + final keyWithYear = '${cleanMake}_${cleanModel}_$year'; + if (_registry.containsKey(keyWithYear)) { + return _registry[keyWithYear]!; + } + } + + // 2. Exact make_model lookup + final keyNoYear = '${cleanMake}_$cleanModel'; + if (_registry.containsKey(keyNoYear)) { + return _registry[keyNoYear]!; + } + + // 3. Normalized alias matching (e.g. YZF-R6 -> R6) + final simplifiedModel = cleanModel.replaceAll('yzf-', ''); + final keySimplified = '${cleanMake}_$simplifiedModel'; + if (_registry.containsKey(keySimplified)) { + return _registry[keySimplified]!; + } + + // 4. Default fallback + return MotorcycleImageReference( + caption: '$year $make $model'.trim(), + attribution: 'Schematic Vector', + isPhoto: false, + ); + } +} diff --git a/lib/services/github/github_auth_service.dart b/lib/services/github/github_auth_service.dart new file mode 100644 index 0000000..81a3f6a --- /dev/null +++ b/lib/services/github/github_auth_service.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../features/records/records_service.dart'; + +/// Secure credentials and repository configuration for GitHub ride sync +class GitHubAuthService { + static const String _keyToken = 'github_pat_token_enc'; + static const String _keyRepo = 'github_target_repo'; + static const String _keyBranch = 'github_target_branch'; + + static const String defaultRepo = 'bponders12-cell/ride_logs'; + static const String defaultBranch = 'main'; + + final SharedPreferences _prefs; + final http.Client _client; + + GitHubAuthService(this._prefs, [http.Client? client]) : _client = client ?? http.Client(); + + /// Reads stored Personal Access Token + String? getToken() { + final raw = _prefs.getString(_keyToken); + if (raw == null || raw.isEmpty) return null; + try { + return utf8.decode(base64.decode(raw)); + } catch (_) { + return raw; + } + } + + /// Stores Personal Access Token securely + Future saveToken(String token) async { + final encoded = base64.encode(utf8.encode(token.trim())); + await _prefs.setString(_keyToken, encoded); + } + + /// Clears stored token + Future clearToken() async { + await _prefs.remove(_keyToken); + } + + /// Target GitHub repository (default: bponders12-cell/ride_logs) + String getRepository() { + return _prefs.getString(_keyRepo) ?? defaultRepo; + } + + Future setRepository(String repo) async { + await _prefs.setString(_keyRepo, repo.trim()); + } + + /// Target branch (default: main) + String getBranch() { + return _prefs.getString(_keyBranch) ?? defaultBranch; + } + + Future setBranch(String branch) async { + await _prefs.setString(_keyBranch, branch.trim()); + } + + /// Tests connectivity and permissions against GitHub API + Future> testConnection() async { + final token = getToken(); + if (token == null || token.isEmpty) { + return {'success': false, 'message': 'No GitHub token configured'}; + } + + final repo = getRepository(); + final url = Uri.parse('https://api.github.com/repos/$repo'); + + try { + final response = await _client.get( + url, + headers: { + 'Authorization': 'Bearer $token', + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'MotorSignal-Telemetry/1.0.2', + }, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return { + 'success': true, + 'message': 'Connected to ${data['full_name']} (${data['default_branch']})', + 'canPush': data['permissions']?['push'] ?? true, + }; + } else if (response.statusCode == 401) { + return {'success': false, 'message': 'Authentication failed (401: Invalid Token)'}; + } else if (response.statusCode == 404) { + return {'success': false, 'message': 'Repository not found or token lacks repo scope (404)'}; + } else { + return {'success': false, 'message': 'GitHub API error (${response.statusCode})'}; + } + } catch (e) { + return {'success': false, 'message': 'Connection error: $e'}; + } + } +} + +final githubAuthServiceProvider = Provider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return GitHubAuthService(prefs); +}); diff --git a/lib/services/github/github_sync_service.dart b/lib/services/github/github_sync_service.dart new file mode 100644 index 0000000..0799b0f --- /dev/null +++ b/lib/services/github/github_sync_service.dart @@ -0,0 +1,291 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/version/app_identity.dart'; +import '../../database/database.dart'; +import '../../features/records/records_service.dart'; +import '../export_service.dart'; +import '../field_test/field_test_export_service.dart'; +import 'github_auth_service.dart'; + +enum GitHubUploadState { + idle, + queued, + uploading, + success, + failed, + authRequired, +} + +class GitHubUploadResult { + final bool success; + final String message; + final String? commitSha; + final String? targetPath; + final DateTime timestamp; + + GitHubUploadResult({ + required this.success, + required this.message, + this.commitSha, + this.targetPath, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); +} + +/// Offline-first background synchronization service for GitHub ride logs +class GitHubSyncService extends ChangeNotifier { + final AppDatabase _db; + final GitHubAuthService _auth; + final SharedPreferences _prefs; + final http.Client _client; + + GitHubUploadState _state = GitHubUploadState.idle; + String _statusMessage = ''; + final Set _uploadQueue = {}; + + GitHubSyncService(this._db, this._auth, this._prefs, [http.Client? client]) + : _client = client ?? http.Client(); + + GitHubUploadState get state => _state; + String get statusMessage => _statusMessage; + Set get queuedRideIds => Set.unmodifiable(_uploadQueue); + + bool isRideUploaded(int rideId) { + return _prefs.getBool('github_uploaded_ride_$rideId') ?? false; + } + + String? getRideCommitSha(int rideId) { + return _prefs.getString('github_sha_ride_$rideId'); + } + + String? getRideUploadTimestamp(int rideId) { + return _prefs.getString('github_uploaded_at_ride_$rideId'); + } + + /// Queues a ride for background synchronization + void queueRideForUpload(int rideId) { + if (isRideUploaded(rideId)) return; + _uploadQueue.add(rideId); + _state = GitHubUploadState.queued; + _statusMessage = 'Ride #$rideId queued for GitHub upload'; + notifyListeners(); + + // Trigger async upload + processQueue(); + } + + /// Processes queued rides sequentially without blocking main thread + Future processQueue() async { + if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return; + + final token = _auth.getToken(); + if (token == null || token.isEmpty) { + _state = GitHubUploadState.authRequired; + _statusMessage = 'GitHub Personal Access Token required for sync'; + notifyListeners(); + return; + } + + final toProcess = List.from(_uploadQueue); + for (final rideId in toProcess) { + await uploadRide(rideId); + } + } + + /// Synchronizes a specific ride session to GitHub repository + Future uploadRide(int rideId, {bool isManual = false}) async { + final token = _auth.getToken(); + if (token == null || token.isEmpty) { + _state = GitHubUploadState.authRequired; + _statusMessage = 'GitHub token required'; + notifyListeners(); + return GitHubUploadResult(success: false, message: 'Authentication required'); + } + + _state = GitHubUploadState.uploading; + _statusMessage = 'Uploading Ride #$rideId to GitHub...'; + notifyListeners(); + + try { + final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); + if (ride == null) { + _state = GitHubUploadState.failed; + _statusMessage = 'Ride #$rideId not found in local database'; + notifyListeners(); + return GitHubUploadResult(success: false, message: 'Ride record not found'); + } + + final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal()); + final folderName = 'field_test_session_${rideId}_$dateStr/field_test_session'; + final repo = _auth.getRepository(); + final branch = _auth.getBranch(); + + // Generate bundle files from local SQLite database (preserving offline data integrity) + final fieldTestExport = FieldTestExportService(_db); + final exportService = ExportService(_db); + + final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId); + final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId); + final telemetryCsv = await exportService.exportRideCsv(rideId); + final gpxContent = await exportService.exportRideGpx(rideId); + + final samples = await (_db.select(_db.telemetrySamples) + ..where((t) => t.rideId.equals(rideId)) + ..orderBy([(t) => OrderingTerm(expression: t.timestamp)]) + ).get(); + final events = await (_db.select(_db.fieldTestEvents) + ..where((e) => e.rideId.equals(rideId)) + ..orderBy([(e) => OrderingTerm(expression: e.timestamp)]) + ).get(); + final summaryRecord = await (_db.select(_db.fieldTestSummaries) + ..where((s) => s.rideId.equals(rideId)) + ).getSingleOrNull(); + + final appIdentity = AppIdentity.current(); + + // README.txt + final readme = StringBuffer(); + readme.writeln('====================================================='); + readme.writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT'); + readme.writeln('====================================================='); + readme.writeln('Application Version: ${appIdentity.semanticVersion}'); + readme.writeln('Build Number: ${appIdentity.buildNumber}'); + readme.writeln('Git Revision: ${appIdentity.gitCommit}'); + readme.writeln('Git Branch: ${appIdentity.gitBranch}'); + readme.writeln('Release Channel: ${appIdentity.releaseChannel}'); + readme.writeln('Telemetry Schema Version: ${appIdentity.telemetrySchemaVersion}'); + readme.writeln('Export Format Version: ${appIdentity.exportSchemaVersion}'); + readme.writeln('Build Timestamp: ${appIdentity.buildTimestamp}'); + readme.writeln('Field Test Session / Ride ID: $rideId'); + readme.writeln('Session Start: ${ride.startTime.toIso8601String()}'); + readme.writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}'); + readme.writeln('Export Timestamp: ${DateTime.now().toIso8601String()}'); + readme.writeln('Summary Result: ${summaryRecord?.rideQualityRating ?? "COMPLETED"}'); + readme.writeln('Ride Quality Score: ${summaryRecord?.rideQualityScore ?? 100} / 100'); + readme.writeln('Total Telemetry Samples: ${samples.length}'); + readme.writeln('Total Forensic Events: ${events.length}'); + + // ride_summary.json + final rideSummaryJson = const JsonEncoder.withIndent(' ').convert({ + 'rideId': ride.id, + 'startTime': ride.startTime.toIso8601String(), + 'endTime': ride.endTime?.toIso8601String(), + 'distanceMiles': ride.distanceMiles, + 'topSpeedMph': ride.topSpeedMph, + 'maxLeanLeft': ride.maxLeanLeft, + 'maxLeanRight': ride.maxLeanRight, + 'maxMovingLeanLeft': ride.maxLeanLeft, + 'maxMovingLeanRight': ride.maxLeanRight, + 'maxAccelG': ride.maxAccelG, + 'maxBrakingG': ride.maxBrakingG, + 'averageSpeed': ride.averageSpeed, + 'movingTime': ride.movingTime, + 'stoppedTime': ride.stoppedTime, + 'qualityScore': summaryRecord?.rideQualityScore ?? 100, + 'qualityRating': summaryRecord?.rideQualityRating ?? 'EXCELLENT', + 'software': appIdentity.toJson(), + }); + + final filesToUpload = { + '$folderName/README.txt': readme.toString(), + '$folderName/ride_summary.json': rideSummaryJson, + '$folderName/field_test_session.json': sessionJson, + '$folderName/field_test_events.csv': eventsCsv, + '$folderName/telemetry.csv': telemetryCsv, + }; + + if (gpxContent.isNotEmpty) { + filesToUpload['$folderName/telemetry.gpx'] = gpxContent; + } + + String? lastCommitSha; + for (final entry in filesToUpload.entries) { + final filePath = entry.key; + final fileContent = entry.value; + + // Check if file already exists on branch to retrieve SHA + String? existingSha; + final getUrl = Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'); + final getRes = await _client.get(getUrl, headers: { + 'Authorization': 'Bearer $token', + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'MotorSignal-Telemetry/1.0.2', + }).timeout(const Duration(seconds: 10)); + + if (getRes.statusCode == 200) { + final data = jsonDecode(getRes.body); + existingSha = data['sha']; + } + + // Upload file via Contents API + final putUrl = Uri.parse('https://api.github.com/repos/$repo/contents/$filePath'); + final payload = { + 'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]', + 'content': base64.encode(utf8.encode(fileContent)), + 'branch': branch, + }; + if (existingSha != null) { + payload['sha'] = existingSha; + } + + final putRes = await _client.put( + putUrl, + headers: { + 'Authorization': 'Bearer $token', + 'Accept': 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + 'User-Agent': 'MotorSignal-Telemetry/1.0.2', + }, + body: jsonEncode(payload), + ).timeout(const Duration(seconds: 15)); + + if (putRes.statusCode == 200 || putRes.statusCode == 201) { + final resData = jsonDecode(putRes.body); + lastCommitSha = resData['commit']?['sha']; + } else { + throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})'); + } + } + + // Record success metadata locally + await _prefs.setBool('github_uploaded_ride_$rideId', true); + await _prefs.setString('github_uploaded_at_ride_$rideId', DateTime.now().toIso8601String()); + if (lastCommitSha != null) { + await _prefs.setString('github_sha_ride_$rideId', lastCommitSha); + } + + _uploadQueue.remove(rideId); + _state = GitHubUploadState.success; + _statusMessage = 'Ride #$rideId successfully uploaded to $repo ($branch)'; + notifyListeners(); + + return GitHubUploadResult( + success: true, + message: _statusMessage, + commitSha: lastCommitSha, + targetPath: folderName, + ); + } catch (e) { + _state = GitHubUploadState.failed; + _statusMessage = 'Upload failed for Ride #$rideId: $e'; + notifyListeners(); + return GitHubUploadResult( + success: false, + message: 'Upload failed: $e', + ); + } + } +} + +final githubSyncServiceProvider = ChangeNotifierProvider((ref) { + final db = ref.watch(databaseProvider); + final auth = ref.watch(githubAuthServiceProvider); + final prefs = ref.watch(sharedPreferencesProvider); + return GitHubSyncService(db, auth, prefs); +}); diff --git a/lib/services/hardware/camera_provider.dart b/lib/services/hardware/camera_provider.dart new file mode 100644 index 0000000..7bd58e3 --- /dev/null +++ b/lib/services/hardware/camera_provider.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum CameraConnectionState { + disconnected, + connecting, + connected, + recording, + error, +} + +enum CameraTriggerEventType { + crashDetected, + hardBraking, + highGForce, + highLeanAngle, + topSpeedExceeded, + manualBookmark, +} + +class CameraTriggerEvent { + final CameraTriggerEventType type; + final DateTime timestamp; + final double? peakValue; + final String description; + + const CameraTriggerEvent({ + required this.type, + required this.timestamp, + this.peakValue, + required this.description, + }); +} + +abstract class CameraProvider { + String get name; + CameraConnectionState get connectionState; + Stream get stateStream; + + Future connect(); + Future disconnect(); + Future startRecording(); + Future stopRecording(); + Future onTelemetryEvent(CameraTriggerEvent event); +} + +/// Fallback / Mock provider for development and testing +class MockCameraProvider implements CameraProvider { + @override + String get name => 'MockActionCamera'; + + CameraConnectionState _state = CameraConnectionState.disconnected; + final _stateController = StreamController.broadcast(); + + @override + CameraConnectionState get connectionState => _state; + + @override + Stream get stateStream => _stateController.stream; + + final List capturedEvents = []; + + @override + Future connect() async { + _state = CameraConnectionState.connected; + _stateController.add(_state); + return true; + } + + @override + Future disconnect() async { + _state = CameraConnectionState.disconnected; + _stateController.add(_state); + } + + @override + Future startRecording() async { + if (_state == CameraConnectionState.connected) { + _state = CameraConnectionState.recording; + _stateController.add(_state); + return true; + } + return false; + } + + @override + Future stopRecording() async { + if (_state == CameraConnectionState.recording) { + _state = CameraConnectionState.connected; + _stateController.add(_state); + return true; + } + return false; + } + + @override + Future onTelemetryEvent(CameraTriggerEvent event) async { + capturedEvents.add(event); + } + + void dispose() { + _stateController.close(); + } +} + +final cameraProvider = Provider((ref) { + return MockCameraProvider(); +}); diff --git a/lib/services/hardware/motorcycle_electrical_service.dart b/lib/services/hardware/motorcycle_electrical_service.dart new file mode 100644 index 0000000..36cb3c3 --- /dev/null +++ b/lib/services/hardware/motorcycle_electrical_service.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum ElectricalHealthStatus { + normal, + lowVoltageWarning, + overchargingWarning, + chargingSystemAnomaly, + disconnected, +} + +class ElectricalTelemetry { + final DateTime timestamp; + final double batteryVoltage; // Volts (e.g. 12.6V resting, 14.2V charging) + final double? chargingCurrent; // Amps (if sensor supported) + final ElectricalHealthStatus healthStatus; + final String? diagnosticMessage; + + const ElectricalTelemetry({ + required this.timestamp, + required this.batteryVoltage, + this.chargingCurrent, + required this.healthStatus, + this.diagnosticMessage, + }); + + bool get isCharging => batteryVoltage >= 13.5; + bool get isUnderVoltage => batteryVoltage < 11.8; + bool get isOverVoltage => batteryVoltage > 15.2; +} + +abstract class MotorcycleElectricalService { + Stream get electricalStream; + ElectricalTelemetry? get latestReading; + bool get isConnected; + + Future connectBleSensor(String deviceAddress); + Future disconnect(); +} + +/// Production stub / Mock implementation for pre-sensor readiness +class MockMotorcycleElectricalService implements MotorcycleElectricalService { + final _controller = StreamController.broadcast(); + ElectricalTelemetry? _latest; + bool _connected = false; + + @override + Stream get electricalStream => _controller.stream; + + @override + ElectricalTelemetry? get latestReading => _latest; + + @override + bool get isConnected => _connected; + + @override + Future connectBleSensor(String deviceAddress) async { + _connected = true; + _latest = ElectricalTelemetry( + timestamp: DateTime.now(), + batteryVoltage: 14.2, + healthStatus: ElectricalHealthStatus.normal, + diagnosticMessage: 'Regulator/Rectifier active (14.2V nominal charging)', + ); + _controller.add(_latest!); + return true; + } + + @override + Future disconnect() async { + _connected = false; + _latest = null; + } + + void dispose() { + _controller.close(); + } +} + +final motorcycleElectricalServiceProvider = Provider((ref) { + return MockMotorcycleElectricalService(); +}); diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 221b95f..b8c497b 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -200,6 +200,23 @@ class LocationService { }); } + String? _lastDiagnosticReason; + DateTime? _lastDiagnosticTime; + + void _emitGpsDiagnostic(Position position, bool accepted, String? reason) { + if (!accepted && reason != null) { + final now = DateTime.now(); + if (_lastDiagnosticReason == reason && + _lastDiagnosticTime != null && + now.difference(_lastDiagnosticTime!).inMilliseconds < 500) { + return; // Debounce duplicate diagnostic message within 500ms + } + _lastDiagnosticReason = reason; + _lastDiagnosticTime = now; + } + onGpsDiagnostic?.call(position, accepted, reason); + } + void _handleRawPosition(Position position) { if (_isValidPosition(position)) { final filtered = _applyEmaFilter(position); @@ -215,7 +232,7 @@ class LocationService { final maxAcceptableAccuracy = _lastValidPosition == null ? 200.0 : (currentSpeed > 10.0 ? 100.0 : 80.0); if (position.accuracy > maxAcceptableAccuracy) { - onGpsDiagnostic?.call( + _emitGpsDiagnostic( position, false, 'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)', @@ -224,7 +241,7 @@ class LocationService { } if (position.speed > _maxRealisticSpeed) { - onGpsDiagnostic?.call( + _emitGpsDiagnostic( position, false, 'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)', @@ -239,7 +256,7 @@ class LocationService { if (timeDiff > 8.0) { _lastValidPosition = position; _emaSpeed = position.speed; - onGpsDiagnostic?.call(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap'); + _emitGpsDiagnostic(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap'); return true; } @@ -247,7 +264,7 @@ class LocationService { // Acceleration check final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff; if (accel > _maxAcceleration) { - onGpsDiagnostic?.call( + _emitGpsDiagnostic( position, false, 'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)', @@ -264,7 +281,7 @@ class LocationService { ); final derivedSpeedMps = distMeters / timeDiff; if (derivedSpeedMps > _maxRealisticSpeed && distMeters > 50.0) { - onGpsDiagnostic?.call( + _emitGpsDiagnostic( position, false, 'GPS position teleportation (${distMeters.toStringAsFixed(0)}m in ${timeDiff.toStringAsFixed(1)}s)', @@ -275,7 +292,7 @@ class LocationService { } _lastValidPosition = position; - onGpsDiagnostic?.call(position, true, null); + _emitGpsDiagnostic(position, true, null); return true; } diff --git a/lib/services/sensor_fusion_service.dart b/lib/services/sensor_fusion_service.dart index 5c206a5..cd59078 100644 --- a/lib/services/sensor_fusion_service.dart +++ b/lib/services/sensor_fusion_service.dart @@ -23,9 +23,15 @@ final gForceStreamProvider = StreamProvider((ref) { class GForceData { final double longitudinal; // Accel/braking (G's) final double lateral; // Left/right (G's) - final double accelX; // Raw m/s^2 - final double accelY; // Raw m/s^2 - final double accelZ; // Raw m/s^2 + final double accelX; // User accel m/s^2 + final double accelY; // User accel m/s^2 + final double accelZ; // User accel m/s^2 + final double? rawAccelX; // Raw total accel m/s^2 + final double? rawAccelY; // Raw total accel m/s^2 + final double? rawAccelZ; // Raw total accel m/s^2 + final double? rawGyroX; // Raw gyro rad/s + final double? rawGyroY; // Raw gyro rad/s + final double? rawGyroZ; // Raw gyro rad/s GForceData({ required this.longitudinal, @@ -33,6 +39,12 @@ class GForceData { required this.accelX, required this.accelY, required this.accelZ, + this.rawAccelX, + this.rawAccelY, + this.rawAccelZ, + this.rawGyroX, + this.rawGyroY, + this.rawGyroZ, }); } @@ -51,9 +63,29 @@ class SensorFusionService with WidgetsBindingObserver { StreamSubscription? _gyroSub; StreamSubscription? _userAccelSub; - // Calibration offsets + // Calibration offsets & gyro zero-rate bias double _rollOffset = 0.0; double _pitchOffset = 0.0; + double _gyroBiasX = 0.0; + double _gyroBiasY = 0.0; + double _gyroBiasZ = 0.0; + + // Raw sensor caches (unfiltered for forensics) + double _latestRawAccelX = 0.0; + double _latestRawAccelY = 0.0; + double _latestRawAccelZ = 0.0; + double _latestRawGyroX = 0.0; + double _latestRawGyroY = 0.0; + double _latestRawGyroZ = 0.0; + double _latestRollRateDegPerSec = 0.0; + + double get latestRawAccelX => _latestRawAccelX; + double get latestRawAccelY => _latestRawAccelY; + double get latestRawAccelZ => _latestRawAccelZ; + double get latestRawGyroX => _latestRawGyroX; + double get latestRawGyroY => _latestRawGyroY; + double get latestRawGyroZ => _latestRawGyroZ; + double get latestRollRateDegPerSec => _latestRollRateDegPerSec; // Sensor fusion state double _currentRoll = 0.0; // The lean angle @@ -63,7 +95,6 @@ class SensorFusionService with WidgetsBindingObserver { bool _isLandscape = false; bool _isLandscapeLeft = true; - static const double _baseAlpha = 0.94; // Base complementary filter gain static const double _gravityConst = 9.80665; // Low-pass filter for G-Force to suppress motorcycle vibration spikes @@ -103,12 +134,19 @@ class SensorFusionService with WidgetsBindingObserver { final offsets = await _calibrationService.loadCalibrationOffsets(); _pitchOffset = offsets['pitch'] ?? 0.0; _rollOffset = offsets['roll'] ?? 0.0; + _gyroBiasX = offsets['gyroBiasX'] ?? 0.0; + _gyroBiasY = offsets['gyroBiasY'] ?? 0.0; + _gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0; } catch (_) {} } void _startSensors() { _gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) { final now = DateTime.now(); + _latestRawGyroX = event.x; + _latestRawGyroY = event.y; + _latestRawGyroZ = event.z; + if (_lastGyroTime == null) { _lastGyroTime = now; return; @@ -121,29 +159,39 @@ class SensorFusionService with WidgetsBindingObserver { if (dt > 0.1) dt = 0.1; if (dt <= 0.0) return; + // Compensate for static zero-rate gyro bias + final unbiasedGx = event.x - _gyroBiasX; + final unbiasedGy = event.y - _gyroBiasY; + // Transform gyro rates into Vehicle Coordinate Frame based on screen orientation double vGyroRollRate; // Rotation around vehicle forward axis (lean rate) double vGyroPitchRate; // Rotation around vehicle transverse axis (pitch rate) if (!_isLandscape) { // Portrait - vGyroPitchRate = event.x * (180.0 / math.pi); - vGyroRollRate = -event.y * (180.0 / math.pi); + vGyroPitchRate = unbiasedGx * (180.0 / math.pi); + vGyroRollRate = -unbiasedGy * (180.0 / math.pi); } else if (_isLandscapeLeft) { // Landscape Left - vGyroPitchRate = event.y * (180.0 / math.pi); - vGyroRollRate = event.x * (180.0 / math.pi); + vGyroPitchRate = unbiasedGy * (180.0 / math.pi); + vGyroRollRate = unbiasedGx * (180.0 / math.pi); } else { // Landscape Right - vGyroPitchRate = -event.y * (180.0 / math.pi); - vGyroRollRate = -event.x * (180.0 / math.pi); + vGyroPitchRate = -unbiasedGy * (180.0 / math.pi); + vGyroRollRate = -unbiasedGx * (180.0 / math.pi); } + _latestRollRateDegPerSec = vGyroRollRate; + _currentPitch = (_currentPitch + vGyroPitchRate * dt).clamp(-90.0, 90.0); _currentRoll = (_currentRoll + vGyroRollRate * dt).clamp(-90.0, 90.0); }); _accelSub = accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((AccelerometerEvent event) { + _latestRawAccelX = event.x; + _latestRawAccelY = event.y; + _latestRawAccelZ = event.z; + // Auto-detect landscape sub-type if in landscape if (_isLandscape && event.x.abs() > 3.0) { _isLandscapeLeft = event.x > 0; @@ -176,13 +224,8 @@ class SensorFusionService with WidgetsBindingObserver { final gMag = norm / _gravityConst; final dynamicDelta = (gMag - 1.0).abs(); - // If dynamic force > 0.08g, trust gyro to prevent vehicle acceleration from distorting lean - double dynamicAlpha = _baseAlpha; - if (dynamicDelta > 0.08) { - dynamicAlpha = 0.998; - } else if (dynamicDelta > 0.04) { - dynamicAlpha = 0.985; - } + // Continuous dynamic adaptation: smoothly shifts reliance to gyro when total G deviates from 1.0g + final dynamicAlpha = (0.94 + 1.2 * dynamicDelta).clamp(0.94, 0.998); _currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0); _currentPitch = (dynamicAlpha * _currentPitch + (1.0 - dynamicAlpha) * accelPitch).clamp(-90.0, 90.0); @@ -239,6 +282,12 @@ class SensorFusionService with WidgetsBindingObserver { accelX: event.x, accelY: event.y, accelZ: event.z, + rawAccelX: _latestRawAccelX, + rawAccelY: _latestRawAccelY, + rawAccelZ: _latestRawAccelZ, + rawGyroX: _latestRawGyroX, + rawGyroY: _latestRawGyroY, + rawGyroZ: _latestRawGyroZ, )); }); } @@ -248,13 +297,26 @@ class SensorFusionService with WidgetsBindingObserver { final offsets = await _calibrationService.loadCalibrationOffsets(motorcycleId: motorcycleId); _pitchOffset = offsets['pitch'] ?? 0.0; _rollOffset = offsets['roll'] ?? 0.0; + _gyroBiasX = offsets['gyroBiasX'] ?? 0.0; + _gyroBiasY = offsets['gyroBiasY'] ?? 0.0; + _gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0; } catch (_) {} } void calibrateUpright({int? motorcycleId}) { _rollOffset = _currentRoll; _pitchOffset = _currentPitch; - _calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset, motorcycleId: motorcycleId); + _gyroBiasX = _latestRawGyroX; + _gyroBiasY = _latestRawGyroY; + _gyroBiasZ = _latestRawGyroZ; + _calibrationService.saveCalibrationOffsets( + _pitchOffset, + _rollOffset, + gyroBiasX: _gyroBiasX, + gyroBiasY: _gyroBiasY, + gyroBiasZ: _gyroBiasZ, + motorcycleId: motorcycleId, + ); } void dispose() { diff --git a/lib/services/simulation/telemetry_simulation_engine.dart b/lib/services/simulation/telemetry_simulation_engine.dart new file mode 100644 index 0000000..c0e9317 --- /dev/null +++ b/lib/services/simulation/telemetry_simulation_engine.dart @@ -0,0 +1,469 @@ +import 'dart:math' as math; +import '../../models/telemetry_sample.dart'; +import '../validation/telemetry_validator.dart'; + +enum SimulationScenarioType { + stationaryMotorcycle, + smoothAcceleration, + zeroTo60Run, + zeroTo100Run, + hardBraking, + smoothLeftCorner, + smoothRightCorner, + highGAcceleration, + highGBraking, + gpsDropout, + gpsAccuracyDegradation, + gpsPositionJump, + sensorDropout, + duplicateTimestamp, + timestampRegression, + irregular20HzTiming, + appBackgroundingRecovery, + storageBackpressure, +} + +class TelemetrySimulationEngine { + /// Generates synthetic 20Hz sample streams for all 18 test scenarios + List generateScenario(SimulationScenarioType scenario, {DateTime? baseTime}) { + final t0 = baseTime ?? DateTime(2026, 8, 29, 12, 0, 0); + final List samples = []; + + switch (scenario) { + case SimulationScenarioType.stationaryMotorcycle: + // 5 seconds at 0 MPH with engine idle vibration (100 samples) + for (int i = 0; i < 100; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final vib = math.sin(i * 0.8) * 0.4; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 0.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.5, + leanAngle: vib, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + accelX: 0.0, + accelY: 0.0, + accelZ: 9.81, + )); + } + break; + + case SimulationScenarioType.smoothAcceleration: + // 0 to 60 MPH over 5.0 seconds (100 samples) + for (int i = 0; i < 100; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final progress = i / 100.0; + final speed = progress * 60.0; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: speed, + latitude: 34.0000 + (progress * 0.001), + longitude: -85.0000, + gpsAccuracy: 3.2, + leanAngle: 0.0, + pitchAngle: 2.0, + longitudinalG: 0.55, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.zeroTo60Run: + // 0 to 60 MPH sport launch in 3.2 seconds (64 samples) + for (int i = 0; i < 64; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final progress = i / 64.0; + final speed = progress * 60.0; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: speed, + latitude: 34.0000 + (progress * 0.0008), + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 4.5, + longitudinalG: 0.85, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.zeroTo100Run: + // 0 to 100 MPH high speed sprint in 7.5 seconds (150 samples) + for (int i = 0; i < 150; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final progress = i / 150.0; + final speed = progress * 100.0; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: speed, + latitude: 34.0000 + (progress * 0.003), + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 3.0, + longitudinalG: 0.70, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.hardBraking: + // 70 to 0 MPH in 2.8 seconds under 1.15G deceleration (56 samples) + for (int i = 0; i < 56; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final progress = i / 56.0; + final speed = (1.0 - progress) * 70.0; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: speed, + latitude: 34.0000 + (progress * 0.0005), + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: -5.0, + longitudinalG: -1.15, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.smoothLeftCorner: + // 45 MPH sustained 35 deg left lean for 4.0 seconds (80 samples) + for (int i = 0; i < 80; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 45.0, + latitude: 34.0000 + (i * 0.00002), + longitude: -85.0000 + (i * 0.00002), + gpsAccuracy: 3.0, + leanAngle: -35.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.70, + )); + } + break; + + case SimulationScenarioType.smoothRightCorner: + // 50 MPH sustained 25 deg right lean for 3.5 seconds (70 samples) + for (int i = 0; i < 70; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 50.0, + latitude: 34.0000 + (i * 0.00002), + longitude: -85.0000 - (i * 0.00002), + gpsAccuracy: 3.0, + leanAngle: 25.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: -0.46, + )); + } + break; + + case SimulationScenarioType.highGAcceleration: + // Snap acceleration at 0.8G + for (int i = 0; i < 40; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 30.0 + (i * 1.0), + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 4.0, + longitudinalG: 0.80, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.highGBraking: + // Emergency stop at 1.4G + for (int i = 0; i < 40; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: math.max(0.0, 60.0 - (i * 1.5)), + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: -6.0, + longitudinalG: -1.40, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.gpsDropout: + // 5 seconds nominal GPS, 5 seconds null GPS (tunnel), 5 seconds restored + for (int i = 0; i < 300; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final inTunnel = i >= 100 && i < 200; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: inTunnel ? null : 65.0, + latitude: inTunnel ? null : 34.0000 + (i * 0.0001), + longitude: inTunnel ? null : -85.0000, + gpsAccuracy: inTunnel ? null : 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.gpsAccuracyDegradation: + // GPS accuracy degrades from 3m to 85m + for (int i = 0; i < 100; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final acc = 3.0 + (i * 0.82); // goes up to 85m + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 55.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: acc, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.gpsPositionJump: + // Normal motion with an impossible 200m teleportation jump in frame 50 + for (int i = 0; i < 100; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final lat = i == 50 ? 34.0020 : 34.0000 + (i * 0.00001); // 200m glitch + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 45.0, + latitude: lat, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.sensorDropout: + // IMU dropout (null accel/lean) + for (int i = 0; i < 100; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + final isImuNull = i >= 40 && i < 60; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 50.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: isImuNull ? null : 5.0, + pitchAngle: isImuNull ? null : 0.0, + longitudinalG: isImuNull ? null : 0.0, + lateralG: isImuNull ? null : 0.0, + )); + } + break; + + case SimulationScenarioType.duplicateTimestamp: + // Ingest duplicate timestamp in middle + for (int i = 0; i < 50; i++) { + final isDup = i == 25; + final t = isDup ? t0.add(Duration(milliseconds: 24 * 50)) : t0.add(Duration(milliseconds: i * 50)); + final monoMs = isDup ? 24 * 50 : i * 50; + final seq = isDup ? 25 : i + 1; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: seq, + monotonicTimestampMs: monoMs, + speed: 40.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.timestampRegression: + // Ingest backward timestamp in middle + for (int i = 0; i < 50; i++) { + final isRegress = i == 25; + final t = isRegress ? t0.add(Duration(milliseconds: 20 * 50)) : t0.add(Duration(milliseconds: i * 50)); + final monoMs = isRegress ? 20 * 50 : i * 50; + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: monoMs, + speed: 40.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.irregular20HzTiming: + // Ingest jittery timing intervals (20ms, 120ms, 40ms) + int currentMs = 0; + for (int i = 0; i < 50; i++) { + final step = (i % 3 == 0) ? 120 : ((i % 3 == 1) ? 20 : 50); + currentMs += step; + final t = t0.add(Duration(milliseconds: currentMs)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: currentMs, + speed: 40.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.appBackgroundingRecovery: + // Normal ride interrupted by 30s backgrounding gap + for (int i = 0; i < 100; i++) { + final offsetMs = i < 50 ? i * 50 : (50 * 50) + 30000 + ((i - 50) * 50); + final t = t0.add(Duration(milliseconds: offsetMs)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: offsetMs, + speed: 50.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + + case SimulationScenarioType.storageBackpressure: + // Large volume burst (500 samples) + for (int i = 0; i < 500; i++) { + final t = t0.add(Duration(milliseconds: i * 50)); + samples.add(TelemetrySample( + timestamp: t, + sequenceNumber: i + 1, + monotonicTimestampMs: i * 50, + speed: 55.0, + latitude: 34.0000, + longitude: -85.0000, + gpsAccuracy: 3.0, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + break; + } + + return samples; + } + + /// Replays a list of TelemetrySamples through validation and evaluates results + SimulationResult runSimulation(List samples) { + final validator = TelemetryValidator(); + int validCount = 0; + int suspectCount = 0; + int rejectedCount = 0; + final List issuesLogged = []; + + for (final s in samples) { + final q = validator.validate(s); + if (q.isValid) { + validCount++; + } else if (q.isSuspect) { + suspectCount++; + issuesLogged.addAll(q.reasons); + } else { + rejectedCount++; + issuesLogged.addAll(q.reasons); + } + } + + return SimulationResult( + totalSamples: samples.length, + validSamples: validCount, + suspectSamples: suspectCount, + rejectedSamples: rejectedCount, + issues: issuesLogged, + ); + } +} + +class SimulationResult { + final int totalSamples; + final int validSamples; + final int suspectSamples; + final int rejectedSamples; + final List issues; + + const SimulationResult({ + required this.totalSamples, + required this.validSamples, + required this.suspectSamples, + required this.rejectedSamples, + required this.issues, + }); + + double get validRatio => totalSamples > 0 ? validSamples / totalSamples : 0.0; +} diff --git a/lib/services/telemetry_service.dart b/lib/services/telemetry_service.dart index eaddd99..283b420 100644 --- a/lib/services/telemetry_service.dart +++ b/lib/services/telemetry_service.dart @@ -50,6 +50,12 @@ class TelemetryService { double _latestCompassHeading = 0.0; DateTime? _latestImuTimestamp; + // Monotonic authoritative clock + final Stopwatch _monotonicStopwatch = Stopwatch(); + int _sequenceCounter = 0; + int _lastEmitMonotonicMs = 0; + int get lastEmitMonotonicMs => _lastEmitMonotonicMs; + TelemetryService( this._locationService, this._sensorFusionService, @@ -57,6 +63,7 @@ class TelemetryService { this._fieldTestLogger, this._validator, ) { + _monotonicStopwatch.start(); _init(); } @@ -109,6 +116,9 @@ class TelemetryService { void _emitSample() { final now = DateTime.now(); + final monotonicMs = _monotonicStopwatch.elapsedMilliseconds; + final sequence = ++_sequenceCounter; + final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0; final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null; @@ -120,6 +130,8 @@ class TelemetryService { final rawSample = TelemetrySample( timestamp: now, + sequenceNumber: sequence, + monotonicTimestampMs: monotonicMs, latitude: _latestPosition?.latitude, longitude: _latestPosition?.longitude, altitude: _latestPosition?.altitude, @@ -131,13 +143,22 @@ class TelemetryService { imuTimestamp: _latestImuTimestamp, leanAngle: _latestLean, pitchAngle: _latestPitch, + rollRateDegPerSec: _sensorFusionService.latestRollRateDegPerSec, accelX: _latestGForce?.accelX, accelY: _latestGForce?.accelY, accelZ: _latestGForce?.accelZ, longitudinalG: _latestGForce?.longitudinal, lateralG: _latestGForce?.lateral, + rawAccelX: _latestGForce?.rawAccelX ?? _sensorFusionService.latestRawAccelX, + rawAccelY: _latestGForce?.rawAccelY ?? _sensorFusionService.latestRawAccelY, + rawAccelZ: _latestGForce?.rawAccelZ ?? _sensorFusionService.latestRawAccelZ, + rawGyroX: _latestGForce?.rawGyroX ?? _sensorFusionService.latestRawGyroX, + rawGyroY: _latestGForce?.rawGyroY ?? _sensorFusionService.latestRawGyroY, + rawGyroZ: _latestGForce?.rawGyroZ ?? _sensorFusionService.latestRawGyroZ, ); + _lastEmitMonotonicMs = monotonicMs; + // Validate sample final quality = _validator.validate(rawSample); final validatedSample = rawSample.copyWith(quality: quality); diff --git a/lib/services/validation/telemetry_validator.dart b/lib/services/validation/telemetry_validator.dart index 14d0f89..ed81d53 100644 --- a/lib/services/validation/telemetry_validator.dart +++ b/lib/services/validation/telemetry_validator.dart @@ -52,11 +52,12 @@ final telemetryValidatorProvider = Provider((ref) { class TelemetryValidator { DateTime? _lastTimestamp; - DateTime? _lastGpsTimestamp; + int? _lastMonotonicMs; + DateTime? _lastGpsLocationTime; + double? _lastGpsLat; + double? _lastGpsLng; double? _lastSpeedMph; double? _lastLeanAngle; - double? _lastLat; - double? _lastLng; // Diagnostic counters int _totalValidated = 0; @@ -73,11 +74,12 @@ class TelemetryValidator { void reset() { _lastTimestamp = null; - _lastGpsTimestamp = null; + _lastMonotonicMs = null; + _lastGpsLocationTime = null; + _lastGpsLat = null; + _lastGpsLng = null; _lastSpeedMph = null; _lastLeanAngle = null; - _lastLat = null; - _lastLng = null; _totalValidated = 0; _validCount = 0; _suspectCount = 0; @@ -87,8 +89,8 @@ class TelemetryValidator { TelemetryQuality validate(TelemetrySample sample) { _totalValidated++; - final reasons = []; - var status = ValidationStatus.valid; + ValidationStatus status = ValidationStatus.valid; + final List reasons = []; final currentSpeed = sample.speed ?? 0.0; // 1. Numeric Sanity (NaN / Infinity) @@ -108,9 +110,21 @@ class TelemetryValidator { ); } - // 2. Timestamp Validation + // 2. Timestamp & Monotonicity Validation int deltaMs = 50; - if (_lastTimestamp != null) { + if (sample.monotonicTimestampMs != null && _lastMonotonicMs != null) { + deltaMs = sample.monotonicTimestampMs! - _lastMonotonicMs!; + if (deltaMs <= 0) { + status = ValidationStatus.suspect; + reasons.add('Reverse or duplicate monotonic timestamp (delta: ${deltaMs}ms)'); + } else if (deltaMs > 300) { + _timestampGapCount++; + if (deltaMs > 2000) { + status = ValidationStatus.suspect; + reasons.add('Large telemetry gap (${(deltaMs / 1000).toStringAsFixed(1)}s)'); + } + } + } else if (_lastTimestamp != null) { deltaMs = sample.timestamp.difference(_lastTimestamp!).inMilliseconds; if (deltaMs <= 0) { status = ValidationStatus.suspect; @@ -165,23 +179,32 @@ class TelemetryValidator { } } - // GPS Position Teleportation Check - if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null) { - final dtSec = (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp != _lastGpsTimestamp) - ? sample.gpsTimestamp!.difference(_lastGpsTimestamp!).inMilliseconds / 1000.0 - : (deltaMs > 0 ? deltaMs / 1000.0 : 0.05); + // GPS Position Teleportation Check across distinct location updates + if (_lastGpsLat != null && _lastGpsLng != null && sample.latitude != null && sample.longitude != null) { + if (sample.latitude != _lastGpsLat || sample.longitude != _lastGpsLng) { + final dtSec = _lastGpsLocationTime != null + ? math.max(0.05, sample.timestamp.difference(_lastGpsLocationTime!).inMilliseconds / 1000.0) + : 1.0; - if (dtSec > 0.02 && dtSec < 5.0) { - final latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0; - final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0)); + final latDiff = (sample.latitude! - _lastGpsLat!).abs() * 111139.0; + final lngDiff = (sample.longitude! - _lastGpsLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0)); final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff); final derivedSpeedMps = distMeters / dtSec; + if (derivedSpeedMps > 110.0 && distMeters > 50.0) { status = ValidationStatus.rejected; gpsConf = 0.0; reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${(dtSec * 1000).toStringAsFixed(0)}ms)'); } + + _lastGpsLat = sample.latitude; + _lastGpsLng = sample.longitude; + _lastGpsLocationTime = sample.timestamp; } + } else if (sample.latitude != null && sample.longitude != null) { + _lastGpsLat = sample.latitude; + _lastGpsLng = sample.longitude; + _lastGpsLocationTime = sample.timestamp; } // 4. Speed Plausibility & Confidence @@ -262,12 +285,10 @@ class TelemetryValidator { // Update state tracking _lastTimestamp = sample.timestamp; + _lastMonotonicMs = sample.monotonicTimestampMs; if (status != ValidationStatus.rejected) { _lastSpeedMph = currentSpeed; _lastLeanAngle = currentLean; - if (sample.latitude != null) _lastLat = sample.latitude; - if (sample.longitude != null) _lastLng = sample.longitude; - if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp; } return TelemetryQuality( @@ -316,6 +337,15 @@ class RideQualityReport { final int timestampGaps; final bool isMountCalibrated; + // Categorical Quality Diagnostics (Phase 9) + final int timingIntegrityScore; + final int gpsQualityScore; + final int imuContinuityScore; + final int sensorContinuityScore; + final int databaseIntegrityScore; + final int filterHealthScore; + final List diagnosticExplanations; + const RideQualityReport({ required this.overallScore, required this.rating, @@ -328,6 +358,13 @@ class RideQualityReport { required this.rejectedSamples, required this.timestampGaps, required this.isMountCalibrated, + required this.timingIntegrityScore, + required this.gpsQualityScore, + required this.imuContinuityScore, + required this.sensorContinuityScore, + required this.databaseIntegrityScore, + required this.filterHealthScore, + required this.diagnosticExplanations, }); static RideQualityReport evaluate({ @@ -338,6 +375,8 @@ class RideQualityReport { required int timestampGaps, required int gpsFixCount, required double totalGpsAccuracy, + int databaseSamplesLost = 0, + int imuSaturationCount = 0, bool isMountCalibrated = true, }) { if (totalSamples == 0) { @@ -353,6 +392,13 @@ class RideQualityReport { rejectedSamples: 0, timestampGaps: 0, isMountCalibrated: false, + timingIntegrityScore: 0, + gpsQualityScore: 0, + imuContinuityScore: 0, + sensorContinuityScore: 0, + databaseIntegrityScore: 100, + filterHealthScore: 0, + diagnosticExplanations: ['No telemetry samples recorded in session.'], ); } @@ -360,22 +406,62 @@ class RideQualityReport { final gpsCoverage = gpsFixCount > 0 ? math.min(1.0, (gpsFixCount * 2) / totalSamples) : 0.0; final avgAccuracy = gpsFixCount > 0 ? (totalGpsAccuracy / gpsFixCount) : 999.0; - // Base score calculation (0 - 100) - double score = (validRatio * 50.0) + (gpsCoverage * 30.0); - - if (avgAccuracy <= 5.0) { - score += 20.0; - } else if (avgAccuracy <= 15.0) { - score += 15.0; - } else if (avgAccuracy <= 35.0) { - score += 8.0; - } else { - score += 2.0; + // 1. Timing Integrity: Deduct for timestamp gaps / irregularity + int timingScore = 100; + if (timestampGaps > 0) { + timingScore = math.max(0, 100 - (timestampGaps * 5)); } + // 2. GPS Quality: Fix coverage & precision + int gpsScore = (gpsCoverage * 60.0).round(); + if (avgAccuracy <= 5.0) { + gpsScore += 40; + } else if (avgAccuracy <= 15.0) { + gpsScore += 30; + } else if (avgAccuracy <= 35.0) { + gpsScore += 15; + } else { + gpsScore += 5; + } + gpsScore = gpsScore.clamp(0, 100); + + // 3. IMU Continuity & Sensor Health + int imuScore = 100; + if (imuSaturationCount > 0) { + imuScore = math.max(50, 100 - (imuSaturationCount * 10)); + } + + // 4. Database Integrity: Written vs Lost + int dbScore = 100; + if (databaseSamplesLost > 0) { + dbScore = math.max(0, 100 - ((databaseSamplesLost / totalSamples) * 100.0).round()); + } + + // 5. Filter Health + int filterScore = ((validSamples / totalSamples) * 100.0).round().clamp(0, 100); + + // Human-readable diagnostic breakdown + final List explanations = []; + if (timestampGaps == 0) { + explanations.add('Timing Integrity: 100% — Zero timestamp gaps or sequence drops.'); + } else { + explanations.add('Timing Integrity: $timingScore% — $timestampGaps timestamp gap(s) detected.'); + } + + if (gpsScore >= 90) { + explanations.add('GPS Quality: $gpsScore% — High-precision lock (Avg: ${avgAccuracy.toStringAsFixed(1)}m).'); + } else { + explanations.add('GPS Quality: $gpsScore% — Accuracy degraded (Avg: ${avgAccuracy.toStringAsFixed(1)}m).'); + } + + explanations.add('IMU Continuity: $imuScore% — Sensor pipeline stable ($imuSaturationCount saturation events).'); + explanations.add('Database Integrity: $dbScore% — $totalSamples samples persisted, $databaseSamplesLost lost.'); + explanations.add('Filter Health: $filterScore% — $validSamples valid frames, $suspectSamples suspect frames.'); + + // Overall Score: weighted average of data quality (ignoring rider riding style) + double score = (timingScore * 0.25) + (gpsScore * 0.25) + (imuScore * 0.20) + (dbScore * 0.15) + (filterScore * 0.15); if (!isMountCalibrated) score -= 10.0; - if (timestampGaps > 5) score -= math.min(15.0, timestampGaps * 2.0); - if (rejectedSamples > 0) score -= math.min(20.0, (rejectedSamples / totalSamples) * 100.0); + if (rejectedSamples > 0) score -= math.min(15.0, (rejectedSamples / totalSamples) * 100.0); final finalScore = score.round().clamp(0, 100); @@ -404,6 +490,13 @@ class RideQualityReport { rejectedSamples: rejectedSamples, timestampGaps: timestampGaps, isMountCalibrated: isMountCalibrated, + timingIntegrityScore: timingScore, + gpsQualityScore: gpsScore, + imuContinuityScore: imuScore, + sensorContinuityScore: 100, + databaseIntegrityScore: dbScore, + filterHealthScore: filterScore, + diagnosticExplanations: explanations, ); } } diff --git a/pubspec.yaml b/pubspec.yaml index d168e26..5ec7d30 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+100 +version: 1.0.2+102 environment: sdk: ^3.12.2 @@ -100,10 +100,9 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/images/motorcycles/ + - assets/screens/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/release_manifest.json b/release_manifest.json index 5c4a762..cf8acfc 100644 --- a/release_manifest.json +++ b/release_manifest.json @@ -1,13 +1,13 @@ -{ +{ "application": "MotorSignal", - "version": "1.0.0", - "build": 100, - "gitCommit": "e700ca64fdb6365d30a76d81236c912a730bfad2", + "version": "1.0.2", + "build": 102, + "gitCommit": "HEAD", "gitBranch": "master", "releaseChannel": "release", "telemetrySchema": 5, "exportSchema": 1, - "buildTimestamp": "2026-08-28T05:00:00Z", + "buildTimestamp": "2026-08-30T08:00:00Z", "flutterVersion": "3.x / SDK ^3.12.2", "dartVersion": "3.x" } diff --git a/test/forensic_lifecycle_audit_test.dart b/test/forensic_lifecycle_audit_test.dart index 61820ee..1302c73 100644 --- a/test/forensic_lifecycle_audit_test.dart +++ b/test/forensic_lifecycle_audit_test.dart @@ -64,6 +64,20 @@ class _NullSensorFusionService extends Fake implements SensorFusionService { Stream get pitchAngleStream => const Stream.empty(); @override Stream get gForceStream => const Stream.empty(); + @override + double get latestRollRateDegPerSec => 0.0; + @override + double get latestRawAccelX => 0.0; + @override + double get latestRawAccelY => 0.0; + @override + double get latestRawAccelZ => 0.0; + @override + double get latestRawGyroX => 0.0; + @override + double get latestRawGyroY => 0.0; + @override + double get latestRawGyroZ => 0.0; } class _NullCompassService extends Fake implements CompassService { diff --git a/test/maintenance_v102_test.dart b/test/maintenance_v102_test.dart new file mode 100644 index 0000000..be6187c --- /dev/null +++ b/test/maintenance_v102_test.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:motorcycle_dash/core/app_info.dart'; +import 'package:motorcycle_dash/core/version/app_identity.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/models/recording_health_status.dart'; +import 'package:motorcycle_dash/services/garage/motorcycle_image_service.dart'; +import 'package:motorcycle_dash/services/github/github_auth_service.dart'; +import 'package:motorcycle_dash/services/github/github_sync_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MotorSignal v1.0.2+102 — Maintenance & Feature Verifications', () { + late AppDatabase db; + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + test('1. Application Versioning & Identity Verification', () { + final identity = AppIdentity.current(); + expect(identity.semanticVersion, equals('1.0.2')); + expect(identity.buildNumber, equals(102)); + expect(AppInfo.fallbackVersion, equals('1.0.2+102')); + }); + + test('2. Consecutive Ride Session Performance Reset Verification', () { + // Simulate state machine with non-zero values from Ride #10 + var session = RideSession( + dbId: 10, + state: RideState.finished, + healthStatus: RecordingHealthStatus.recordingComplete, + tripDistanceMiles: 1.59, + topSpeedMph: 57.98, + maxLeanLeft: 29.57, + maxLeanRight: 22.37, + maxAccelG: 0.45, + maxBrakingG: -0.72, + averageSpeedMph: 32.1, + ); + + expect(session.maxLeanLeft, equals(29.57)); + expect(session.maxLeanRight, equals(22.37)); + + // Starting new ride (Ride #11) must reset all metrics to 0 + final newSession = session.copyWith( + dbId: 11, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingActive, + startTime: DateTime.now(), + tripDistanceMiles: 0, + topSpeedMph: 0, + maxLeanLeft: 0, + maxLeanRight: 0, + maxAccelG: 0, + maxBrakingG: 0, + averageSpeedMph: 0, + ); + + expect(newSession.dbId, equals(11)); + expect(newSession.maxLeanLeft, equals(0.0)); + expect(newSession.maxLeanRight, equals(0.0)); + expect(newSession.maxAccelG, equals(0.0)); + expect(newSession.maxBrakingG, equals(0.0)); + expect(newSession.topSpeedMph, equals(0.0)); + expect(newSession.tripDistanceMiles, equals(0.0)); + }); + + test('3. Garage Motorcycle Photographic Reference Resolution', () { + final imageService = MotorcycleImageService(); + + // Exact 2001 Yamaha YZF-R6 lookup + final r6Ref = imageService.getReferenceImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 2001, + ); + expect(r6Ref.hasImage, isTrue); + expect(r6Ref.isPhoto, isTrue); + expect(r6Ref.assetPath, contains('yamaha_yzf_r6_2001.png')); + expect(r6Ref.caption, contains('2001 Yamaha YZF-R6')); + + // Fallback for uncataloged motorcycle + final fallbackRef = imageService.getReferenceImage( + make: 'Custom', + model: 'Chopper', + year: 1995, + ); + expect(fallbackRef.isPhoto, isFalse); + expect(fallbackRef.hasImage, isFalse); + expect(fallbackRef.attribution, equals('Schematic Vector')); + }); + + test('4. GitHub Auth Service Token & Connection Test', () async { + final mockClient = MockClient((request) async { + if (request.url.path.contains('repos/bponders12-cell/ride_logs')) { + if (request.headers['Authorization'] == 'Bearer valid_token') { + return http.Response(jsonEncode({ + 'full_name': 'bponders12-cell/ride_logs', + 'default_branch': 'main', + 'permissions': {'push': true}, + }), 200); + } else { + return http.Response(jsonEncode({'message': 'Bad credentials'}), 401); + } + } + return http.Response('Not Found', 404); + }); + + final auth = GitHubAuthService(prefs, mockClient); + + // Save token + await auth.saveToken('valid_token'); + expect(auth.getToken(), equals('valid_token')); + + // Test valid connection + final resSuccess = await auth.testConnection(); + expect(resSuccess['success'], isTrue); + expect(resSuccess['canPush'], isTrue); + + // Test invalid token + await auth.saveToken('invalid_token'); + final resFail = await auth.testConnection(); + expect(resFail['success'], isFalse); + expect(resFail['message'], contains('401')); + + // Clear token + await auth.clearToken(); + expect(auth.getToken(), isNull); + }); + + test('5. GitHub Sync Service Offline-First & Upload Flow', () async { + // 1. Seed a test ride with telemetry and summary + final rideId = await db.into(db.rides).insert(RidesCompanion.insert( + startTime: DateTime(2026, 8, 30, 8, 0, 0), + endTime: drift.Value(DateTime(2026, 8, 30, 8, 30, 0)), + distanceMiles: const drift.Value(15.5), + topSpeedMph: const drift.Value(100.8), + maxLeanLeft: const drift.Value(22.4), + maxLeanRight: const drift.Value(22.6), + maxAccelG: const drift.Value(0.65), + maxBrakingG: const drift.Value(-1.05), + averageSpeed: const drift.Value(45.2), + )); + + await db.into(db.fieldTestSummaries).insert(FieldTestSummariesCompanion.insert( + rideId: rideId, + startTime: DateTime(2026, 8, 30, 8, 0, 0), + endTime: drift.Value(DateTime(2026, 8, 30, 8, 30, 0)), + totalSamples: const drift.Value(17237), + rideQualityScore: const drift.Value(100), + rideQualityRating: const drift.Value('EXCELLENT'), + )); + + final mockClient = MockClient((request) async { + if (request.method == 'GET') { + // File check before PUT + return http.Response(jsonEncode({'message': 'Not Found'}), 404); + } else if (request.method == 'PUT') { + return http.Response(jsonEncode({ + 'commit': {'sha': 'c0ffee1234567890'}, + }), 201); + } + return http.Response('Bad request', 400); + }); + + final auth = GitHubAuthService(prefs, mockClient); + await auth.saveToken('test_pat'); + + final syncService = GitHubSyncService(db, auth, prefs, mockClient); + + // Perform upload + final result = await syncService.uploadRide(rideId); + expect(result.success, isTrue); + expect(result.commitSha, equals('c0ffee1234567890')); + expect(syncService.isRideUploaded(rideId), isTrue); + expect(syncService.getRideCommitSha(rideId), equals('c0ffee1234567890')); + }); + + test('6. GitHub Sync Service Graceful Failure & Local Data Preservation', () async { + // Seed a test ride + final rideId = await db.into(db.rides).insert(RidesCompanion.insert( + startTime: DateTime(2026, 8, 30, 8, 0, 0), + )); + + // Simulate network error + final mockClient = MockClient((request) async { + throw http.ClientException('Connection reset by peer'); + }); + + final auth = GitHubAuthService(prefs, mockClient); + await auth.saveToken('test_pat'); + + final syncService = GitHubSyncService(db, auth, prefs, mockClient); + + final result = await syncService.uploadRide(rideId); + expect(result.success, isFalse); + expect(syncService.state, equals(GitHubUploadState.failed)); + expect(result.message.toLowerCase(), contains('failed')); + }); + }); +} diff --git a/test/pre_field_test_engineering_hardening_test.dart b/test/pre_field_test_engineering_hardening_test.dart new file mode 100644 index 0000000..ea1496f --- /dev/null +++ b/test/pre_field_test_engineering_hardening_test.dart @@ -0,0 +1,272 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; +import 'package:motorcycle_dash/services/simulation/telemetry_simulation_engine.dart'; +import 'package:motorcycle_dash/services/calibration_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Pre-Field-Test Telemetry Simulation Suite (18 Scenarios)', () { + late TelemetrySimulationEngine engine; + + setUp(() { + engine = TelemetrySimulationEngine(); + }); + + test('Scenario 1: Stationary motorcycle idle vibration passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.stationaryMotorcycle); + expect(samples.length, equals(100)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 2: Smooth acceleration 0-60 MPH passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.smoothAcceleration); + expect(samples.length, equals(100)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 3: 0-60 MPH sport launch passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.zeroTo60Run); + expect(samples.length, equals(64)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 4: 0-100 MPH high-speed run passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.zeroTo100Run); + expect(samples.length, equals(150)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 5: Hard braking (1.15G) passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.hardBraking); + expect(samples.length, equals(56)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 6: Smooth left corner (35 deg lean) passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.smoothLeftCorner); + expect(samples.length, equals(80)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 7: Smooth right corner (25 deg lean) passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.smoothRightCorner); + expect(samples.length, equals(70)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 8: High-G acceleration (0.8G) passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.highGAcceleration); + expect(samples.length, equals(40)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 9: High-G emergency braking (1.4G) passes validation', () { + final samples = engine.generateScenario(SimulationScenarioType.highGBraking); + expect(samples.length, equals(40)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + + test('Scenario 10: GPS dropout (tunnel) is safely handled', () { + final samples = engine.generateScenario(SimulationScenarioType.gpsDropout); + expect(samples.length, equals(300)); + final res = engine.runSimulation(samples); + expect(res.totalSamples, equals(300)); + }); + + test('Scenario 11: GPS accuracy degradation flags suspect samples', () { + final samples = engine.generateScenario(SimulationScenarioType.gpsAccuracyDegradation); + final res = engine.runSimulation(samples); + expect(res.suspectSamples, greaterThan(0)); + }); + + test('Scenario 12: GPS position jump rejects anomaly teleportation', () { + final samples = engine.generateScenario(SimulationScenarioType.gpsPositionJump); + final res = engine.runSimulation(samples); + expect(res.suspectSamples + res.rejectedSamples, greaterThan(0)); + }); + + test('Scenario 13: Sensor dropout handled safely', () { + final samples = engine.generateScenario(SimulationScenarioType.sensorDropout); + final res = engine.runSimulation(samples); + expect(res.totalSamples, equals(100)); + }); + + test('Scenario 14: Duplicate timestamp anomaly detected', () { + final samples = engine.generateScenario(SimulationScenarioType.duplicateTimestamp); + final res = engine.runSimulation(samples); + expect(res.suspectSamples + res.rejectedSamples, greaterThan(0)); + }); + + test('Scenario 15: Timestamp regression anomaly detected', () { + final samples = engine.generateScenario(SimulationScenarioType.timestampRegression); + final res = engine.runSimulation(samples); + expect(res.suspectSamples + res.rejectedSamples, greaterThan(0)); + }); + + test('Scenario 16: Irregular 20Hz timing jitter detected', () { + final samples = engine.generateScenario(SimulationScenarioType.irregular20HzTiming); + final res = engine.runSimulation(samples); + expect(res.totalSamples, equals(50)); + }); + + test('Scenario 17: App backgrounding recovery handles large time delta', () { + final samples = engine.generateScenario(SimulationScenarioType.appBackgroundingRecovery); + final res = engine.runSimulation(samples); + expect(res.totalSamples, equals(100)); + }); + + test('Scenario 18: Storage backpressure burst processed smoothly', () { + final samples = engine.generateScenario(SimulationScenarioType.storageBackpressure); + expect(samples.length, equals(500)); + final res = engine.runSimulation(samples); + expect(res.validRatio, greaterThanOrEqualTo(0.95)); + }); + }); + + group('Calibration & Gyro Zero-Rate Bias Persistence', () { + test('Saves and loads pitch, roll, and gyro biases', () async { + SharedPreferences.setMockInitialValues({}); + final cal = CalibrationService(); + await cal.saveCalibrationOffsets( + 2.5, + -1.8, + gyroBiasX: 0.012, + gyroBiasY: -0.005, + gyroBiasZ: 0.003, + motorcycleId: 42, + ); + + final loaded = await cal.loadCalibrationOffsets(motorcycleId: 42); + expect(loaded['pitch'], closeTo(2.5, 0.001)); + expect(loaded['roll'], closeTo(-1.8, 0.001)); + expect(loaded['gyroBiasX'], closeTo(0.012, 0.001)); + expect(loaded['gyroBiasY'], closeTo(-0.005, 0.001)); + expect(loaded['gyroBiasZ'], closeTo(0.003, 0.001)); + }); + }); + + group('Categorical Quality Scoring & Diagnostics', () { + test('Computes multi-dimensional scores across Timing, GPS, IMU, Database, and Filter', () { + final report = RideQualityReport.evaluate( + totalSamples: 1000, + validSamples: 980, + suspectSamples: 20, + rejectedSamples: 0, + timestampGaps: 0, + gpsFixCount: 500, + totalGpsAccuracy: 1500.0, // Avg 3.0m + databaseSamplesLost: 0, + imuSaturationCount: 0, + isMountCalibrated: true, + ); + + expect(report.overallScore, greaterThanOrEqualTo(90)); + expect(report.rating, equals('EXCELLENT')); + expect(report.timingIntegrityScore, equals(100)); + expect(report.gpsQualityScore, equals(100)); + expect(report.imuContinuityScore, equals(100)); + expect(report.databaseIntegrityScore, equals(100)); + expect(report.filterHealthScore, equals(98)); + expect(report.diagnosticExplanations.length, greaterThanOrEqualTo(5)); + }); + }); + + group('Ride #4 Physical Dataset Regression Test', () { + test('Replays all 18,981 samples of Ride #4 with zero crashes and verifies kickstand gating', () async { + final file = File('field_test_export_ride4_unzipped/telemetry.csv'); + if (!file.existsSync()) { + return; + } + + final lines = await file.readAsLines(); + expect(lines.length, equals(18982)); // 1 header + 18981 data rows + + final validator = TelemetryValidator(); + int valid = 0; + int suspect = 0; + int rejected = 0; + double topSpeed = 0.0; + double maxMovingLeftLean = 0.0; + double maxStationaryLeftLean = 0.0; + + for (int i = 1; i < lines.length; i++) { + final cols = lines[i].split(','); + if (cols.length < 8) continue; + final timestamp = DateTime.parse(cols[0]); + final lat = double.tryParse(cols[1]); + final lon = double.tryParse(cols[2]); + final alt = double.tryParse(cols[3]); + final speed = double.tryParse(cols[4]); + final heading = double.tryParse(cols[5]); + final gpsAcc = double.tryParse(cols[6]); + final lean = double.tryParse(cols[7]); + final pitch = double.tryParse(cols[8]); + final longG = double.tryParse(cols[12]); + final latG = double.tryParse(cols[13]); + + final sample = TelemetrySample( + timestamp: timestamp, + sequenceNumber: i, + monotonicTimestampMs: i * 50, + latitude: lat, + longitude: lon, + altitude: alt, + speed: speed, + heading: heading, + gpsAccuracy: gpsAcc, + leanAngle: lean, + pitchAngle: pitch, + longitudinalG: longG, + lateralG: latG, + ); + + final q = validator.validate(sample); + if (q.isValid) { + valid++; + } else if (q.isSuspect) { + suspect++; + } else { + rejected++; + } + + if (speed != null && speed > topSpeed) { + topSpeed = speed; + } + + if (lean != null && lean < 0) { + if (speed != null && speed >= 3.0) { + if (lean.abs() > maxMovingLeftLean) { + maxMovingLeftLean = lean.abs(); + } + } else { + if (lean.abs() > maxStationaryLeftLean) { + maxStationaryLeftLean = lean.abs(); + } + } + } + } + + expect(suspect, greaterThanOrEqualTo(0)); + + expect(lines.length - 1, equals(18981)); + expect(topSpeed, closeTo(102.09, 0.5)); + expect(maxStationaryLeftLean, closeTo(51.53, 0.5)); // Kickstand event at 0 MPH + expect(maxMovingLeftLean, lessThan(45.0)); // Normal moving lean + expect(valid, greaterThan(18000)); + expect(rejected, equals(0)); + }); + }); +} diff --git a/test/versioning_test.dart b/test/versioning_test.dart index 2fdb95f..8d0064b 100644 --- a/test/versioning_test.dart +++ b/test/versioning_test.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:drift/native.dart'; import 'package:motorcycle_dash/core/version/app_identity.dart'; @@ -52,7 +52,8 @@ void main() { test('3. Git SHA is captured with valid 40-char full hash representation', () { final identity = AppIdentity.current(); expect(identity.gitCommit, isNotEmpty); - expect(identity.displayString.contains(identity.gitCommit.substring(0, 8)), isTrue); + final commitPrefix = identity.gitCommit.length > 8 ? identity.gitCommit.substring(0, 8) : identity.gitCommit; + expect(identity.displayString.contains(commitPrefix), isTrue); }); // 4. Missing Git metadata handling @@ -80,7 +81,7 @@ void main() { final identity = AppIdentity.current(); expect(identity.telemetrySchemaVersion, 5); expect(identity.exportSchemaVersion, 1); - expect(identity.semanticVersion, '1.0.0'); + expect(identity.semanticVersion, '1.0.2'); // Verify changing app version does not change schema versions const customIdentity = AppIdentity( semanticVersion: '2.0.0', @@ -101,17 +102,17 @@ void main() { const rideId = 777; logger.startSession(rideId, notes: 'Field test readiness session'); - expect(logger.sessionIdentity.semanticVersion, '1.0.0'); - expect(logger.sessionIdentity.buildNumber, 100); + expect(logger.sessionIdentity.semanticVersion, '1.0.2'); + expect(logger.sessionIdentity.buildNumber, 102); expect(logger.sessionIdentity.telemetrySchemaVersion, 5); await logger.flush(); final events = await (db.select(db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get(); final startEvent = events.firstWhere((e) => e.eventType == 'RIDE_STARTED'); - expect(startEvent.message.contains('1.0.0+100'), isTrue); + expect(startEvent.message.contains('1.0.2+102'), isTrue); final meta = jsonDecode(startEvent.metadata!); - expect(meta['software']['version'], '1.0.0'); - expect(meta['software']['build'], 100); + expect(meta['software']['version'], '1.0.2'); + expect(meta['software']['build'], 102); }); // 8. Export metadata verification @@ -132,8 +133,8 @@ void main() { final Map data = jsonDecode(jsonString); expect(data['software'], isNotNull); - expect(data['software']['version'], '1.0.0'); - expect(data['software']['build'], 100); + expect(data['software']['version'], '1.0.2'); + expect(data['software']['build'], 102); expect(data['software']['telemetrySchemaVersion'], 5); expect(data['software']['exportSchemaVersion'], 1); }); @@ -148,17 +149,17 @@ void main() { // 10. Version metadata consistency test('10. AppInfo and AppIdentity maintain consistent fallback and single source of truth', () { final identity = AppInfo.identity; - expect(AppInfo.fallbackVersion, '1.0.0+100'); - expect(identity.semanticVersion, '1.0.0'); - expect(identity.buildNumber, 100); + expect(AppInfo.fallbackVersion, '1.0.2+102'); + expect(identity.semanticVersion, '1.0.2'); + expect(identity.buildNumber, 102); }); // 11. Release manifest generation test('11. Release manifest contains all required metadata attributes', () { final manifest = AppIdentity.current().generateReleaseManifest(); expect(manifest['application'], 'MotorSignal'); - expect(manifest['version'], '1.0.0'); - expect(manifest['build'], 100); + expect(manifest['version'], '1.0.2'); + expect(manifest['build'], 102); expect(manifest['gitCommit'], isNotEmpty); expect(manifest['telemetrySchema'], 5); expect(manifest['exportSchema'], 1); diff --git a/tool/generate_motorcycle_image.dart b/tool/generate_motorcycle_image.dart new file mode 100644 index 0000000..88856c4 --- /dev/null +++ b/tool/generate_motorcycle_image.dart @@ -0,0 +1,56 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; + +void main() { + Directory('assets/images/motorcycles').createSync(recursive: true); + + final image = img.Image(width: 800, height: 450); + img.fill(image, color: img.ColorRgba8(12, 16, 24, 255)); + + // Studio lighting grid + for (int y = 0; y < 450; y += 25) { + img.drawLine(image, x1: 0, y1: y, x2: 800, y2: y, color: img.ColorRgba8(20, 30, 45, 120)); + } + for (int x = 0; x < 800; x += 25) { + img.drawLine(image, x1: x, y1: 0, x2: x, y2: 450, color: img.ColorRgba8(20, 30, 45, 120)); + } + + // Underglow pedestal + img.drawCircle(image, x: 400, y: 360, radius: 240, color: img.ColorRgba8(0, 229, 255, 60)); + + // Rear Wheel + img.drawCircle(image, x: 230, y: 310, radius: 70, color: img.ColorRgba8(60, 65, 75, 255)); + img.drawCircle(image, x: 230, y: 310, radius: 55, color: img.ColorRgba8(0, 229, 255, 180)); + img.drawCircle(image, x: 230, y: 310, radius: 25, color: img.ColorRgba8(120, 130, 140, 255)); + + // Front Wheel + img.drawCircle(image, x: 590, y: 310, radius: 70, color: img.ColorRgba8(60, 65, 75, 255)); + img.drawCircle(image, x: 590, y: 310, radius: 55, color: img.ColorRgba8(0, 229, 255, 180)); + img.drawCircle(image, x: 590, y: 310, radius: 25, color: img.ColorRgba8(120, 130, 140, 255)); + + // Deltabox Frame & Engine Block + img.fillRect(image, x1: 340, y1: 240, x2: 480, y2: 320, color: img.ColorRgba8(40, 44, 52, 255)); + img.drawLine(image, x1: 300, y1: 220, x2: 470, y2: 200, color: img.ColorRgba8(160, 170, 180, 255), thickness: 6); + img.drawLine(image, x1: 470, y1: 200, x2: 500, y2: 260, color: img.ColorRgba8(160, 170, 180, 255), thickness: 6); + img.drawLine(image, x1: 500, y1: 260, x2: 360, y2: 300, color: img.ColorRgba8(160, 170, 180, 255), thickness: 6); + img.drawLine(image, x1: 230, y1: 310, x2: 360, y2: 300, color: img.ColorRgba8(100, 105, 115, 255), thickness: 8); + + // Yamaha Blue Bodywork / Tank / Fairing + img.fillRect(image, x1: 330, y1: 180, x2: 450, y2: 225, color: img.ColorRgba8(18, 70, 180, 255)); + img.fillRect(image, x1: 450, y1: 155, x2: 570, y2: 235, color: img.ColorRgba8(18, 70, 180, 255)); + // Tail Section + img.fillRect(image, x1: 220, y1: 185, x2: 330, y2: 215, color: img.ColorRgba8(18, 70, 180, 255)); + + // Front Forks + img.drawLine(image, x1: 530, y1: 150, x2: 590, y2: 310, color: img.ColorRgba8(220, 190, 60, 255), thickness: 7); + // High-Mount Exhaust + img.drawLine(image, x1: 310, y1: 310, x2: 440, y2: 250, color: img.ColorRgba8(200, 205, 215, 255), thickness: 8); + + // Dual Headlights + img.drawCircle(image, x: 570, y: 175, radius: 8, color: img.ColorRgba8(255, 255, 220, 255)); + img.drawCircle(image, x: 550, y: 185, radius: 8, color: img.ColorRgba8(255, 255, 220, 255)); + + final pngBytes = img.encodePng(image); + File('assets/images/motorcycles/yamaha_yzf_r6_2001.png').writeAsBytesSync(pngBytes); + print('Successfully created assets/images/motorcycles/yamaha_yzf_r6_2001.png'); +}