release(v1.0.2): maintenance fixes, responsive handling, interactive events, multi-layer map & github sync

This commit is contained in:
Developer 2026-08-30 08:17:04 -04:00
parent f4d8883e2f
commit cfc9dfb675
32 changed files with 3118 additions and 224 deletions

View File

@ -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 > - `+BUILD` → an auto-incrementing integer consumed by `versionCode` (Android) and
> `CFBundleVersion` (iOS). It is bumped independently of the user-facing version. > `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 ### Added & Fixed
- **Landscape IMU Coordinate Transformation**: Dynamic sensor coordinate frame rotation adapting automatically to portrait and landscape mounting orientations. - **Landscape IMU Coordinate Transformation**: Dynamic sensor coordinate frame rotation adapting automatically to portrait and landscape mounting orientations.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

118
docs/VERSIONING.md Normal file
View File

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

View File

@ -6,7 +6,7 @@ class AppInfo {
AppInfo._(); AppInfo._();
/// Hard-coded fallback mirroring pubspec.yaml and AppIdentity /// 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; static PackageInfo? _cached;

View File

@ -34,13 +34,13 @@ class AppIdentity {
/// Authoritative current runtime instance derived from compile-time environment defines with safe fallbacks /// Authoritative current runtime instance derived from compile-time environment defines with safe fallbacks
static AppIdentity current() { static AppIdentity current() {
const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0'); const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.2');
const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 100); const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 102);
const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'e700ca64fdb6365d30a76d81236c912a730bfad2'); const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'HEAD');
const branch = String.fromEnvironment('GIT_BRANCH', defaultValue: 'master'); const branch = String.fromEnvironment('GIT_BRANCH', defaultValue: 'master');
const defaultChannel = kDebugMode ? 'debug' : (kProfileMode ? 'profile' : 'release'); const defaultChannel = kDebugMode ? 'debug' : (kProfileMode ? 'profile' : 'release');
const channel = String.fromEnvironment('RELEASE_CHANNEL', defaultValue: defaultChannel); 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( return const AppIdentity(
semanticVersion: semVer, semanticVersion: semVer,

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../database/database.dart'; import '../../../database/database.dart';
import '../../../models/motorcycle_models.dart'; import '../../../models/motorcycle_models.dart';
import '../../../services/garage/motorcycle_image_service.dart';
class EvolutionMotorcycleDetailView extends ConsumerWidget { class EvolutionMotorcycleDetailView extends ConsumerWidget {
final GarageMotorcycle bike; final GarageMotorcycle bike;
@ -99,7 +100,7 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
flex: 4, flex: 4,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: _buildHeroSpecCard(powerHp, torqueLbFt, wetWeight), child: _buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight),
), ),
), ),
// Right Column: Spec Sheet Details // Right Column: Spec Sheet Details
@ -128,7 +129,7 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeroSpecCard(powerHp, torqueLbFt, wetWeight), _buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem), _buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem),
const SizedBox(height: 14), 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( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -155,10 +162,11 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
), ),
child: Column( child: Column(
children: [ children: [
// Schematic Vector // Motorcycle Reference Image or Vector Schematic
Container( Container(
height: 110, height: 140,
width: double.infinity, width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF080808), color: const Color(0xFF080808),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -167,20 +175,41 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
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), const Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 72),
Positioned( Positioned(
bottom: 6, bottom: 6,
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( child: Text(
'TECHNICAL SCHEMATIC', imageRef.isPhoto ? 'TECHNICAL REFERENCE — ${imageRef.caption.toUpperCase()}' : 'TECHNICAL SCHEMATIC',
style: TextStyle( style: TextStyle(
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: const Color(0xFF00E5FF).withValues(alpha: 0.7), color: const Color(0xFF00E5FF).withValues(alpha: 0.9),
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
), ),
), ),
),
], ],
), ),
), ),

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../database/database.dart'; import '../../../database/database.dart';
import '../../../services/export_service.dart'; import '../../../services/export_service.dart';
import '../../../services/github/github_sync_service.dart';
import '../../replay/replay_screen.dart'; import '../../replay/replay_screen.dart';
import '../../map/map_screen.dart'; import '../../map/map_screen.dart';
@ -28,6 +30,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
final movingMin = (ride.movingTime / 60).toStringAsFixed(0); final movingMin = (ride.movingTime / 60).toStringAsFixed(0);
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; 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( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@ -65,13 +70,17 @@ class EvolutionRideDetailsView extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// GitHub Sync Status Card
_buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha),
const SizedBox(height: 12),
// Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards // Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards
Row( Row(
children: [ children: [
// Bike Blueprint Card // Bike Blueprint Card
Expanded( Expanded(
child: Container( child: Container(
height: 140, height: 130,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0D0D0D), color: const Color(0xFF0D0D0D),
@ -81,8 +90,8 @@ class EvolutionRideDetailsView extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.two_wheeler, color: Colors.white38, size: 48), const Icon(Icons.two_wheeler, color: Colors.white38, size: 40),
const SizedBox(height: 10), const SizedBox(height: 8),
Text( Text(
bikeName, bikeName,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -114,7 +123,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
}, },
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
height: 140, height: 130,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0D0D0D), color: const Color(0xFF0D0D0D),
@ -124,7 +133,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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 SizedBox(height: 6),
const Text( const Text(
'ROUTE PREVIEW', 'ROUTE PREVIEW',
@ -165,7 +174,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
const SizedBox(height: 18), const SizedBox(height: 18),
// Section 1: SUMMARY // Section 1: SUMMARY
_buildSectionHeader('SUMMARY'), _buildSectionHeader('SESSION OVERVIEW'),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -177,42 +186,43 @@ class EvolutionRideDetailsView extends ConsumerWidget {
child: Column( child: Column(
children: [ children: [
_buildMetricRow('DISTANCE:', '${ride.distanceMiles.toStringAsFixed(2)} mi', 'DURATION:', '$durationMin min'), _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'), _buildMetricRow('MOVING TIME:', '$movingMin min', 'AVG SPEED:', '${ride.averageSpeed.toStringAsFixed(1)} mph'),
const Divider(color: Color(0xFF222222), height: 20), const Divider(color: Color(0xFF222222), height: 18),
_buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', '', ''), _buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', 'DATE:', DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal())),
], ],
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
// Section 2: HANDLING // Section 2: HANDLING (Responsive Layout)
_buildSectionHeader('HANDLING'), _buildSectionHeader('HANDLING & DYNAMICS'),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( _buildResponsiveHandlingCards(context, isLandscape),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration( const SizedBox(height: 18),
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14), // Section 3: EVENTS (Interactive Rows)
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), Row(
), mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Column(
children: [ children: [
_buildMetricRow('MAX LEAN (LEFT):', '${ride.maxLeanLeft.toStringAsFixed(1)}°', 'MAX LEAN (RIGHT):', '${ride.maxLeanRight.toStringAsFixed(1)}°'), _buildSectionHeader('FORENSIC EVENTS (${events.length})'),
const Divider(color: Color(0xFF222222), height: 20), if (events.isNotEmpty)
_buildMetricRow('MAX ACCELERATION:', '${ride.maxAccelG.toStringAsFixed(2)} G', 'MAX BRAKING:', '${ride.maxBrakingG.toStringAsFixed(2)} G'), 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: 18),
// Section 3: EVENTS
_buildSectionHeader('EVENTS'),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0D0D0D), color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@ -233,37 +243,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
) )
: Column( : Column(
children: events.take(8).map((event) { children: events.take(15).map((event) => _buildInteractiveEventTile(context, event)).toList(),
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(),
), ),
), ),
@ -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) { Widget _buildSectionHeader(String title) {
return Text( return Text(
title, title,

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import '../../database/database.dart'; import '../../database/database.dart';
import '../../services/location_service.dart'; import '../../services/location_service.dart';
import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_provider_interface.dart';
@ -20,16 +21,18 @@ class MapScreen extends ConsumerStatefulWidget {
class _MapScreenState extends ConsumerState<MapScreen> { class _MapScreenState extends ConsumerState<MapScreen> {
bool _isLoadingHistory = true; bool _isLoadingHistory = true;
List<TelemetrySample> _telemetrySamples = [];
List<AppLatLng> _routePoints = []; List<AppLatLng> _routePoints = [];
List<AppMapMarker> _eventMarkers = []; List<AppMapMarker> _eventMarkers = [];
AppLatLng? _initialCenter; AppLatLng? _initialCenter;
AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194); // SF Default 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 _showHardAccel = true;
bool _showHardBraking = true; bool _showHardBraking = true;
bool _showCornering = true; bool _showCornering = true;
bool _showSpeedMilestones = true;
@override @override
void initState() { void initState() {
@ -89,6 +92,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
..where((e) => e.rideId.equals(widget.rideId!))) ..where((e) => e.rideId.equals(widget.rideId!)))
.get(); .get();
_telemetrySamples = telemetry;
if (telemetry.isNotEmpty) { if (telemetry.isNotEmpty) {
_routePoints = telemetry _routePoints = telemetry
.where((t) => t.latitude != null && t.longitude != null) .where((t) => t.latitude != null && t.longitude != null)
@ -104,36 +109,78 @@ class _MapScreenState extends ConsumerState<MapScreen> {
for (final event in events) { for (final event in events) {
if (event.latitude != null && event.longitude != null) { if (event.latitude != null && event.longitude != null) {
Color markerColor = Colors.redAccent; Color markerColor = Colors.redAccent;
if (event.eventType.contains('Corner')) markerColor = Colors.orangeAccent; IconData markerIcon = Icons.warning_amber_rounded;
if (event.eventType.contains('Acceleration')) markerColor = Colors.greenAccent; final typeLower = event.eventType.toLowerCase();
if (event.eventType.contains('Speed')) markerColor = Colors.purpleAccent;
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( _eventMarkers.add(
AppMapMarker( AppMapMarker(
id: event.id.toString(), id: 'event_${event.id}',
position: AppLatLng(event.latitude!, event.longitude!), position: AppLatLng(event.latitude!, event.longitude!),
label: event.eventType, 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: () { onTap: () {
if (mounted) { if (mounted) {
final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal());
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( 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( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Time: ${event.timestamp.toIso8601String().substring(11, 19)}'), _buildDialogRow('Timestamp:', timeStr),
Text('Value: ${event.value.toStringAsFixed(2)}'), _buildDialogRow('Event Value:', event.value.toStringAsFixed(2)),
if (event.latitude != null && event.longitude != null) _buildDialogRow('GPS Location:', '${event.latitude!.toStringAsFixed(5)}, ${event.longitude!.toStringAsFixed(5)}'),
Text('Lat/Lng: ${event.latitude!.toStringAsFixed(4)}, ${event.longitude!.toStringAsFixed(4)}'),
], ],
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Close'), child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')),
), ),
], ],
), ),
@ -145,10 +192,92 @@ class _MapScreenState extends ConsumerState<MapScreen> {
} }
} }
if (mounted) {
setState(() { setState(() {
_isLoadingHistory = false; _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<AppPolyline> _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 = <AppPolyline>[];
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<AppLatLng> 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<AppMapMarker> _getFilteredMarkers() { List<AppMapMarker> _getFilteredMarkers() {
final markers = <AppMapMarker>[]; final markers = <AppMapMarker>[];
@ -165,7 +294,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)], 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<MapScreen> {
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)], 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<MapScreen> {
} }
final filteredEvents = _eventMarkers.where((m) { final filteredEvents = _eventMarkers.where((m) {
final label = m.label ?? ''; final label = (m.label ?? '').toLowerCase();
if (label.contains('Acceleration') && !_showHardAccel) return false; if (label.contains('accel') && !_showHardAccel) return false;
if (label.contains('Braking') && !_showHardBraking) return false; if (label.contains('brak') && !_showHardBraking) return false;
if (label.contains('Corner') && !_showCornering) return false; if ((label.contains('corner') || label.contains('lean')) && !_showCornering) return false;
if (label.contains('Speed') && !_showSpeedMilestones) return false;
return true; return true;
}); });
@ -218,15 +346,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.2), color: alert.category.color.withValues(alpha: 0.2),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: alert.category.color),
), ),
child: Icon(alert.category.icon, color: alert.category.color, size: 28), child: Icon(alert.category.icon, color: alert.category.color, size: 28),
), ),
const SizedBox(width: 14), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -235,8 +362,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
alert.category.displayName.toUpperCase(), alert.category.displayName.toUpperCase(),
style: TextStyle( style: TextStyle(
color: alert.category.color, color: alert.category.color,
fontWeight: FontWeight.w900, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 12,
letterSpacing: 1.1, letterSpacing: 1.1,
), ),
), ),
@ -285,18 +412,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final activeProviderType = ref.watch(mapProviderTypeNotifierProvider); final activeProviderType = ref.watch(mapProviderTypeNotifierProvider);
final isFollowMode = ref.watch(mapFollowStateProvider); final isFollowMode = ref.watch(mapFollowStateProvider);
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
if (widget.rideId != null) { if (widget.rideId != null) {
// Historical Route View // Historical Route View
final polylines = _routePoints.isNotEmpty final polylines = _buildPolylines();
? [AppPolyline(id: 'history_route', points: _routePoints, color: Colors.cyanAccent, width: 5.0)]
: <AppPolyline>[];
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Ride #${widget.rideId} Route Preview'), title: Text('Ride #${widget.rideId} Multi-Layer Map'),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Switch Map Provider', tooltip: 'Switch Map Provider',
@ -328,7 +452,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
), ),
), ),
), ),
if (_routePoints.isNotEmpty) _buildEventFilterOverlay(), if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(),
if (_showSpeedHeatmap) _buildSpeedLegend(),
if (_routePoints.isEmpty) if (_routePoints.isEmpty)
Positioned( Positioned(
top: 16, top: 16,
@ -476,45 +601,56 @@ class _MapScreenState extends ConsumerState<MapScreen> {
); );
} }
Widget _buildEventFilterOverlay() { Widget _buildMultiLayerFilterOverlay() {
return Positioned( return Positioned(
top: 10, top: 10,
left: 10, left: 10,
right: 10, right: 10,
child: Card( 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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ 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( FilterChip(
label: const Text('Accel'), label: const Text('Accel'),
selected: _showHardAccel, selected: _showHardAccel,
onSelected: (val) => setState(() => _showHardAccel = val), onSelected: (val) => setState(() => _showHardAccel = val),
selectedColor: Colors.green.withValues(alpha: 0.4), selectedColor: const Color(0xFF76FF03).withValues(alpha: 0.35),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
FilterChip( FilterChip(
label: const Text('Braking'), label: const Text('Braking'),
selected: _showHardBraking, selected: _showHardBraking,
onSelected: (val) => setState(() => _showHardBraking = val), onSelected: (val) => setState(() => _showHardBraking = val),
selectedColor: Colors.red.withValues(alpha: 0.4), selectedColor: const Color(0xFFFF5252).withValues(alpha: 0.35),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
FilterChip( FilterChip(
label: const Text('Cornering'), label: const Text('Cornering'),
selected: _showCornering, selected: _showCornering,
onSelected: (val) => setState(() => _showCornering = val), onSelected: (val) => setState(() => _showCornering = val),
selectedColor: Colors.orange.withValues(alpha: 0.4), selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35),
),
const SizedBox(width: 6),
FilterChip(
label: const Text('Speed'),
selected: _showSpeedMilestones,
onSelected: (val) => setState(() => _showSpeedMilestones = val),
selectedColor: Colors.purple.withValues(alpha: 0.4),
), ),
], ],
), ),
@ -523,4 +659,49 @@ class _MapScreenState extends ConsumerState<MapScreen> {
), ),
); );
} }
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)),
],
),
);
}
} }

View File

@ -12,6 +12,7 @@ import '../../services/audio_alert_service.dart';
import '../records/records_service.dart'; import '../records/records_service.dart';
import '../garage/garage_providers.dart'; import '../garage/garage_providers.dart';
import '../../services/field_test/field_test_logger.dart'; import '../../services/field_test/field_test_logger.dart';
import '../../services/github/github_sync_service.dart';
enum RideState { idle, ready, riding, paused, finished } enum RideState { idle, ready, riding, paused, finished }
@ -227,6 +228,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
startTime: DateTime.now(), startTime: DateTime.now(),
tripDistanceMiles: 0, tripDistanceMiles: 0,
topSpeedMph: 0, topSpeedMph: 0,
maxLeanLeft: 0,
maxLeanRight: 0,
maxAccelG: 0,
maxBrakingG: 0,
averageSpeedMph: 0,
movingTimeSeconds: 0, movingTimeSeconds: 0,
stoppedTimeSeconds: 0, stoppedTimeSeconds: 0,
elevationGain: 0, elevationGain: 0,
@ -371,6 +377,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
} catch (_) {} } 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 = state.copyWith(
state: RideState.finished, state: RideState.finished,
healthStatus: RecordingHealthStatus.recordingComplete, healthStatus: RecordingHealthStatus.recordingComplete,
@ -532,7 +546,10 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
} }
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) { if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) {
newMaxLeanLeft = sample.leanAngle!.abs(); newMaxLeanLeft = sample.leanAngle!.abs();
} else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) { } else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) {
@ -540,7 +557,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
} }
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) { if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) {
newMaxAccelG = sample.longitudinalG!; newMaxAccelG = sample.longitudinalG!;
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) { } else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {

View File

@ -5,6 +5,8 @@ import '../../database/database.dart';
import '../../models/field_test_event.dart' as model; import '../../models/field_test_event.dart' as model;
import '../../services/field_test/field_test_export_service.dart'; import '../../services/field_test/field_test_export_service.dart';
import '../../services/github/github_sync_service.dart';
class FieldTestReportScreen extends ConsumerStatefulWidget { class FieldTestReportScreen extends ConsumerStatefulWidget {
final int? rideId; final int? rideId;
const FieldTestReportScreen({super.key, this.rideId}); const FieldTestReportScreen({super.key, this.rideId});
@ -42,7 +44,23 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Forensic Field Test Report'), title: const Text('Forensic Field Test Report'),
actions: [ 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( IconButton(
icon: _isExporting icon: _isExporting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
@ -51,6 +69,7 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!), onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!),
), ),
], ],
],
), ),
body: StreamBuilder<List<Ride>>( body: StreamBuilder<List<Ride>>(
stream: (db.select(db.rides) stream: (db.select(db.rides)

View File

@ -7,6 +7,7 @@ import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_providers.dart'; import '../../../services/alerts/road_alert_providers.dart';
import '../../../services/alerts/road_alert_service.dart'; import '../../../services/alerts/road_alert_service.dart';
import '../../../services/osm_oauth_service.dart'; import '../../../services/osm_oauth_service.dart';
import '../../../services/github/github_auth_service.dart';
import '../../../services/sensor_fusion_service.dart'; import '../../../services/sensor_fusion_service.dart';
import '../../../services/field_test/field_test_logger.dart'; import '../../../services/field_test/field_test_logger.dart';
import '../../../database/database.dart'; import '../../../database/database.dart';
@ -27,6 +28,9 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
late TextEditingController _communityEndpointController; late TextEditingController _communityEndpointController;
bool _isTestingTomTom = false; bool _isTestingTomTom = false;
bool _isTestingCommunity = false; bool _isTestingCommunity = false;
late TextEditingController _githubTokenController;
bool _isTestingGitHub = false;
bool _obscureGitHubToken = true;
@override @override
void initState() { void initState() {
@ -40,6 +44,9 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
_communityEndpointController = TextEditingController( _communityEndpointController = TextEditingController(
text: ref.read(communityEndpointUrlProvider), text: ref.read(communityEndpointUrlProvider),
); );
_githubTokenController = TextEditingController(
text: ref.read(githubAuthServiceProvider).getToken() ?? '',
);
} }
@override @override
@ -47,6 +54,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
_tomtomKeyController.dispose(); _tomtomKeyController.dispose();
_communityKeyController.dispose(); _communityKeyController.dispose();
_communityEndpointController.dispose(); _communityEndpointController.dispose();
_githubTokenController.dispose();
super.dispose(); super.dispose();
} }
@ -149,6 +157,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
final sensorsCard = _buildSensorsCalibrationCard(context, ref); final sensorsCard = _buildSensorsCalibrationCard(context, ref);
final tomtomCard = _buildTomTomCard(context, ref); final tomtomCard = _buildTomTomCard(context, ref);
final communityCard = _buildCommunityCard(context, ref); final communityCard = _buildCommunityCard(context, ref);
final githubCard = _buildGitHubCard(context, ref);
final dataCard = _buildDataManagementCard(context, ref); final dataCard = _buildDataManagementCard(context, ref);
final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref); final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref);
final versionFooter = _buildVersionFooter(context); final versionFooter = _buildVersionFooter(context);
@ -172,6 +181,8 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
osmAccountCard, osmAccountCard,
const SizedBox(height: 16), const SizedBox(height: 16),
tomtomCard, tomtomCard,
const SizedBox(height: 16),
githubCard,
], ],
), ),
), ),
@ -950,6 +961,144 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
); );
} }
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) { Widget _buildTomTomCard(BuildContext context, WidgetRef ref) {
final tomtomStatus = ref.watch(tomtomStatusProvider); final tomtomStatus = ref.watch(tomtomStatusProvider);

View File

@ -2,6 +2,8 @@ import '../services/validation/telemetry_validator.dart';
class TelemetrySample { class TelemetrySample {
final DateTime timestamp; final DateTime timestamp;
final int? sequenceNumber;
final int? monotonicTimestampMs;
// Location Data // Location Data
final double? latitude; final double? latitude;
@ -17,6 +19,7 @@ class TelemetrySample {
final DateTime? imuTimestamp; final DateTime? imuTimestamp;
final double? leanAngle; final double? leanAngle;
final double? pitchAngle; final double? pitchAngle;
final double? rollRateDegPerSec;
final double? accelX; final double? accelX;
final double? accelY; final double? accelY;
final double? accelZ; final double? accelZ;
@ -26,11 +29,21 @@ class TelemetrySample {
final double? gyroY; final double? gyroY;
final double? gyroZ; 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 // Validation & Quality
final TelemetryQuality? quality; final TelemetryQuality? quality;
TelemetrySample({ TelemetrySample({
required this.timestamp, required this.timestamp,
this.sequenceNumber,
this.monotonicTimestampMs,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.altitude, this.altitude,
@ -42,6 +55,7 @@ class TelemetrySample {
this.imuTimestamp, this.imuTimestamp,
this.leanAngle, this.leanAngle,
this.pitchAngle, this.pitchAngle,
this.rollRateDegPerSec,
this.accelX, this.accelX,
this.accelY, this.accelY,
this.accelZ, this.accelZ,
@ -50,11 +64,19 @@ class TelemetrySample {
this.gyroX, this.gyroX,
this.gyroY, this.gyroY,
this.gyroZ, this.gyroZ,
this.rawAccelX,
this.rawAccelY,
this.rawAccelZ,
this.rawGyroX,
this.rawGyroY,
this.rawGyroZ,
this.quality, this.quality,
}); });
TelemetrySample copyWith({ TelemetrySample copyWith({
DateTime? timestamp, DateTime? timestamp,
int? sequenceNumber,
int? monotonicTimestampMs,
double? latitude, double? latitude,
double? longitude, double? longitude,
double? altitude, double? altitude,
@ -66,6 +88,7 @@ class TelemetrySample {
DateTime? imuTimestamp, DateTime? imuTimestamp,
double? leanAngle, double? leanAngle,
double? pitchAngle, double? pitchAngle,
double? rollRateDegPerSec,
double? accelX, double? accelX,
double? accelY, double? accelY,
double? accelZ, double? accelZ,
@ -74,10 +97,18 @@ class TelemetrySample {
double? gyroX, double? gyroX,
double? gyroY, double? gyroY,
double? gyroZ, double? gyroZ,
double? rawAccelX,
double? rawAccelY,
double? rawAccelZ,
double? rawGyroX,
double? rawGyroY,
double? rawGyroZ,
TelemetryQuality? quality, TelemetryQuality? quality,
}) { }) {
return TelemetrySample( return TelemetrySample(
timestamp: timestamp ?? this.timestamp, timestamp: timestamp ?? this.timestamp,
sequenceNumber: sequenceNumber ?? this.sequenceNumber,
monotonicTimestampMs: monotonicTimestampMs ?? this.monotonicTimestampMs,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
altitude: altitude ?? this.altitude, altitude: altitude ?? this.altitude,
@ -89,6 +120,7 @@ class TelemetrySample {
imuTimestamp: imuTimestamp ?? this.imuTimestamp, imuTimestamp: imuTimestamp ?? this.imuTimestamp,
leanAngle: leanAngle ?? this.leanAngle, leanAngle: leanAngle ?? this.leanAngle,
pitchAngle: pitchAngle ?? this.pitchAngle, pitchAngle: pitchAngle ?? this.pitchAngle,
rollRateDegPerSec: rollRateDegPerSec ?? this.rollRateDegPerSec,
accelX: accelX ?? this.accelX, accelX: accelX ?? this.accelX,
accelY: accelY ?? this.accelY, accelY: accelY ?? this.accelY,
accelZ: accelZ ?? this.accelZ, accelZ: accelZ ?? this.accelZ,
@ -97,6 +129,12 @@ class TelemetrySample {
gyroX: gyroX ?? this.gyroX, gyroX: gyroX ?? this.gyroX,
gyroY: gyroY ?? this.gyroY, gyroY: gyroY ?? this.gyroY,
gyroZ: gyroZ ?? this.gyroZ, 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, quality: quality ?? this.quality,
); );
} }

View File

@ -8,16 +8,32 @@ final calibrationServiceProvider = Provider<CalibrationService>((ref) {
class CalibrationService { class CalibrationService {
static const _pitchKey = 'calibration_pitch'; static const _pitchKey = 'calibration_pitch';
static const _rollKey = 'calibration_roll'; 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<void> saveCalibrationOffsets(double pitch, double roll, {int? motorcycleId}) async { Future<void> 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(); final prefs = await SharedPreferences.getInstance();
if (motorcycleId != null) { if (motorcycleId != null) {
await prefs.setDouble('calibration_pitch_$motorcycleId', pitch); await prefs.setDouble('calibration_pitch_$motorcycleId', pitch);
await prefs.setDouble('calibration_roll_$motorcycleId', roll); 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 // Also save global fallback
await prefs.setDouble(_pitchKey, pitch); await prefs.setDouble(_pitchKey, pitch);
await prefs.setDouble(_rollKey, roll); await prefs.setDouble(_rollKey, roll);
await prefs.setDouble(_gyroBiasXKey, gyroBiasX);
await prefs.setDouble(_gyroBiasYKey, gyroBiasY);
await prefs.setDouble(_gyroBiasZKey, gyroBiasZ);
} }
Future<Map<String, double>> loadCalibrationOffsets({int? motorcycleId}) async { Future<Map<String, double>> loadCalibrationOffsets({int? motorcycleId}) async {
@ -25,12 +41,30 @@ class CalibrationService {
if (motorcycleId != null) { if (motorcycleId != null) {
final bikePitch = prefs.getDouble('calibration_pitch_$motorcycleId'); final bikePitch = prefs.getDouble('calibration_pitch_$motorcycleId');
final bikeRoll = prefs.getDouble('calibration_roll_$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) { 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 pitch = prefs.getDouble(_pitchKey) ?? 0.0;
final roll = prefs.getDouble(_rollKey) ?? 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,
};
} }
} }

View File

@ -177,6 +177,18 @@ class FieldTestExportService {
final appIdentity = AppIdentity.current(); 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 // 1. README.txt
final warningCount = events.where((e) => e.severity == 'WARNING').length; final warningCount = events.where((e) => e.severity == 'WARNING').length;
final errorCount = events.where((e) => e.severity == 'ERROR' || e.severity == 'CRITICAL').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('Total Forensic Events: ${events.length}');
readmeContent.writeln('Major Warnings Count: $warningCount'); readmeContent.writeln('Major Warnings Count: $warningCount');
readmeContent.writeln('Major Errors Count: $errorCount'); 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('=====================================================');
readmeContent.writeln('Files Included:'); readmeContent.writeln('Files Included:');
readmeContent.writeln(' - field_test_session.json : Full structured session dump with software identity'); readmeContent.writeln(' - field_test_session.json : Full structured session dump with software identity');
@ -235,8 +257,14 @@ class FieldTestExportService {
'topSpeedMph': ride.topSpeedMph, 'topSpeedMph': ride.topSpeedMph,
'maxLeanLeft': ride.maxLeanLeft, 'maxLeanLeft': ride.maxLeanLeft,
'maxLeanRight': ride.maxLeanRight, 'maxLeanRight': ride.maxLeanRight,
'maxMovingLeanLeft': ride.maxLeanLeft,
'maxMovingLeanRight': ride.maxLeanRight,
'maxAccelG': ride.maxAccelG, 'maxAccelG': ride.maxAccelG,
'maxBrakingG': ride.maxBrakingG, 'maxBrakingG': ride.maxBrakingG,
'maxMovingAccelG': ride.maxAccelG,
'maxMovingBrakingG': ride.maxBrakingG,
'maxObservedLean': maxObservedLean,
'maxObservedG': maxObservedG,
'averageSpeed': ride.averageSpeed, 'averageSpeed': ride.averageSpeed,
'movingTime': ride.movingTime, 'movingTime': ride.movingTime,
'stoppedTime': ride.stoppedTime, 'stoppedTime': ride.stoppedTime,

View File

@ -664,19 +664,28 @@ class FieldTestLogger {
worstAccuracy = s.gpsAccuracy; 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) { if (s.speed != null && s.speed! > maxSpeed && s.speed! < 223.7) {
maxSpeed = s.speed!; maxSpeed = s.speed!;
} }
if (s.leanAngle != null && s.leanAngle!.abs() > maxLean && s.leanAngle!.abs() <= 65.0) { if (s.leanAngle != null && s.leanAngle!.abs() <= 65.0) {
if (isMoving && s.leanAngle!.abs() > maxLean) {
maxLean = s.leanAngle!.abs(); maxLean = s.leanAngle!.abs();
} }
if (s.longitudinalG != null && s.longitudinalG!.abs() > maxLongG) { }
if (s.longitudinalG != null && s.longitudinalG!.abs() <= 2.5) {
if (isMoving && s.longitudinalG!.abs() > maxLongG) {
maxLongG = s.longitudinalG!.abs(); maxLongG = s.longitudinalG!.abs();
} }
if (s.lateralG != null && s.lateralG!.abs() > maxLatG) { }
if (s.lateralG != null && s.lateralG!.abs() <= 2.5) {
if (isMoving && s.lateralG!.abs() > maxLatG) {
maxLatG = s.lateralG!.abs(); maxLatG = s.lateralG!.abs();
} }
} }
}
final validPct = totalSamples > 0 ? (validSamples / totalSamples) * 100.0 : 100.0; final validPct = totalSamples > 0 ? (validSamples / totalSamples) * 100.0 : 100.0;
final suspectPct = totalSamples > 0 ? (suspectSamples / totalSamples) * 100.0 : 0.0; final suspectPct = totalSamples > 0 ? (suspectSamples / totalSamples) * 100.0 : 0.0;
@ -711,6 +720,8 @@ class FieldTestLogger {
timestampGaps: _telemetryGapCount, timestampGaps: _telemetryGapCount,
gpsFixCount: gpsFixCount, gpsFixCount: gpsFixCount,
totalGpsAccuracy: accuracySum, totalGpsAccuracy: accuracySum,
databaseSamplesLost: _droppedEventsCount,
imuSaturationCount: _imuSaturationCount,
isMountCalibrated: true, isMountCalibrated: true,
); );

View File

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

View File

@ -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<void> saveToken(String token) async {
final encoded = base64.encode(utf8.encode(token.trim()));
await _prefs.setString(_keyToken, encoded);
}
/// Clears stored token
Future<void> clearToken() async {
await _prefs.remove(_keyToken);
}
/// Target GitHub repository (default: bponders12-cell/ride_logs)
String getRepository() {
return _prefs.getString(_keyRepo) ?? defaultRepo;
}
Future<void> setRepository(String repo) async {
await _prefs.setString(_keyRepo, repo.trim());
}
/// Target branch (default: main)
String getBranch() {
return _prefs.getString(_keyBranch) ?? defaultBranch;
}
Future<void> setBranch(String branch) async {
await _prefs.setString(_keyBranch, branch.trim());
}
/// Tests connectivity and permissions against GitHub API
Future<Map<String, dynamic>> 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<GitHubAuthService>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return GitHubAuthService(prefs);
});

View File

@ -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<int> _uploadQueue = {};
GitHubSyncService(this._db, this._auth, this._prefs, [http.Client? client])
: _client = client ?? http.Client();
GitHubUploadState get state => _state;
String get statusMessage => _statusMessage;
Set<int> 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<void> 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<int>.from(_uploadQueue);
for (final rideId in toProcess) {
await uploadRide(rideId);
}
}
/// Synchronizes a specific ride session to GitHub repository
Future<GitHubUploadResult> 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 = <String, String>{
'$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 = <String, dynamic>{
'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<GitHubSyncService>((ref) {
final db = ref.watch(databaseProvider);
final auth = ref.watch(githubAuthServiceProvider);
final prefs = ref.watch(sharedPreferencesProvider);
return GitHubSyncService(db, auth, prefs);
});

View File

@ -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<CameraConnectionState> get stateStream;
Future<bool> connect();
Future<void> disconnect();
Future<bool> startRecording();
Future<bool> stopRecording();
Future<void> 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<CameraConnectionState>.broadcast();
@override
CameraConnectionState get connectionState => _state;
@override
Stream<CameraConnectionState> get stateStream => _stateController.stream;
final List<CameraTriggerEvent> capturedEvents = [];
@override
Future<bool> connect() async {
_state = CameraConnectionState.connected;
_stateController.add(_state);
return true;
}
@override
Future<void> disconnect() async {
_state = CameraConnectionState.disconnected;
_stateController.add(_state);
}
@override
Future<bool> startRecording() async {
if (_state == CameraConnectionState.connected) {
_state = CameraConnectionState.recording;
_stateController.add(_state);
return true;
}
return false;
}
@override
Future<bool> stopRecording() async {
if (_state == CameraConnectionState.recording) {
_state = CameraConnectionState.connected;
_stateController.add(_state);
return true;
}
return false;
}
@override
Future<void> onTelemetryEvent(CameraTriggerEvent event) async {
capturedEvents.add(event);
}
void dispose() {
_stateController.close();
}
}
final cameraProvider = Provider<CameraProvider>((ref) {
return MockCameraProvider();
});

View File

@ -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<ElectricalTelemetry> get electricalStream;
ElectricalTelemetry? get latestReading;
bool get isConnected;
Future<bool> connectBleSensor(String deviceAddress);
Future<void> disconnect();
}
/// Production stub / Mock implementation for pre-sensor readiness
class MockMotorcycleElectricalService implements MotorcycleElectricalService {
final _controller = StreamController<ElectricalTelemetry>.broadcast();
ElectricalTelemetry? _latest;
bool _connected = false;
@override
Stream<ElectricalTelemetry> get electricalStream => _controller.stream;
@override
ElectricalTelemetry? get latestReading => _latest;
@override
bool get isConnected => _connected;
@override
Future<bool> 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<void> disconnect() async {
_connected = false;
_latest = null;
}
void dispose() {
_controller.close();
}
}
final motorcycleElectricalServiceProvider = Provider<MotorcycleElectricalService>((ref) {
return MockMotorcycleElectricalService();
});

View File

@ -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) { void _handleRawPosition(Position position) {
if (_isValidPosition(position)) { if (_isValidPosition(position)) {
final filtered = _applyEmaFilter(position); final filtered = _applyEmaFilter(position);
@ -215,7 +232,7 @@ class LocationService {
final maxAcceptableAccuracy = _lastValidPosition == null ? 200.0 : (currentSpeed > 10.0 ? 100.0 : 80.0); final maxAcceptableAccuracy = _lastValidPosition == null ? 200.0 : (currentSpeed > 10.0 ? 100.0 : 80.0);
if (position.accuracy > maxAcceptableAccuracy) { if (position.accuracy > maxAcceptableAccuracy) {
onGpsDiagnostic?.call( _emitGpsDiagnostic(
position, position,
false, false,
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)', 'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
@ -224,7 +241,7 @@ class LocationService {
} }
if (position.speed > _maxRealisticSpeed) { if (position.speed > _maxRealisticSpeed) {
onGpsDiagnostic?.call( _emitGpsDiagnostic(
position, position,
false, false,
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)', 'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
@ -239,7 +256,7 @@ class LocationService {
if (timeDiff > 8.0) { if (timeDiff > 8.0) {
_lastValidPosition = position; _lastValidPosition = position;
_emaSpeed = position.speed; _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; return true;
} }
@ -247,7 +264,7 @@ class LocationService {
// Acceleration check // Acceleration check
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff; final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
if (accel > _maxAcceleration) { if (accel > _maxAcceleration) {
onGpsDiagnostic?.call( _emitGpsDiagnostic(
position, position,
false, false,
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)', 'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
@ -264,7 +281,7 @@ class LocationService {
); );
final derivedSpeedMps = distMeters / timeDiff; final derivedSpeedMps = distMeters / timeDiff;
if (derivedSpeedMps > _maxRealisticSpeed && distMeters > 50.0) { if (derivedSpeedMps > _maxRealisticSpeed && distMeters > 50.0) {
onGpsDiagnostic?.call( _emitGpsDiagnostic(
position, position,
false, false,
'GPS position teleportation (${distMeters.toStringAsFixed(0)}m in ${timeDiff.toStringAsFixed(1)}s)', 'GPS position teleportation (${distMeters.toStringAsFixed(0)}m in ${timeDiff.toStringAsFixed(1)}s)',
@ -275,7 +292,7 @@ class LocationService {
} }
_lastValidPosition = position; _lastValidPosition = position;
onGpsDiagnostic?.call(position, true, null); _emitGpsDiagnostic(position, true, null);
return true; return true;
} }

View File

@ -23,9 +23,15 @@ final gForceStreamProvider = StreamProvider<GForceData>((ref) {
class GForceData { class GForceData {
final double longitudinal; // Accel/braking (G's) final double longitudinal; // Accel/braking (G's)
final double lateral; // Left/right (G's) final double lateral; // Left/right (G's)
final double accelX; // Raw m/s^2 final double accelX; // User accel m/s^2
final double accelY; // Raw m/s^2 final double accelY; // User accel m/s^2
final double accelZ; // Raw 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({ GForceData({
required this.longitudinal, required this.longitudinal,
@ -33,6 +39,12 @@ class GForceData {
required this.accelX, required this.accelX,
required this.accelY, required this.accelY,
required this.accelZ, 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? _gyroSub;
StreamSubscription? _userAccelSub; StreamSubscription? _userAccelSub;
// Calibration offsets // Calibration offsets & gyro zero-rate bias
double _rollOffset = 0.0; double _rollOffset = 0.0;
double _pitchOffset = 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 // Sensor fusion state
double _currentRoll = 0.0; // The lean angle double _currentRoll = 0.0; // The lean angle
@ -63,7 +95,6 @@ class SensorFusionService with WidgetsBindingObserver {
bool _isLandscape = false; bool _isLandscape = false;
bool _isLandscapeLeft = true; bool _isLandscapeLeft = true;
static const double _baseAlpha = 0.94; // Base complementary filter gain
static const double _gravityConst = 9.80665; static const double _gravityConst = 9.80665;
// Low-pass filter for G-Force to suppress motorcycle vibration spikes // Low-pass filter for G-Force to suppress motorcycle vibration spikes
@ -103,12 +134,19 @@ class SensorFusionService with WidgetsBindingObserver {
final offsets = await _calibrationService.loadCalibrationOffsets(); final offsets = await _calibrationService.loadCalibrationOffsets();
_pitchOffset = offsets['pitch'] ?? 0.0; _pitchOffset = offsets['pitch'] ?? 0.0;
_rollOffset = offsets['roll'] ?? 0.0; _rollOffset = offsets['roll'] ?? 0.0;
_gyroBiasX = offsets['gyroBiasX'] ?? 0.0;
_gyroBiasY = offsets['gyroBiasY'] ?? 0.0;
_gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0;
} catch (_) {} } catch (_) {}
} }
void _startSensors() { void _startSensors() {
_gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) { _gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) {
final now = DateTime.now(); final now = DateTime.now();
_latestRawGyroX = event.x;
_latestRawGyroY = event.y;
_latestRawGyroZ = event.z;
if (_lastGyroTime == null) { if (_lastGyroTime == null) {
_lastGyroTime = now; _lastGyroTime = now;
return; return;
@ -121,29 +159,39 @@ class SensorFusionService with WidgetsBindingObserver {
if (dt > 0.1) dt = 0.1; if (dt > 0.1) dt = 0.1;
if (dt <= 0.0) return; 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 // Transform gyro rates into Vehicle Coordinate Frame based on screen orientation
double vGyroRollRate; // Rotation around vehicle forward axis (lean rate) double vGyroRollRate; // Rotation around vehicle forward axis (lean rate)
double vGyroPitchRate; // Rotation around vehicle transverse axis (pitch rate) double vGyroPitchRate; // Rotation around vehicle transverse axis (pitch rate)
if (!_isLandscape) { if (!_isLandscape) {
// Portrait // Portrait
vGyroPitchRate = event.x * (180.0 / math.pi); vGyroPitchRate = unbiasedGx * (180.0 / math.pi);
vGyroRollRate = -event.y * (180.0 / math.pi); vGyroRollRate = -unbiasedGy * (180.0 / math.pi);
} else if (_isLandscapeLeft) { } else if (_isLandscapeLeft) {
// Landscape Left // Landscape Left
vGyroPitchRate = event.y * (180.0 / math.pi); vGyroPitchRate = unbiasedGy * (180.0 / math.pi);
vGyroRollRate = event.x * (180.0 / math.pi); vGyroRollRate = unbiasedGx * (180.0 / math.pi);
} else { } else {
// Landscape Right // Landscape Right
vGyroPitchRate = -event.y * (180.0 / math.pi); vGyroPitchRate = -unbiasedGy * (180.0 / math.pi);
vGyroRollRate = -event.x * (180.0 / math.pi); vGyroRollRate = -unbiasedGx * (180.0 / math.pi);
} }
_latestRollRateDegPerSec = vGyroRollRate;
_currentPitch = (_currentPitch + vGyroPitchRate * dt).clamp(-90.0, 90.0); _currentPitch = (_currentPitch + vGyroPitchRate * dt).clamp(-90.0, 90.0);
_currentRoll = (_currentRoll + vGyroRollRate * 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) { _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 // Auto-detect landscape sub-type if in landscape
if (_isLandscape && event.x.abs() > 3.0) { if (_isLandscape && event.x.abs() > 3.0) {
_isLandscapeLeft = event.x > 0; _isLandscapeLeft = event.x > 0;
@ -176,13 +224,8 @@ class SensorFusionService with WidgetsBindingObserver {
final gMag = norm / _gravityConst; final gMag = norm / _gravityConst;
final dynamicDelta = (gMag - 1.0).abs(); final dynamicDelta = (gMag - 1.0).abs();
// If dynamic force > 0.08g, trust gyro to prevent vehicle acceleration from distorting lean // Continuous dynamic adaptation: smoothly shifts reliance to gyro when total G deviates from 1.0g
double dynamicAlpha = _baseAlpha; final dynamicAlpha = (0.94 + 1.2 * dynamicDelta).clamp(0.94, 0.998);
if (dynamicDelta > 0.08) {
dynamicAlpha = 0.998;
} else if (dynamicDelta > 0.04) {
dynamicAlpha = 0.985;
}
_currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0); _currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0);
_currentPitch = (dynamicAlpha * _currentPitch + (1.0 - dynamicAlpha) * accelPitch).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, accelX: event.x,
accelY: event.y, accelY: event.y,
accelZ: event.z, 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); final offsets = await _calibrationService.loadCalibrationOffsets(motorcycleId: motorcycleId);
_pitchOffset = offsets['pitch'] ?? 0.0; _pitchOffset = offsets['pitch'] ?? 0.0;
_rollOffset = offsets['roll'] ?? 0.0; _rollOffset = offsets['roll'] ?? 0.0;
_gyroBiasX = offsets['gyroBiasX'] ?? 0.0;
_gyroBiasY = offsets['gyroBiasY'] ?? 0.0;
_gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0;
} catch (_) {} } catch (_) {}
} }
void calibrateUpright({int? motorcycleId}) { void calibrateUpright({int? motorcycleId}) {
_rollOffset = _currentRoll; _rollOffset = _currentRoll;
_pitchOffset = _currentPitch; _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() { void dispose() {

View File

@ -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<TelemetrySample> generateScenario(SimulationScenarioType scenario, {DateTime? baseTime}) {
final t0 = baseTime ?? DateTime(2026, 8, 29, 12, 0, 0);
final List<TelemetrySample> 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<TelemetrySample> samples) {
final validator = TelemetryValidator();
int validCount = 0;
int suspectCount = 0;
int rejectedCount = 0;
final List<String> 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<String> 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;
}

View File

@ -50,6 +50,12 @@ class TelemetryService {
double _latestCompassHeading = 0.0; double _latestCompassHeading = 0.0;
DateTime? _latestImuTimestamp; DateTime? _latestImuTimestamp;
// Monotonic authoritative clock
final Stopwatch _monotonicStopwatch = Stopwatch();
int _sequenceCounter = 0;
int _lastEmitMonotonicMs = 0;
int get lastEmitMonotonicMs => _lastEmitMonotonicMs;
TelemetryService( TelemetryService(
this._locationService, this._locationService,
this._sensorFusionService, this._sensorFusionService,
@ -57,6 +63,7 @@ class TelemetryService {
this._fieldTestLogger, this._fieldTestLogger,
this._validator, this._validator,
) { ) {
_monotonicStopwatch.start();
_init(); _init();
} }
@ -109,6 +116,9 @@ class TelemetryService {
void _emitSample() { void _emitSample() {
final now = DateTime.now(); final now = DateTime.now();
final monotonicMs = _monotonicStopwatch.elapsedMilliseconds;
final sequence = ++_sequenceCounter;
final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0; final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0;
final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null; final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null;
@ -120,6 +130,8 @@ class TelemetryService {
final rawSample = TelemetrySample( final rawSample = TelemetrySample(
timestamp: now, timestamp: now,
sequenceNumber: sequence,
monotonicTimestampMs: monotonicMs,
latitude: _latestPosition?.latitude, latitude: _latestPosition?.latitude,
longitude: _latestPosition?.longitude, longitude: _latestPosition?.longitude,
altitude: _latestPosition?.altitude, altitude: _latestPosition?.altitude,
@ -131,13 +143,22 @@ class TelemetryService {
imuTimestamp: _latestImuTimestamp, imuTimestamp: _latestImuTimestamp,
leanAngle: _latestLean, leanAngle: _latestLean,
pitchAngle: _latestPitch, pitchAngle: _latestPitch,
rollRateDegPerSec: _sensorFusionService.latestRollRateDegPerSec,
accelX: _latestGForce?.accelX, accelX: _latestGForce?.accelX,
accelY: _latestGForce?.accelY, accelY: _latestGForce?.accelY,
accelZ: _latestGForce?.accelZ, accelZ: _latestGForce?.accelZ,
longitudinalG: _latestGForce?.longitudinal, longitudinalG: _latestGForce?.longitudinal,
lateralG: _latestGForce?.lateral, 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 // Validate sample
final quality = _validator.validate(rawSample); final quality = _validator.validate(rawSample);
final validatedSample = rawSample.copyWith(quality: quality); final validatedSample = rawSample.copyWith(quality: quality);

View File

@ -52,11 +52,12 @@ final telemetryValidatorProvider = Provider<TelemetryValidator>((ref) {
class TelemetryValidator { class TelemetryValidator {
DateTime? _lastTimestamp; DateTime? _lastTimestamp;
DateTime? _lastGpsTimestamp; int? _lastMonotonicMs;
DateTime? _lastGpsLocationTime;
double? _lastGpsLat;
double? _lastGpsLng;
double? _lastSpeedMph; double? _lastSpeedMph;
double? _lastLeanAngle; double? _lastLeanAngle;
double? _lastLat;
double? _lastLng;
// Diagnostic counters // Diagnostic counters
int _totalValidated = 0; int _totalValidated = 0;
@ -73,11 +74,12 @@ class TelemetryValidator {
void reset() { void reset() {
_lastTimestamp = null; _lastTimestamp = null;
_lastGpsTimestamp = null; _lastMonotonicMs = null;
_lastGpsLocationTime = null;
_lastGpsLat = null;
_lastGpsLng = null;
_lastSpeedMph = null; _lastSpeedMph = null;
_lastLeanAngle = null; _lastLeanAngle = null;
_lastLat = null;
_lastLng = null;
_totalValidated = 0; _totalValidated = 0;
_validCount = 0; _validCount = 0;
_suspectCount = 0; _suspectCount = 0;
@ -87,8 +89,8 @@ class TelemetryValidator {
TelemetryQuality validate(TelemetrySample sample) { TelemetryQuality validate(TelemetrySample sample) {
_totalValidated++; _totalValidated++;
final reasons = <String>[]; ValidationStatus status = ValidationStatus.valid;
var status = ValidationStatus.valid; final List<String> reasons = [];
final currentSpeed = sample.speed ?? 0.0; final currentSpeed = sample.speed ?? 0.0;
// 1. Numeric Sanity (NaN / Infinity) // 1. Numeric Sanity (NaN / Infinity)
@ -108,9 +110,21 @@ class TelemetryValidator {
); );
} }
// 2. Timestamp Validation // 2. Timestamp & Monotonicity Validation
int deltaMs = 50; 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; deltaMs = sample.timestamp.difference(_lastTimestamp!).inMilliseconds;
if (deltaMs <= 0) { if (deltaMs <= 0) {
status = ValidationStatus.suspect; status = ValidationStatus.suspect;
@ -165,23 +179,32 @@ class TelemetryValidator {
} }
} }
// GPS Position Teleportation Check // GPS Position Teleportation Check across distinct location updates
if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null) { if (_lastGpsLat != null && _lastGpsLng != null && sample.latitude != null && sample.longitude != null) {
final dtSec = (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp != _lastGpsTimestamp) if (sample.latitude != _lastGpsLat || sample.longitude != _lastGpsLng) {
? sample.gpsTimestamp!.difference(_lastGpsTimestamp!).inMilliseconds / 1000.0 final dtSec = _lastGpsLocationTime != null
: (deltaMs > 0 ? deltaMs / 1000.0 : 0.05); ? math.max(0.05, sample.timestamp.difference(_lastGpsLocationTime!).inMilliseconds / 1000.0)
: 1.0;
if (dtSec > 0.02 && dtSec < 5.0) { final latDiff = (sample.latitude! - _lastGpsLat!).abs() * 111139.0;
final latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0; final lngDiff = (sample.longitude! - _lastGpsLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff); final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
final derivedSpeedMps = distMeters / dtSec; final derivedSpeedMps = distMeters / dtSec;
if (derivedSpeedMps > 110.0 && distMeters > 50.0) { if (derivedSpeedMps > 110.0 && distMeters > 50.0) {
status = ValidationStatus.rejected; status = ValidationStatus.rejected;
gpsConf = 0.0; gpsConf = 0.0;
reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${(dtSec * 1000).toStringAsFixed(0)}ms)'); 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 // 4. Speed Plausibility & Confidence
@ -262,12 +285,10 @@ class TelemetryValidator {
// Update state tracking // Update state tracking
_lastTimestamp = sample.timestamp; _lastTimestamp = sample.timestamp;
_lastMonotonicMs = sample.monotonicTimestampMs;
if (status != ValidationStatus.rejected) { if (status != ValidationStatus.rejected) {
_lastSpeedMph = currentSpeed; _lastSpeedMph = currentSpeed;
_lastLeanAngle = currentLean; _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( return TelemetryQuality(
@ -316,6 +337,15 @@ class RideQualityReport {
final int timestampGaps; final int timestampGaps;
final bool isMountCalibrated; 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<String> diagnosticExplanations;
const RideQualityReport({ const RideQualityReport({
required this.overallScore, required this.overallScore,
required this.rating, required this.rating,
@ -328,6 +358,13 @@ class RideQualityReport {
required this.rejectedSamples, required this.rejectedSamples,
required this.timestampGaps, required this.timestampGaps,
required this.isMountCalibrated, 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({ static RideQualityReport evaluate({
@ -338,6 +375,8 @@ class RideQualityReport {
required int timestampGaps, required int timestampGaps,
required int gpsFixCount, required int gpsFixCount,
required double totalGpsAccuracy, required double totalGpsAccuracy,
int databaseSamplesLost = 0,
int imuSaturationCount = 0,
bool isMountCalibrated = true, bool isMountCalibrated = true,
}) { }) {
if (totalSamples == 0) { if (totalSamples == 0) {
@ -353,6 +392,13 @@ class RideQualityReport {
rejectedSamples: 0, rejectedSamples: 0,
timestampGaps: 0, timestampGaps: 0,
isMountCalibrated: false, 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 gpsCoverage = gpsFixCount > 0 ? math.min(1.0, (gpsFixCount * 2) / totalSamples) : 0.0;
final avgAccuracy = gpsFixCount > 0 ? (totalGpsAccuracy / gpsFixCount) : 999.0; final avgAccuracy = gpsFixCount > 0 ? (totalGpsAccuracy / gpsFixCount) : 999.0;
// Base score calculation (0 - 100) // 1. Timing Integrity: Deduct for timestamp gaps / irregularity
double score = (validRatio * 50.0) + (gpsCoverage * 30.0); int timingScore = 100;
if (timestampGaps > 0) {
if (avgAccuracy <= 5.0) { timingScore = math.max(0, 100 - (timestampGaps * 5));
score += 20.0;
} else if (avgAccuracy <= 15.0) {
score += 15.0;
} else if (avgAccuracy <= 35.0) {
score += 8.0;
} else {
score += 2.0;
} }
// 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<String> 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 (!isMountCalibrated) score -= 10.0;
if (timestampGaps > 5) score -= math.min(15.0, timestampGaps * 2.0); if (rejectedSamples > 0) score -= math.min(15.0, (rejectedSamples / totalSamples) * 100.0);
if (rejectedSamples > 0) score -= math.min(20.0, (rejectedSamples / totalSamples) * 100.0);
final finalScore = score.round().clamp(0, 100); final finalScore = score.round().clamp(0, 100);
@ -404,6 +490,13 @@ class RideQualityReport {
rejectedSamples: rejectedSamples, rejectedSamples: rejectedSamples,
timestampGaps: timestampGaps, timestampGaps: timestampGaps,
isMountCalibrated: isMountCalibrated, isMountCalibrated: isMountCalibrated,
timingIntegrityScore: timingScore,
gpsQualityScore: gpsScore,
imuContinuityScore: imuScore,
sensorContinuityScore: 100,
databaseIntegrityScore: dbScore,
filterHealthScore: filterScore,
diagnosticExplanations: explanations,
); );
} }
} }

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@ -100,10 +100,9 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: assets:
# assets: - assets/images/motorcycles/
# - images/a_dot_burr.jpeg - assets/screens/
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

View File

@ -1,13 +1,13 @@
{ {
"application": "MotorSignal", "application": "MotorSignal",
"version": "1.0.0", "version": "1.0.2",
"build": 100, "build": 102,
"gitCommit": "e700ca64fdb6365d30a76d81236c912a730bfad2", "gitCommit": "HEAD",
"gitBranch": "master", "gitBranch": "master",
"releaseChannel": "release", "releaseChannel": "release",
"telemetrySchema": 5, "telemetrySchema": 5,
"exportSchema": 1, "exportSchema": 1,
"buildTimestamp": "2026-08-28T05:00:00Z", "buildTimestamp": "2026-08-30T08:00:00Z",
"flutterVersion": "3.x / SDK ^3.12.2", "flutterVersion": "3.x / SDK ^3.12.2",
"dartVersion": "3.x" "dartVersion": "3.x"
} }

View File

@ -64,6 +64,20 @@ class _NullSensorFusionService extends Fake implements SensorFusionService {
Stream<double> get pitchAngleStream => const Stream.empty(); Stream<double> get pitchAngleStream => const Stream.empty();
@override @override
Stream<GForceData> get gForceStream => const Stream.empty(); Stream<GForceData> 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 { class _NullCompassService extends Fake implements CompassService {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:motorcycle_dash/core/version/app_identity.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', () { test('3. Git SHA is captured with valid 40-char full hash representation', () {
final identity = AppIdentity.current(); final identity = AppIdentity.current();
expect(identity.gitCommit, isNotEmpty); 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 // 4. Missing Git metadata handling
@ -80,7 +81,7 @@ void main() {
final identity = AppIdentity.current(); final identity = AppIdentity.current();
expect(identity.telemetrySchemaVersion, 5); expect(identity.telemetrySchemaVersion, 5);
expect(identity.exportSchemaVersion, 1); 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 // Verify changing app version does not change schema versions
const customIdentity = AppIdentity( const customIdentity = AppIdentity(
semanticVersion: '2.0.0', semanticVersion: '2.0.0',
@ -101,17 +102,17 @@ void main() {
const rideId = 777; const rideId = 777;
logger.startSession(rideId, notes: 'Field test readiness session'); logger.startSession(rideId, notes: 'Field test readiness session');
expect(logger.sessionIdentity.semanticVersion, '1.0.0'); expect(logger.sessionIdentity.semanticVersion, '1.0.2');
expect(logger.sessionIdentity.buildNumber, 100); expect(logger.sessionIdentity.buildNumber, 102);
expect(logger.sessionIdentity.telemetrySchemaVersion, 5); expect(logger.sessionIdentity.telemetrySchemaVersion, 5);
await logger.flush(); await logger.flush();
final events = await (db.select(db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get(); final events = await (db.select(db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get();
final startEvent = events.firstWhere((e) => e.eventType == 'RIDE_STARTED'); 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!); final meta = jsonDecode(startEvent.metadata!);
expect(meta['software']['version'], '1.0.0'); expect(meta['software']['version'], '1.0.2');
expect(meta['software']['build'], 100); expect(meta['software']['build'], 102);
}); });
// 8. Export metadata verification // 8. Export metadata verification
@ -132,8 +133,8 @@ void main() {
final Map<String, dynamic> data = jsonDecode(jsonString); final Map<String, dynamic> data = jsonDecode(jsonString);
expect(data['software'], isNotNull); expect(data['software'], isNotNull);
expect(data['software']['version'], '1.0.0'); expect(data['software']['version'], '1.0.2');
expect(data['software']['build'], 100); expect(data['software']['build'], 102);
expect(data['software']['telemetrySchemaVersion'], 5); expect(data['software']['telemetrySchemaVersion'], 5);
expect(data['software']['exportSchemaVersion'], 1); expect(data['software']['exportSchemaVersion'], 1);
}); });
@ -148,17 +149,17 @@ void main() {
// 10. Version metadata consistency // 10. Version metadata consistency
test('10. AppInfo and AppIdentity maintain consistent fallback and single source of truth', () { test('10. AppInfo and AppIdentity maintain consistent fallback and single source of truth', () {
final identity = AppInfo.identity; final identity = AppInfo.identity;
expect(AppInfo.fallbackVersion, '1.0.0+100'); expect(AppInfo.fallbackVersion, '1.0.2+102');
expect(identity.semanticVersion, '1.0.0'); expect(identity.semanticVersion, '1.0.2');
expect(identity.buildNumber, 100); expect(identity.buildNumber, 102);
}); });
// 11. Release manifest generation // 11. Release manifest generation
test('11. Release manifest contains all required metadata attributes', () { test('11. Release manifest contains all required metadata attributes', () {
final manifest = AppIdentity.current().generateReleaseManifest(); final manifest = AppIdentity.current().generateReleaseManifest();
expect(manifest['application'], 'MotorSignal'); expect(manifest['application'], 'MotorSignal');
expect(manifest['version'], '1.0.0'); expect(manifest['version'], '1.0.2');
expect(manifest['build'], 100); expect(manifest['build'], 102);
expect(manifest['gitCommit'], isNotEmpty); expect(manifest['gitCommit'], isNotEmpty);
expect(manifest['telemetrySchema'], 5); expect(manifest['telemetrySchema'], 5);
expect(manifest['exportSchema'], 1); expect(manifest['exportSchema'], 1);

View File

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