feat: Evolution UI theme, zero-lag 20Hz speedometer, interactive route preview, and Waze alert relay server
|
|
@ -44,3 +44,9 @@ app.*.map.json
|
|||
/android/app/profile
|
||||
/android/app/release
|
||||
*.apk
|
||||
|
||||
# Local Agent / IDE cache
|
||||
.agent/
|
||||
.agents/
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
|
|
|||
15
CHANGELOG.md
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,15 +195,21 @@ 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),
|
||||
_buildDataBox(context, 'STATUS', session.state.name.toUpperCase()),
|
||||
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()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,100 +113,125 @@ 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: InkWell(
|
||||
onTap: () => context.push('/garage/${bike.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.two_wheeler, size: 36, color: Colors.cyanAccent),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (bike.isDefault)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.cyanAccent.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.cyanAccent),
|
||||
),
|
||||
child: const Text(
|
||||
'CURRENT',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
spec != null
|
||||
? '${spec.displacementCc?.toStringAsFixed(0) ?? "—"} cc • ${spec.horsepower?.toStringAsFixed(0) ?? "—"} hp'
|
||||
: '${bike.year} • ${bike.makeName}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.directions_bike, size: 18),
|
||||
label: const Text('View Specs & Stats'),
|
||||
onPressed: () => context.push('/garage/${bike.id}'),
|
||||
),
|
||||
if (!bike.isDefault)
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white30),
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isEvolution ? const Color(0xFF141414) : Colors.white10,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: isEvolution ? Border.all(color: const Color(0xFF222222)) : null,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')),
|
||||
);
|
||||
},
|
||||
child: const Text('Set as Current'),
|
||||
child: const Icon(Icons.two_wheeler, size: 36, color: Color(0xFF00E5FF)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
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: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFF00E5FF)),
|
||||
),
|
||||
child: const Text(
|
||||
'CURRENT',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF00E5FF),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
spec != null
|
||||
? '${spec.displacementCc?.toStringAsFixed(0) ?? "—"} cc • ${spec.horsepower?.toStringAsFixed(0) ?? "—"} hp'
|
||||
: '${bike.year} • ${bike.makeName}',
|
||||
style: TextStyle(
|
||||
fontFamily: isEvolution ? 'JetBrains Mono' : null,
|
||||
color: isEvolution ? const Color(0xFF00E5FF) : Colors.white70,
|
||||
fontSize: 13,
|
||||
fontWeight: isEvolution ? FontWeight.w700 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.directions_bike, size: 18),
|
||||
label: const Text('View Specs & Stats'),
|
||||
onPressed: () => context.push('/garage/${bike.id}'),
|
||||
),
|
||||
if (!bike.isDefault)
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white30),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')),
|
||||
);
|
||||
},
|
||||
child: const Text('Set as Current'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,16 +310,54 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
UnifiedMapView(
|
||||
center: _initialCenter ?? _fallbackLocation,
|
||||
zoom: 15.0,
|
||||
polylines: polylines,
|
||||
markers: _getFilteredMarkers(),
|
||||
),
|
||||
_buildEventFilterOverlay(),
|
||||
],
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,28 +438,40 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
UnifiedMapView(
|
||||
center: currentPos,
|
||||
zoom: 17.0,
|
||||
markers: mapMarkers,
|
||||
),
|
||||
if (!isFollowMode)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
child: FloatingActionButton.extended(
|
||||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: const Text('RECENTER', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||
},
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
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,
|
||||
right: 24,
|
||||
child: FloatingActionButton.extended(
|
||||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: const Text('RECENTER', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
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();
|
||||
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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 latOffset = radiusMeters / 111000.0;
|
||||
final lngOffset = radiusMeters / (111000.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);
|
||||
|
||||
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 [];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
if (_emaSpeed == null || _emaSpeed == 0.0) {
|
||||
_emaSpeed = rawSpeed;
|
||||
} else {
|
||||
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
||||
}
|
||||
} else {
|
||||
_emaSpeed = 0.0;
|
||||
}
|
||||
_emaSpeed = 0.0;
|
||||
} else {
|
||||
// Transition zone (0.6 - 0.8 m/s): maintain current stationary lock state
|
||||
if (_isStationaryLocked) {
|
||||
_emaSpeed = 0.0;
|
||||
if (_emaSpeed == null || _emaSpeed == 0.0) {
|
||||
_emaSpeed = rawSpeed;
|
||||
} 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!;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
gtk
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
74
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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(`====================================================`);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
flutter_tts
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
|
|
|
|||