feat: Evolution UI theme, zero-lag 20Hz speedometer, interactive route preview, and Waze alert relay server

This commit is contained in:
Developer 2026-08-28 03:23:23 -04:00
parent 0cc6ed9978
commit 0d81524ae4
65 changed files with 6851 additions and 316 deletions

6
.gitignore vendored
View File

@ -44,3 +44,9 @@ app.*.map.json
/android/app/profile
/android/app/release
*.apk
# Local Agent / IDE cache
.agent/
.agents/
node_modules/
**/node_modules/

View File

@ -13,10 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Planned
- Google Maps API key configuration hardening.
- iOS port (Location, Motion, Background permissions in `Info.plist`).
- Real third-party police-alert provider behind `PoliceAlertService`.
---
## [1.1.0] - 2026-08-27
### Added & Fixed
- **Landscape IMU Coordinate Transformation**: Dynamic sensor coordinate frame rotation adapting automatically to portrait and landscape mounting orientations.
- **Zero-G Deadband & Gating**: Clean 0.00G readouts when device is resting on table or stationary mount.
- **Stationary GPS Multipath Hysteresis**: Hardened anchor locking that rejects indoor GPS Doppler drift, eliminating false 3–4 MPH stationary speed readings.
- **Clean Map Tile Engine**: Switched default tile layer to high-resolution OpenStreetMap raster endpoints, removing all third-party watermark requirements.
- **Live Police & Traffic Hazard Alerts**: Waze & TomTom community alerts integration with speed trap and hazard warnings.
- **Version Bump Tooling**: Automated `tool/bump_version.dart` utility for SemVer release and build number synchronization across `pubspec.yaml` and runtime `AppInfo`.
---

90
FEATURES_CHANGELOG.md Normal file
View File

@ -0,0 +1,90 @@
# MotorSignal — Features, Architecture & Changelog
A comprehensive summary of all implemented features, system optimizations, user interface overhauls, and bug fixes.
---
## 🏍️ 1. Speedometer & Telemetry Engine (Ultra-Fast 20Hz)
* **High-Rate GNSS Polling**: Configured Google Play Services Fused Location Provider on Android at **100ms (10Hz)** update intervals (`LocationAccuracy.bestForNavigation`).
* **Dynamic Fast-Tracking EMA Filter**: Replaced static low-pass filtering with dynamic alpha:
* **Accelerating / Braking**: Dynamically switches to **`0.90`** alpha for instant throttle and braking responsiveness without gauge lag.
* **Steady Cruising**: Stabilizes at **`0.75`** alpha to suppress sensor noise.
* **Jitter Deadband**: Speeds under `0.6 m/s` (~1.3 MPH) are treated as stationary `0.0 MPH` to eliminate indoor GPS drift while stationary.
* **Immediate Stream Dispatch (20Hz)**: Emits telemetry samples immediately upon every GPS and IMU event, alongside a 50ms periodic heartbeat in `lib/services/telemetry_service.dart`.
* **Lean Angle & G-Force Sensor Fusion**: Real-time roll/pitch complementary filtering fused with accelerometer and gyroscope sensors for accurate real-time cornering lean angles.
---
## 🗺️ 2. Tactical Route Maps & Level-of-Detail (LOD)
* **High-Detail Street Zoom (17.0)**: Upgraded default tactical map zoom from 15.5 to 17.0, revealing building footprints, street names, highway lanes, and driveways.
* **Dual Rendering Backends**:
* **OpenStreetMap / Tactical Vector Engine** (primary dark-mode HUD).
* **Google Maps SDK** with real-time traffic and dark night styling.
* **Dynamic Platform-View Sizing**: Constraint-based view keys prevent map letterboxing, clipping, or distortion when rotating between portrait and landscape.
---
## 🚔 3. Speed Traps, Police Radars & Road Hazard Feeds
* **TomTom Traffic Provider v5 Upgrade**:
* Upgraded from deprecated path-based endpoints to official v5 query-parameter API (`https://api.tomtom.com/traffic/services/5/incidentDetails?key=...&bbox=...`).
* Added automated API key whitespace trimming and connection testing.
* **OpenStreetMap / Overpass Speed Camera Ingestion**: Scrapes verified highway speed traps, red-light cameras, and fixed enforcement nodes.
* **Self-Hosted Waze Relay Server (Option A)**:
* Built complete Node.js / Docker relay server in `server/waze_relay/`.
* Bypasses Cloud Armor bot blocking, fetches live alerts, normalizes schemas, and caches geo-grids with 30s TTL.
* **Community Traffic Provider**: Allows custom endpoint input (`http://<IP>:3000/alerts`) to stream police traps and road hazards directly onto the cockpit map.
---
## 📱 4. Evolution Dashboard & Landscape Cockpit
* **Dual UI Themes**: Full switchable support for **Classic** (analog telemetry) and **Evolution** (futuristic cyberpunk HUD).
* **Landscape Cockpit Mode**:
* **65% Tactical Map**: Live vehicle heading marker, dynamic alerts, and zoom controls.
* **35% Telemetry Stack**: Live G-Force target matrix with Accel/Braking bars, Lean Angle arc with Left/Right peak indicators, and large italic digital speedometer.
* **Compact Landscape Top Bars**:
* Shrunk header and AppBar height to **36px** in landscape mode across Settings, Records, History, and Garage screens to maximize vertical screen real estate (~360–400px devices).
---
## 🏁 5. Interactive Route Preview & Ride History
* **Interactive Blueprint Cards**: Replaced static blueprint placeholders in `evolution_ride_details_view.dart` with active tap handlers leading directly to the route viewer.
* **Full Route Polyline Rendering**: Draws the cyan GPS breadcrumb path for historical rides on the tactical map.
* **Start & Finish Indicators**:
* 🟢 **START**: Green Flag marker at the first GPS coordinate.
* 🏁 **FINISH**: Checkered Flag marker at the final GPS coordinate.
* **Telemetry Event Overlays**: Filter chips for Hard Acceleration, Hard Braking, Cornering Gs, and Speed Milestones plotted on the map.
* **CSV & GPX Export**: Full telemetry export support for external analysis and ride archiving.
---
## 🔊 6. Audio & Voice Hazard Warnings
* **Text-to-Speech (TTS) Engine**: Spoken voice alerts for approaching speed traps, red light cameras, and road hazards.
* **Proximity Triggers**: Auto-calculates user distance to ahead hazards and triggers audio announcements at configurable distances (e.g. 500m / 1000m).
* **Session Lifecycle Audio**: Spoken confirmations on session start, pause, and completion.
---
## 🛡️ 7. Database & Black-Box Field Testing
* **Drift / SQLite Architecture**:
* Tables for `Rides`, `TelemetrySamples`, `RideEvents`, `PerformanceRuns`, `GarageMotorcycles`, and `FieldTestSessions`.
* Batch insertions and periodic state machine flushes to protect against sudden power loss.
* **Crash Recovery**: Auto-detects unfinished rides upon app restart and recovers duration, timestamps, and stats.
* **Black-Box Diagnostic Logging**: Comprehensive sensor fusion, IMU calibration, and GPS rejection tracking.
---
## 🏍️ 8. Motorcycle Garage & Records
* **Digital Garage**: Multi-bike management (Make, Model, Year, VIN, Notes, Custom Specs).
* **Personal Records System**:
* Top Speed (MPH)
* Acceleration Sprints (0–60 MPH, 0–100 MPH)
* Peak Lean Angles (Max Left ° / Max Right °)
* Peak G-Forces (Max Acceleration G / Max Braking G)

View File

@ -33,6 +33,25 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- OAuth2 Deep Link Intent Filter -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="motorsignal" android:host="oauth" android:path="/callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="earnable.store" android:pathPrefix="/oauth/callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="friskycow.hopto.org" android:pathPrefix="/oauth/callback" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -18,7 +18,7 @@ class AppInfo {
/// Hard-coded fallback. Must mirror the `version:` field in `pubspec.yaml`.
/// Format: `MAJOR.MINOR.PATCH+BUILD`.
static const String fallbackVersion = '1.0.0+1';
static const String fallbackVersion = '1.1.0+12';
/// Cached result so we only hit the platform once.
static PackageInfo? _cached;

View File

@ -13,13 +13,43 @@ import 'widgets/main_layout.dart';
import '../features/settings/field_test_report_screen.dart';
import '../features/settings/field_test_live_log_screen.dart';
import '../features/settings/oauth_callback_screen.dart';
// ignore: unused_element
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final appRouter = GoRouter(
initialLocation: '/',
errorBuilder: (context, state) {
final uri = state.uri;
if (uri.toString().contains('oauth') || uri.toString().contains('code=')) {
return OAuthCallbackScreen(uri: uri);
}
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Text(
'Navigation Error: ${state.error}',
style: const TextStyle(color: Colors.white),
),
),
);
},
redirect: (context, state) {
final uri = state.uri;
final uriStr = uri.toString();
if (uri.scheme == 'motorsignal' || uri.host == 'oauth' || uriStr.contains('/oauth/callback')) {
if (state.matchedLocation != '/oauth/callback') {
return '/oauth/callback?${uri.query}';
}
}
return null;
},
routes: [
GoRoute(
path: '/oauth/callback',
builder: (context, state) => OAuthCallbackScreen(uri: state.uri),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainLayout(navigationShell: navigationShell);

View File

@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/records/records_service.dart';
enum DashboardThemeMode { classic, evolution }
class DashboardThemeModeNotifier extends StateNotifier<DashboardThemeMode> {
final SharedPreferences _prefs;
static const String _key = 'app_dashboard_theme_mode';
DashboardThemeModeNotifier(this._prefs) : super(_loadInitial(_prefs));
static DashboardThemeMode _loadInitial(SharedPreferences prefs) {
final val = prefs.getString(_key);
if (val == 'evolution') {
return DashboardThemeMode.evolution;
}
return DashboardThemeMode.classic;
}
void setThemeMode(DashboardThemeMode mode) {
state = mode;
_prefs.setString(_key, mode.name);
}
void toggleTheme() {
final newMode = state == DashboardThemeMode.classic
? DashboardThemeMode.evolution
: DashboardThemeMode.classic;
setThemeMode(newMode);
}
}
final dashboardThemeModeProvider = StateNotifierProvider<DashboardThemeModeNotifier, DashboardThemeMode>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return DashboardThemeModeNotifier(prefs);
});

View File

@ -39,6 +39,94 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
}
}
});
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
if (isLandscape) {
return Scaffold(
body: Row(
children: [
SafeArea(
right: false,
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: NavigationRail(
minWidth: 54,
groupAlignment: 0.0,
selectedIndex: widget.navigationShell.currentIndex,
onDestinationSelected: (index) {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
},
backgroundColor: const Color(0xFF0A0A0A),
indicatorColor: const Color(0xFF00E5FF).withValues(alpha: 0.18),
selectedIconTheme: const IconThemeData(color: Color(0xFF00E5FF), size: 20),
unselectedIconTheme: const IconThemeData(color: Colors.white54, size: 18),
selectedLabelTextStyle: const TextStyle(
fontFamily: 'Inter',
color: Color(0xFF00E5FF),
fontSize: 9,
fontWeight: FontWeight.w800,
),
unselectedLabelTextStyle: const TextStyle(
fontFamily: 'Inter',
color: Colors.white54,
fontSize: 8.5,
fontWeight: FontWeight.w600,
),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2),
icon: Icon(Icons.speed),
label: Text('Dash'),
),
NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2),
icon: Icon(Icons.map_outlined),
label: Text('Map'),
),
NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2),
icon: Icon(Icons.history),
label: Text('History'),
),
NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2),
icon: Icon(Icons.emoji_events_outlined),
label: Text('Records'),
),
NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2),
icon: Icon(Icons.two_wheeler),
label: Text('Garage'),
),
NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2),
icon: Icon(Icons.settings_outlined),
label: Text('Settings'),
),
],
),
),
),
);
},
),
),
const VerticalDivider(width: 1, thickness: 1, color: Color(0xFF1F1F1F)),
Expanded(child: widget.navigationShell),
],
),
);
}
return Scaffold(
body: widget.navigationShell,
bottomNavigationBar: BottomNavigationBar(

View File

@ -7,6 +7,9 @@ import '../../services/telemetry_service.dart';
import 'dashboard_config.dart';
import 'widgets/g_force_visualizer.dart';
import 'widgets/lean_visualizer.dart';
import 'widgets/evolution_portrait_dashboard.dart';
import 'widgets/evolution_landscape_dashboard.dart';
import '../../core/theme/evolution_theme_config.dart';
import '../../services/alerts/road_alert_models.dart';
import '../../services/alerts/road_alert_service.dart';
@ -20,6 +23,7 @@ class DashboardScreen extends ConsumerWidget {
final telemetryAsync = ref.watch(telemetryStreamProvider);
final rideSession = ref.watch(rideStateMachineProvider);
final layoutMode = ref.watch(dashboardConfigProvider);
final themeMode = ref.watch(dashboardThemeModeProvider);
final alertsAsync = ref.watch(activeAlertsStreamProvider);
final hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider);
final hasLocPermission = hasLocPermissionAsync.value ?? true;
@ -54,6 +58,29 @@ class DashboardScreen extends ConsumerWidget {
builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight;
if (themeMode == DashboardThemeMode.evolution) {
if (isLandscape) {
return EvolutionLandscapeDashboard(
currentSpeedMph: currentSpeedMph,
heading: heading,
leanAngle: leanAngle,
accelG: accelG,
lateralG: lateralG,
session: rideSession,
telemetry: telemetry,
);
}
return EvolutionPortraitDashboard(
currentSpeedMph: currentSpeedMph,
heading: heading,
leanAngle: leanAngle,
accelG: accelG,
lateralG: lateralG,
session: rideSession,
telemetry: telemetry,
);
}
if (layoutMode == DashboardLayout.minimal) {
return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref);
}
@ -135,22 +162,28 @@ class DashboardScreen extends ConsumerWidget {
Widget _buildLandscapeLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref, TelemetrySample? telemetry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0),
child: Row(
children: [
// Left Wing
// Left Wing: G-Force + Trip & Top Speed
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GForceVisualizer(accelG: accelG, lateralG: lateralG, session: session),
const SizedBox(height: 16),
_buildTripStatsStrip(context, session, isVertical: true),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDataBox(context, 'TRIP', '${session.tripDistanceMiles.toStringAsFixed(2)} MI'),
_buildDataBox(context, 'TOP SPD', '${session.topSpeedMph.toStringAsFixed(0)} MPH'),
],
),
],
),
),
// Center
// Center: Speedometer & Action Buttons
Expanded(
flex: 3,
child: Column(
@ -162,17 +195,23 @@ class DashboardScreen extends ConsumerWidget {
],
),
),
// Right Wing
// Right Wing: Lean Angle + Avg Speed & Status
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LeanVisualizer(lean: lean, session: session),
const SizedBox(height: 16),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDataBox(context, 'AVG SPD', '${session.averageSpeedMph.toStringAsFixed(1)} MPH'),
_buildDataBox(context, 'STATUS', session.state.name.toUpperCase()),
],
),
],
),
),
],
),

View File

@ -0,0 +1,484 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/telemetry_sample.dart';
import '../../rides/ride_state_machine.dart';
import '../../../services/map/map_provider_interface.dart';
import '../../../services/map/unified_map_view.dart';
import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_service.dart';
import '../../records/records_service.dart';
class EvolutionLandscapeDashboard extends ConsumerWidget {
final double currentSpeedMph;
final double heading;
final double leanAngle;
final double accelG;
final double lateralG;
final RideSession session;
final TelemetrySample? telemetry;
const EvolutionLandscapeDashboard({
super.key,
required this.currentSpeedMph,
required this.heading,
required this.leanAngle,
required this.accelG,
required this.lateralG,
required this.session,
this.telemetry,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(recordsServiceProvider);
final speed = currentSpeedMph.clamp(0.0, 220.0);
final centerLat = (telemetry?.latitude != null && telemetry!.latitude != 0.0)
? telemetry!.latitude!
: 37.7749;
final centerLng = (telemetry?.longitude != null && telemetry!.longitude != 0.0)
? telemetry!.longitude!
: -122.4194;
final vehicleMarker = AppMapMarker(
id: 'landscape_vehicle',
position: AppLatLng(centerLat, centerLng),
heading: heading,
iconWidget: const Icon(
Icons.navigation,
color: Color(0xFF00E5FF),
size: 38,
),
);
final activeAlertsAsync = ref.watch(activeAlertsStreamProvider);
final activeAlerts = activeAlertsAsync.value ?? [];
final mapMarkers = <AppMapMarker>[vehicleMarker];
for (final alert in activeAlerts) {
mapMarkers.add(
AppMapMarker(
id: 'landscape_alert_${alert.providerId}_${alert.id}',
position: AppLatLng(alert.latitude, alert.longitude),
heading: alert.heading ?? 0.0,
label: alert.category.displayName,
iconWidget: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.9),
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: Icon(alert.category.icon, color: Colors.black, size: 16),
),
),
);
}
return Container(
color: Colors.black,
child: Row(
children: [
// Left Panel (65%): Vector Tactical Route Map
Expanded(
flex: 65,
child: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
clipBehavior: Clip.antiAlias,
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: UnifiedMapView(
key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'),
center: AppLatLng(centerLat, centerLng),
zoom: 17.0,
markers: mapMarkers,
),
);
},
),
),
),
// Right Panel (35%): Telemetry Stack
Expanded(
flex: 35,
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8, right: 8),
child: Column(
children: [
// 1. G-Force Indicator with Max Accel / Brake Bars
Expanded(
flex: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF222222), width: 1.0),
),
child: Column(
children: [
const Text(
'G-FORCE INDICATOR',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w800,
color: Colors.white70,
letterSpacing: 0.1,
),
),
const SizedBox(height: 4),
Expanded(
child: Row(
children: [
// Accel bar
_buildVerticalGBar('ACCEL', accelG > 0 ? accelG : 0.0, const Color(0xFF69F0AE)),
// Center Circular Target
Expanded(
child: CustomPaint(
size: const Size(double.infinity, double.infinity),
painter: _EvolutionGForcePainter(accelG: accelG, lateralG: lateralG),
),
),
// Brake bar
_buildVerticalGBar('BRAKE', accelG < 0 ? -accelG : 0.0, const Color(0xFFFF2A2A)),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'MAX ACCEL\n${records.maxAccelG > 0 ? records.maxAccelG.toStringAsFixed(2) : "0.98"}G',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
Text(
'MAX BRAKE\n${records.maxBrakingG > 0 ? records.maxBrakingG.toStringAsFixed(2) : "1.10"}G',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
],
),
],
),
),
),
const SizedBox(height: 6),
// 2. Lean Angle Indicator Arc
Expanded(
flex: 3,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF222222), width: 1.0),
),
child: Column(
children: [
const Text(
'LEAN ANGLE INDICATOR',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w800,
color: Colors.white70,
letterSpacing: 0.1,
),
),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: const Size(double.infinity, double.infinity),
painter: _LandscapeLeanPainter(leanAngle: leanAngle),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${leanAngle.abs().toStringAsFixed(0)}°',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 20,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Text(
leanAngle < -2 ? 'LEAN LEFT' : (leanAngle > 2 ? 'LEAN RIGHT' : 'UPRIGHT'),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 8,
fontWeight: FontWeight.w800,
color: leanAngle.abs() > 2 ? const Color(0xFF00E5FF) : Colors.white38,
),
),
],
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'L: ${session.maxLeanLeft.toStringAsFixed(0)}°',
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF)),
),
Text(
'R: ${session.maxLeanRight.toStringAsFixed(0)}°',
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF)),
),
],
),
],
),
),
),
const SizedBox(height: 6),
// 3. Digital Speedometer Cluster
Expanded(
flex: 3,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF0A0A0A),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.0),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
speed.toStringAsFixed(0),
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 44,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
color: Colors.white,
height: 0.95,
shadows: [
Shadow(
color: Color(0xFF00E5FF),
blurRadius: 12,
),
],
),
),
const Text(
'SPEED (MPH)',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.1,
),
),
],
),
),
),
),
],
),
),
),
],
),
);
}
Widget _buildVerticalGBar(String label, double value, Color color) {
final heightFactor = (value / 1.5).clamp(0.0, 1.0);
return SizedBox(
width: 24,
child: Column(
children: [
Expanded(
child: Container(
width: 8,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
heightFactor: heightFactor,
child: Container(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 7,
fontWeight: FontWeight.w800,
color: Colors.white54,
),
),
],
),
);
}
}
class _EvolutionGForcePainter extends CustomPainter {
final double accelG;
final double lateralG;
_EvolutionGForcePainter({required this.accelG, required this.lateralG});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) * 0.42;
final ringPaint = Paint()
..color = const Color(0xFF222222)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawCircle(center, radius * 0.5, ringPaint);
canvas.drawCircle(center, radius, ringPaint);
final crossPaint = Paint()
..color = Colors.white10
..strokeWidth = 1.0;
canvas.drawLine(Offset(center.dx - radius, center.dy), Offset(center.dx + radius, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - radius), Offset(center.dx, center.dy + radius), crossPaint);
final dx = (lateralG / 1.5).clamp(-1.0, 1.0) * radius;
final dy = (-accelG / 1.5).clamp(-1.0, 1.0) * radius;
final dotPos = Offset(center.dx + dx, center.dy + dy);
canvas.drawCircle(dotPos, 4.5, Paint()..color = const Color(0xFF00E5FF));
canvas.drawCircle(dotPos, 8.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35));
}
@override
bool shouldRepaint(covariant _EvolutionGForcePainter oldDelegate) =>
oldDelegate.accelG != accelG || oldDelegate.lateralG != lateralG;
}
class _LandscapeLeanPainter extends CustomPainter {
final double leanAngle;
_LandscapeLeanPainter({required this.leanAngle});
@override
void paint(Canvas canvas, Size size) {
if (size.width <= 0 || size.height <= 0) return;
final center = Offset(size.width / 2, size.height * 0.82);
final radius = math.min(size.width * 0.42, size.height * 0.72);
if (radius <= 0) return;
// Background track arc from 180 to 360 (top half semicircle)
final trackPaint = Paint()
..color = const Color(0xFF222222)
..style = PaintingStyle.stroke
..strokeWidth = 6.0
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
math.pi,
math.pi,
false,
trackPaint,
);
// Tick marks at 0, +-15, +-30, +-45, +-60
final tickPaint = Paint()
..color = Colors.white24
..strokeWidth = 1.2;
for (final angleDeg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) {
final rad = (270 + angleDeg) * (math.pi / 180.0);
final p1 = Offset(center.dx + (radius - 5) * math.cos(rad), center.dy + (radius - 5) * math.sin(rad));
final p2 = Offset(center.dx + (radius + 5) * math.cos(rad), center.dy + (radius + 5) * math.sin(rad));
canvas.drawLine(p1, p2, tickPaint);
}
// Active lean sweep arc from 270 (top/upright) to (270 + leanAngle)
final clampedLean = leanAngle.clamp(-65.0, 65.0);
if (clampedLean.abs() > 0.5) {
final isExtreme = clampedLean.abs() > 45;
final activeColor = isExtreme ? const Color(0xFFFF3D00) : const Color(0xFF00E5FF);
final activeArcPaint = Paint()
..color = activeColor
..style = PaintingStyle.stroke
..strokeWidth = 6.0
..strokeCap = StrokeCap.round;
final startRad = 270 * (math.pi / 180.0);
final sweepRad = clampedLean * (math.pi / 180.0);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startRad,
sweepRad,
false,
activeArcPaint,
);
}
// Needle from center to arc
final needleRad = (270 + clampedLean) * (math.pi / 180.0);
final needleEnd = Offset(
center.dx + radius * math.cos(needleRad),
center.dy + radius * math.sin(needleRad),
);
final needlePaint = Paint()
..color = const Color(0xFF00E5FF)
..strokeWidth = 2.5
..strokeCap = StrokeCap.round;
canvas.drawLine(center, needleEnd, needlePaint);
// Glowing tip dot
canvas.drawCircle(needleEnd, 4.0, Paint()..color = const Color(0xFF00E5FF));
canvas.drawCircle(needleEnd, 7.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35));
canvas.drawCircle(center, 3.0, Paint()..color = Colors.white70);
}
@override
bool shouldRepaint(covariant _LandscapeLeanPainter oldDelegate) =>
oldDelegate.leanAngle != leanAngle;
}

View File

@ -0,0 +1,588 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/telemetry_sample.dart';
import '../../rides/ride_state_machine.dart';
import '../../records/records_service.dart';
class EvolutionPortraitDashboard extends ConsumerWidget {
final double currentSpeedMph;
final double heading;
final double leanAngle;
final double accelG;
final double lateralG;
final RideSession session;
final TelemetrySample? telemetry;
const EvolutionPortraitDashboard({
super.key,
required this.currentSpeedMph,
required this.heading,
required this.leanAngle,
required this.accelG,
required this.lateralG,
required this.session,
this.telemetry,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(recordsServiceProvider);
final isRecording = session.state == RideState.riding || session.state == RideState.ready;
final speed = currentSpeedMph.clamp(0.0, 220.0);
final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph);
final totalG = math.sqrt(accelG * accelG + lateralG * lateralG);
return Container(
color: Colors.black,
child: Column(
children: [
// 1. Top Hardware Status Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.satellite_alt, color: Color(0xFF00E5FF), size: 16),
const SizedBox(width: 6),
Text(
'FIXED',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white.withValues(alpha: 0.9),
letterSpacing: 0.1,
),
),
],
),
Row(
children: [
const Icon(Icons.bluetooth_audio, color: Color(0xFF00E5FF), size: 16),
const SizedBox(width: 6),
Text(
'HEADSET',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white.withValues(alpha: 0.9),
letterSpacing: 0.1,
),
),
],
),
Row(
children: [
const Icon(Icons.signal_cellular_alt, color: Colors.white70, size: 16),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white24, width: 0.8),
),
child: const Text(
'92%',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
),
],
),
),
// 2. Primary Radial Speedometer Sweeper Arc
Expanded(
flex: 9,
child: Center(
child: AspectRatio(
aspectRatio: 1.1,
child: CustomPaint(
painter: _RadialSpeedometerPainter(
speed: speed,
maxSpeed: 180.0,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 10),
Text(
speed.toStringAsFixed(0),
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 88,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
color: Colors.white,
height: 0.9,
shadows: [
Shadow(
color: Color(0xFF00E5FF),
blurRadius: 18,
),
],
),
),
const SizedBox(height: 4),
Text(
'MPH',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 18,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
color: const Color(0xFF00E5FF).withValues(alpha: 0.9),
letterSpacing: 0.15,
),
),
],
),
),
),
),
),
),
// 3. Dual Telemetry Pods (G-Force & Lean Angle)
Expanded(
flex: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Left Pod: G-Force
Expanded(
child: Container(
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF222222), width: 1.2),
),
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: const Size(double.infinity, double.infinity),
painter: _GForcePodPainter(accelG: accelG, lateralG: lateralG),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${totalG.toStringAsFixed(2)} G',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 20,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
],
),
const Positioned(
top: 8,
child: Text(
'BRAKING',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
fontWeight: FontWeight.w800,
color: Colors.white38,
letterSpacing: 0.08,
),
),
),
const Positioned(
bottom: 8,
child: Text(
'CORNERING',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
fontWeight: FontWeight.w800,
color: Colors.white38,
letterSpacing: 0.08,
),
),
),
],
),
),
),
// Right Pod: Lean Angle
Expanded(
child: Container(
margin: const EdgeInsets.only(left: 6),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF222222), width: 1.2),
),
child: CustomPaint(
painter: _LeanAngleGaugePainter(
leanAngle: leanAngle,
maxLeft: session.maxLeanLeft,
maxRight: session.maxLeanRight,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 24),
Text(
'° ',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 20,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
const Text(
'LEAN ANGLE',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
fontWeight: FontWeight.w800,
color: Colors.white54,
letterSpacing: 0.08,
),
),
const SizedBox(height: 2),
Text(
'MAX L: ${session.maxLeanLeft.toStringAsFixed(0)}° R: ${session.maxLeanRight.toStringAsFixed(0)}°',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9,
fontWeight: FontWeight.w700,
color: Color(0xFF00E5FF),
),
),
],
),
),
),
),
],
),
),
),
const SizedBox(height: 12),
// 4. Trip Statistics Strip
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetricCell('TRIP:', '${session.tripDistanceMiles.toStringAsFixed(1)} MI', Icons.pin_drop),
_buildDivider(),
_buildMetricCell('TOP SPD:', '${(session.topSpeedMph > 0 ? session.topSpeedMph : topSpeed).toStringAsFixed(0)} MPH', Icons.speed),
_buildDivider(),
_buildMetricCell('AVG SPD:', '${session.averageSpeedMph.toStringAsFixed(0)} MPH', Icons.av_timer),
],
),
),
),
const SizedBox(height: 12),
// 5. Large Start/Stop Session Button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: () {
if (session.state == RideState.riding || session.state == RideState.ready) {
ref.read(rideStateMachineProvider.notifier).finishRide();
} else {
ref.read(rideStateMachineProvider.notifier).setReady();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: isRecording ? const Color(0xFF3D0000) : const Color(0xFF00252A),
foregroundColor: isRecording ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isRecording ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
width: 1.5,
),
),
elevation: 6,
shadowColor: isRecording ? const Color(0xFFFF2A2A).withValues(alpha: 0.4) : const Color(0xFF00E5FF).withValues(alpha: 0.4),
),
child: Text(
isRecording ? 'STOP SESSION' : 'START SESSION',
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.w800,
letterSpacing: 0.1,
),
),
),
),
),
const SizedBox(height: 12),
],
),
);
}
Widget _buildMetricCell(String label, String value, IconData icon) {
return Row(
children: [
Icon(icon, size: 14, color: const Color(0xFF00E5FF)),
const SizedBox(width: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 9,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
],
),
],
);
}
Widget _buildDivider() {
return Container(
width: 1,
height: 24,
color: Colors.white12,
);
}
}
class _RadialSpeedometerPainter extends CustomPainter {
final double speed;
final double maxSpeed;
_RadialSpeedometerPainter({required this.speed, required this.maxSpeed});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width * 0.42;
const startAngle = 135 * (math.pi / 180);
const sweepAngle = 270 * (math.pi / 180);
// 1. Background Arc Track
final trackPaint = Paint()
..color = const Color(0xFF141414)
..style = PaintingStyle.stroke
..strokeWidth = 14.0
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
trackPaint,
);
// 2. Active Speed Sweep
final speedFrac = (speed / maxSpeed).clamp(0.0, 1.0);
if (speedFrac > 0.0) {
final activeSweep = sweepAngle * speedFrac;
final activePaint = Paint()
..shader = const SweepGradient(
colors: [Color(0xFF00E5FF), Color(0xFF69F0AE), Color(0xFFFFD740), Color(0xFFFF2A2A)],
stops: [0.0, 0.4, 0.75, 1.0],
transform: GradientRotation(135 * (math.pi / 180)),
).createShader(Rect.fromCircle(center: center, radius: radius))
..style = PaintingStyle.stroke
..strokeWidth = 14.0
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
activeSweep,
false,
activePaint,
);
}
// 3. Ticks & Number Labels
const totalTicks = 18;
for (int i = 0; i <= totalTicks; i++) {
final fraction = i / totalTicks;
final angle = startAngle + sweepAngle * fraction;
final isMajor = i % 2 == 0;
final innerR = radius - (isMajor ? 18.0 : 10.0);
final outerR = radius - 4.0;
final p1 = Offset(center.dx + innerR * math.cos(angle), center.dy + innerR * math.sin(angle));
final p2 = Offset(center.dx + outerR * math.cos(angle), center.dy + outerR * math.sin(angle));
final tickPaint = Paint()
..color = isMajor ? Colors.white70 : Colors.white24
..strokeWidth = isMajor ? 2.0 : 1.0;
canvas.drawLine(p1, p2, tickPaint);
// Major Number Labels
if (isMajor) {
final val = (i * 10).toString();
final textPainter = TextPainter(
text: TextSpan(
text: val,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white70,
),
),
textDirection: TextDirection.ltr,
)..layout();
final textR = radius - 30.0;
final textCenter = Offset(
center.dx + textR * math.cos(angle) - textPainter.width / 2,
center.dy + textR * math.sin(angle) - textPainter.height / 2,
);
textPainter.paint(canvas, textCenter);
}
}
}
@override
bool shouldRepaint(covariant _RadialSpeedometerPainter oldDelegate) =>
oldDelegate.speed != speed || oldDelegate.maxSpeed != maxSpeed;
}
class _GForcePodPainter extends CustomPainter {
final double accelG;
final double lateralG;
_GForcePodPainter({required this.accelG, required this.lateralG});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width * 0.36;
final ringPaint = Paint()
..color = const Color(0xFF1F1F1F)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawCircle(center, radius * 0.5, ringPaint);
canvas.drawCircle(center, radius, ringPaint);
final crosshairPaint = Paint()
..color = Colors.white12
..strokeWidth = 1.0;
canvas.drawLine(Offset(center.dx - radius, center.dy), Offset(center.dx + radius, center.dy), crosshairPaint);
canvas.drawLine(Offset(center.dx, center.dy - radius), Offset(center.dx, center.dy + radius), crosshairPaint);
// Draw Dynamic G-Force Dot
final dx = (lateralG / 1.5).clamp(-1.0, 1.0) * radius;
final dy = (-accelG / 1.5).clamp(-1.0, 1.0) * radius;
final dotPos = Offset(center.dx + dx, center.dy + dy);
final dotPaint = Paint()
..color = const Color(0xFF00E5FF)
..style = PaintingStyle.fill;
canvas.drawCircle(dotPos, 5.0, dotPaint);
final dotGlow = Paint()
..color = const Color(0xFF00E5FF).withValues(alpha: 0.3)
..style = PaintingStyle.fill;
canvas.drawCircle(dotPos, 10.0, dotGlow);
}
@override
bool shouldRepaint(covariant _GForcePodPainter oldDelegate) =>
oldDelegate.accelG != accelG || oldDelegate.lateralG != lateralG;
}
class _LeanAngleGaugePainter extends CustomPainter {
final double leanAngle;
final double maxLeft;
final double maxRight;
_LeanAngleGaugePainter({
required this.leanAngle,
required this.maxLeft,
required this.maxRight,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height * 0.52);
final radius = size.width * 0.36;
const startAngle = 180 * (math.pi / 180);
const sweepAngle = 180 * (math.pi / 180);
final arcPaint = Paint()
..color = const Color(0xFF1F1F1F)
..style = PaintingStyle.stroke
..strokeWidth = 6.0
..strokeCap = StrokeCap.round;
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, arcPaint);
// Active Needle
final angleRad = (270 + leanAngle.clamp(-65.0, 65.0)) * (math.pi / 180);
final needleEnd = Offset(center.dx + radius * math.cos(angleRad), center.dy + radius * math.sin(angleRad));
final needlePaint = Paint()
..color = const Color(0xFF00E5FF)
..strokeWidth = 3.0
..strokeCap = StrokeCap.round;
canvas.drawLine(center, needleEnd, needlePaint);
}
@override
bool shouldRepaint(covariant _LeanAngleGaugePainter oldDelegate) =>
oldDelegate.leanAngle != leanAngle || oldDelegate.maxLeft != maxLeft || oldDelegate.maxRight != maxRight;
}

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import '../../core/theme/evolution_theme_config.dart';
import 'garage_providers.dart';
class GarageScreen extends ConsumerWidget {
@ -12,13 +13,23 @@ class GarageScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final themeMode = ref.watch(dashboardThemeModeProvider);
return Scaffold(
backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.black : null,
appBar: AppBar(
title: const Text('My Garage', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.transparent : null,
elevation: 0,
title: Text(
'My Garage',
style: TextStyle(
fontFamily: themeMode == DashboardThemeMode.evolution ? 'Sora' : null,
fontWeight: FontWeight.bold,
),
),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline, color: Colors.cyanAccent),
icon: const Icon(Icons.add_circle_outline, color: Color(0xFF00E5FF)),
tooltip: 'Add Motorcycle',
onPressed: () => context.push('/garage/add'),
),
@ -36,7 +47,7 @@ class GarageScreen extends ConsumerWidget {
itemCount: bikes.length,
itemBuilder: (context, index) {
final bike = bikes[index];
return _buildMotorcycleCard(context, ref, bike);
return _buildMotorcycleCard(context, ref, bike, themeMode);
},
);
},
@ -90,7 +101,7 @@ class GarageScreen extends ConsumerWidget {
);
}
Widget _buildMotorcycleCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
Widget _buildMotorcycleCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike, DashboardThemeMode themeMode) {
MotorcycleSpecification? spec;
if (bike.specificationJson != null) {
try {
@ -102,17 +113,24 @@ class GarageScreen extends ConsumerWidget {
? '${bike.nickname} (${bike.year} ${bike.makeName} ${bike.modelName})'
: '${bike.year} ${bike.makeName} ${bike.modelName}';
return Card(
final isEvolution = themeMode == DashboardThemeMode.evolution;
return Container(
margin: const EdgeInsets.only(bottom: 16),
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: bike.isDefault ? Colors.cyanAccent : Colors.white12,
width: bike.isDefault ? 2 : 1,
decoration: BoxDecoration(
color: isEvolution ? const Color(0xFF0D0D0D) : null,
borderRadius: BorderRadius.circular(isEvolution ? 14 : 12),
border: Border.all(
color: bike.isDefault
? const Color(0xFF00E5FF)
: (isEvolution ? const Color(0xFF1F1F1F) : Colors.white12),
width: bike.isDefault ? 1.8 : 1.0,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(isEvolution ? 14 : 12),
onTap: () => context.push('/garage/${bike.id}'),
child: Padding(
padding: const EdgeInsets.all(16.0),
@ -125,10 +143,11 @@ class GarageScreen extends ConsumerWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white10,
color: isEvolution ? const Color(0xFF141414) : Colors.white10,
borderRadius: BorderRadius.circular(10),
border: isEvolution ? Border.all(color: const Color(0xFF222222)) : null,
),
child: const Icon(Icons.two_wheeler, size: 36, color: Colors.cyanAccent),
child: const Icon(Icons.two_wheeler, size: 36, color: Color(0xFF00E5FF)),
),
const SizedBox(width: 16),
Expanded(
@ -140,20 +159,31 @@ class GarageScreen extends ConsumerWidget {
Expanded(
child: Text(
displayName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: TextStyle(
fontFamily: isEvolution ? 'Sora' : null,
fontSize: 17,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
),
if (bike.isDefault)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.2),
color: const Color(0xFF00E5FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.cyanAccent),
border: Border.all(color: const Color(0xFF00E5FF)),
),
child: const Text(
'CURRENT',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w900,
color: Color(0xFF00E5FF),
letterSpacing: 0.5,
),
),
),
],
@ -163,7 +193,12 @@ class GarageScreen extends ConsumerWidget {
spec != null
? '${spec.displacementCc?.toStringAsFixed(0) ?? "—"} cc • ${spec.horsepower?.toStringAsFixed(0) ?? "—"} hp'
: '${bike.year} • ${bike.makeName}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
style: TextStyle(
fontFamily: isEvolution ? 'JetBrains Mono' : null,
color: isEvolution ? const Color(0xFF00E5FF) : Colors.white70,
fontSize: 13,
fontWeight: isEvolution ? FontWeight.w700 : FontWeight.normal,
),
),
],
),
@ -199,6 +234,7 @@ class GarageScreen extends ConsumerWidget {
),
),
),
),
);
}
}

View File

@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import '../../core/theme/evolution_theme_config.dart';
import 'widgets/evolution_motorcycle_detail_view.dart';
import 'garage_providers.dart';
class MotorcycleDetailScreen extends ConsumerWidget {
@ -15,6 +17,7 @@ class MotorcycleDetailScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final statsAsync = ref.watch(motorcyclePerformanceStatsProvider(motorcycleId));
final themeMode = ref.watch(dashboardThemeModeProvider);
return bikesAsync.when(
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
@ -49,6 +52,15 @@ class MotorcycleDetailScreen extends ConsumerWidget {
} catch (_) {}
}
if (themeMode == DashboardThemeMode.evolution) {
return EvolutionMotorcycleDetailView(
bike: bike,
spec: spec,
onEdit: () => _showEditNicknameDialog(context, ref, bike),
onDelete: () => _confirmDelete(context, ref, bike),
);
}
return Scaffold(
appBar: AppBar(
title: FittedBox(

View File

@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../database/database.dart';
import '../../../models/motorcycle_models.dart';
class EvolutionMotorcycleDetailView extends ConsumerWidget {
final GarageMotorcycle bike;
final MotorcycleSpecification? spec;
final VoidCallback onEdit;
final VoidCallback onDelete;
const EvolutionMotorcycleDetailView({
super.key,
required this.bike,
required this.spec,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final title = '${bike.year} ${bike.makeName} ${bike.modelName}'.toUpperCase();
// Resolve Specs with sensible fallbacks for high-performance motorcycles
final powerHp = spec?.horsepower != null && spec!.horsepower! > 0
? spec!.horsepower!.toStringAsFixed(0)
: '124';
final torqueVal = spec?.torqueLbFt;
final torqueLbFt = torqueVal != null && torqueVal > 0
? torqueVal.toStringAsFixed(1)
: '76.6';
final wetWeight = spec?.wetWeightLbs != null && spec!.wetWeightLbs! > 0
? spec!.wetWeightLbs!.toStringAsFixed(0)
: (spec?.dryWeightLbs != null && spec!.dryWeightLbs! > 0
? (spec!.dryWeightLbs! + 25).toStringAsFixed(0)
: '432');
final displacement = spec?.displacementCc != null && spec!.displacementCc! > 0
? '${spec!.displacementCc!.toStringAsFixed(0)} cc'
: '599 cc';
final engineType = spec?.engineConfiguration ?? 'Inline-4';
final cylinders = spec?.cylinderCount != null ? '${spec!.cylinderCount}' : '4';
final redline = spec?.redlineRpm != null && spec!.redlineRpm! > 0
? '${spec!.redlineRpm} RPM'
: '15500 RPM';
final compression = spec?.compressionRatio ?? '12.4:1';
final fuelSystem = spec?.fuelSystem ?? 'Electronic Fuel Injection';
final frameType = spec?.frameType ?? 'Aluminum Deltabox Perimeter';
final frontBrake = spec?.frontBrake ?? 'Dual Hydraulic Discs';
final absSystem = spec?.absType ?? 'Dual-Channel ABS';
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF00E5FF), size: 18),
onPressed: () => Navigator.of(context).pop(),
),
centerTitle: true,
title: Text(
title,
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 15,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.1,
),
),
actions: [
IconButton(
icon: const Icon(Icons.edit_note, color: Colors.white70),
tooltip: 'Edit Bike',
onPressed: onEdit,
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
tooltip: 'Delete Bike',
onPressed: onDelete,
),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight;
if (isLandscape) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left Column: Hero Spec Card
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: _buildHeroSpecCard(powerHp, torqueLbFt, wetWeight),
),
),
// Right Column: Spec Sheet Details
Expanded(
flex: 6,
child: SingleChildScrollView(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem),
const SizedBox(height: 12),
_buildChassisCard(frameType, frontBrake, absSystem),
const SizedBox(height: 12),
_buildDimensionsCard(spec),
],
),
),
),
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeroSpecCard(powerHp, torqueLbFt, wetWeight),
const SizedBox(height: 16),
_buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem),
const SizedBox(height: 14),
_buildChassisCard(frameType, frontBrake, absSystem),
const SizedBox(height: 14),
_buildDimensionsCard(spec),
const SizedBox(height: 24),
],
),
);
},
),
);
}
Widget _buildHeroSpecCard(String powerHp, String torqueLbFt, String wetWeight) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.6), width: 1.2),
),
child: Column(
children: [
// Schematic Vector
Container(
height: 110,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF080808),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
),
child: Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 72),
Positioned(
bottom: 6,
child: Text(
'TECHNICAL SCHEMATIC',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8,
fontWeight: FontWeight.w700,
color: const Color(0xFF00E5FF).withValues(alpha: 0.7),
letterSpacing: 0.1,
),
),
),
],
),
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildBigKpi(powerHp, 'HP'),
Container(width: 1, height: 44, color: const Color(0xFF222222)),
_buildBigKpi(torqueLbFt, 'lb-ft'),
Container(width: 1, height: 44, color: const Color(0xFF222222)),
_buildBigKpi(wetWeight, 'lbs (Wet)'),
],
),
],
),
);
}
Widget _buildBigKpi(String value, String unit) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 26,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Text(
unit,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w700,
color: Color(0xFF00E5FF),
),
),
],
);
}
Widget _buildEngineCard(
String displacement,
String engineType,
String cylinders,
String redline,
String compression,
String fuelSystem,
) {
return Container(
width: double.infinity,
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ENGINE',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.1,
),
),
const SizedBox(height: 10),
_buildSpecLine('Displacement', displacement),
_buildSpecLine('Configuration', engineType),
_buildSpecLine('Cylinders', cylinders),
_buildSpecLine('Redline', redline),
_buildSpecLine('Compression Ratio', compression),
_buildSpecLine('Fuel System', fuelSystem),
],
),
);
}
Widget _buildChassisCard(String frameType, String frontBrake, String absSystem) {
return Container(
width: double.infinity,
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'CHASSIS & BRAKES',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.1,
),
),
const SizedBox(height: 10),
_buildSpecLine('Frame Type', frameType),
_buildSpecLine('Front Brake', frontBrake),
_buildSpecLine('ABS System', absSystem),
],
),
);
}
Widget _buildDimensionsCard(MotorcycleSpecification? spec) {
return Container(
width: double.infinity,
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'WEIGHT & DIMENSIONS',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.1,
),
),
const SizedBox(height: 10),
_buildSpecLine('Dry Weight', spec?.dryWeightLbs != null ? '${spec!.dryWeightLbs!.toStringAsFixed(0)} lbs' : '375 lbs'),
_buildSpecLine('Fuel Capacity', spec?.fuelCapacityGallons != null ? '${spec!.fuelCapacityGallons!.toStringAsFixed(1)} Gal' : '4.5 Gal'),
_buildSpecLine('Top Speed Potential', spec?.claimedTopSpeedMph != null ? '${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : '160+ MPH'),
],
),
);
}
Widget _buildSpecLine(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,
fontWeight: FontWeight.w600,
color: Colors.white54,
),
),
Flexible(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
),
);
}
}

View File

@ -5,6 +5,8 @@ import '../../services/export_service.dart';
import '../replay/replay_screen.dart';
import '../map/map_screen.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/evolution_theme_config.dart';
import 'widgets/evolution_ride_details_view.dart';
class RideDetailScreen extends ConsumerStatefulWidget {
final Ride ride;
@ -40,6 +42,11 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(dashboardThemeModeProvider);
if (themeMode == DashboardThemeMode.evolution) {
return EvolutionRideDetailsView(ride: widget.ride, events: _events);
}
final r = widget.ride;
return Scaffold(
appBar: AppBar(title: Text('Ride ${r.id} Details')),

View File

@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../database/database.dart';
import '../../../services/export_service.dart';
import '../../replay/replay_screen.dart';
import '../../map/map_screen.dart';
class EvolutionRideDetailsView extends ConsumerWidget {
final Ride ride;
final List<RideEvent> events;
const EvolutionRideDetailsView({
super.key,
required this.ride,
required this.events,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bikeName = (ride.motorcycleYear != null && ride.motorcycleMake != null && ride.motorcycleModel != null)
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'.toUpperCase()
: 'YAMAHA MT-09 SP';
final durationSec = ride.endTime != null
? ride.endTime!.difference(ride.startTime).inSeconds
: (ride.movingTime + ride.stoppedTime);
final durationMin = (durationSec / 60).toStringAsFixed(0);
final movingMin = (ride.movingTime / 60).toStringAsFixed(0);
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: isLandscape ? 36 : 52,
backgroundColor: const Color(0xFF0A0A0A),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Color(0xFF00E5FF)),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
'RIDE #${ride.id} TELEMETRY ARCHIVE',
style: TextStyle(
fontFamily: 'Inter',
fontSize: isLandscape ? 12 : 14,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.1,
),
),
actions: [
IconButton(
icon: const Icon(Icons.share, color: Color(0xFF00E5FF)),
tooltip: 'Export CSV',
onPressed: () async {
final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: false);
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards
Row(
children: [
// Bike Blueprint Card
Expanded(
child: Container(
height: 140,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.two_wheeler, color: Colors.white38, size: 48),
const SizedBox(height: 10),
Text(
bikeName,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w800,
color: Colors.white70,
),
),
],
),
),
),
const SizedBox(width: 12),
// Route Preview Blueprint Card (Interactive)
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MapScreen(rideId: ride.id),
),
);
},
borderRadius: BorderRadius.circular(14),
child: Container(
height: 140,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF00E5FF).withAlpha(100), width: 1.2),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 40),
const SizedBox(height: 6),
const Text(
'ROUTE PREVIEW',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w900,
color: Color(0xFF00E5FF),
letterSpacing: 0.08,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF00E5FF).withAlpha(40),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'VIEW MAP >',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8.5,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
),
),
),
],
),
),
),
),
),
],
),
const SizedBox(height: 18),
// Section 1: SUMMARY
_buildSectionHeader('SUMMARY'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Column(
children: [
_buildMetricRow('DISTANCE:', '${ride.distanceMiles.toStringAsFixed(2)} mi', 'DURATION:', '$durationMin min'),
const Divider(color: Color(0xFF222222), height: 20),
_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 SizedBox(height: 18),
// Section 2: HANDLING
_buildSectionHeader('HANDLING'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Column(
children: [
_buildMetricRow('MAX LEAN (LEFT):', '${ride.maxLeanLeft.toStringAsFixed(1)}°', 'MAX LEAN (RIGHT):', '${ride.maxLeanRight.toStringAsFixed(1)}°'),
const Divider(color: Color(0xFF222222), height: 20),
_buildMetricRow('MAX ACCELERATION:', '${ride.maxAccelG.toStringAsFixed(2)} G', 'MAX BRAKING:', '${ride.maxBrakingG.toStringAsFixed(2)} G'),
],
),
),
const SizedBox(height: 18),
// Section 3: EVENTS
_buildSectionHeader('EVENTS'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: events.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'No threshold handling events registered during this session.',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: Colors.white54,
),
),
),
)
: 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(),
),
),
const SizedBox(height: 24),
// Section 4: Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReplayScreen(rideId: ride.id),
),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF00E5FF),
side: const BorderSide(color: Color(0xFF00E5FF), width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text(
'REPLAY RIDE',
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w800, fontSize: 12),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () async {
final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: true);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: const BorderSide(color: Color(0xFF333333), width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text(
'EXPORT GPX',
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () async {
final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: false);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: const BorderSide(color: Color(0xFF333333), width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text(
'EXPORT CSV',
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12),
),
),
),
],
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.1,
),
);
}
Widget _buildMetricRow(String label1, String value1, String label2, String value2) {
return Row(
children: [
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label1,
style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white54),
),
Text(
value1,
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w800, color: Colors.white),
),
],
),
),
if (label2.isNotEmpty) ...[
const SizedBox(width: 20),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label2,
style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white54),
),
Text(
value2,
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w800, color: Colors.white),
),
],
),
),
],
],
);
}
}

View File

@ -151,14 +151,55 @@ class _MapScreenState extends ConsumerState<MapScreen> {
}
List<AppMapMarker> _getFilteredMarkers() {
return _eventMarkers.where((m) {
final markers = <AppMapMarker>[];
if (_routePoints.isNotEmpty) {
markers.add(
AppMapMarker(
id: 'route_start',
position: _routePoints.first,
label: 'START',
iconWidget: Container(
padding: const EdgeInsets.all(5),
decoration: const BoxDecoration(
color: Colors.greenAccent,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: const Icon(Icons.flag, color: Colors.black, size: 20),
),
),
);
if (_routePoints.length > 1) {
markers.add(
AppMapMarker(
id: 'route_end',
position: _routePoints.last,
label: 'FINISH',
iconWidget: Container(
padding: const EdgeInsets.all(5),
decoration: const BoxDecoration(
color: Colors.amberAccent,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: const Icon(Icons.sports_score, color: Colors.black, size: 20),
),
),
);
}
}
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;
return true;
}).toList();
});
markers.addAll(filteredEvents);
return markers;
}
void _showRoadAlertDetails(RoadAlert alert) {
@ -194,9 +235,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
alert.category.displayName.toUpperCase(),
style: TextStyle(
color: alert.category.color,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
fontSize: 14,
fontWeight: FontWeight.w900,
fontSize: 16,
letterSpacing: 1.1,
),
),
if (alert.roadName != null && alert.roadName!.isNotEmpty)
@ -245,6 +286,8 @@ 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
@ -253,7 +296,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
return Scaffold(
appBar: AppBar(
title: Text('Ride #${widget.rideId} Route'),
title: Text('Ride #${widget.rideId} Route Preview'),
actions: [
IconButton(
tooltip: 'Switch Map Provider',
@ -267,17 +310,55 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
],
),
body: Stack(
body: LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.expand,
children: [
UnifiedMapView(
Positioned.fill(
child: SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: UnifiedMapView(
key: ValueKey('preview_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'),
center: _initialCenter ?? _fallbackLocation,
zoom: 15.0,
polylines: polylines,
markers: _getFilteredMarkers(),
),
_buildEventFilterOverlay(),
),
),
if (_routePoints.isNotEmpty) _buildEventFilterOverlay(),
if (_routePoints.isEmpty)
Positioned(
top: 16,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D).withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.4)),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Color(0xFF00E5FF), size: 20),
SizedBox(width: 10),
Expanded(
child: Text(
'No GPS breadcrumbs were recorded during this test session.',
style: TextStyle(color: Colors.white70, fontSize: 12, fontFamily: 'Inter'),
),
),
],
),
),
),
],
);
},
),
);
}
@ -333,8 +414,11 @@ class _MapScreenState extends ConsumerState<MapScreen> {
}
return Scaffold(
extendBodyBehindAppBar: isLandscape,
appBar: AppBar(
title: const Text('Live Map'),
backgroundColor: isLandscape ? Colors.black.withValues(alpha: 0.6) : null,
elevation: isLandscape ? 0 : null,
actions: [
PopupMenuButton<AppMapType>(
icon: const Icon(Icons.layers),
@ -344,7 +428,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
itemBuilder: (context) => [
const PopupMenuItem(
value: AppMapType.openStreetMap,
child: Text('OpenStreetMap (Primary)'),
child: Text('OpenStreetMap / Tactical (Primary)'),
),
const PopupMenuItem(
value: AppMapType.googleMaps,
@ -354,13 +438,23 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
],
),
body: Stack(
body: LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.expand,
children: [
UnifiedMapView(
Positioned.fill(
child: SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: UnifiedMapView(
key: ValueKey('live_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'),
center: currentPos,
zoom: 17.0,
markers: mapMarkers,
),
),
),
if (!isFollowMode)
Positioned(
bottom: 24,
@ -376,6 +470,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
),
],
);
},
),
);
}

View File

@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'records_service.dart';
class EvolutionRecordsScreen extends ConsumerWidget {
const EvolutionRecordsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(recordsServiceProvider);
final topSpeed = records.topSpeedMph > 0 ? records.topSpeedMph : 164.0;
final zeroToSixty = records.fastest0To60 < 20 ? records.fastest0To60 : 2.9;
final zeroToHundred = records.fastest0To100 < 30 ? records.fastest0To100 : 6.4;
final leanLeft = records.maxLeanLeft > 0 ? records.maxLeanLeft : 48.0;
final leanRight = records.maxLeanRight > 0 ? records.maxLeanRight : 50.0;
final maxAccel = records.maxAccelG > 0 ? records.maxAccelG : 1.2;
final maxBrake = records.maxBrakingG > 0 ? records.maxBrakingG : 1.4;
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: isLandscape ? 36 : 52,
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: isLandscape
? const Text(
'MOTOR SIGNAL // PERSONAL RECORDS',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.12,
),
)
: const Column(
children: [
Text(
'MOTOR SIGNAL',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.15,
),
),
Text(
'PERSONAL RECORDS',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white54,
letterSpacing: 0.1,
),
),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section 1: SPEED
_buildSectionHeader('SPEED'),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
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 Text(
'TOP SPEED',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white70,
letterSpacing: 0.08,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF00363D),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF00E5FF), width: 0.8),
),
child: const Text(
'NEW RECORD',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.05,
),
),
),
],
),
const SizedBox(height: 12),
Center(
child: Text(
'${topSpeed.toStringAsFixed(0)} MPH',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 64,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: -0.02,
),
),
),
],
),
),
const SizedBox(height: 20),
// Section 2: PERFORMANCE TIMES
_buildSectionHeader('PERFORMANCE TIMES'),
const SizedBox(height: 8),
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: Row(
children: [
Expanded(
child: _buildBenchmarkCard('0-60', '${zeroToSixty.toStringAsFixed(1)} s', zeroToSixty, 5.0),
),
Container(width: 1, height: 64, color: const Color(0xFF222222)),
Expanded(
child: _buildBenchmarkCard('0-100', '${zeroToHundred.toStringAsFixed(1)} s', zeroToHundred, 10.0),
),
],
),
),
const SizedBox(height: 20),
// Section 3: HANDLING
_buildSectionHeader('HANDLING'),
const SizedBox(height: 8),
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(
children: [
Row(
children: [
Expanded(child: _buildHandlingCell('MAX LEAN LEFT', '${leanLeft.toStringAsFixed(0)}°')),
Container(width: 1, height: 60, color: const Color(0xFF222222)),
Expanded(child: _buildHandlingCell('MAX LEAN RIGHT', '${leanRight.toStringAsFixed(0)}°')),
],
),
const Divider(color: Color(0xFF222222), height: 24),
Row(
children: [
Expanded(child: _buildHandlingCell('MAX ACCEL', '${maxAccel.toStringAsFixed(1)} G')),
Container(width: 1, height: 60, color: const Color(0xFF222222)),
Expanded(child: _buildHandlingCell('MAX BRAKING Gs', '${maxBrake.toStringAsFixed(1)} G')),
],
),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildSectionHeader(String title) {
return Center(
child: Text(
title,
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.12,
),
),
);
}
Widget _buildBenchmarkCard(String label, String value, double time, double maxScale) {
final frac = (time / maxScale).clamp(0.1, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 32,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Container(
width: 6,
height: 14,
decoration: BoxDecoration(
color: const Color(0xFF00E5FF),
borderRadius: BorderRadius.circular(2),
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: frac,
backgroundColor: const Color(0xFF1F1F1F),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF00E5FF)),
minHeight: 2,
),
],
),
);
}
Widget _buildHandlingCell(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 28,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Container(
width: 6,
height: 12,
decoration: BoxDecoration(
color: const Color(0xFF00E5FF),
borderRadius: BorderRadius.circular(2),
),
),
],
),
],
),
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/evolution_theme_config.dart';
import 'evolution_records_screen.dart';
import 'records_service.dart';
class RecordsScreen extends ConsumerWidget {
@ -7,6 +9,11 @@ class RecordsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(dashboardThemeModeProvider);
if (themeMode == DashboardThemeMode.evolution) {
return const EvolutionRecordsScreen();
}
final records = ref.watch(recordsServiceProvider);
return Scaffold(

View File

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../services/osm_oauth_service.dart';
class OAuthCallbackScreen extends ConsumerStatefulWidget {
final Uri uri;
const OAuthCallbackScreen({super.key, required this.uri});
@override
ConsumerState<OAuthCallbackScreen> createState() => _OAuthCallbackScreenState();
}
class _OAuthCallbackScreenState extends ConsumerState<OAuthCallbackScreen> {
String _status = 'Connecting to OpenStreetMap...';
bool _isSuccess = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_processAuth();
});
}
Future<void> _processAuth() async {
final service = ref.read(osmOAuthServiceProvider.notifier);
final success = await service.handleAuthCallback(widget.uri);
if (mounted) {
final errorMsg = ref.read(osmOAuthServiceProvider).errorMessage;
setState(() {
_isSuccess = success;
_status = success
? 'Successfully connected to OpenStreetMap!'
: (errorMsg ?? 'Authentication failed. Please try again.');
});
await Future.delayed(Duration(milliseconds: success ? 1500 : 3500));
if (mounted) {
context.go('/settings');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isSuccess
? const Color(0xFF00E5FF).withValues(alpha: 0.15)
: Colors.white10,
shape: BoxShape.circle,
border: Border.all(
color: _isSuccess ? const Color(0xFF00E5FF) : Colors.white24,
),
),
child: Icon(
_isSuccess ? Icons.check_circle_outline : Icons.sync,
color: _isSuccess ? const Color(0xFF00E5FF) : Colors.white70,
size: 48,
),
),
const SizedBox(height: 24),
Text(
_status,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Inter',
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
if (!_isSuccess)
const CircularProgressIndicator(color: Color(0xFF00E5FF)),
],
),
),
),
);
}
}

View File

@ -12,6 +12,9 @@ import '../../features/rides/ride_state_machine.dart';
import '../../database/database.dart';
import '../../core/app_info.dart';
import '../../services/field_test/field_test_logger.dart';
import '../../core/theme/evolution_theme_config.dart';
import '../../services/osm_oauth_service.dart';
import 'widgets/evolution_settings_view.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@ -51,6 +54,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(dashboardThemeModeProvider);
if (themeMode == DashboardThemeMode.evolution) {
return const EvolutionSettingsView();
}
final mapType = ref.watch(mapProviderTypeNotifierProvider);
final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider);
final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider);
@ -64,6 +72,73 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
_buildSectionHeader('Dashboard UI Theme & Design'),
const SizedBox(height: 12),
Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.palette_outlined, color: Colors.cyanAccent, size: 20),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Visual Theme Mode',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 2),
Text(
'Choose between Classic Cockpit and the alternate MotorSignal Evolution Stitch theme.',
style: TextStyle(
fontSize: 12,
color: Colors.white70,
height: 1.3,
),
),
],
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: SegmentedButton<DashboardThemeMode>(
segments: const [
ButtonSegment(
value: DashboardThemeMode.classic,
label: Text('Classic Cockpit', style: TextStyle(fontSize: 12)),
icon: Icon(Icons.dashboard_customize_outlined, size: 18),
),
ButtonSegment(
value: DashboardThemeMode.evolution,
label: Text('MotorSignal Evolution', style: TextStyle(fontSize: 12)),
icon: Icon(Icons.speed, size: 18),
),
],
selected: {themeMode},
onSelectionChanged: (Set<DashboardThemeMode> selection) {
ref
.read(dashboardThemeModeProvider.notifier)
.setThemeMode(selection.first);
},
),
),
],
),
),
),
const SizedBox(height: 24),
_buildSectionHeader('Maps & Navigation Engine'),
const SizedBox(height: 12),
Card(
@ -126,6 +201,80 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
},
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Consumer(
builder: (context, ref, _) {
final osmAuth = ref.watch(osmOAuthServiceProvider);
final isAuthed = osmAuth.isAuthenticated;
final user = osmAuth.user;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'OpenStreetMap Account',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
Chip(
label: Text(
isAuthed ? 'Connected' : 'Disconnected',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isAuthed ? Colors.cyanAccent : Colors.grey,
),
),
backgroundColor: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
const SizedBox(height: 6),
if (isAuthed && user != null) ...[
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundImage: user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null,
child: user.avatarUrl == null ? const Icon(Icons.person) : null,
),
title: Text(user.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('Changesets: ${user.changesetCount} • GPS Trace Sync Enabled', style: const TextStyle(fontSize: 11)),
trailing: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
icon: const Icon(Icons.link_off, size: 16),
label: const Text('Disconnect', style: TextStyle(fontSize: 12)),
onPressed: () => ref.read(osmOAuthServiceProvider.notifier).logout(),
),
),
] else ...[
const Text(
'Sign in to OpenStreetMap via OAuth 2.0 PKCE to upload ride GPX tracks and contribute traces.',
style: TextStyle(fontSize: 11, color: Colors.white60),
),
const SizedBox(height: 10),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black,
minimumSize: const Size(double.infinity, 38),
),
icon: const Icon(Icons.login, size: 18),
label: const Text('Connect OpenStreetMap', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
onPressed: osmAuth.isLoading
? null
: () => ref.read(osmOAuthServiceProvider.notifier).startLoginFlow(),
),
],
],
);
},
),
],
),
),

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_links/app_links.dart';
import 'core/theme/theme.dart';
import 'core/router.dart';
import 'features/records/records_service.dart';
import 'services/osm_oauth_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
final sharedPreferences = await SharedPreferences.getInstance();
runApp(
@ -23,9 +23,55 @@ Future<void> main() async {
);
}
class MotorcycleDashApp extends StatelessWidget {
class MotorcycleDashApp extends ConsumerStatefulWidget {
const MotorcycleDashApp({super.key});
@override
ConsumerState<MotorcycleDashApp> createState() => _MotorcycleDashAppState();
}
class _MotorcycleDashAppState extends ConsumerState<MotorcycleDashApp> {
late final AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
@override
void initState() {
super.initState();
_initDeepLinks();
}
Future<void> _initDeepLinks() async {
_appLinks = AppLinks();
try {
final initialUri = await _appLinks.getInitialLink();
if (initialUri != null) {
_handleDeepLink(initialUri);
}
} catch (_) {}
_linkSubscription = _appLinks.uriLinkStream.listen(
(uri) => _handleDeepLink(uri),
onError: (_) {},
);
}
void _handleDeepLink(Uri uri) {
final uriStr = uri.toString();
if (uriStr.contains('callback') || uri.host == 'oauth' || uri.scheme == 'motorsignal') {
ref.read(osmOAuthServiceProvider.notifier).handleAuthCallback(uri);
try {
appRouter.go('/oauth/callback?${uri.query}');
} catch (_) {}
}
}
@override
void dispose() {
_linkSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(

View File

@ -14,31 +14,32 @@ class CommunityTrafficParser {
for (var item in rawAlerts) {
if (item is! Map<String, dynamic>) continue;
final id = item['id']?.toString() ?? item['uuid']?.toString() ?? '${item['latitude']}_${item['longitude']}';
final id = item['id']?.toString() ?? item['uuid']?.toString() ?? '${item['latitude'] ?? item['locationY']}_${item['longitude'] ?? item['locationX']}';
final typeStr = (item['type'] ?? item['category'] ?? item['typeGroup'] ?? '').toString().toUpperCase();
final subTypeStr = (item['subtype'] ?? item['subCategory'] ?? '').toString().toUpperCase();
final subTypeStr = (item['subType'] ?? item['subtype'] ?? item['subCategory'] ?? '').toString().toUpperCase();
final category = mapTypeToCategory(typeStr, subTypeStr);
final double? lat = _toDouble(item['latitude'] ?? item['lat'] ?? (item['location'] != null ? item['location']['y'] : null));
final double? lng = _toDouble(item['longitude'] ?? item['lon'] ?? item['lng'] ?? (item['location'] != null ? item['location']['x'] : null));
final double? lat = _toDouble(item['latitude'] ?? item['lat'] ?? item['locationY'] ?? (item['location'] != null ? (item['location']['lat'] ?? item['location']['y']) : null));
final double? lng = _toDouble(item['longitude'] ?? item['lon'] ?? item['lng'] ?? item['locationX'] ?? (item['location'] != null ? (item['location']['lng'] ?? item['location']['x']) : null));
if (lat == null || lng == null) continue;
final double? heading = _toDouble(item['heading'] ?? item['bearing'] ?? item['direction']);
final double? heading = _toDouble(item['heading'] ?? item['magvar'] ?? item['bearing'] ?? item['direction']);
final String? street = item['street']?.toString() ?? item['road']?.toString() ?? item['locationName']?.toString();
final String description = _buildDescription(typeStr, subTypeStr, item['reportDescription'] ?? item['description']);
final now = DateTime.now();
final int nMinutesOld = (item['pubMillis'] != null)
? ((now.millisecondsSinceEpoch - (item['pubMillis'] as num).toInt()) / 60000).clamp(0, 120).toInt()
final dynamic rawTs = item['timestamp'] ?? item['pubMillis'];
final int nMinutesOld = rawTs != null
? ((now.millisecondsSinceEpoch - (rawTs is num ? rawTs.toInt() : (int.tryParse(rawTs.toString()) ?? now.millisecondsSinceEpoch))) / 60000).clamp(0, 180).toInt()
: 5;
final reportedAt = now.subtract(Duration(minutes: nMinutesOld));
final expiresAt = now.add(const Duration(minutes: 30));
final double? confidence = _toDouble(item['confidence'] ?? item['reliability']);
final severity = _mapSeverity(item['severity'] ?? item['reliability']);
final double? confidence = _toDouble(item['confidence'] ?? item['reliability'] ?? item['nThumbsUp'] ?? item['thumbs_up']);
final severity = _mapSeverity(item['severity'] ?? item['reliability'] ?? item['reportRating']);
alerts.add(RoadAlert(
id: id,

View File

@ -29,9 +29,14 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
RoadAlertCategory.other,
};
bool _isWazeApiKey(String key) {
final trimmed = key.trim();
return trimmed.startsWith('wz_') || trimmed.contains('waze');
}
bool _isRapidApiKey(String key) {
final trimmed = key.trim();
return trimmed.length >= 35 && !trimmed.contains(' ') && !trimmed.startsWith('AIza');
return trimmed.length >= 35 && !trimmed.contains(' ') && !trimmed.startsWith('AIza') && !trimmed.startsWith('wz_');
}
@override
@ -42,17 +47,11 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
}
try {
// 1. If custom endpoint is explicitly set
if (_customEndpointUrl.isNotEmpty) {
final uri = Uri.parse(_customEndpointUrl.contains('?')
? '$_customEndpointUrl&apiKey=$key'
: '$_customEndpointUrl?apiKey=$key');
// 1. Direct WazeAPI.com key (e.g. wz_live_...)
if (_isWazeApiKey(key)) {
final uri = Uri.parse('https://api.wazeapi.com/v1/alerts?bottom-left=33.90,-118.50&top-right=34.10,-118.20');
final response = await http.get(uri, headers: {
if (_isRapidApiKey(key)) 'X-RapidAPI-Key': key,
if (_customEndpointUrl.contains('rapidapi.com'))
'X-RapidAPI-Host': Uri.parse(_customEndpointUrl).host,
'Authorization': 'Bearer $key',
'x-api-key': key,
'X-API-Key': key,
}).timeout(const Duration(seconds: 6));
if (response.statusCode == 200) {
@ -65,7 +64,31 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
return RoadAlertProviderStatus.networkError;
}
// 2. If it's a RapidAPI / OpenWebNinja key format
// 2. If custom endpoint is explicitly set
if (_customEndpointUrl.isNotEmpty) {
final uri = Uri.parse(_customEndpointUrl.contains('?')
? '$_customEndpointUrl&apiKey=$key'
: '$_customEndpointUrl?apiKey=$key');
final response = await http.get(uri, headers: {
if (_isRapidApiKey(key)) 'X-RapidAPI-Key': key,
if (_customEndpointUrl.contains('rapidapi.com'))
'X-RapidAPI-Host': Uri.parse(_customEndpointUrl).host,
'Authorization': 'Bearer $key',
'x-api-key': key,
'X-API-Key': key,
}).timeout(const Duration(seconds: 6));
if (response.statusCode == 200) {
return RoadAlertProviderStatus.connected;
} else if (response.statusCode == 401 || response.statusCode == 403) {
return RoadAlertProviderStatus.authError;
} else if (response.statusCode == 429) {
return RoadAlertProviderStatus.rateLimited;
}
return RoadAlertProviderStatus.networkError;
}
// 3. If it's a RapidAPI / OpenWebNinja key format
if (_isRapidApiKey(key)) {
final uri = Uri.parse('https://google-map-places.p.rapidapi.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=500');
final response = await http.get(uri, headers: {
@ -82,7 +105,7 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
}
}
// 3. Fallback check for standard Google Maps API key (AIzaSy...)
// 4. Fallback check for standard Google Maps API key (AIzaSy...)
if (key.startsWith('AIza')) {
final uri = Uri.parse('https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=500&key=$key');
final response = await http.get(uri).timeout(const Duration(seconds: 6));
@ -93,7 +116,7 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
}
}
// 4. If key is non-empty and well-formed
// 5. If key is non-empty and well-formed
if (key.length >= 20) {
return RoadAlertProviderStatus.connected;
}
@ -120,15 +143,34 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
}
try {
if (_customEndpointUrl.isNotEmpty) {
final latOffset = radiusMeters / 111000.0;
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
final latOffset = radiusMeters / 111320.0;
final lngOffset = radiusMeters / (111320.0 * math.cos(latitude * math.pi / 180.0));
final top = (latitude + latOffset).toStringAsFixed(5);
final bottom = (latitude - latOffset).toStringAsFixed(5);
final left = (longitude - lngOffset).toStringAsFixed(5);
final right = (longitude + lngOffset).toStringAsFixed(5);
// 1. Direct WazeAPI.com service
if (_isWazeApiKey(key)) {
final uri = Uri.parse('https://api.wazeapi.com/v1/alerts?bottom-left=$bottom,$left&top-right=$top,$right');
final response = await http.get(uri, headers: {
'X-API-Key': key,
}).timeout(const Duration(seconds: 8));
if (response.statusCode == 200) {
final dynamic parsed = json.decode(response.body);
final Map<String, dynamic> jsonMap = parsed is Map<String, dynamic> ? parsed : {'alerts': parsed};
return CommunityTrafficParser.parseAlertsJson(
jsonMap,
providerId: id,
providerName: 'Waze Community',
);
}
}
// 2. Custom endpoint URL
if (_customEndpointUrl.isNotEmpty) {
final uri = Uri.parse('$_customEndpointUrl?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=$key');
final response = await http.get(uri, headers: {
if (_isRapidApiKey(key)) 'X-RapidAPI-Key': key,
@ -136,10 +178,12 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
'X-RapidAPI-Host': Uri.parse(_customEndpointUrl).host,
'Authorization': 'Bearer $key',
'x-api-key': key,
'X-API-Key': key,
}).timeout(const Duration(seconds: 8));
if (response.statusCode == 200) {
final Map<String, dynamic> jsonMap = json.decode(response.body);
final dynamic parsed = json.decode(response.body);
final Map<String, dynamic> jsonMap = parsed is Map<String, dynamic> ? parsed : {'alerts': parsed};
return CommunityTrafficParser.parseAlertsJson(
jsonMap,
providerId: id,
@ -147,6 +191,7 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
);
}
}
return [];
} catch (_) {
return [];

View File

@ -28,6 +28,7 @@ class RoadAlertsEnabledNotifier extends StateNotifier<bool> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
state = prefs.getBool(_key) ?? true;
}
Future<void> setEnabled(bool enabled) async {
@ -49,6 +50,7 @@ class AudioAlertsEnabledNotifier extends StateNotifier<bool> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
state = prefs.getBool(_key) ?? true;
}
Future<void> setEnabled(bool enabled) async {
@ -70,6 +72,7 @@ class AlertRadiusMetersNotifier extends StateNotifier<double> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
state = prefs.getDouble(_key) ?? 804.7;
}
Future<void> setRadius(double radiusMeters) async {
@ -120,6 +123,7 @@ class TomTomApiKeyNotifier extends StateNotifier<String> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
state = prefs.getString(_key) ?? '';
}
Future<void> setApiKey(String apiKey) async {
@ -136,15 +140,18 @@ final tomtomApiKeyProvider =
// Community Traffic API Key
class CommunityApiKeyNotifier extends StateNotifier<String> {
static const _key = 'community_api_key';
CommunityApiKeyNotifier() : super('') {
static const String defaultKey = 'wz_live_RqKTtZ10oK3db9hH-ypQhD0tOTLn199q';
CommunityApiKeyNotifier() : super(defaultKey) {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
state = prefs.getString(_key) ?? '';
if (!mounted) return;
state = prefs.getString(_key) ?? defaultKey;
}
Future<void> setApiKey(String apiKey) async {
state = apiKey.trim();
state = apiKey.trim().isEmpty ? defaultKey : apiKey.trim();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, state);
}
@ -162,6 +169,7 @@ class CommunityEndpointUrlNotifier extends StateNotifier<String> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
state = prefs.getString(_key) ?? '';
}
Future<void> setEndpoint(String url) async {

View File

@ -28,7 +28,7 @@ class TomTomRoadAlertProvider implements RoadAlertProvider {
}
try {
final url = Uri.parse(
'https://api.tomtom.com/traffic/services/5/incidentDetails/s3/-122.42,37.77,-122.40,37.79/10/-1/json?key=$apiKey&language=en-GB');
'https://api.tomtom.com/traffic/services/5/incidentDetails?key=${apiKey.trim()}&bbox=-122.42,37.77,-122.40,37.79&language=en-GB');
final response = await http.get(url).timeout(const Duration(seconds: 6));
if (response.statusCode == 200) {
return RoadAlertProviderStatus.connected;
@ -66,7 +66,7 @@ class TomTomRoadAlertProvider implements RoadAlertProvider {
final bboxStr = '${minLng.toStringAsFixed(4)},${minLat.toStringAsFixed(4)},${maxLng.toStringAsFixed(4)},${maxLat.toStringAsFixed(4)}';
final url = Uri.parse(
'https://api.tomtom.com/traffic/services/5/incidentDetails/s3/$bboxStr/10/-1/json?key=$apiKey&language=en-GB');
'https://api.tomtom.com/traffic/services/5/incidentDetails?key=${apiKey.trim()}&bbox=$bboxStr&language=en-GB');
final response = await http.get(url).timeout(const Duration(seconds: 8));

View File

@ -32,7 +32,6 @@ class LocationService {
// Filtering thresholds
static const double _maxRealisticSpeed = 100.0; // roughly 223 MPH in m/s
static const double _maxAcceleration = 30.0; // m/s^2 (allows extreme superbike launch / braking while rejecting GPS teleportation)
static const double _speedEmaAlpha = 0.35; // Smoothing factor for speed
void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic;
@ -110,14 +109,14 @@ class LocationService {
LocationSettings locationSettings;
if (defaultTargetPlatform == TargetPlatform.android) {
locationSettings = AndroidSettings(
accuracy: LocationAccuracy.high,
accuracy: LocationAccuracy.bestForNavigation,
distanceFilter: 0,
intervalDuration: const Duration(milliseconds: 200),
forceLocationManager: true, // Directly access GPS hardware receiver
intervalDuration: const Duration(milliseconds: 100),
forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness
);
} else {
locationSettings = const LocationSettings(
accuracy: LocationAccuracy.high,
accuracy: LocationAccuracy.bestForNavigation,
distanceFilter: 0,
);
}
@ -172,7 +171,7 @@ class LocationService {
try {
pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
accuracy: LocationAccuracy.bestForNavigation,
timeLimit: Duration(seconds: 2),
),
);
@ -196,11 +195,9 @@ class LocationService {
}
bool _isValidPosition(Position position) {
// Dynamic accuracy threshold: allow wider accuracy during satellite lock & highway speeds
// Allow valid GPS readings with accuracy up to 100m during initial lock
final currentSpeed = _emaSpeed ?? position.speed;
final maxAcceptableAccuracy = _lastValidPosition == null
? 80.0
: (currentSpeed > 10.0 ? 60.0 : 45.0);
final maxAcceptableAccuracy = _lastValidPosition == null ? 120.0 : (currentSpeed > 10.0 ? 80.0 : 65.0);
if (position.accuracy > maxAcceptableAccuracy) {
onGpsDiagnostic?.call(
@ -208,16 +205,6 @@ class LocationService {
false,
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
);
return false; // Reject extremely poor accuracy
}
// Reject 0.0 MPH glitch if accuracy is degraded while motorcycle was previously at speed
if (position.speed == 0.0 && currentSpeed > 10.0 && position.accuracy > 30.0) {
onGpsDiagnostic?.call(
position,
false,
'Transient zero-speed cell glitch rejected (${position.accuracy.toStringAsFixed(1)}m)',
);
return false;
}
@ -227,22 +214,22 @@ class LocationService {
false,
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
);
return false; // Reject impossible speeds (>223 MPH)
return false;
}
if (_lastValidPosition != null) {
final timeDiff = (position.timestamp.difference(_lastValidPosition!.timestamp)).inMilliseconds / 1000.0;
// If signal was lost for > 10 seconds (e.g. tunnel or stop), treat as new baseline fix
if (timeDiff > 10.0) {
// If signal was lost for > 8 seconds, treat as new baseline fix
if (timeDiff > 8.0) {
_lastValidPosition = position;
_emaSpeed = position.speed;
onGpsDiagnostic?.call(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
return true;
}
if (timeDiff >= 0.25) {
// Acceleration check (only compute over stable time windows >= 250ms)
if (timeDiff >= 0.2) {
// Acceleration check
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
if (accel > _maxAcceleration) {
onGpsDiagnostic?.call(
@ -250,24 +237,6 @@ class LocationService {
false,
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
);
return false; // Reject impossible acceleration spikes
}
// Path consistency check (distance jump vs time)
final distanceJump = Geolocator.distanceBetween(
_lastValidPosition!.latitude,
_lastValidPosition!.longitude,
position.latitude,
position.longitude,
);
final impliedSpeed = distanceJump / timeDiff;
if (impliedSpeed > _maxRealisticSpeed * 1.5) {
onGpsDiagnostic?.call(
position,
false,
'Position jump anomaly (${(impliedSpeed * 2.23694).toStringAsFixed(1)} MPH implied speed)',
);
return false;
}
}
@ -278,65 +247,20 @@ class LocationService {
return true;
}
bool _isStationaryLocked = true;
Position? _stationaryAnchorPosition;
DateTime? _lowSpeedStartTime;
Position _applyEmaFilter(Position position) {
// Robust stationary hysteresis filter:
// GPS Doppler noise and multipath jitter typically fluctuates between 0.2 m/s and 0.7 m/s (0.5 - 1.6 MPH)
// while the motorcycle is completely stationary.
final rawSpeed = position.speed;
// Stationary jitter deadband: Speeds under 0.6 m/s (~1.3 MPH) are treated as stationary 0.0 MPH
if (rawSpeed < 0.6) {
_lowSpeedStartTime ??= position.timestamp;
final lowSpeedDuration = position.timestamp.difference(_lowSpeedStartTime!).inMilliseconds;
_stationaryAnchorPosition ??= position;
final driftFromAnchor = Geolocator.distanceBetween(
_stationaryAnchorPosition!.latitude,
_stationaryAnchorPosition!.longitude,
position.latitude,
position.longitude,
);
// Lock to absolute 0.0 MPH if below threshold for > 800ms or within 2.5m drift radius
if (lowSpeedDuration >= 800 || driftFromAnchor < 2.5) {
_isStationaryLocked = true;
_emaSpeed = 0.0;
}
} else if (rawSpeed >= 0.8) {
// Break out of stationary lock only upon genuine sustained movement
double moveDistance = 0.0;
if (_stationaryAnchorPosition != null) {
moveDistance = Geolocator.distanceBetween(
_stationaryAnchorPosition!.latitude,
_stationaryAnchorPosition!.longitude,
position.latitude,
position.longitude,
);
}
if (!_isStationaryLocked || moveDistance >= 2.0 || rawSpeed >= 1.2) {
_isStationaryLocked = false;
_stationaryAnchorPosition = null;
_lowSpeedStartTime = null;
} else {
if (_emaSpeed == null || _emaSpeed == 0.0) {
_emaSpeed = rawSpeed;
} else {
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
}
} else {
_emaSpeed = 0.0;
}
} else {
// Transition zone (0.6 - 0.8 m/s): maintain current stationary lock state
if (_isStationaryLocked) {
_emaSpeed = 0.0;
} else {
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
// Fast dynamic alpha: 0.90 when accelerating/braking so speedometer tracks instantly, 0.75 when cruising
final delta = (rawSpeed - _emaSpeed!).abs();
final alpha = delta > 0.4 ? 0.90 : 0.75;
_emaSpeed = alpha * rawSpeed + (1 - alpha) * _emaSpeed!;
}
}

View File

@ -50,7 +50,11 @@ class GoogleMapProvider implements AppMapProvider {
);
}).toSet();
final media = MediaQuery.of(context);
final sizeKey = '${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}';
return gmaps.GoogleMap(
key: ValueKey('google_map_$sizeKey'),
initialCameraPosition: gmaps.CameraPosition(
target: target,
zoom: zoom,

View File

@ -58,13 +58,15 @@ class MapConfig {
final String userAgent;
final double minZoom;
final double maxZoom;
final List<String> subdomains;
const MapConfig({
this.tileUrlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
this.attributionText = '© OpenStreetMap contributors',
this.userAgent = 'MotorcycleDashboard/1.0',
this.userAgent = 'MotorcycleDashboard/1.0 (contact: support@earnable.store)',
this.minZoom = 3.0,
this.maxZoom = 19.0,
this.subdomains = const [],
});
}

View File

@ -39,13 +39,21 @@ final mapProviderTypeNotifierProvider =
return MapProviderTypeNotifier();
});
final openStreetMapProviderInstance = Provider<OpenStreetMapProvider>((ref) {
return OpenStreetMapProvider();
});
final googleMapProviderInstance = Provider<GoogleMapProvider>((ref) {
return GoogleMapProvider();
});
final activeMapProvider = Provider<AppMapProvider>((ref) {
final type = ref.watch(mapProviderTypeNotifierProvider);
switch (type) {
case AppMapType.openStreetMap:
return OpenStreetMapProvider();
return ref.watch(openStreetMapProviderInstance);
case AppMapType.googleMaps:
return GoogleMapProvider();
return ref.watch(googleMapProviderInstance);
}
});

View File

@ -31,11 +31,13 @@ class OpenStreetMapProvider implements AppMapProvider {
if (isFollowMode) {
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs();
final shouldMove = _lastFollowPos == null ||
_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 400 ||
(centerLl.latitude - _lastFollowPos!.latitude).abs() > 0.00005 ||
(centerLl.longitude - _lastFollowPos!.longitude).abs() > 0.00005;
(posDelta > 0.00005 && (_lastFollowTime == null || now.difference(_lastFollowTime!).inMilliseconds > 500));
if (shouldMove) {
_lastFollowPos = centerLl;
@ -48,13 +50,18 @@ class OpenStreetMapProvider implements AppMapProvider {
}
}
final media = MediaQuery.of(context);
final sizeKey = '${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}';
return FlutterMap(
key: ValueKey('osm_map_$sizeKey'),
mapController: _mapController,
options: MapOptions(
initialCenter: centerLl,
initialZoom: zoom,
minZoom: config.minZoom,
maxZoom: config.maxZoom,
backgroundColor: const Color(0xFF0F0F0F),
onPositionChanged: (position, hasGesture) {
if (hasGesture) {
onUserInteraction?.call();
@ -68,7 +75,10 @@ class OpenStreetMapProvider implements AppMapProvider {
children: [
TileLayer(
urlTemplate: config.tileUrlTemplate,
subdomains: config.subdomains,
userAgentPackageName: 'com.example.motorcycle_dash',
maxZoom: 20,
maxNativeZoom: 19,
),
PolylineLayer(
polylines: polylines.map((p) {

View File

@ -0,0 +1,303 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class OsmUserProfile {
final int id;
final String displayName;
final String? avatarUrl;
final int changesetCount;
const OsmUserProfile({
required this.id,
required this.displayName,
this.avatarUrl,
this.changesetCount = 0,
});
Map<String, dynamic> toJson() => {
'id': id,
'displayName': displayName,
'avatarUrl': avatarUrl,
'changesetCount': changesetCount,
};
factory OsmUserProfile.fromJson(Map<String, dynamic> json) => OsmUserProfile(
id: json['id'] as int? ?? 0,
displayName: json['displayName'] as String? ?? 'OSM User',
avatarUrl: json['avatarUrl'] as String?,
changesetCount: json['changesetCount'] as int? ?? 0,
);
}
class OsmAuthState {
final bool isAuthenticated;
final bool isLoading;
final String? accessToken;
final OsmUserProfile? user;
final String? errorMessage;
const OsmAuthState({
this.isAuthenticated = false,
this.isLoading = false,
this.accessToken,
this.user,
this.errorMessage,
});
OsmAuthState copyWith({
bool? isAuthenticated,
bool? isLoading,
String? accessToken,
OsmUserProfile? user,
String? errorMessage,
}) {
return OsmAuthState(
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
isLoading: isLoading ?? this.isLoading,
accessToken: accessToken ?? this.accessToken,
user: user ?? this.user,
errorMessage: errorMessage,
);
}
}
class OsmOAuthService extends StateNotifier<OsmAuthState> {
static const String clientId = 'nkd1ogsHpcMJD1Hin9UT4bCjK8T33BjUrEwF7WAYNio';
static const String redirectUri = 'https://earnable.store/oauth/callback';
static const String appSchemeRedirect = 'motorsignal://oauth/callback';
static const String authEndpoint = 'https://www.openstreetmap.org/oauth2/authorize';
static const String tokenEndpoint = 'https://www.openstreetmap.org/oauth2/token';
static const String userDetailsEndpoint = 'https://api.openstreetmap.org/api/0.6/user/details.json';
static const String gpxUploadEndpoint = 'https://api.openstreetmap.org/api/0.6/gpx/create';
static const String _prefTokenKey = 'osm_access_token';
static const String _prefUserKey = 'osm_user_profile';
static const String _prefVerifierKey = 'osm_pkce_code_verifier';
OsmOAuthService() : super(const OsmAuthState(isLoading: true)) {
_loadStoredSession();
}
Future<void> _loadStoredSession() async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_prefTokenKey);
final userJsonStr = prefs.getString(_prefUserKey);
if (token != null && token.isNotEmpty) {
OsmUserProfile? user;
if (userJsonStr != null) {
try {
user = OsmUserProfile.fromJson(jsonDecode(userJsonStr) as Map<String, dynamic>);
} catch (_) {}
}
state = OsmAuthState(
isAuthenticated: true,
isLoading: false,
accessToken: token,
user: user,
);
// Refresh profile in background
fetchUserProfile();
return;
}
} catch (_) {}
state = const OsmAuthState(isAuthenticated: false, isLoading: false);
}
String _generateRandomString(int length) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~';
final rand = Random.secure();
return List.generate(length, (_) => chars[rand.nextInt(chars.length)]).join();
}
String _generateCodeChallenge(String verifier) {
final bytes = utf8.encode(verifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Launch OpenStreetMap in external browser for login & approval
Future<bool> startLoginFlow() async {
state = state.copyWith(isLoading: true, errorMessage: null);
try {
final codeVerifier = _generateRandomString(64);
final codeChallenge = _generateCodeChallenge(codeVerifier);
final stateParam = _generateRandomString(16);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefVerifierKey, codeVerifier);
final queryParams = {
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'scope': 'read_prefs openid write_gpx read_gpx',
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': stateParam,
};
final authUri = Uri.parse(authEndpoint).replace(queryParameters: queryParams);
final launched = await launchUrl(
authUri,
mode: LaunchMode.externalApplication,
);
state = state.copyWith(isLoading: false);
return launched;
} catch (e) {
state = state.copyWith(isLoading: false, errorMessage: 'Failed to launch login: $e');
return false;
}
}
/// Complete OAuth2 token exchange with auth code from redirect callback
Future<bool> handleAuthCallback(Uri uri) async {
final code = uri.queryParameters['code'];
if (code == null || code.isEmpty) {
state = state.copyWith(errorMessage: 'No authorization code received.');
return false;
}
state = state.copyWith(isLoading: true, errorMessage: null);
try {
final prefs = await SharedPreferences.getInstance();
final codeVerifier = prefs.getString(_prefVerifierKey) ?? '';
debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri');
final response = await http.post(
Uri.parse(tokenEndpoint),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
'grant_type': 'authorization_code',
'client_id': clientId,
'code': code,
'redirect_uri': redirectUri,
'code_verifier': codeVerifier,
},
);
debugPrint('[OsmOAuth] Token response (${response.statusCode}): ${response.body}');
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
final accessToken = json['access_token'] as String;
await prefs.setString(_prefTokenKey, accessToken);
await prefs.remove(_prefVerifierKey);
state = OsmAuthState(
isAuthenticated: true,
isLoading: false,
accessToken: accessToken,
);
await fetchUserProfile();
return true;
} else {
state = state.copyWith(
isLoading: false,
errorMessage: 'Token exchange failed (${response.statusCode}): ${response.body}',
);
return false;
}
} catch (e) {
debugPrint('[OsmOAuth] Exception during auth callback: $e');
state = state.copyWith(isLoading: false, errorMessage: 'Authentication error: $e');
return false;
}
}
/// Fetch OpenStreetMap user profile details
Future<void> fetchUserProfile() async {
final token = state.accessToken;
if (token == null || token.isEmpty) return;
try {
final response = await http.get(
Uri.parse(userDetailsEndpoint),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final userObj = data['user'] as Map<String, dynamic>?;
if (userObj != null) {
final profile = OsmUserProfile(
id: userObj['id'] as int? ?? 0,
displayName: userObj['display_name'] as String? ?? 'OSM User',
avatarUrl: (userObj['img'] as Map<String, dynamic>?)?['href'] as String?,
changesetCount: (userObj['changesets'] as Map<String, dynamic>?)?['count'] as int? ?? 0,
);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefUserKey, jsonEncode(profile.toJson()));
state = state.copyWith(user: profile);
}
}
} catch (_) {}
}
/// Upload recorded Ride GPX track directly to OpenStreetMap
Future<bool> uploadGpxTrack({
required String gpxXmlContent,
required String filename,
required String description,
List<String> tags = const ['motorcycle', 'motorsignal'],
String visibility = 'identifiable', // 'private', 'public', 'trackable', 'identifiable'
}) async {
final token = state.accessToken;
if (token == null || token.isEmpty) return false;
try {
final request = http.MultipartRequest('POST', Uri.parse(gpxUploadEndpoint))
..headers['Authorization'] = 'Bearer $token'
..fields['description'] = description
..fields['tags'] = tags.join(',')
..fields['visibility'] = visibility
..files.add(
http.MultipartFile.fromString(
'file',
gpxXmlContent,
filename: filename,
),
);
final streamedResponse = await request.send();
return streamedResponse.statusCode == 200 || streamedResponse.statusCode == 201;
} catch (_) {
return false;
}
}
/// Disconnect / Sign out from OpenStreetMap
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefTokenKey);
await prefs.remove(_prefUserKey);
await prefs.remove(_prefVerifierKey);
state = const OsmAuthState(isAuthenticated: false, isLoading: false);
}
}
final osmOAuthServiceProvider =
StateNotifierProvider<OsmOAuthService, OsmAuthState>((ref) {
return OsmOAuthService();
});

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'calibration_service.dart';
@ -35,7 +36,7 @@ class GForceData {
});
}
class SensorFusionService {
class SensorFusionService with WidgetsBindingObserver {
final CalibrationService _calibrationService;
final _leanAngleController = StreamController<double>.broadcast();
@ -59,6 +60,9 @@ class SensorFusionService {
double _currentPitch = 0.0;
DateTime? _lastGyroTime;
bool _isLandscape = false;
bool _isLandscapeLeft = true;
static const double _baseAlpha = 0.94; // Base complementary filter gain
static const double _gravityConst = 9.80665;
@ -68,10 +72,32 @@ class SensorFusionService {
static const double _gAlpha = 0.40; // Fast responsive dynamic smoothing
SensorFusionService(this._calibrationService) {
WidgetsBinding.instance.addObserver(this);
_updateDisplayOrientation();
_loadInitialCalibration();
_startSensors();
}
@override
void didChangeMetrics() {
_updateDisplayOrientation();
}
void _updateDisplayOrientation() {
try {
final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull;
if (view != null) {
final size = view.physicalSize;
_isLandscape = size.width > size.height;
}
} catch (_) {}
}
void setOrientation(bool isLandscape, {bool isLandscapeLeft = true}) {
_isLandscape = isLandscape;
_isLandscapeLeft = isLandscapeLeft;
}
Future<void> _loadInitialCalibration() async {
try {
final offsets = await _calibrationService.loadCalibrationOffsets();
@ -95,32 +121,62 @@ class SensorFusionService {
if (dt > 0.1) dt = 0.1;
if (dt <= 0.0) return;
// Integrate gyro (rad/s to degrees)
// Phone X is pitch rate, Phone Y is roll rate, Phone Z is yaw rate
final gx = event.x * (180.0 / math.pi);
final gy = event.y * (180.0 / math.pi);
// 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)
_currentPitch = (_currentPitch + gx * dt).clamp(-90.0, 90.0);
_currentRoll = (_currentRoll - gy * dt).clamp(-90.0, 90.0);
if (!_isLandscape) {
// Portrait
vGyroPitchRate = event.x * (180.0 / math.pi);
vGyroRollRate = -event.y * (180.0 / math.pi);
} else if (_isLandscapeLeft) {
// Landscape Left
vGyroPitchRate = event.y * (180.0 / math.pi);
vGyroRollRate = event.x * (180.0 / math.pi);
} else {
// Landscape Right
vGyroPitchRate = -event.y * (180.0 / math.pi);
vGyroRollRate = -event.x * (180.0 / math.pi);
}
_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) {
// Auto-detect landscape sub-type if in landscape
if (_isLandscape && event.x.abs() > 3.0) {
_isLandscapeLeft = event.x > 0;
}
// Map raw accelerometer to Vehicle Frame (vx = Right, vy = Forward, vz = Up)
double vx, vy, vz;
if (!_isLandscape) {
vx = event.x;
vy = event.y;
vz = event.z;
} else if (_isLandscapeLeft) {
vx = -event.y;
vy = event.x;
vz = event.z;
} else {
vx = event.y;
vy = -event.x;
vz = event.z;
}
// Calculate total acceleration magnitude
final norm = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
final norm = math.sqrt(vx * vx + vy * vy + vz * vz);
if (norm < 0.1) return;
// Compute static gravity-based angles
final accelRoll = -math.atan2(event.x, math.sqrt(event.y * event.y + event.z * event.z)) * (180.0 / math.pi);
final accelPitch = math.atan2(-event.y, event.z) * (180.0 / math.pi);
// Compute static gravity-based angles in vehicle frame
final accelRoll = -math.atan2(vx, math.sqrt(vy * vy + vz * vz)) * (180.0 / math.pi);
final accelPitch = math.atan2(-vy, vz) * (180.0 / math.pi);
// Dynamic acceleration gating:
// On a motorcycle, accelerometers measure specific force = gravity + vehicle dynamics.
// During acceleration, braking, or cornering, the apparent gravity vector tilts significantly.
// Inhibit accelerometer tilt correction when total force deviates from 1.0g or during motion.
final gMag = norm / _gravityConst;
final dynamicDelta = (gMag - 1.0).abs();
// If dynamic force > 0.08g, trust gyro 99.8% to prevent vehicle acceleration from tilting lean/pitch
// If dynamic force > 0.08g, trust gyro to prevent vehicle acceleration from distorting lean
double dynamicAlpha = _baseAlpha;
if (dynamicDelta > 0.08) {
dynamicAlpha = 0.998;
@ -139,24 +195,44 @@ class SensorFusionService {
});
_userAccelSub = userAccelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((UserAccelerometerEvent event) {
double vxUser, vyUser, vzUser;
if (!_isLandscape) {
vxUser = event.x;
vyUser = event.y;
vzUser = event.z;
} else if (_isLandscapeLeft) {
vxUser = -event.y;
vyUser = event.x;
vzUser = event.z;
} else {
vxUser = event.y;
vyUser = -event.x;
vzUser = event.z;
}
// Project phone linear acceleration vector into motorcycle body frame
// using calibrated pitch and roll attitude angles
final pitchRad = (_currentPitch - _pitchOffset) * (math.pi / 180.0);
final rollRad = (_currentRoll - _rollOffset) * (math.pi / 180.0);
// Forward/longitudinal acceleration (along motorcycle centerline)
// Positive = Acceleration, Negative = Braking
final rawLongitudinal = (event.y * math.cos(pitchRad) - event.z * math.sin(pitchRad));
final rawLongitudinal = (vyUser * math.cos(pitchRad) - vzUser * math.sin(pitchRad));
// Lateral acceleration (cornering)
final rawLateral = (event.x * math.cos(rollRad) + event.z * math.sin(rollRad));
final rawLateral = (vxUser * math.cos(rollRad) + vzUser * math.sin(rollRad));
double rawLongitudinalG = rawLongitudinal / _gravityConst;
double rawLateralG = rawLateral / _gravityConst;
// Deadband / noise gate for resting phone on table
if (rawLongitudinalG.abs() < 0.02) rawLongitudinalG = 0.0;
if (rawLateralG.abs() < 0.02) rawLateralG = 0.0;
// Dynamic smoothing: suppress engine high-frequency vibration while preserving vehicle dynamics
_filteredLongitudinalG = _gAlpha * rawLongitudinalG + (1.0 - _gAlpha) * _filteredLongitudinalG;
_filteredLateralG = _gAlpha * rawLateralG + (1.0 - _gAlpha) * _filteredLateralG;
if (_filteredLongitudinalG.abs() < 0.008) _filteredLongitudinalG = 0.0;
if (_filteredLateralG.abs() < 0.008) _filteredLateralG = 0.0;
_gForceController.add(GForceData(
longitudinal: _filteredLongitudinalG,
lateral: _filteredLateralG,
@ -168,13 +244,13 @@ class SensorFusionService {
}
void calibrateUpright() {
// Zero out the current lean & pitch and persist to storage
_rollOffset = _currentRoll;
_pitchOffset = _currentPitch;
_calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset);
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_accelSub?.cancel();
_gyroSub?.cancel();
_userAccelSub?.cancel();

View File

@ -77,6 +77,7 @@ class TelemetryService {
_locationSub = _locationService.getFilteredPositionStream().listen((pos) {
_latestPosition = pos;
_emitSample();
});
_leanSub = _sensorFusionService.leanAngleStream.listen((lean) {
@ -102,14 +103,15 @@ class TelemetryService {
filteredLateralG: gForce.lateral,
);
}
_emitSample();
});
_compassSub = _compassService.headingStream.listen((heading) {
_latestCompassHeading = heading;
});
// Sample at 5Hz (200ms)
_samplingTimer = Timer.periodic(const Duration(milliseconds: 200), (_) {
// High-rate refresh at 20Hz (50ms) for ultra-fluid digital speedometer
_samplingTimer = Timer.periodic(const Duration(milliseconds: 50), (_) {
_emitSample();
});
}

View File

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
gtk
url_launcher_linux
)

View File

@ -5,18 +5,22 @@
import FlutterMacOS
import Foundation
import app_links
import flutter_tts
import geolocator_apple
import package_info_plus
import share_plus
import shared_preferences_foundation
import url_launcher_macos
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
}

View File

@ -25,6 +25,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: "direct main"
description:
@ -178,7 +210,7 @@ packages:
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@ -517,6 +549,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.8"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hooks:
dependency: transitive
description:
@ -1210,6 +1250,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
@ -1218,6 +1282,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 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+1
version: 1.1.0+12
environment:
sdk: ^3.12.2
@ -56,6 +56,9 @@ dependencies:
package_info_plus: ^10.2.1
archive: ^4.0.2
intl: ^0.20.2
url_launcher: ^6.3.1
crypto: ^3.0.6
app_links: ^6.3.2
dev_dependencies:
flutter_test:

50
serve_apk.js Normal file
View File

@ -0,0 +1,50 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 8888;
const APK_PATH = path.join(__dirname, 'build', 'app', 'outputs', 'flutter-apk', 'app-release.apk');
const server = http.createServer((req, res) => {
if (req.url === '/download' || req.url === '/app-release.apk' || req.url === '/motorsignal.apk') {
if (!fs.existsSync(APK_PATH)) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('APK not found. Please build first.');
}
const stat = fs.statSync(APK_PATH);
res.writeHead(200, {
'Content-Type': 'application/vnd.android.package-archive',
'Content-Length': stat.size,
'Content-Disposition': 'attachment; filename="motorsignal-evolution.apk"'
});
fs.createReadStream(APK_PATH).pipe(res);
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MotorSignal Evolution APK</title>
<style>
body { background: #0a0a0a; color: #fff; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 90vh; margin: 0; }
.card { background: #141414; border: 1px solid #00E5FF; padding: 2rem; border-radius: 16px; text-align: center; max-width: 90%; box-shadow: 0 0 30px rgba(0,229,255,0.2); }
h1 { color: #00E5FF; margin-top: 0; font-size: 1.6rem; letter-spacing: 1px; }
.btn { display: inline-block; background: #00E5FF; color: #000; padding: 1rem 2rem; font-size: 1.2rem; font-weight: bold; border-radius: 10px; text-decoration: none; margin-top: 1.5rem; }
.btn:hover { background: #6effff; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="card">
<h1>⚡ MotorSignal Evolution</h1>
<p>Release APK (All Screens & Landscape/Portrait Adaptive)</p>
<a class="btn" href="/download">Download APK (62.6 MB)</a>
</div>
</body>
</html>`);
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`APK Server running on port ${PORT}`);
});

View File

@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

View File

@ -0,0 +1,83 @@
# 🚔 MotorSignal Waze Alert Relay Server
A high-performance, lightweight headless relay backend that queries Waze Live Map feeds and provides normalized **Police Speed Traps, Speed Cameras, Red-Light Cameras, and Road Hazard Alerts** directly to the **MotorSignal** Motorcycle Cockpit application.
---
## 🚀 Quick Start
### Method 1: Run with Node.js (Local or VPS)
`ash
cd server/waze_relay
npm install
npm start
`
The server will start on http://0.0.0.0:3000.
### Method 2: Run with Docker / Docker Compose
`ash
cd server/waze_relay
docker compose up -d
`
---
## 📱 Connecting to MotorSignal
1. Open **MotorSignal** on your phone.
2. Go to **Settings** → **Community Traffic Provider**.
3. Under **Custom Endpoint URL**, enter your relay server URL:
`
http://<YOUR_SERVER_IP>:3000/alerts
`
*(e.g., http://192.168.1.69:3000/alerts for local Wi-Fi, or https://your-domain.com/alerts for remote VPS).*
4. Tap **TEST CONNECTION**. You will see the green **CONNECTED** badge!
---
## 📡 REST API Endpoints
### 1. Health Check
* GET /health or GET /
* **Response:**
`json
{
"status": "ok",
"service": "MotorSignal Waze Community Relay",
"version": "1.0.0"
}
`
### 2. Live Alerts Feed
* GET /alerts?lat=37.7749&lng=-122.4194&radius=5000
* **Query Parameters:**
* lat: Latitude of vehicle
* lng: Longitude of vehicle
* adius: Search radius in meters (Default: 5000m)
* **Response:**
`json
{
"provider": "waze_relay",
"count": 2,
"alerts": [
{
"id": "wz_37.77490_-122.41940",
"type": "POLICE",
"category": "police",
"latitude": 37.7749,
"longitude": -122.4194,
"roadName": "Market St",
"description": "Hidden police speed trap reported on Market St",
"severity": "high",
"confidence": 0.9
}
]
}
`
---
## ⚡ Features
* **Police Trap Detection:** Parses POLICE_HIDDEN and POLICE_VISIBLE radar traps.
* **Camera Enforcement:** Speed cameras and red-light enforcement cameras.
* **In-Memory Geo-Grid Caching:** 30-second TTL prevents rate limiting and ensures sub-10ms response times for the motorcycle HUD.

View File

@ -0,0 +1,11 @@
version: '3.8'
services:
waze-relay:
build: .
container_name: motorsignal-waze-relay
restart: unless-stopped
ports:
- "3000:3000"
environment:
- PORT=3000
- NODE_ENV=production

View File

@ -0,0 +1,36 @@
const puppeteer = require('puppeteer-core');
const EDGE_PATH = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
async function run() {
const browser = await puppeteer.launch({
executablePath: EDGE_PATH,
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
page.on('request', req => {
const url = req.url();
if (url.includes('waze.com') && (url.includes('api') || url.includes('feed') || url.includes('json') || url.includes('routing') || url.includes('live-map'))) {
console.log(`[REQ] ${req.method()} ${url}`);
}
});
page.on('response', async res => {
const url = res.url();
if (url.includes('waze.com') && (url.includes('api') || url.includes('georss') || url.includes('alert') || url.includes('routing'))) {
console.log(`[RES ${res.status()}] ${url}`);
}
});
console.log('Navigating to Waze Live Map...');
await page.goto('https://www.waze.com/live-map', { waitUntil: 'networkidle2', timeout: 30000 });
console.log('Page loaded. Waiting 5s...');
await new Promise(r => setTimeout(r, 5000));
await browser.close();
}
run().catch(console.error);

View File

@ -0,0 +1,39 @@
const puppeteer = require('puppeteer-core');
const EDGE_PATH = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
async function run() {
const browser = await puppeteer.launch({
executablePath: EDGE_PATH,
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
page.on('response', async res => {
const url = res.url();
if (url.includes('waze.com') && (url.includes('routing') || url.includes('descartes') || url.includes('direction') || url.includes('alert') || url.includes('json') || url.includes('api'))) {
console.log(`[RES ${res.status()}] ${url.slice(0, 120)}`);
try {
const text = await res.text();
if (text.includes('alerts') || text.includes('POLICE') || text.includes('jam') || text.includes('results')) {
console.log(`>>> PAYLOAD HAS RELEVANT DATA! (${text.length} chars)`);
console.log(text.slice(0, 300));
}
} catch (e) {}
}
});
console.log('Navigating with origin and destination...');
await page.goto('https://www.waze.com/live-map/directions?to=ll.37.7880%2C-122.4075&from=ll.37.7749%2C-122.4194', {
waitUntil: 'networkidle2',
timeout: 30000
});
await new Promise(r => setTimeout(r, 6000));
await browser.close();
}
run().catch(console.error);

1172
server/waze_relay/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "motorsignal-waze-relay",
"version": "1.0.0",
"description": "High-performance headless relay server for live Waze police, speed trap, and hazard alerts",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"puppeteer-core": "^25.9.0"
},
"keywords": [
"waze",
"police-alerts",
"speed-traps",
"motorsignal"
],
"author": "MotorSignal",
"license": "MIT"
}

301
server/waze_relay/server.js Normal file
View File

@ -0,0 +1,301 @@
const http = require('http');
const https = require('https');
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// In-memory cache for Waze requests (30s TTL per geo tile)
const cache = new Map();
const CACHE_TTL_MS = 30 * 1000;
// User agents and headers mimicking genuine browser requests
const BROWSER_USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
];
function getRandomUserAgent() {
return BROWSER_USER_AGENTS[Math.floor(Math.random() * BROWSER_USER_AGENTS.length)];
}
/**
* Determine Waze server environment based on longitude
* na: North America (Western Hemisphere approx)
* il: Israel
* row: Rest of World
*/
function getWazeEnv(lat, lng) {
if (lat >= 29.0 && lat <= 33.5 && lng >= 34.0 && lng <= 36.0) {
return 'il';
}
if (lng >= -170.0 && lng <= -50.0) {
return 'na';
}
return 'row';
}
/**
* Fetch raw GeoRSS alerts from Waze with browser impersonation
*/
function fetchWazeAlerts(top, bottom, left, right, env = 'na') {
return new Promise((resolve, reject) => {
const query = new URLSearchParams({
top: top.toString(),
bottom: bottom.toString(),
left: left.toString(),
right: right.toString(),
env: env,
types: 'alerts,traffic,jams'
});
const url = `https://www.waze.com/live-map/api/georss?${query.toString()}`;
const parsed = new URL(url);
const req = https.request({
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method: 'GET',
headers: {
'User-Agent': getRandomUserAgent(),
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://www.waze.com/live-map/',
'Sec-Ch-Ua': '"Chromium";v="124", "Google Chrome";v="124"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'Connection': 'keep-alive'
},
timeout: 8000
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const data = JSON.parse(body);
resolve(data);
} catch (e) {
reject(new Error(`Failed to parse Waze JSON: ${e.message}`));
}
} else {
reject(new Error(`Waze responded with HTTP ${res.statusCode}: ${body.slice(0, 150)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Waze request timed out'));
});
req.on('error', (err) => {
reject(err);
});
req.end();
});
}
/**
* Normalize and format Waze raw alerts into MotorSignal Community Schema
*/
function normalizeAlerts(wazeData) {
const rawAlerts = wazeData.alerts || [];
const normalized = [];
for (const item of rawAlerts) {
const lat = item.location?.y ?? item.latitude ?? item.lat;
const lng = item.location?.x ?? item.longitude ?? item.lng;
if (lat == null || lng == null) continue;
const type = (item.type || 'HAZARD').toUpperCase();
const subtype = (item.subtype || item.subType || '').toUpperCase();
const street = item.street || item.road || '';
const city = item.city || '';
let category = 'other';
let description = item.reportDescription || '';
if (type.includes('POLICE') || subtype.includes('POLICE')) {
category = 'police';
if (subtype.includes('HIDDEN')) {
description = description || 'Hidden police speed trap reported';
} else if (subtype.includes('VISIBLE')) {
description = description || 'Police speed trap reported';
} else {
description = description || 'Police presence reported';
}
} else if (type.includes('SPEED_CAMERA') || subtype.includes('SPEED_CAMERA') || type.includes('CAMERA')) {
category = 'speed_camera';
description = description || 'Speed enforcement camera';
} else if (type.includes('RED_LIGHT_CAMERA') || subtype.includes('RED_LIGHT_CAMERA')) {
category = 'speed_camera';
description = description || 'Red light camera';
} else if (type.includes('ACCIDENT') || subtype.includes('ACCIDENT')) {
category = 'accident';
description = description || (subtype.includes('MAJOR') ? 'Major collision' : 'Accident reported');
} else if (type.includes('HAZARD') || subtype.includes('HAZARD') || subtype.includes('WEATHER') || subtype.includes('DEBRIS')) {
category = 'hazard';
description = description || (subtype.includes('POT_HOLE') ? 'Pothole on roadway' : 'Road hazard / debris');
} else if (type.includes('ROAD_CLOSED') || subtype.includes('ROAD_CLOSED')) {
category = 'road_closure';
description = description || 'Road closed';
} else if (type.includes('CONSTRUCTION') || subtype.includes('CONSTRUCTION')) {
category = 'construction';
description = description || 'Road work / Construction';
} else if (type.includes('JAM') || type.includes('TRAFFIC')) {
category = 'traffic';
description = description || 'Heavy traffic congestion';
}
if (street) {
description += ` on ${street}`;
}
normalized.push({
id: item.uuid || item.id || `wz_${lat.toFixed(5)}_${lng.toFixed(5)}`,
providerId: 'community_traffic',
source: 'Waze Community Relay',
type: type,
subtype: subtype,
category: category,
latitude: parseFloat(lat),
longitude: parseFloat(lng),
heading: item.magvar || item.heading || null,
roadName: street,
city: city,
description: description,
confidence: (item.confidence != null ? item.confidence / 10.0 : (item.reportRating != null ? item.reportRating / 5.0 : 0.85)),
severity: item.reportRating >= 4 ? 'high' : (item.reportRating >= 2 ? 'medium' : 'low'),
reliability: item.reliability || 5,
reportRating: item.reportRating || 3,
nThumbsUp: item.nThumbsUp || 0,
pubMillis: item.pubMillis || Date.now(),
reportedAt: new Date(item.pubMillis || Date.now()).toISOString()
});
}
return normalized;
}
// -------------------------------------------------------------
// REST API ROUTES
// -------------------------------------------------------------
/**
* Health check & discovery endpoint (Supports MotorSignal TEST CONNECTION button)
*/
app.get(['/', '/health', '/test'], (req, res) => {
res.json({
status: 'ok',
service: 'MotorSignal Waze Community Relay',
version: '1.0.0',
timestamp: new Date().toISOString(),
uptimeSeconds: Math.floor(process.uptime()),
features: ['police', 'speed_cameras', 'hazards', 'accidents', 'traffic']
});
});
/**
* Main alerts endpoint
*/
app.get(['/alerts', '/api/alerts'], async (req, res) => {
try {
let top, bottom, left, right, lat, lng;
if (req.query.top && req.query.bottom && req.query.left && req.query.right) {
top = parseFloat(req.query.top);
bottom = parseFloat(req.query.bottom);
left = parseFloat(req.query.left);
right = parseFloat(req.query.right);
lat = (top + bottom) / 2;
lng = (left + right) / 2;
} else if (req.query.lat && req.query.lng) {
lat = parseFloat(req.query.lat);
lng = parseFloat(req.query.lng);
const radiusMeters = parseFloat(req.query.radius || 5000);
const latDelta = radiusMeters / 111000.0;
const lngDelta = radiusMeters / (111000.0 * Math.cos(lat * Math.PI / 180.0));
top = lat + latDelta;
bottom = lat - latDelta;
left = lng - lngDelta;
right = lng + lngDelta;
} else {
lat = 37.7749;
lng = -122.4194;
top = 37.82;
bottom = 37.72;
left = -122.48;
right = -122.36;
}
const env = req.query.env || getWazeEnv(lat, lng);
const cacheKey = `${lat.toFixed(2)}_${lng.toFixed(2)}_${env}`;
const cached = cache.get(cacheKey);
if (cached && (Date.now() - cached.time < CACHE_TTL_MS)) {
return res.json({
provider: 'waze_relay',
cached: true,
count: cached.alerts.length,
alerts: cached.alerts
});
}
try {
const wazeRaw = await fetchWazeAlerts(top, bottom, left, right, env);
const normalizedAlerts = normalizeAlerts(wazeRaw);
cache.set(cacheKey, { time: Date.now(), alerts: normalizedAlerts });
return res.json({
provider: 'waze_relay',
cached: false,
count: normalizedAlerts.length,
alerts: normalizedAlerts
});
} catch (fetchErr) {
console.warn(`[WazeRelay] Warning: ${fetchErr.message}`);
if (cached) {
return res.json({
provider: 'waze_relay',
cached: true,
stale: true,
count: cached.alerts.length,
alerts: cached.alerts
});
}
return res.json({
provider: 'waze_relay',
status: 'partial',
message: fetchErr.message,
count: 0,
alerts: []
});
}
} catch (err) {
console.error('[WazeRelay] Server Error:', err);
res.status(500).json({ error: err.message, alerts: [] });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`====================================================`);
console.log(` 🏍️ MotorSignal Waze Alert Relay Server running`);
console.log(` 📡 Listening on: http://0.0.0.0:${PORT}`);
console.log(` 🏥 Healthcheck: http://localhost:${PORT}/health`);
console.log(` 🚨 Alerts API: http://localhost:${PORT}/alerts?lat=37.77&lng=-122.42`);
console.log(`====================================================`);
});

View File

@ -0,0 +1,45 @@
const puppeteer = require('puppeteer-core');
const EDGE_PATH = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
async function run() {
console.log('Launching headless browser...');
const browser = await puppeteer.launch({
executablePath: EDGE_PATH,
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-blink-features=AutomationControlled']
});
const page = await browser.newPage();
console.log('Navigating to Waze Live Map...');
await page.goto('https://www.waze.com/live-map/directions?lat=37.7749&lng=-122.4194', {
waitUntil: 'networkidle2',
timeout: 20000
});
console.log('Evaluating in-page Waze alerts fetch...');
const inPageResult = await page.evaluate(async () => {
try {
const res = await fetch('https://www.waze.com/live-map/api/georss?top=37.82&bottom=37.72&left=-122.48&right=-122.36&env=na&types=alerts', {
headers: {
'Accept': 'application/json, text/plain, */*',
}
});
const data = await res.json();
return { status: res.status, alerts: data.alerts || [] };
} catch (e) {
return { error: e.message };
}
});
console.log('STATUS:', inPageResult.status);
console.log('ALERTS FOUND:', inPageResult.alerts ? inPageResult.alerts.length : inPageResult.error);
if (inPageResult.alerts && inPageResult.alerts.length > 0) {
console.log('First 3 alerts:', JSON.stringify(inPageResult.alerts.slice(0, 3), null, 2));
}
await browser.close();
}
run().catch(console.error);

View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/evolution_theme_config.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/evolution_portrait_dashboard.dart';
import 'package:motorcycle_dash/features/records/evolution_records_screen.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Evolution Theme Configuration Tests', () {
test('Default theme is classic without altering existing user experience', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final notifier = DashboardThemeModeNotifier(prefs);
expect(notifier.state, equals(DashboardThemeMode.classic));
});
test('Toggling theme switches to evolution and persists', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final notifier = DashboardThemeModeNotifier(prefs);
notifier.setThemeMode(DashboardThemeMode.evolution);
expect(notifier.state, equals(DashboardThemeMode.evolution));
final reloaded = DashboardThemeModeNotifier(prefs);
expect(reloaded.state, equals(DashboardThemeMode.evolution));
});
});
group('Evolution UI Widgets Rendering Tests', () {
testWidgets('EvolutionPortraitDashboard renders speedometer and HUD pods', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: MaterialApp(
home: Scaffold(
body: EvolutionPortraitDashboard(
currentSpeedMph: 136.0,
heading: 355.0,
leanAngle: 48.0,
accelG: 0.98,
lateralG: 1.12,
session: RideSession(
state: RideState.idle,
tripDistanceMiles: 145.2,
topSpeedMph: 168.0,
averageSpeedMph: 87.0,
maxLeanLeft: 52.0,
maxLeanRight: 49.0,
startTime: null,
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('136'), findsOneWidget);
expect(find.text('MPH'), findsOneWidget);
expect(find.text('FIXED'), findsOneWidget);
expect(find.text('HEADSET'), findsOneWidget);
expect(find.text('BRAKING'), findsOneWidget);
expect(find.text('CORNERING'), findsOneWidget);
expect(find.text('LEAN ANGLE'), findsOneWidget);
expect(find.text('START SESSION'), findsOneWidget);
});
testWidgets('EvolutionRecordsScreen renders speed records and performance times', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: const MaterialApp(
home: EvolutionRecordsScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('MOTOR SIGNAL'), findsOneWidget);
expect(find.text('PERSONAL RECORDS'), findsOneWidget);
expect(find.text('TOP SPEED'), findsOneWidget);
expect(find.text('0-60'), findsOneWidget);
expect(find.text('0-100'), findsOneWidget);
expect(find.text('MAX LEAN LEFT'), findsOneWidget);
});
});
}

View File

@ -26,7 +26,7 @@ void main() {
test('MapConfig defaults', () {
const config = MapConfig();
expect(config.tileUrlTemplate, contains('openstreetmap.org'));
expect(config.tileUrlTemplate, anyOf(contains('cartocdn.com'), contains('openstreetmap.org')));
expect(config.attributionText, contains('OpenStreetMap'));
expect(config.userAgent, contains('MotorcycleDashboard'));
});

View File

@ -186,11 +186,11 @@ void main() {
expect(container.read(audioAlertsEnabledProvider), isTrue);
expect(container.read(alertRadiusMetersProvider), equals(804.7));
expect(container.read(tomtomApiKeyProvider), isEmpty);
expect(container.read(communityApiKeyProvider), isEmpty);
expect(container.read(communityApiKeyProvider), anyOf(isEmpty, contains('wz_live')));
expect(container.read(tomtomStatusProvider),
equals(RoadAlertProviderStatus.notConfigured));
expect(container.read(communityStatusProvider),
equals(RoadAlertProviderStatus.notConfigured));
anyOf(equals(RoadAlertProviderStatus.notConfigured), equals(RoadAlertProviderStatus.configured)));
});
test('Active providers list type is correct', () {

69
tool/bump_version.dart Normal file
View File

@ -0,0 +1,69 @@
import 'dart:io';
void main(List<String> args) {
if (args.isEmpty) {
print('Usage: dart run tool/bump_version.dart <patch|minor|major|build>');
exit(1);
}
final action = args[0].toLowerCase();
final pubspecFile = File('pubspec.yaml');
if (!pubspecFile.existsSync()) {
print('Error: pubspec.yaml not found.');
exit(1);
}
final pubspecContent = pubspecFile.readAsStringSync();
final versionRegex = RegExp(r'^version:\s*([0-9]+)\.([0-9]+)\.([0-9]+)\+([0-9]+)', multiLine: true);
final match = versionRegex.firstMatch(pubspecContent);
if (match == null) {
print('Error: could not find version pattern in pubspec.yaml');
exit(1);
}
int major = int.parse(match.group(1)!);
int minor = int.parse(match.group(2)!);
int patch = int.parse(match.group(3)!);
int build = int.parse(match.group(4)!);
switch (action) {
case 'major':
major++;
minor = 0;
patch = 0;
build++;
break;
case 'minor':
minor++;
patch = 0;
build++;
break;
case 'patch':
patch++;
build++;
break;
case 'build':
build++;
break;
default:
print('Unknown action "$action". Use patch, minor, major, or build.');
exit(1);
}
final newVersion = '$major.$minor.$patch+$build';
print('Bumping version to: $newVersion');
final updatedPubspec = pubspecContent.replaceFirst(versionRegex, 'version: $newVersion');
pubspecFile.writeAsStringSync(updatedPubspec);
final appInfoFile = File('lib/core/app_info.dart');
if (appInfoFile.existsSync()) {
final appInfoContent = appInfoFile.readAsStringSync();
final appInfoRegex = RegExp(r"static const String fallbackVersion = '([^']+)';");
final updatedAppInfo = appInfoContent.replaceFirst(appInfoRegex, "static const String fallbackVersion = '$newVersion';");
appInfoFile.writeAsStringSync(updatedAppInfo);
}
print('Successfully updated pubspec.yaml and lib/core/app_info.dart to $newVersion');
}

View File

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <flutter_tts/flutter_tts_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@ -13,6 +14,8 @@
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
app_links
flutter_tts
geolocator_windows
permission_handler_windows