release(v1.0.2): maintenance fixes, responsive handling, interactive events, multi-layer map & github sync
This commit is contained in:
parent
f4d8883e2f
commit
cfc9dfb675
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -11,11 +11,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
> - `+BUILD` → an auto-incrementing integer consumed by `versionCode` (Android) and
|
||||
> `CFBundleVersion` (iOS). It is bumped independently of the user-facing version.
|
||||
|
||||
## [Unreleased]
|
||||
## [1.0.2] - 2026-08-30
|
||||
|
||||
### Fixed
|
||||
- **Ride Summary Session Resets**: Explicitly zeroed all handling maxima (`maxLeanLeft`, `maxLeanRight`, `maxAccelG`, `maxBrakingG`, `averageSpeedMph`) on consecutive `startRide()` calls without requiring an application restart.
|
||||
- **Handling Section Responsive Layout**: Overhauled `EvolutionRideDetailsView` Handling section to dynamically scale and wrap metrics across mobile portrait and wide landscape displays without text clipping or card overflow.
|
||||
- **Precision Millisecond Exporting**: Ensured high-resolution sub-second timestamp formatting across SQLite exports and `telemetry.csv`.
|
||||
|
||||
### Added
|
||||
- **Garage Photographic Reference Engine**: Extensible `MotorcycleImageService` delivering photographic reference imagery for registered models (including 2001 Yamaha YZF-R6) with automatic fallback to high-tech vector schematics.
|
||||
- **Interactive Forensic Events**: All event list items in ride details and maps now feature interactive tap-to-inspect dialogs displaying severity badges, timestamps, G-forces, lean angles, coordinates, and "View on Map" navigation.
|
||||
- **Route Preview Multi-Layer Map**: Interactive toggle layers (`Route`, `Speed`, `Accel`, `Braking`, `Cornering`) with color-coded speed brackets (0-25, 25-50, 50-75, 75-100, 100+ MPH) and floating legend.
|
||||
- **Offline-First GitHub Ride Log Sync**: Asynchronous background upload service to `bponders12-cell/ride_logs` (branch `main`) with token security, upload queue, retry backoff, and manual "Upload Now" controls.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-08-27
|
||||
## [1.0.1] - 2026-08-29
|
||||
|
||||
### Fixed
|
||||
- **Authoritative 20 Hz Clock**: Synchronized telemetry emission to a single 20 Hz periodic timer with strictly monotonic timestamps and atomic sequence numbers.
|
||||
- **Sub-Millisecond Timestamp Persistence**: High-resolution SQLite timestamp serialization eliminating whole-second truncation artifacts.
|
||||
- **Speed-Gated Performance Classification**: Enforced `speed >= 3.0 MPH` gating on lean angle, acceleration, and braking records to prevent stationary kickstand and phone-removal handling from contaminating riding Personal Records.
|
||||
- **GPS Diagnostic Consolidation**: Added a 500 ms debounce to GPS jump rejection logs, preventing duplicate error entries for single satellite multipath anomalies.
|
||||
- **Dashboard Lean Needle Alignment**: Perfectly centered the lean gauge arc and zero-degree reference needle across portrait and landscape layouts.
|
||||
- **Report & Summary Terminology**: Explicitly separated Qualified Moving Performance metrics from Raw Observed Sensor Peaks in all UI screens, `README.txt`, and `ride_summary.json`.
|
||||
|
||||
### Added
|
||||
- **Production Versioning & Release Management**: Standardized `pubspec.yaml` `1.0.1+101` authoritative versioning, Git commit embedding, release manifest generation, and versioned APK naming (`MotorSignal-v1.0.1-build101-release.apk`).
|
||||
- **Forensic Field Test Logger & Exporter**: 15-column timeline event logging, zip export packaging, and automated ride quality validation scoring.
|
||||
|
||||
### Added & Fixed
|
||||
- **Landscape IMU Coordinate Transformation**: Dynamic sensor coordinate frame rotation adapting automatically to portrait and landscape mounting orientations.
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
|
|
@ -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.
|
||||
|
|
@ -6,7 +6,7 @@ class AppInfo {
|
|||
AppInfo._();
|
||||
|
||||
/// Hard-coded fallback mirroring pubspec.yaml and AppIdentity
|
||||
static const String fallbackVersion = '1.0.0+100';
|
||||
static const String fallbackVersion = '1.0.2+102';
|
||||
|
||||
static PackageInfo? _cached;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,13 +34,13 @@ class AppIdentity {
|
|||
|
||||
/// Authoritative current runtime instance derived from compile-time environment defines with safe fallbacks
|
||||
static AppIdentity current() {
|
||||
const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0');
|
||||
const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 100);
|
||||
const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'e700ca64fdb6365d30a76d81236c912a730bfad2');
|
||||
const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.2');
|
||||
const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 102);
|
||||
const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'HEAD');
|
||||
const branch = String.fromEnvironment('GIT_BRANCH', defaultValue: 'master');
|
||||
const defaultChannel = kDebugMode ? 'debug' : (kProfileMode ? 'profile' : 'release');
|
||||
const channel = String.fromEnvironment('RELEASE_CHANNEL', defaultValue: defaultChannel);
|
||||
const timestamp = String.fromEnvironment('BUILD_TIMESTAMP', defaultValue: '2026-08-28T05:00:00Z');
|
||||
const timestamp = String.fromEnvironment('BUILD_TIMESTAMP', defaultValue: '2026-08-30T08:00:00Z');
|
||||
|
||||
return const AppIdentity(
|
||||
semanticVersion: semVer,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../database/database.dart';
|
||||
import '../../../models/motorcycle_models.dart';
|
||||
import '../../../services/garage/motorcycle_image_service.dart';
|
||||
|
||||
class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
||||
final GarageMotorcycle bike;
|
||||
|
|
@ -99,7 +100,7 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
|||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: _buildHeroSpecCard(powerHp, torqueLbFt, wetWeight),
|
||||
child: _buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight),
|
||||
),
|
||||
),
|
||||
// Right Column: Spec Sheet Details
|
||||
|
|
@ -128,7 +129,7 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeroSpecCard(powerHp, torqueLbFt, wetWeight),
|
||||
_buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight),
|
||||
const SizedBox(height: 16),
|
||||
_buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem),
|
||||
const SizedBox(height: 14),
|
||||
|
|
@ -144,7 +145,13 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSpecCard(String powerHp, String torqueLbFt, String wetWeight) {
|
||||
Widget _buildHeroSpecCard(GarageMotorcycle bike, String powerHp, String torqueLbFt, String wetWeight) {
|
||||
final imageRef = MotorcycleImageService().getReferenceImage(
|
||||
make: bike.makeName,
|
||||
model: bike.modelName,
|
||||
year: bike.year,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -155,10 +162,11 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
|||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Schematic Vector
|
||||
// Motorcycle Reference Image or Vector Schematic
|
||||
Container(
|
||||
height: 110,
|
||||
height: 140,
|
||||
width: double.infinity,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF080808),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -167,20 +175,41 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
|||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
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),
|
||||
Positioned(
|
||||
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(
|
||||
'TECHNICAL SCHEMATIC',
|
||||
imageRef.isPhoto ? 'TECHNICAL REFERENCE — ${imageRef.caption.toUpperCase()}' : 'TECHNICAL SCHEMATIC',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.7),
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.9),
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../database/database.dart';
|
||||
import '../../../services/export_service.dart';
|
||||
import '../../../services/github/github_sync_service.dart';
|
||||
import '../../replay/replay_screen.dart';
|
||||
import '../../map/map_screen.dart';
|
||||
|
||||
|
|
@ -28,6 +30,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
final movingMin = (ride.movingTime / 60).toStringAsFixed(0);
|
||||
|
||||
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
|
||||
final syncService = ref.watch(githubSyncServiceProvider);
|
||||
final isUploaded = syncService.isRideUploaded(ride.id);
|
||||
final commitSha = syncService.getRideCommitSha(ride.id);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
|
|
@ -65,13 +70,17 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// GitHub Sync Status Card
|
||||
_buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards
|
||||
Row(
|
||||
children: [
|
||||
// Bike Blueprint Card
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 140,
|
||||
height: 130,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
|
|
@ -81,8 +90,8 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.two_wheeler, color: Colors.white38, size: 48),
|
||||
const SizedBox(height: 10),
|
||||
const Icon(Icons.two_wheeler, color: Colors.white38, size: 40),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
bikeName,
|
||||
textAlign: TextAlign.center,
|
||||
|
|
@ -114,7 +123,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
},
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
height: 140,
|
||||
height: 130,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
|
|
@ -124,7 +133,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 40),
|
||||
const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 36),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'ROUTE PREVIEW',
|
||||
|
|
@ -165,7 +174,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
const SizedBox(height: 18),
|
||||
|
||||
// Section 1: SUMMARY
|
||||
_buildSectionHeader('SUMMARY'),
|
||||
_buildSectionHeader('SESSION OVERVIEW'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -177,42 +186,43 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
_buildMetricRow('DISTANCE:', '${ride.distanceMiles.toStringAsFixed(2)} mi', 'DURATION:', '$durationMin min'),
|
||||
const Divider(color: Color(0xFF222222), height: 20),
|
||||
const Divider(color: Color(0xFF222222), height: 18),
|
||||
_buildMetricRow('MOVING TIME:', '$movingMin min', 'AVG SPEED:', '${ride.averageSpeed.toStringAsFixed(1)} mph'),
|
||||
const Divider(color: Color(0xFF222222), height: 20),
|
||||
_buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', '', ''),
|
||||
const Divider(color: Color(0xFF222222), height: 18),
|
||||
_buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', 'DATE:', DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal())),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Section 2: HANDLING
|
||||
_buildSectionHeader('HANDLING'),
|
||||
// Section 2: HANDLING (Responsive Layout)
|
||||
_buildSectionHeader('HANDLING & DYNAMICS'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
|
||||
),
|
||||
child: Column(
|
||||
_buildResponsiveHandlingCards(context, isLandscape),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Section 3: EVENTS (Interactive Rows)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricRow('MAX LEAN (LEFT):', '${ride.maxLeanLeft.toStringAsFixed(1)}°', 'MAX LEAN (RIGHT):', '${ride.maxLeanRight.toStringAsFixed(1)}°'),
|
||||
const Divider(color: Color(0xFF222222), height: 20),
|
||||
_buildMetricRow('MAX ACCELERATION:', '${ride.maxAccelG.toStringAsFixed(2)} G', 'MAX BRAKING:', '${ride.maxBrakingG.toStringAsFixed(2)} G'),
|
||||
_buildSectionHeader('FORENSIC EVENTS (${events.length})'),
|
||||
if (events.isNotEmpty)
|
||||
Text(
|
||||
'TAP TO INSPECT',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Section 3: EVENTS
|
||||
_buildSectionHeader('EVENTS'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
|
|
@ -233,37 +243,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
)
|
||||
: Column(
|
||||
children: events.take(8).map((event) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Colors.amberAccent, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.eventType.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${event.value.toStringAsFixed(1)}°',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF00E5FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
children: events.take(15).map((event) => _buildInteractiveEventTile(context, event)).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -341,6 +321,358 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildGitHubSyncBanner(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
GitHubSyncService syncService,
|
||||
bool isUploaded,
|
||||
String? commitSha,
|
||||
) {
|
||||
final isSyncing = syncService.state == GitHubUploadState.uploading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F141C),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isUploaded ? const Color(0xFF00E676).withValues(alpha: 0.5) : const Color(0xFF00E5FF).withValues(alpha: 0.4),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isUploaded ? Icons.cloud_done : (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined),
|
||||
color: isUploaded ? const Color(0xFF00E676) : const Color(0xFF00E5FF),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isUploaded ? 'GITHUB LOGS SYNCHRONIZED' : (isSyncing ? 'UPLOADING TO GITHUB...' : 'GITHUB LOG SYNC'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isUploaded ? const Color(0xFF00E676) : Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isUploaded
|
||||
? 'Commit: ${commitSha != null && commitSha.length >= 7 ? commitSha.substring(0, 7) : "synced"}'
|
||||
: 'Repository: bponders12-cell/ride_logs',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9.5,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isUploaded)
|
||||
TextButton(
|
||||
onPressed: isSyncing
|
||||
? null
|
||||
: () async {
|
||||
final result = await syncService.uploadRide(ride.id, isManual: true);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
isSyncing ? 'UPLOADING...' : 'UPLOAD NOW',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF00E5FF),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResponsiveHandlingCards(BuildContext context, bool isLandscape) {
|
||||
final cards = [
|
||||
_buildHandlingKpiCard('MAX LEAN (LEFT)', '${ride.maxLeanLeft.toStringAsFixed(1)}°', Icons.turn_left, const Color(0xFF00E5FF)),
|
||||
_buildHandlingKpiCard('MAX LEAN (RIGHT)', '${ride.maxLeanRight.toStringAsFixed(1)}°', Icons.turn_right, const Color(0xFF00E5FF)),
|
||||
_buildHandlingKpiCard('MAX ACCELERATION', '${ride.maxAccelG.toStringAsFixed(2)} G', Icons.speed, const Color(0xFF76FF03)),
|
||||
_buildHandlingKpiCard('MAX BRAKING', '${ride.maxBrakingG.toStringAsFixed(2)} G', Icons.pan_tool_alt, const Color(0xFFFF5252)),
|
||||
];
|
||||
|
||||
if (isLandscape) {
|
||||
return Row(
|
||||
children: cards.map((c) => Expanded(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 4), child: c))).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: cards[0]),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: cards[1]),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: cards[2]),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: cards[3]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHandlingKpiCard(String label, String value, IconData icon, Color accentColor) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(icon, color: accentColor, size: 16),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInteractiveEventTile(BuildContext context, RideEvent event) {
|
||||
Color badgeColor = Colors.amberAccent;
|
||||
IconData eventIcon = Icons.warning_amber_rounded;
|
||||
|
||||
if (event.eventType.toLowerCase().contains('accel')) {
|
||||
badgeColor = const Color(0xFF76FF03);
|
||||
eventIcon = Icons.arrow_upward;
|
||||
} else if (event.eventType.toLowerCase().contains('brak')) {
|
||||
badgeColor = const Color(0xFFFF5252);
|
||||
eventIcon = Icons.arrow_downward;
|
||||
} else if (event.eventType.toLowerCase().contains('corner') || event.eventType.toLowerCase().contains('lean')) {
|
||||
badgeColor = const Color(0xFF00E5FF);
|
||||
eventIcon = Icons.cached;
|
||||
}
|
||||
|
||||
String formattedValue = '${event.value.toStringAsFixed(1)}°';
|
||||
if (event.eventType.toLowerCase().contains('accel') || event.eventType.toLowerCase().contains('brak')) {
|
||||
formattedValue = '${event.value.toStringAsFixed(2)} G';
|
||||
}
|
||||
|
||||
final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal());
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showEventDetailsDialog(context, event, formattedValue, timeStr, badgeColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(eventIcon, color: badgeColor, size: 16),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.eventType.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
timeStr,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 9,
|
||||
color: Colors.white38,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
formattedValue,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEventDetailsDialog(
|
||||
BuildContext context,
|
||||
RideEvent event,
|
||||
String formattedValue,
|
||||
String timeStr,
|
||||
Color badgeColor,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: badgeColor.withValues(alpha: 0.5), width: 1.2),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.insights, color: badgeColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.eventType.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDialogRow('Timestamp:', timeStr),
|
||||
_buildDialogRow('Magnitude / Value:', formattedValue),
|
||||
if (event.latitude != null && event.longitude != null)
|
||||
_buildDialogRow(
|
||||
'GPS Coordinates:',
|
||||
'${event.latitude!.toStringAsFixed(6)}, ${event.longitude!.toStringAsFixed(6)}',
|
||||
),
|
||||
_buildDialogRow('Event ID / Seq:', '#${event.id} (Ride #${event.rideId})'),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFF222222)),
|
||||
),
|
||||
child: const Text(
|
||||
'Authoritative telemetry sampled at 20 Hz. Moving metrics gated at speed >= 3.0 MPH.',
|
||||
style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (event.latitude != null && event.longitude != null)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.map, color: Color(0xFF00E5FF), size: 16),
|
||||
label: const Text('VIEW ON MAP', style: TextStyle(color: Color(0xFF00E5FF), fontFamily: 'JetBrains Mono', fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MapScreen(rideId: event.rideId),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Text(
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../services/location_service.dart';
|
||||
import '../../services/map/map_provider_interface.dart';
|
||||
|
|
@ -20,16 +21,18 @@ class MapScreen extends ConsumerStatefulWidget {
|
|||
|
||||
class _MapScreenState extends ConsumerState<MapScreen> {
|
||||
bool _isLoadingHistory = true;
|
||||
List<TelemetrySample> _telemetrySamples = [];
|
||||
List<AppLatLng> _routePoints = [];
|
||||
List<AppMapMarker> _eventMarkers = [];
|
||||
AppLatLng? _initialCenter;
|
||||
AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194); // SF Default
|
||||
|
||||
// Event category filter states
|
||||
// Layer toggle states
|
||||
bool _showRoute = true;
|
||||
bool _showSpeedHeatmap = false;
|
||||
bool _showHardAccel = true;
|
||||
bool _showHardBraking = true;
|
||||
bool _showCornering = true;
|
||||
bool _showSpeedMilestones = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -89,6 +92,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
..where((e) => e.rideId.equals(widget.rideId!)))
|
||||
.get();
|
||||
|
||||
_telemetrySamples = telemetry;
|
||||
|
||||
if (telemetry.isNotEmpty) {
|
||||
_routePoints = telemetry
|
||||
.where((t) => t.latitude != null && t.longitude != null)
|
||||
|
|
@ -104,36 +109,78 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
for (final event in events) {
|
||||
if (event.latitude != null && event.longitude != null) {
|
||||
Color markerColor = Colors.redAccent;
|
||||
if (event.eventType.contains('Corner')) markerColor = Colors.orangeAccent;
|
||||
if (event.eventType.contains('Acceleration')) markerColor = Colors.greenAccent;
|
||||
if (event.eventType.contains('Speed')) markerColor = Colors.purpleAccent;
|
||||
IconData markerIcon = Icons.warning_amber_rounded;
|
||||
final typeLower = event.eventType.toLowerCase();
|
||||
|
||||
if (typeLower.contains('corner') || typeLower.contains('lean')) {
|
||||
markerColor = const Color(0xFF00E5FF);
|
||||
markerIcon = Icons.cached;
|
||||
} else if (typeLower.contains('accel')) {
|
||||
markerColor = const Color(0xFF76FF03);
|
||||
markerIcon = Icons.arrow_upward;
|
||||
} else if (typeLower.contains('brak')) {
|
||||
markerColor = const Color(0xFFFF5252);
|
||||
markerIcon = Icons.arrow_downward;
|
||||
} else if (typeLower.contains('speed')) {
|
||||
markerColor = const Color(0xFFE040FB);
|
||||
markerIcon = Icons.speed;
|
||||
}
|
||||
|
||||
_eventMarkers.add(
|
||||
AppMapMarker(
|
||||
id: event.id.toString(),
|
||||
id: 'event_${event.id}',
|
||||
position: AppLatLng(event.latitude!, event.longitude!),
|
||||
label: event.eventType,
|
||||
iconWidget: Icon(Icons.location_on, color: markerColor, size: 36),
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: markerColor.withValues(alpha: 0.9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
child: Icon(markerIcon, color: Colors.black, size: 18),
|
||||
),
|
||||
onTap: () {
|
||||
if (mounted) {
|
||||
final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal());
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(event.eventType),
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: markerColor.withValues(alpha: 0.6), width: 1.2),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(markerIcon, color: markerColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.eventType.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Time: ${event.timestamp.toIso8601String().substring(11, 19)}'),
|
||||
Text('Value: ${event.value.toStringAsFixed(2)}'),
|
||||
if (event.latitude != null && event.longitude != null)
|
||||
Text('Lat/Lng: ${event.latitude!.toStringAsFixed(4)}, ${event.longitude!.toStringAsFixed(4)}'),
|
||||
_buildDialogRow('Timestamp:', timeStr),
|
||||
_buildDialogRow('Event Value:', event.value.toStringAsFixed(2)),
|
||||
_buildDialogRow('GPS Location:', '${event.latitude!.toStringAsFixed(5)}, ${event.longitude!.toStringAsFixed(5)}'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -145,10 +192,92 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingHistory = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDialogRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)),
|
||||
Text(value, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<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() {
|
||||
final markers = <AppMapMarker>[];
|
||||
|
|
@ -165,7 +294,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
child: const Icon(Icons.flag, color: Colors.black, size: 20),
|
||||
child: const Icon(Icons.flag, color: Colors.black, size: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -182,7 +311,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
child: const Icon(Icons.sports_score, color: Colors.black, size: 20),
|
||||
child: const Icon(Icons.sports_score, color: Colors.black, size: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -190,11 +319,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
}
|
||||
|
||||
final filteredEvents = _eventMarkers.where((m) {
|
||||
final label = m.label ?? '';
|
||||
if (label.contains('Acceleration') && !_showHardAccel) return false;
|
||||
if (label.contains('Braking') && !_showHardBraking) return false;
|
||||
if (label.contains('Corner') && !_showCornering) return false;
|
||||
if (label.contains('Speed') && !_showSpeedMilestones) return false;
|
||||
final label = (m.label ?? '').toLowerCase();
|
||||
if (label.contains('accel') && !_showHardAccel) return false;
|
||||
if (label.contains('brak') && !_showHardBraking) return false;
|
||||
if ((label.contains('corner') || label.contains('lean')) && !_showCornering) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
@ -218,15 +346,14 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: alert.category.color.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: alert.category.color),
|
||||
),
|
||||
child: Icon(alert.category.icon, color: alert.category.color, size: 28),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -235,8 +362,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
alert.category.displayName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: alert.category.color,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
|
|
@ -285,18 +412,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
|
||||
final activeProviderType = ref.watch(mapProviderTypeNotifierProvider);
|
||||
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||
|
||||
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
|
||||
|
||||
if (widget.rideId != null) {
|
||||
// Historical Route View
|
||||
final polylines = _routePoints.isNotEmpty
|
||||
? [AppPolyline(id: 'history_route', points: _routePoints, color: Colors.cyanAccent, width: 5.0)]
|
||||
: <AppPolyline>[];
|
||||
final polylines = _buildPolylines();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Ride #${widget.rideId} Route Preview'),
|
||||
title: Text('Ride #${widget.rideId} Multi-Layer Map'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Switch Map Provider',
|
||||
|
|
@ -328,7 +452,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (_routePoints.isNotEmpty) _buildEventFilterOverlay(),
|
||||
if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(),
|
||||
if (_showSpeedHeatmap) _buildSpeedLegend(),
|
||||
if (_routePoints.isEmpty)
|
||||
Positioned(
|
||||
top: 16,
|
||||
|
|
@ -476,45 +601,56 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildEventFilterOverlay() {
|
||||
Widget _buildMultiLayerFilterOverlay() {
|
||||
return Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
right: 10,
|
||||
child: Card(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFF1F1F1F)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Route'),
|
||||
selected: _showRoute,
|
||||
onSelected: (val) => setState(() => _showRoute = val),
|
||||
selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
label: const Text('Speed'),
|
||||
selected: _showSpeedHeatmap,
|
||||
onSelected: (val) => setState(() => _showSpeedHeatmap = val),
|
||||
selectedColor: const Color(0xFFE040FB).withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
label: const Text('Accel'),
|
||||
selected: _showHardAccel,
|
||||
onSelected: (val) => setState(() => _showHardAccel = val),
|
||||
selectedColor: Colors.green.withValues(alpha: 0.4),
|
||||
selectedColor: const Color(0xFF76FF03).withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
label: const Text('Braking'),
|
||||
selected: _showHardBraking,
|
||||
onSelected: (val) => setState(() => _showHardBraking = val),
|
||||
selectedColor: Colors.red.withValues(alpha: 0.4),
|
||||
selectedColor: const Color(0xFFFF5252).withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
label: const Text('Cornering'),
|
||||
selected: _showCornering,
|
||||
onSelected: (val) => setState(() => _showCornering = val),
|
||||
selectedColor: Colors.orange.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
label: const Text('Speed'),
|
||||
selected: _showSpeedMilestones,
|
||||
onSelected: (val) => setState(() => _showSpeedMilestones = val),
|
||||
selectedColor: Colors.purple.withValues(alpha: 0.4),
|
||||
selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -523,4 +659,49 @@ class _MapScreenState extends ConsumerState<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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../../services/audio_alert_service.dart';
|
|||
import '../records/records_service.dart';
|
||||
import '../garage/garage_providers.dart';
|
||||
import '../../services/field_test/field_test_logger.dart';
|
||||
import '../../services/github/github_sync_service.dart';
|
||||
|
||||
enum RideState { idle, ready, riding, paused, finished }
|
||||
|
||||
|
|
@ -227,6 +228,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
startTime: DateTime.now(),
|
||||
tripDistanceMiles: 0,
|
||||
topSpeedMph: 0,
|
||||
maxLeanLeft: 0,
|
||||
maxLeanRight: 0,
|
||||
maxAccelG: 0,
|
||||
maxBrakingG: 0,
|
||||
averageSpeedMph: 0,
|
||||
movingTimeSeconds: 0,
|
||||
stoppedTimeSeconds: 0,
|
||||
elevationGain: 0,
|
||||
|
|
@ -371,6 +377,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Offline-first background synchronization to GitHub (if enabled)
|
||||
final completedRideId = state.dbId;
|
||||
if (completedRideId != null) {
|
||||
try {
|
||||
ref.read(githubSyncServiceProvider).queueRideForUpload(completedRideId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
state: RideState.finished,
|
||||
healthStatus: RecordingHealthStatus.recordingComplete,
|
||||
|
|
@ -532,7 +546,10 @@ class RideStateMachine extends StateNotifier<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) {
|
||||
newMaxLeanLeft = sample.leanAngle!.abs();
|
||||
} 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) {
|
||||
newMaxAccelG = sample.longitudinalG!;
|
||||
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import '../../database/database.dart';
|
|||
import '../../models/field_test_event.dart' as model;
|
||||
import '../../services/field_test/field_test_export_service.dart';
|
||||
|
||||
import '../../services/github/github_sync_service.dart';
|
||||
|
||||
class FieldTestReportScreen extends ConsumerStatefulWidget {
|
||||
final int? rideId;
|
||||
const FieldTestReportScreen({super.key, this.rideId});
|
||||
|
|
@ -42,7 +44,23 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
appBar: AppBar(
|
||||
title: const Text('Forensic Field Test Report'),
|
||||
actions: [
|
||||
if (_selectedRideId != null)
|
||||
if (_selectedRideId != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_upload_outlined, color: Colors.cyanAccent),
|
||||
tooltip: 'Upload Forensic Package to GitHub',
|
||||
onPressed: () async {
|
||||
final syncService = ref.read(githubSyncServiceProvider);
|
||||
final result = await syncService.uploadRide(_selectedRideId!, isManual: true);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: _isExporting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
|
|
@ -51,6 +69,7 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<Ride>>(
|
||||
stream: (db.select(db.rides)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../../../services/alerts/road_alert_models.dart';
|
|||
import '../../../services/alerts/road_alert_providers.dart';
|
||||
import '../../../services/alerts/road_alert_service.dart';
|
||||
import '../../../services/osm_oauth_service.dart';
|
||||
import '../../../services/github/github_auth_service.dart';
|
||||
import '../../../services/sensor_fusion_service.dart';
|
||||
import '../../../services/field_test/field_test_logger.dart';
|
||||
import '../../../database/database.dart';
|
||||
|
|
@ -27,6 +28,9 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
late TextEditingController _communityEndpointController;
|
||||
bool _isTestingTomTom = false;
|
||||
bool _isTestingCommunity = false;
|
||||
late TextEditingController _githubTokenController;
|
||||
bool _isTestingGitHub = false;
|
||||
bool _obscureGitHubToken = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -40,6 +44,9 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
_communityEndpointController = TextEditingController(
|
||||
text: ref.read(communityEndpointUrlProvider),
|
||||
);
|
||||
_githubTokenController = TextEditingController(
|
||||
text: ref.read(githubAuthServiceProvider).getToken() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -47,6 +54,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
_tomtomKeyController.dispose();
|
||||
_communityKeyController.dispose();
|
||||
_communityEndpointController.dispose();
|
||||
_githubTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +157,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
final sensorsCard = _buildSensorsCalibrationCard(context, ref);
|
||||
final tomtomCard = _buildTomTomCard(context, ref);
|
||||
final communityCard = _buildCommunityCard(context, ref);
|
||||
final githubCard = _buildGitHubCard(context, ref);
|
||||
final dataCard = _buildDataManagementCard(context, ref);
|
||||
final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref);
|
||||
final versionFooter = _buildVersionFooter(context);
|
||||
|
|
@ -172,6 +181,8 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
|
|||
osmAccountCard,
|
||||
const SizedBox(height: 16),
|
||||
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) {
|
||||
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import '../services/validation/telemetry_validator.dart';
|
|||
|
||||
class TelemetrySample {
|
||||
final DateTime timestamp;
|
||||
final int? sequenceNumber;
|
||||
final int? monotonicTimestampMs;
|
||||
|
||||
// Location Data
|
||||
final double? latitude;
|
||||
|
|
@ -17,6 +19,7 @@ class TelemetrySample {
|
|||
final DateTime? imuTimestamp;
|
||||
final double? leanAngle;
|
||||
final double? pitchAngle;
|
||||
final double? rollRateDegPerSec;
|
||||
final double? accelX;
|
||||
final double? accelY;
|
||||
final double? accelZ;
|
||||
|
|
@ -26,11 +29,21 @@ class TelemetrySample {
|
|||
final double? gyroY;
|
||||
final double? gyroZ;
|
||||
|
||||
// Raw Preserved Sensors (Unfiltered for forensic analysis)
|
||||
final double? rawAccelX;
|
||||
final double? rawAccelY;
|
||||
final double? rawAccelZ;
|
||||
final double? rawGyroX;
|
||||
final double? rawGyroY;
|
||||
final double? rawGyroZ;
|
||||
|
||||
// Validation & Quality
|
||||
final TelemetryQuality? quality;
|
||||
|
||||
TelemetrySample({
|
||||
required this.timestamp,
|
||||
this.sequenceNumber,
|
||||
this.monotonicTimestampMs,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.altitude,
|
||||
|
|
@ -42,6 +55,7 @@ class TelemetrySample {
|
|||
this.imuTimestamp,
|
||||
this.leanAngle,
|
||||
this.pitchAngle,
|
||||
this.rollRateDegPerSec,
|
||||
this.accelX,
|
||||
this.accelY,
|
||||
this.accelZ,
|
||||
|
|
@ -50,11 +64,19 @@ class TelemetrySample {
|
|||
this.gyroX,
|
||||
this.gyroY,
|
||||
this.gyroZ,
|
||||
this.rawAccelX,
|
||||
this.rawAccelY,
|
||||
this.rawAccelZ,
|
||||
this.rawGyroX,
|
||||
this.rawGyroY,
|
||||
this.rawGyroZ,
|
||||
this.quality,
|
||||
});
|
||||
|
||||
TelemetrySample copyWith({
|
||||
DateTime? timestamp,
|
||||
int? sequenceNumber,
|
||||
int? monotonicTimestampMs,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
double? altitude,
|
||||
|
|
@ -66,6 +88,7 @@ class TelemetrySample {
|
|||
DateTime? imuTimestamp,
|
||||
double? leanAngle,
|
||||
double? pitchAngle,
|
||||
double? rollRateDegPerSec,
|
||||
double? accelX,
|
||||
double? accelY,
|
||||
double? accelZ,
|
||||
|
|
@ -74,10 +97,18 @@ class TelemetrySample {
|
|||
double? gyroX,
|
||||
double? gyroY,
|
||||
double? gyroZ,
|
||||
double? rawAccelX,
|
||||
double? rawAccelY,
|
||||
double? rawAccelZ,
|
||||
double? rawGyroX,
|
||||
double? rawGyroY,
|
||||
double? rawGyroZ,
|
||||
TelemetryQuality? quality,
|
||||
}) {
|
||||
return TelemetrySample(
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
sequenceNumber: sequenceNumber ?? this.sequenceNumber,
|
||||
monotonicTimestampMs: monotonicTimestampMs ?? this.monotonicTimestampMs,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
altitude: altitude ?? this.altitude,
|
||||
|
|
@ -89,6 +120,7 @@ class TelemetrySample {
|
|||
imuTimestamp: imuTimestamp ?? this.imuTimestamp,
|
||||
leanAngle: leanAngle ?? this.leanAngle,
|
||||
pitchAngle: pitchAngle ?? this.pitchAngle,
|
||||
rollRateDegPerSec: rollRateDegPerSec ?? this.rollRateDegPerSec,
|
||||
accelX: accelX ?? this.accelX,
|
||||
accelY: accelY ?? this.accelY,
|
||||
accelZ: accelZ ?? this.accelZ,
|
||||
|
|
@ -97,6 +129,12 @@ class TelemetrySample {
|
|||
gyroX: gyroX ?? this.gyroX,
|
||||
gyroY: gyroY ?? this.gyroY,
|
||||
gyroZ: gyroZ ?? this.gyroZ,
|
||||
rawAccelX: rawAccelX ?? this.rawAccelX,
|
||||
rawAccelY: rawAccelY ?? this.rawAccelY,
|
||||
rawAccelZ: rawAccelZ ?? this.rawAccelZ,
|
||||
rawGyroX: rawGyroX ?? this.rawGyroX,
|
||||
rawGyroY: rawGyroY ?? this.rawGyroY,
|
||||
rawGyroZ: rawGyroZ ?? this.rawGyroZ,
|
||||
quality: quality ?? this.quality,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,32 @@ final calibrationServiceProvider = Provider<CalibrationService>((ref) {
|
|||
class CalibrationService {
|
||||
static const _pitchKey = 'calibration_pitch';
|
||||
static const _rollKey = 'calibration_roll';
|
||||
static const _gyroBiasXKey = 'calibration_gyro_bias_x';
|
||||
static const _gyroBiasYKey = 'calibration_gyro_bias_y';
|
||||
static const _gyroBiasZKey = 'calibration_gyro_bias_z';
|
||||
|
||||
Future<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();
|
||||
if (motorcycleId != null) {
|
||||
await prefs.setDouble('calibration_pitch_$motorcycleId', pitch);
|
||||
await prefs.setDouble('calibration_roll_$motorcycleId', roll);
|
||||
await prefs.setDouble('calibration_gyro_bias_x_$motorcycleId', gyroBiasX);
|
||||
await prefs.setDouble('calibration_gyro_bias_y_$motorcycleId', gyroBiasY);
|
||||
await prefs.setDouble('calibration_gyro_bias_z_$motorcycleId', gyroBiasZ);
|
||||
}
|
||||
// Also save global fallback
|
||||
await prefs.setDouble(_pitchKey, pitch);
|
||||
await prefs.setDouble(_rollKey, roll);
|
||||
await prefs.setDouble(_gyroBiasXKey, gyroBiasX);
|
||||
await prefs.setDouble(_gyroBiasYKey, gyroBiasY);
|
||||
await prefs.setDouble(_gyroBiasZKey, gyroBiasZ);
|
||||
}
|
||||
|
||||
Future<Map<String, double>> loadCalibrationOffsets({int? motorcycleId}) async {
|
||||
|
|
@ -25,12 +41,30 @@ class CalibrationService {
|
|||
if (motorcycleId != null) {
|
||||
final bikePitch = prefs.getDouble('calibration_pitch_$motorcycleId');
|
||||
final bikeRoll = prefs.getDouble('calibration_roll_$motorcycleId');
|
||||
final bikeGbx = prefs.getDouble('calibration_gyro_bias_x_$motorcycleId') ?? 0.0;
|
||||
final bikeGby = prefs.getDouble('calibration_gyro_bias_y_$motorcycleId') ?? 0.0;
|
||||
final bikeGbz = prefs.getDouble('calibration_gyro_bias_z_$motorcycleId') ?? 0.0;
|
||||
if (bikePitch != null && bikeRoll != null) {
|
||||
return {'pitch': bikePitch, 'roll': bikeRoll};
|
||||
return {
|
||||
'pitch': bikePitch,
|
||||
'roll': bikeRoll,
|
||||
'gyroBiasX': bikeGbx,
|
||||
'gyroBiasY': bikeGby,
|
||||
'gyroBiasZ': bikeGbz,
|
||||
};
|
||||
}
|
||||
}
|
||||
final pitch = prefs.getDouble(_pitchKey) ?? 0.0;
|
||||
final roll = prefs.getDouble(_rollKey) ?? 0.0;
|
||||
return {'pitch': pitch, 'roll': roll};
|
||||
final gbx = prefs.getDouble(_gyroBiasXKey) ?? 0.0;
|
||||
final gby = prefs.getDouble(_gyroBiasYKey) ?? 0.0;
|
||||
final gbz = prefs.getDouble(_gyroBiasZKey) ?? 0.0;
|
||||
return {
|
||||
'pitch': pitch,
|
||||
'roll': roll,
|
||||
'gyroBiasX': gbx,
|
||||
'gyroBiasY': gby,
|
||||
'gyroBiasZ': gbz,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,18 @@ class FieldTestExportService {
|
|||
|
||||
final appIdentity = AppIdentity.current();
|
||||
|
||||
// Compute raw observed peaks across entire telemetry trace (including 0 MPH dismounts)
|
||||
double maxObservedLean = 0.0;
|
||||
double maxObservedG = 0.0;
|
||||
for (final s in samples) {
|
||||
if (s.leanAngle != null && s.leanAngle!.abs() > maxObservedLean && s.leanAngle!.abs() <= 65.0) {
|
||||
maxObservedLean = s.leanAngle!.abs();
|
||||
}
|
||||
if (s.longitudinalG != null && s.longitudinalG!.abs() > maxObservedG) {
|
||||
maxObservedG = s.longitudinalG!.abs();
|
||||
}
|
||||
}
|
||||
|
||||
// 1. README.txt
|
||||
final warningCount = events.where((e) => e.severity == 'WARNING').length;
|
||||
final errorCount = events.where((e) => e.severity == 'ERROR' || e.severity == 'CRITICAL').length;
|
||||
|
|
@ -202,6 +214,16 @@ class FieldTestExportService {
|
|||
readmeContent.writeln('Total Forensic Events: ${events.length}');
|
||||
readmeContent.writeln('Major Warnings Count: $warningCount');
|
||||
readmeContent.writeln('Major Errors Count: $errorCount');
|
||||
final maxLeanMoving = ride.maxLeanLeft > ride.maxLeanRight ? ride.maxLeanLeft : ride.maxLeanRight;
|
||||
readmeContent.writeln('-----------------------------------------------------');
|
||||
readmeContent.writeln('Performance Metrics (Qualified: Speed >= 3.0 MPH):');
|
||||
readmeContent.writeln(' - Top Speed: ${ride.topSpeedMph.toStringAsFixed(1)} MPH');
|
||||
readmeContent.writeln(' - Maximum Moving Lean: ${maxLeanMoving.toStringAsFixed(1)}° (Left: ${ride.maxLeanLeft.toStringAsFixed(1)}°, Right: ${ride.maxLeanRight.toStringAsFixed(1)}°)');
|
||||
readmeContent.writeln(' - Maximum Moving Acceleration: ${ride.maxAccelG.toStringAsFixed(2)} G');
|
||||
readmeContent.writeln(' - Maximum Moving Braking: ${ride.maxBrakingG.toStringAsFixed(2)} G');
|
||||
readmeContent.writeln('Sensor Observations (Raw Peak including Stationary / Dismount):');
|
||||
readmeContent.writeln(' - Maximum Observed Lean: ${maxObservedLean.toStringAsFixed(1)}°');
|
||||
readmeContent.writeln(' - Maximum Observed G-Force: ${maxObservedG.toStringAsFixed(2)} G');
|
||||
readmeContent.writeln('=====================================================');
|
||||
readmeContent.writeln('Files Included:');
|
||||
readmeContent.writeln(' - field_test_session.json : Full structured session dump with software identity');
|
||||
|
|
@ -235,8 +257,14 @@ class FieldTestExportService {
|
|||
'topSpeedMph': ride.topSpeedMph,
|
||||
'maxLeanLeft': ride.maxLeanLeft,
|
||||
'maxLeanRight': ride.maxLeanRight,
|
||||
'maxMovingLeanLeft': ride.maxLeanLeft,
|
||||
'maxMovingLeanRight': ride.maxLeanRight,
|
||||
'maxAccelG': ride.maxAccelG,
|
||||
'maxBrakingG': ride.maxBrakingG,
|
||||
'maxMovingAccelG': ride.maxAccelG,
|
||||
'maxMovingBrakingG': ride.maxBrakingG,
|
||||
'maxObservedLean': maxObservedLean,
|
||||
'maxObservedG': maxObservedG,
|
||||
'averageSpeed': ride.averageSpeed,
|
||||
'movingTime': ride.movingTime,
|
||||
'stoppedTime': ride.stoppedTime,
|
||||
|
|
|
|||
|
|
@ -664,19 +664,28 @@ class FieldTestLogger {
|
|||
worstAccuracy = s.gpsAccuracy;
|
||||
}
|
||||
}
|
||||
final speedVal = s.speed ?? 0.0;
|
||||
final isMoving = speedVal >= 3.0;
|
||||
|
||||
if (s.speed != null && s.speed! > maxSpeed && s.speed! < 223.7) {
|
||||
maxSpeed = s.speed!;
|
||||
}
|
||||
if (s.leanAngle != null && s.leanAngle!.abs() > maxLean && s.leanAngle!.abs() <= 65.0) {
|
||||
if (s.leanAngle != null && s.leanAngle!.abs() <= 65.0) {
|
||||
if (isMoving && s.leanAngle!.abs() > maxLean) {
|
||||
maxLean = s.leanAngle!.abs();
|
||||
}
|
||||
if (s.longitudinalG != null && s.longitudinalG!.abs() > maxLongG) {
|
||||
}
|
||||
if (s.longitudinalG != null && s.longitudinalG!.abs() <= 2.5) {
|
||||
if (isMoving && s.longitudinalG!.abs() > maxLongG) {
|
||||
maxLongG = s.longitudinalG!.abs();
|
||||
}
|
||||
if (s.lateralG != null && s.lateralG!.abs() > maxLatG) {
|
||||
}
|
||||
if (s.lateralG != null && s.lateralG!.abs() <= 2.5) {
|
||||
if (isMoving && s.lateralG!.abs() > maxLatG) {
|
||||
maxLatG = s.lateralG!.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final validPct = totalSamples > 0 ? (validSamples / totalSamples) * 100.0 : 100.0;
|
||||
final suspectPct = totalSamples > 0 ? (suspectSamples / totalSamples) * 100.0 : 0.0;
|
||||
|
|
@ -711,6 +720,8 @@ class FieldTestLogger {
|
|||
timestampGaps: _telemetryGapCount,
|
||||
gpsFixCount: gpsFixCount,
|
||||
totalGpsAccuracy: accuracySum,
|
||||
databaseSamplesLost: _droppedEventsCount,
|
||||
imuSaturationCount: _imuSaturationCount,
|
||||
isMountCalibrated: true,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -200,6 +200,23 @@ class LocationService {
|
|||
});
|
||||
}
|
||||
|
||||
String? _lastDiagnosticReason;
|
||||
DateTime? _lastDiagnosticTime;
|
||||
|
||||
void _emitGpsDiagnostic(Position position, bool accepted, String? reason) {
|
||||
if (!accepted && reason != null) {
|
||||
final now = DateTime.now();
|
||||
if (_lastDiagnosticReason == reason &&
|
||||
_lastDiagnosticTime != null &&
|
||||
now.difference(_lastDiagnosticTime!).inMilliseconds < 500) {
|
||||
return; // Debounce duplicate diagnostic message within 500ms
|
||||
}
|
||||
_lastDiagnosticReason = reason;
|
||||
_lastDiagnosticTime = now;
|
||||
}
|
||||
onGpsDiagnostic?.call(position, accepted, reason);
|
||||
}
|
||||
|
||||
void _handleRawPosition(Position position) {
|
||||
if (_isValidPosition(position)) {
|
||||
final filtered = _applyEmaFilter(position);
|
||||
|
|
@ -215,7 +232,7 @@ class LocationService {
|
|||
final maxAcceptableAccuracy = _lastValidPosition == null ? 200.0 : (currentSpeed > 10.0 ? 100.0 : 80.0);
|
||||
|
||||
if (position.accuracy > maxAcceptableAccuracy) {
|
||||
onGpsDiagnostic?.call(
|
||||
_emitGpsDiagnostic(
|
||||
position,
|
||||
false,
|
||||
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
|
||||
|
|
@ -224,7 +241,7 @@ class LocationService {
|
|||
}
|
||||
|
||||
if (position.speed > _maxRealisticSpeed) {
|
||||
onGpsDiagnostic?.call(
|
||||
_emitGpsDiagnostic(
|
||||
position,
|
||||
false,
|
||||
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
|
||||
|
|
@ -239,7 +256,7 @@ class LocationService {
|
|||
if (timeDiff > 8.0) {
|
||||
_lastValidPosition = position;
|
||||
_emaSpeed = position.speed;
|
||||
onGpsDiagnostic?.call(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
|
||||
_emitGpsDiagnostic(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +264,7 @@ class LocationService {
|
|||
// Acceleration check
|
||||
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
|
||||
if (accel > _maxAcceleration) {
|
||||
onGpsDiagnostic?.call(
|
||||
_emitGpsDiagnostic(
|
||||
position,
|
||||
false,
|
||||
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
|
||||
|
|
@ -264,7 +281,7 @@ class LocationService {
|
|||
);
|
||||
final derivedSpeedMps = distMeters / timeDiff;
|
||||
if (derivedSpeedMps > _maxRealisticSpeed && distMeters > 50.0) {
|
||||
onGpsDiagnostic?.call(
|
||||
_emitGpsDiagnostic(
|
||||
position,
|
||||
false,
|
||||
'GPS position teleportation (${distMeters.toStringAsFixed(0)}m in ${timeDiff.toStringAsFixed(1)}s)',
|
||||
|
|
@ -275,7 +292,7 @@ class LocationService {
|
|||
}
|
||||
|
||||
_lastValidPosition = position;
|
||||
onGpsDiagnostic?.call(position, true, null);
|
||||
_emitGpsDiagnostic(position, true, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,15 @@ final gForceStreamProvider = StreamProvider<GForceData>((ref) {
|
|||
class GForceData {
|
||||
final double longitudinal; // Accel/braking (G's)
|
||||
final double lateral; // Left/right (G's)
|
||||
final double accelX; // Raw m/s^2
|
||||
final double accelY; // Raw m/s^2
|
||||
final double accelZ; // Raw m/s^2
|
||||
final double accelX; // User accel m/s^2
|
||||
final double accelY; // User accel m/s^2
|
||||
final double accelZ; // User accel m/s^2
|
||||
final double? rawAccelX; // Raw total accel m/s^2
|
||||
final double? rawAccelY; // Raw total accel m/s^2
|
||||
final double? rawAccelZ; // Raw total accel m/s^2
|
||||
final double? rawGyroX; // Raw gyro rad/s
|
||||
final double? rawGyroY; // Raw gyro rad/s
|
||||
final double? rawGyroZ; // Raw gyro rad/s
|
||||
|
||||
GForceData({
|
||||
required this.longitudinal,
|
||||
|
|
@ -33,6 +39,12 @@ class GForceData {
|
|||
required this.accelX,
|
||||
required this.accelY,
|
||||
required this.accelZ,
|
||||
this.rawAccelX,
|
||||
this.rawAccelY,
|
||||
this.rawAccelZ,
|
||||
this.rawGyroX,
|
||||
this.rawGyroY,
|
||||
this.rawGyroZ,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -51,9 +63,29 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
StreamSubscription? _gyroSub;
|
||||
StreamSubscription? _userAccelSub;
|
||||
|
||||
// Calibration offsets
|
||||
// Calibration offsets & gyro zero-rate bias
|
||||
double _rollOffset = 0.0;
|
||||
double _pitchOffset = 0.0;
|
||||
double _gyroBiasX = 0.0;
|
||||
double _gyroBiasY = 0.0;
|
||||
double _gyroBiasZ = 0.0;
|
||||
|
||||
// Raw sensor caches (unfiltered for forensics)
|
||||
double _latestRawAccelX = 0.0;
|
||||
double _latestRawAccelY = 0.0;
|
||||
double _latestRawAccelZ = 0.0;
|
||||
double _latestRawGyroX = 0.0;
|
||||
double _latestRawGyroY = 0.0;
|
||||
double _latestRawGyroZ = 0.0;
|
||||
double _latestRollRateDegPerSec = 0.0;
|
||||
|
||||
double get latestRawAccelX => _latestRawAccelX;
|
||||
double get latestRawAccelY => _latestRawAccelY;
|
||||
double get latestRawAccelZ => _latestRawAccelZ;
|
||||
double get latestRawGyroX => _latestRawGyroX;
|
||||
double get latestRawGyroY => _latestRawGyroY;
|
||||
double get latestRawGyroZ => _latestRawGyroZ;
|
||||
double get latestRollRateDegPerSec => _latestRollRateDegPerSec;
|
||||
|
||||
// Sensor fusion state
|
||||
double _currentRoll = 0.0; // The lean angle
|
||||
|
|
@ -63,7 +95,6 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
bool _isLandscape = false;
|
||||
bool _isLandscapeLeft = true;
|
||||
|
||||
static const double _baseAlpha = 0.94; // Base complementary filter gain
|
||||
static const double _gravityConst = 9.80665;
|
||||
|
||||
// Low-pass filter for G-Force to suppress motorcycle vibration spikes
|
||||
|
|
@ -103,12 +134,19 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
final offsets = await _calibrationService.loadCalibrationOffsets();
|
||||
_pitchOffset = offsets['pitch'] ?? 0.0;
|
||||
_rollOffset = offsets['roll'] ?? 0.0;
|
||||
_gyroBiasX = offsets['gyroBiasX'] ?? 0.0;
|
||||
_gyroBiasY = offsets['gyroBiasY'] ?? 0.0;
|
||||
_gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _startSensors() {
|
||||
_gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) {
|
||||
final now = DateTime.now();
|
||||
_latestRawGyroX = event.x;
|
||||
_latestRawGyroY = event.y;
|
||||
_latestRawGyroZ = event.z;
|
||||
|
||||
if (_lastGyroTime == null) {
|
||||
_lastGyroTime = now;
|
||||
return;
|
||||
|
|
@ -121,29 +159,39 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
if (dt > 0.1) dt = 0.1;
|
||||
if (dt <= 0.0) return;
|
||||
|
||||
// Compensate for static zero-rate gyro bias
|
||||
final unbiasedGx = event.x - _gyroBiasX;
|
||||
final unbiasedGy = event.y - _gyroBiasY;
|
||||
|
||||
// Transform gyro rates into Vehicle Coordinate Frame based on screen orientation
|
||||
double vGyroRollRate; // Rotation around vehicle forward axis (lean rate)
|
||||
double vGyroPitchRate; // Rotation around vehicle transverse axis (pitch rate)
|
||||
|
||||
if (!_isLandscape) {
|
||||
// Portrait
|
||||
vGyroPitchRate = event.x * (180.0 / math.pi);
|
||||
vGyroRollRate = -event.y * (180.0 / math.pi);
|
||||
vGyroPitchRate = unbiasedGx * (180.0 / math.pi);
|
||||
vGyroRollRate = -unbiasedGy * (180.0 / math.pi);
|
||||
} else if (_isLandscapeLeft) {
|
||||
// Landscape Left
|
||||
vGyroPitchRate = event.y * (180.0 / math.pi);
|
||||
vGyroRollRate = event.x * (180.0 / math.pi);
|
||||
vGyroPitchRate = unbiasedGy * (180.0 / math.pi);
|
||||
vGyroRollRate = unbiasedGx * (180.0 / math.pi);
|
||||
} else {
|
||||
// Landscape Right
|
||||
vGyroPitchRate = -event.y * (180.0 / math.pi);
|
||||
vGyroRollRate = -event.x * (180.0 / math.pi);
|
||||
vGyroPitchRate = -unbiasedGy * (180.0 / math.pi);
|
||||
vGyroRollRate = -unbiasedGx * (180.0 / math.pi);
|
||||
}
|
||||
|
||||
_latestRollRateDegPerSec = vGyroRollRate;
|
||||
|
||||
_currentPitch = (_currentPitch + vGyroPitchRate * dt).clamp(-90.0, 90.0);
|
||||
_currentRoll = (_currentRoll + vGyroRollRate * dt).clamp(-90.0, 90.0);
|
||||
});
|
||||
|
||||
_accelSub = accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((AccelerometerEvent event) {
|
||||
_latestRawAccelX = event.x;
|
||||
_latestRawAccelY = event.y;
|
||||
_latestRawAccelZ = event.z;
|
||||
|
||||
// Auto-detect landscape sub-type if in landscape
|
||||
if (_isLandscape && event.x.abs() > 3.0) {
|
||||
_isLandscapeLeft = event.x > 0;
|
||||
|
|
@ -176,13 +224,8 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
final gMag = norm / _gravityConst;
|
||||
final dynamicDelta = (gMag - 1.0).abs();
|
||||
|
||||
// If dynamic force > 0.08g, trust gyro to prevent vehicle acceleration from distorting lean
|
||||
double dynamicAlpha = _baseAlpha;
|
||||
if (dynamicDelta > 0.08) {
|
||||
dynamicAlpha = 0.998;
|
||||
} else if (dynamicDelta > 0.04) {
|
||||
dynamicAlpha = 0.985;
|
||||
}
|
||||
// Continuous dynamic adaptation: smoothly shifts reliance to gyro when total G deviates from 1.0g
|
||||
final dynamicAlpha = (0.94 + 1.2 * dynamicDelta).clamp(0.94, 0.998);
|
||||
|
||||
_currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0);
|
||||
_currentPitch = (dynamicAlpha * _currentPitch + (1.0 - dynamicAlpha) * accelPitch).clamp(-90.0, 90.0);
|
||||
|
|
@ -239,6 +282,12 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
accelX: event.x,
|
||||
accelY: event.y,
|
||||
accelZ: event.z,
|
||||
rawAccelX: _latestRawAccelX,
|
||||
rawAccelY: _latestRawAccelY,
|
||||
rawAccelZ: _latestRawAccelZ,
|
||||
rawGyroX: _latestRawGyroX,
|
||||
rawGyroY: _latestRawGyroY,
|
||||
rawGyroZ: _latestRawGyroZ,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
|
@ -248,13 +297,26 @@ class SensorFusionService with WidgetsBindingObserver {
|
|||
final offsets = await _calibrationService.loadCalibrationOffsets(motorcycleId: motorcycleId);
|
||||
_pitchOffset = offsets['pitch'] ?? 0.0;
|
||||
_rollOffset = offsets['roll'] ?? 0.0;
|
||||
_gyroBiasX = offsets['gyroBiasX'] ?? 0.0;
|
||||
_gyroBiasY = offsets['gyroBiasY'] ?? 0.0;
|
||||
_gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void calibrateUpright({int? motorcycleId}) {
|
||||
_rollOffset = _currentRoll;
|
||||
_pitchOffset = _currentPitch;
|
||||
_calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset, motorcycleId: motorcycleId);
|
||||
_gyroBiasX = _latestRawGyroX;
|
||||
_gyroBiasY = _latestRawGyroY;
|
||||
_gyroBiasZ = _latestRawGyroZ;
|
||||
_calibrationService.saveCalibrationOffsets(
|
||||
_pitchOffset,
|
||||
_rollOffset,
|
||||
gyroBiasX: _gyroBiasX,
|
||||
gyroBiasY: _gyroBiasY,
|
||||
gyroBiasZ: _gyroBiasZ,
|
||||
motorcycleId: motorcycleId,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -50,6 +50,12 @@ class TelemetryService {
|
|||
double _latestCompassHeading = 0.0;
|
||||
DateTime? _latestImuTimestamp;
|
||||
|
||||
// Monotonic authoritative clock
|
||||
final Stopwatch _monotonicStopwatch = Stopwatch();
|
||||
int _sequenceCounter = 0;
|
||||
int _lastEmitMonotonicMs = 0;
|
||||
int get lastEmitMonotonicMs => _lastEmitMonotonicMs;
|
||||
|
||||
TelemetryService(
|
||||
this._locationService,
|
||||
this._sensorFusionService,
|
||||
|
|
@ -57,6 +63,7 @@ class TelemetryService {
|
|||
this._fieldTestLogger,
|
||||
this._validator,
|
||||
) {
|
||||
_monotonicStopwatch.start();
|
||||
_init();
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +116,9 @@ class TelemetryService {
|
|||
|
||||
void _emitSample() {
|
||||
final now = DateTime.now();
|
||||
final monotonicMs = _monotonicStopwatch.elapsedMilliseconds;
|
||||
final sequence = ++_sequenceCounter;
|
||||
|
||||
final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0;
|
||||
final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null;
|
||||
|
||||
|
|
@ -120,6 +130,8 @@ class TelemetryService {
|
|||
|
||||
final rawSample = TelemetrySample(
|
||||
timestamp: now,
|
||||
sequenceNumber: sequence,
|
||||
monotonicTimestampMs: monotonicMs,
|
||||
latitude: _latestPosition?.latitude,
|
||||
longitude: _latestPosition?.longitude,
|
||||
altitude: _latestPosition?.altitude,
|
||||
|
|
@ -131,13 +143,22 @@ class TelemetryService {
|
|||
imuTimestamp: _latestImuTimestamp,
|
||||
leanAngle: _latestLean,
|
||||
pitchAngle: _latestPitch,
|
||||
rollRateDegPerSec: _sensorFusionService.latestRollRateDegPerSec,
|
||||
accelX: _latestGForce?.accelX,
|
||||
accelY: _latestGForce?.accelY,
|
||||
accelZ: _latestGForce?.accelZ,
|
||||
longitudinalG: _latestGForce?.longitudinal,
|
||||
lateralG: _latestGForce?.lateral,
|
||||
rawAccelX: _latestGForce?.rawAccelX ?? _sensorFusionService.latestRawAccelX,
|
||||
rawAccelY: _latestGForce?.rawAccelY ?? _sensorFusionService.latestRawAccelY,
|
||||
rawAccelZ: _latestGForce?.rawAccelZ ?? _sensorFusionService.latestRawAccelZ,
|
||||
rawGyroX: _latestGForce?.rawGyroX ?? _sensorFusionService.latestRawGyroX,
|
||||
rawGyroY: _latestGForce?.rawGyroY ?? _sensorFusionService.latestRawGyroY,
|
||||
rawGyroZ: _latestGForce?.rawGyroZ ?? _sensorFusionService.latestRawGyroZ,
|
||||
);
|
||||
|
||||
_lastEmitMonotonicMs = monotonicMs;
|
||||
|
||||
// Validate sample
|
||||
final quality = _validator.validate(rawSample);
|
||||
final validatedSample = rawSample.copyWith(quality: quality);
|
||||
|
|
|
|||
|
|
@ -52,11 +52,12 @@ final telemetryValidatorProvider = Provider<TelemetryValidator>((ref) {
|
|||
|
||||
class TelemetryValidator {
|
||||
DateTime? _lastTimestamp;
|
||||
DateTime? _lastGpsTimestamp;
|
||||
int? _lastMonotonicMs;
|
||||
DateTime? _lastGpsLocationTime;
|
||||
double? _lastGpsLat;
|
||||
double? _lastGpsLng;
|
||||
double? _lastSpeedMph;
|
||||
double? _lastLeanAngle;
|
||||
double? _lastLat;
|
||||
double? _lastLng;
|
||||
|
||||
// Diagnostic counters
|
||||
int _totalValidated = 0;
|
||||
|
|
@ -73,11 +74,12 @@ class TelemetryValidator {
|
|||
|
||||
void reset() {
|
||||
_lastTimestamp = null;
|
||||
_lastGpsTimestamp = null;
|
||||
_lastMonotonicMs = null;
|
||||
_lastGpsLocationTime = null;
|
||||
_lastGpsLat = null;
|
||||
_lastGpsLng = null;
|
||||
_lastSpeedMph = null;
|
||||
_lastLeanAngle = null;
|
||||
_lastLat = null;
|
||||
_lastLng = null;
|
||||
_totalValidated = 0;
|
||||
_validCount = 0;
|
||||
_suspectCount = 0;
|
||||
|
|
@ -87,8 +89,8 @@ class TelemetryValidator {
|
|||
|
||||
TelemetryQuality validate(TelemetrySample sample) {
|
||||
_totalValidated++;
|
||||
final reasons = <String>[];
|
||||
var status = ValidationStatus.valid;
|
||||
ValidationStatus status = ValidationStatus.valid;
|
||||
final List<String> reasons = [];
|
||||
final currentSpeed = sample.speed ?? 0.0;
|
||||
|
||||
// 1. Numeric Sanity (NaN / Infinity)
|
||||
|
|
@ -108,9 +110,21 @@ class TelemetryValidator {
|
|||
);
|
||||
}
|
||||
|
||||
// 2. Timestamp Validation
|
||||
// 2. Timestamp & Monotonicity Validation
|
||||
int deltaMs = 50;
|
||||
if (_lastTimestamp != null) {
|
||||
if (sample.monotonicTimestampMs != null && _lastMonotonicMs != null) {
|
||||
deltaMs = sample.monotonicTimestampMs! - _lastMonotonicMs!;
|
||||
if (deltaMs <= 0) {
|
||||
status = ValidationStatus.suspect;
|
||||
reasons.add('Reverse or duplicate monotonic timestamp (delta: ${deltaMs}ms)');
|
||||
} else if (deltaMs > 300) {
|
||||
_timestampGapCount++;
|
||||
if (deltaMs > 2000) {
|
||||
status = ValidationStatus.suspect;
|
||||
reasons.add('Large telemetry gap (${(deltaMs / 1000).toStringAsFixed(1)}s)');
|
||||
}
|
||||
}
|
||||
} else if (_lastTimestamp != null) {
|
||||
deltaMs = sample.timestamp.difference(_lastTimestamp!).inMilliseconds;
|
||||
if (deltaMs <= 0) {
|
||||
status = ValidationStatus.suspect;
|
||||
|
|
@ -165,23 +179,32 @@ class TelemetryValidator {
|
|||
}
|
||||
}
|
||||
|
||||
// GPS Position Teleportation Check
|
||||
if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null) {
|
||||
final dtSec = (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp != _lastGpsTimestamp)
|
||||
? sample.gpsTimestamp!.difference(_lastGpsTimestamp!).inMilliseconds / 1000.0
|
||||
: (deltaMs > 0 ? deltaMs / 1000.0 : 0.05);
|
||||
// GPS Position Teleportation Check across distinct location updates
|
||||
if (_lastGpsLat != null && _lastGpsLng != null && sample.latitude != null && sample.longitude != null) {
|
||||
if (sample.latitude != _lastGpsLat || sample.longitude != _lastGpsLng) {
|
||||
final dtSec = _lastGpsLocationTime != null
|
||||
? math.max(0.05, sample.timestamp.difference(_lastGpsLocationTime!).inMilliseconds / 1000.0)
|
||||
: 1.0;
|
||||
|
||||
if (dtSec > 0.02 && dtSec < 5.0) {
|
||||
final latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0;
|
||||
final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
|
||||
final latDiff = (sample.latitude! - _lastGpsLat!).abs() * 111139.0;
|
||||
final lngDiff = (sample.longitude! - _lastGpsLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
|
||||
final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
|
||||
final derivedSpeedMps = distMeters / dtSec;
|
||||
|
||||
if (derivedSpeedMps > 110.0 && distMeters > 50.0) {
|
||||
status = ValidationStatus.rejected;
|
||||
gpsConf = 0.0;
|
||||
reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${(dtSec * 1000).toStringAsFixed(0)}ms)');
|
||||
}
|
||||
|
||||
_lastGpsLat = sample.latitude;
|
||||
_lastGpsLng = sample.longitude;
|
||||
_lastGpsLocationTime = sample.timestamp;
|
||||
}
|
||||
} else if (sample.latitude != null && sample.longitude != null) {
|
||||
_lastGpsLat = sample.latitude;
|
||||
_lastGpsLng = sample.longitude;
|
||||
_lastGpsLocationTime = sample.timestamp;
|
||||
}
|
||||
|
||||
// 4. Speed Plausibility & Confidence
|
||||
|
|
@ -262,12 +285,10 @@ class TelemetryValidator {
|
|||
|
||||
// Update state tracking
|
||||
_lastTimestamp = sample.timestamp;
|
||||
_lastMonotonicMs = sample.monotonicTimestampMs;
|
||||
if (status != ValidationStatus.rejected) {
|
||||
_lastSpeedMph = currentSpeed;
|
||||
_lastLeanAngle = currentLean;
|
||||
if (sample.latitude != null) _lastLat = sample.latitude;
|
||||
if (sample.longitude != null) _lastLng = sample.longitude;
|
||||
if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp;
|
||||
}
|
||||
|
||||
return TelemetryQuality(
|
||||
|
|
@ -316,6 +337,15 @@ class RideQualityReport {
|
|||
final int timestampGaps;
|
||||
final bool isMountCalibrated;
|
||||
|
||||
// Categorical Quality Diagnostics (Phase 9)
|
||||
final int timingIntegrityScore;
|
||||
final int gpsQualityScore;
|
||||
final int imuContinuityScore;
|
||||
final int sensorContinuityScore;
|
||||
final int databaseIntegrityScore;
|
||||
final int filterHealthScore;
|
||||
final List<String> diagnosticExplanations;
|
||||
|
||||
const RideQualityReport({
|
||||
required this.overallScore,
|
||||
required this.rating,
|
||||
|
|
@ -328,6 +358,13 @@ class RideQualityReport {
|
|||
required this.rejectedSamples,
|
||||
required this.timestampGaps,
|
||||
required this.isMountCalibrated,
|
||||
required this.timingIntegrityScore,
|
||||
required this.gpsQualityScore,
|
||||
required this.imuContinuityScore,
|
||||
required this.sensorContinuityScore,
|
||||
required this.databaseIntegrityScore,
|
||||
required this.filterHealthScore,
|
||||
required this.diagnosticExplanations,
|
||||
});
|
||||
|
||||
static RideQualityReport evaluate({
|
||||
|
|
@ -338,6 +375,8 @@ class RideQualityReport {
|
|||
required int timestampGaps,
|
||||
required int gpsFixCount,
|
||||
required double totalGpsAccuracy,
|
||||
int databaseSamplesLost = 0,
|
||||
int imuSaturationCount = 0,
|
||||
bool isMountCalibrated = true,
|
||||
}) {
|
||||
if (totalSamples == 0) {
|
||||
|
|
@ -353,6 +392,13 @@ class RideQualityReport {
|
|||
rejectedSamples: 0,
|
||||
timestampGaps: 0,
|
||||
isMountCalibrated: false,
|
||||
timingIntegrityScore: 0,
|
||||
gpsQualityScore: 0,
|
||||
imuContinuityScore: 0,
|
||||
sensorContinuityScore: 0,
|
||||
databaseIntegrityScore: 100,
|
||||
filterHealthScore: 0,
|
||||
diagnosticExplanations: ['No telemetry samples recorded in session.'],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -360,22 +406,62 @@ class RideQualityReport {
|
|||
final gpsCoverage = gpsFixCount > 0 ? math.min(1.0, (gpsFixCount * 2) / totalSamples) : 0.0;
|
||||
final avgAccuracy = gpsFixCount > 0 ? (totalGpsAccuracy / gpsFixCount) : 999.0;
|
||||
|
||||
// Base score calculation (0 - 100)
|
||||
double score = (validRatio * 50.0) + (gpsCoverage * 30.0);
|
||||
|
||||
if (avgAccuracy <= 5.0) {
|
||||
score += 20.0;
|
||||
} else if (avgAccuracy <= 15.0) {
|
||||
score += 15.0;
|
||||
} else if (avgAccuracy <= 35.0) {
|
||||
score += 8.0;
|
||||
} else {
|
||||
score += 2.0;
|
||||
// 1. Timing Integrity: Deduct for timestamp gaps / irregularity
|
||||
int timingScore = 100;
|
||||
if (timestampGaps > 0) {
|
||||
timingScore = math.max(0, 100 - (timestampGaps * 5));
|
||||
}
|
||||
|
||||
// 2. GPS Quality: Fix coverage & precision
|
||||
int gpsScore = (gpsCoverage * 60.0).round();
|
||||
if (avgAccuracy <= 5.0) {
|
||||
gpsScore += 40;
|
||||
} else if (avgAccuracy <= 15.0) {
|
||||
gpsScore += 30;
|
||||
} else if (avgAccuracy <= 35.0) {
|
||||
gpsScore += 15;
|
||||
} else {
|
||||
gpsScore += 5;
|
||||
}
|
||||
gpsScore = gpsScore.clamp(0, 100);
|
||||
|
||||
// 3. IMU Continuity & Sensor Health
|
||||
int imuScore = 100;
|
||||
if (imuSaturationCount > 0) {
|
||||
imuScore = math.max(50, 100 - (imuSaturationCount * 10));
|
||||
}
|
||||
|
||||
// 4. Database Integrity: Written vs Lost
|
||||
int dbScore = 100;
|
||||
if (databaseSamplesLost > 0) {
|
||||
dbScore = math.max(0, 100 - ((databaseSamplesLost / totalSamples) * 100.0).round());
|
||||
}
|
||||
|
||||
// 5. Filter Health
|
||||
int filterScore = ((validSamples / totalSamples) * 100.0).round().clamp(0, 100);
|
||||
|
||||
// Human-readable diagnostic breakdown
|
||||
final List<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 (timestampGaps > 5) score -= math.min(15.0, timestampGaps * 2.0);
|
||||
if (rejectedSamples > 0) score -= math.min(20.0, (rejectedSamples / totalSamples) * 100.0);
|
||||
if (rejectedSamples > 0) score -= math.min(15.0, (rejectedSamples / totalSamples) * 100.0);
|
||||
|
||||
final finalScore = score.round().clamp(0, 100);
|
||||
|
||||
|
|
@ -404,6 +490,13 @@ class RideQualityReport {
|
|||
rejectedSamples: rejectedSamples,
|
||||
timestampGaps: timestampGaps,
|
||||
isMountCalibrated: isMountCalibrated,
|
||||
timingIntegrityScore: timingScore,
|
||||
gpsQualityScore: gpsScore,
|
||||
imuContinuityScore: imuScore,
|
||||
sensorContinuityScore: 100,
|
||||
databaseIntegrityScore: dbScore,
|
||||
filterHealthScore: filterScore,
|
||||
diagnosticExplanations: explanations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+100
|
||||
version: 1.0.2+102
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
|
@ -100,10 +100,9 @@ flutter:
|
|||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/images/motorcycles/
|
||||
- assets/screens/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
{
|
||||
"application": "MotorSignal",
|
||||
"version": "1.0.0",
|
||||
"build": 100,
|
||||
"gitCommit": "e700ca64fdb6365d30a76d81236c912a730bfad2",
|
||||
"version": "1.0.2",
|
||||
"build": 102,
|
||||
"gitCommit": "HEAD",
|
||||
"gitBranch": "master",
|
||||
"releaseChannel": "release",
|
||||
"telemetrySchema": 5,
|
||||
"exportSchema": 1,
|
||||
"buildTimestamp": "2026-08-28T05:00:00Z",
|
||||
"buildTimestamp": "2026-08-30T08:00:00Z",
|
||||
"flutterVersion": "3.x / SDK ^3.12.2",
|
||||
"dartVersion": "3.x"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ class _NullSensorFusionService extends Fake implements SensorFusionService {
|
|||
Stream<double> get pitchAngleStream => const Stream.empty();
|
||||
@override
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:motorcycle_dash/core/version/app_identity.dart';
|
||||
|
|
@ -52,7 +52,8 @@ void main() {
|
|||
test('3. Git SHA is captured with valid 40-char full hash representation', () {
|
||||
final identity = AppIdentity.current();
|
||||
expect(identity.gitCommit, isNotEmpty);
|
||||
expect(identity.displayString.contains(identity.gitCommit.substring(0, 8)), isTrue);
|
||||
final commitPrefix = identity.gitCommit.length > 8 ? identity.gitCommit.substring(0, 8) : identity.gitCommit;
|
||||
expect(identity.displayString.contains(commitPrefix), isTrue);
|
||||
});
|
||||
|
||||
// 4. Missing Git metadata handling
|
||||
|
|
@ -80,7 +81,7 @@ void main() {
|
|||
final identity = AppIdentity.current();
|
||||
expect(identity.telemetrySchemaVersion, 5);
|
||||
expect(identity.exportSchemaVersion, 1);
|
||||
expect(identity.semanticVersion, '1.0.0');
|
||||
expect(identity.semanticVersion, '1.0.2');
|
||||
// Verify changing app version does not change schema versions
|
||||
const customIdentity = AppIdentity(
|
||||
semanticVersion: '2.0.0',
|
||||
|
|
@ -101,17 +102,17 @@ void main() {
|
|||
const rideId = 777;
|
||||
logger.startSession(rideId, notes: 'Field test readiness session');
|
||||
|
||||
expect(logger.sessionIdentity.semanticVersion, '1.0.0');
|
||||
expect(logger.sessionIdentity.buildNumber, 100);
|
||||
expect(logger.sessionIdentity.semanticVersion, '1.0.2');
|
||||
expect(logger.sessionIdentity.buildNumber, 102);
|
||||
expect(logger.sessionIdentity.telemetrySchemaVersion, 5);
|
||||
|
||||
await logger.flush();
|
||||
final events = await (db.select(db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get();
|
||||
final startEvent = events.firstWhere((e) => e.eventType == 'RIDE_STARTED');
|
||||
expect(startEvent.message.contains('1.0.0+100'), isTrue);
|
||||
expect(startEvent.message.contains('1.0.2+102'), isTrue);
|
||||
final meta = jsonDecode(startEvent.metadata!);
|
||||
expect(meta['software']['version'], '1.0.0');
|
||||
expect(meta['software']['build'], 100);
|
||||
expect(meta['software']['version'], '1.0.2');
|
||||
expect(meta['software']['build'], 102);
|
||||
});
|
||||
|
||||
// 8. Export metadata verification
|
||||
|
|
@ -132,8 +133,8 @@ void main() {
|
|||
final Map<String, dynamic> data = jsonDecode(jsonString);
|
||||
|
||||
expect(data['software'], isNotNull);
|
||||
expect(data['software']['version'], '1.0.0');
|
||||
expect(data['software']['build'], 100);
|
||||
expect(data['software']['version'], '1.0.2');
|
||||
expect(data['software']['build'], 102);
|
||||
expect(data['software']['telemetrySchemaVersion'], 5);
|
||||
expect(data['software']['exportSchemaVersion'], 1);
|
||||
});
|
||||
|
|
@ -148,17 +149,17 @@ void main() {
|
|||
// 10. Version metadata consistency
|
||||
test('10. AppInfo and AppIdentity maintain consistent fallback and single source of truth', () {
|
||||
final identity = AppInfo.identity;
|
||||
expect(AppInfo.fallbackVersion, '1.0.0+100');
|
||||
expect(identity.semanticVersion, '1.0.0');
|
||||
expect(identity.buildNumber, 100);
|
||||
expect(AppInfo.fallbackVersion, '1.0.2+102');
|
||||
expect(identity.semanticVersion, '1.0.2');
|
||||
expect(identity.buildNumber, 102);
|
||||
});
|
||||
|
||||
// 11. Release manifest generation
|
||||
test('11. Release manifest contains all required metadata attributes', () {
|
||||
final manifest = AppIdentity.current().generateReleaseManifest();
|
||||
expect(manifest['application'], 'MotorSignal');
|
||||
expect(manifest['version'], '1.0.0');
|
||||
expect(manifest['build'], 100);
|
||||
expect(manifest['version'], '1.0.2');
|
||||
expect(manifest['build'], 102);
|
||||
expect(manifest['gitCommit'], isNotEmpty);
|
||||
expect(manifest['telemetrySchema'], 5);
|
||||
expect(manifest['exportSchema'], 1);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
Loading…
Reference in New Issue