+
+
\ No newline at end of file
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_consolidated_flows.png b/docs/design/motorsignal-hybrid-v1/references/screen_consolidated_flows.png
new file mode 100644
index 0000000..5de8ec5
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_consolidated_flows.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_day_dashboard.html b/docs/design/motorsignal-hybrid-v1/references/screen_day_dashboard.html
new file mode 100644
index 0000000..17bbedf
--- /dev/null
+++ b/docs/design/motorsignal-hybrid-v1/references/screen_day_dashboard.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+MotorSignal - Hybrid Riding Dashboard (Day Mode Goal)
+
+
+
+
+
+
+
Design Goal: Anti-Glare High-Luminance Contrast
Target: Minimum 48×48px Action Boundaries
Hybrid Riding Dashboard — Daylight Cockpit Layout
Comparing standard high-contrast day mode against a simulated 200% accessibility enlarged text scale. Features pinned critical primary actions and top/bottom system safe area insets.
State A: Standard Scale (100%)Design Target: 390 × 844 px
02:02
GPS: Fix|Phone IMU: Active|Ext: None
Active Vehicle
2001 Yamaha YZF-R6
REC • LIVE
Current Speed
58mph
Top:84 mph
Avg:52 mph
Trip Distance
15.7mi
Target: 42.0 mi
Elapsed Time
16min
Moving: 14m 20s
Lean Dynamics (Internal IMU)Zero Calibrated
Current Lean
18° R
Real-time Angle
Max Lean Angle
27° R
Session Max (Neutral)
Elev. Gain:+69 m
Hard Braking:0.49 G
State B: 200% Text Scale Demonstration(Simulated Accessibility Scale)
≥48dp Target Pinned
02:02
GPS: Fix|IMU: Act|Ext: None
Vehicle
2001 Yamaha YZF-R6
REC • LIVE
Scrollable Telemetry RegionSafe Area: Inset
Current Speed
58mph
Top: 84 mph
Avg: 52 mph
Trip Dist.
15.7mi
Target: 42.0 mi
Elapsed Time
16min
Moving: 14m 20s
Lean Dynamics (Internal IMU)Live Sensor
Current Lean
18° R
Real-time Angle
Max Lean Angle
27° R
Neutral High-Contrast
Elev. Gain:+69 m
Braking:0.49 G
Pinned Critical ActionsTarget: ≥48×48px
56dp (≥48dp)
56dp (≥48dp)
+
\ No newline at end of file
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_day_dashboard.png b/docs/design/motorsignal-hybrid-v1/references/screen_day_dashboard.png
new file mode 100644
index 0000000..d1b39cd
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_day_dashboard.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_group_ride_hub.html b/docs/design/motorsignal-hybrid-v1/references/screen_group_ride_hub.html
new file mode 100644
index 0000000..b3ae1ba
--- /dev/null
+++ b/docs/design/motorsignal-hybrid-v1/references/screen_group_ride_hub.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+MotorSignal - Authenticated Group Ride Hub
+
+
+
+
+
+
+
Revised ride card structure decoupling meeting logistics into a dedicated full-width row with isolated action rows, preventing text collisions across standard and enlarged accessibility display scales.
Standard GPS logging, local ride archives, and public feed access. Multi-sensor telemetry sync requires Pro subscription.
+
+
+
+
+
+
+ Sensor Connections
+ 1 Active • 1 Disconnected
+
+
+
+
+
+
+ smartphone
+
+
+
Internal Phone IMU
+
Active (100Hz Gyro / Accel)
+
+
+ Online
+
+
+
+
+
+
+ bluetooth_searching
+
+
+
External Bike IMU Sensor
+
Disconnected • No Paired BLE
+
+
+
+
+
+
+
+
+ Account Security
+
+
+
+
+
+ security
+
+
+
Two-Factor Authentication (2FA)
+
Status: Disabled (Not Configured)
+
+
+
+
+
Protect cloud session telemetry and garage specs with TOTP authenticator or hardware passkeys.
+
+
+
+
+
+
+ Telemetry Sync Scope
+ Proposed Protocol
+
+
+ Automatic synchronization is a proposed background service pending device battery and bandwidth testing. Deletions distinguish between local cache vs. cloud archive purge.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Interactive Modal Architecture & System Dialogs
+
+
Specifications for Share Ride recipient selection, scoped deletion confirmation, and network fallback states.
+
+
+
+
+
+
+
+
+ share
+
Share Telemetry Replay
+
+ Recipient Selection
+
+
+
Select specific riders, pack channels, or external platforms to dispatch session telemetry.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ delete_forever
+
Delete Ride Session
+
+ Scoped Purge
+
+
+
+ Specify deletion scope. Do not assume cloud records are automatically expunged when clearing phone storage.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cloud_download
+
Data Pipeline Protocol
+
+ Architecture Note
+
+
+
+
+
+ 1. File Download Stage
+
+
When replaying a cloud ride, client fetches the complete binary `.pb` or SQLite session record before parsing. Not live video-style streaming.
+
+
+
+
+ 2. Local Memory Buffering
+
+
"100% Buffered" indicator denotes in-memory decompression of 100Hz IMU vectors to prevent scrubbing stutter.
+
+
+
+
+ 3. Proposed Auto-Sync Rule
+
+
Automatic background sync is documented as proposed UX pending validation of operating system background execution limits.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edge & Exception States Matrix
+
+
Comprehensive fallback patterns for loading, empty filters, network error, offline queueing, and permission denial.
+
+
+
+
+
+
+ progress_activity
+
+
+
Loading Telemetry
+
Downloading session binary (14.2 MB) from encrypted cloud store...
+
+
+
+
+ State: Downloading
+
+
+
+
+
+ filter_alt_off
+
+
+
No Rides Found
+
No telemetry recorded for "Track Mode" under selected date filter.
+
+
+ State: Empty
+
+
+
+
+
+ cloud_off
+
+
+
Sync Failure
+
Cloud handshake timed out. Remote server unreachable.
+
+
+ State: Error
+
+
+
+
+
+ save
+
+
+
Offline Queued
+
2 rides stored locally in SQLite database cache pending uplink.
+
+ Auto-sync Proposed
+ State: Queued
+
+
+
+
+
+ location_disabled
+
+
+
Location Denied
+
Precise background GPS permission required for route telemetry.
Authoritative token values, component patterns, and platform safe-area implementation contracts.
+
+
+
+
+
+
+
+ palette
+ Semantic Color Architecture
+
+
+
+
+ Canvas Surface
+ #0B1326
+
+
+ Card Primary
+ #111C38
+
+
+ Card Secondary
+ #17223B
+
+
+ Border / Stroke
+ #2A3652
+
+
+ Brand Cyan Accent
+ #06B6D4
+
+
+ Active Recording (REC)
+ #10B981
+
+
+ Caution / Pause Ride
+ #F59E0B
+
+
+ Danger / Scoped Delete
+ #EF4444
+
+
+
+
+
+
+
+ touch_app
+ Touch Targets & Type Hierarchy
+
+
+
+
+ Standard Interactive Minimum:
+
48px × 48px CSS / Flutter Logical Pixels
+
Enforced on all list items, segmented tabs, menu dots, and header navigation buttons.
+
+
+
+ Primary Riding Controls:
+
56px–64px Target Height
+
Pinned Pause and Finish Ride actions maximize hit-area under motorcycle vibration.
+
+
+
+ Typography Stack:
+
+
Speed Hero: 72px JetBrains Mono bold
+
Metrics: 16–24px JetBrains Mono bold
+
Headings: 14–18px Inter bold
+
Labels/Captions: 10–12px Inter medium
+
+
+
+
+
+
+
+
+ devices
+ Platform Safe Areas & Implementation
+
+
+
+
+ Flutter Implementation:
+
+ Use SafeArea(top: true, bottom: true) or query MediaQuery.of(context).padding for Notch, Dynamic Island, and gesture bar clearances.
+
+
+
+
+ Web / PWA Implementation:
+
+ Apply env(safe-area-inset-top) to sticky app bars and env(safe-area-inset-bottom) to fixed navigation sheets.
+
+
+
+
+ Map Overlay Separation Goal:
+
+ Top-right map controls vs. bottom telemetry is designated as a layout goal pending physical multi-device testing across varied screen aspect ratios.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_remaining_flows.png b/docs/design/motorsignal-hybrid-v1/references/screen_remaining_flows.png
new file mode 100644
index 0000000..c11245a
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_remaining_flows.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_replay_hud.html b/docs/design/motorsignal-hybrid-v1/references/screen_replay_hud.html
new file mode 100644
index 0000000..1ab2d1c
--- /dev/null
+++ b/docs/design/motorsignal-hybrid-v1/references/screen_replay_hud.html
@@ -0,0 +1,583 @@
+
+
+
+
+
+MotorSignal Telemetry & Community - Ride History & Replay HUD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+MotorSignal Telemetry OS
+
+
Ride History & Replay HUD Specification
+
High-Fidelity Dual-Viewport Mobile Prototype (iOS 390x844 Frame Specification)
+
+ System Architecture & Interaction Specifications
+
+
Engineered constraints, platform layout standards, and user experience guarantees.
+
+
+
+
+
+
+
+
+
1. Map Controls vs Telemetry Overlay
+
+ Floating map controls (+, -, layers) are strictly docked to the Top-Right quadrant with isolated pointer-event boundaries. They are decoupled from lower telemetry clusters to prevent scrub mis-taps while riding or reviewing.
+
+
+
+Rule: 48dp separation zone
+
+
+
+
+
+
+
+
+
2. Platform Safe Areas
+
+ Dynamic padding utilizes env(safe-area-inset-top) (47px iOS island clearance) and env(safe-area-inset-bottom) (34px home indicator buffer) ensuring zero occlusion on gesture devices.
+
+
+
+Rule: Full notch & home bar immunity
+
+
+
+
+
+
+
+
+
3. Telemetry Streaming & Cache
+
+ Sessions store IMU 100Hz packets in IndexedDB/SQLite. Visualizer displays green 100% Buffered state. When roaming without LTE, offline queue alerts notify riders of local persistence.
+
+
+
+State: Zero packet loss cache
+
+
+
+
+
+
+
+
+
4. Destructive Deletion Pattern
+
+ Red (#EF4444) is strictly reserved for destructive deletion. Tapping the header trash icon requires an intentional two-step confirmation sheet to purge irreversible GPS and lean-angle logs.
+
+
+
+Security: 2-step verification modal
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_replay_hud.png b/docs/design/motorsignal-hybrid-v1/references/screen_replay_hud.png
new file mode 100644
index 0000000..ecebcfb
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_replay_hud.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_01_dashboard_night.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_01_dashboard_night.png
new file mode 100644
index 0000000..d84857a
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_01_dashboard_night.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_02_social_hub.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_02_social_hub.png
new file mode 100644
index 0000000..7413b10
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_02_social_hub.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_03_group_rides_hub.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_03_group_rides_hub.png
new file mode 100644
index 0000000..db2c76a
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_03_group_rides_hub.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_04_group_rides_screen.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_04_group_rides_screen.png
new file mode 100644
index 0000000..4391246
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_04_group_rides_screen.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_05_dashboard_day.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_05_dashboard_day.png
new file mode 100644
index 0000000..a4ab02b
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_05_dashboard_day.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_06_dashboard_day_large_text.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_06_dashboard_day_large_text.png
new file mode 100644
index 0000000..9516e6a
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_06_dashboard_day_large_text.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_07_dashboard_riding_state.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_07_dashboard_riding_state.png
new file mode 100644
index 0000000..791366c
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_07_dashboard_riding_state.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_08_dashboard_stopped.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_08_dashboard_stopped.png
new file mode 100644
index 0000000..e572aef
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_08_dashboard_stopped.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_09_running_app.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_09_running_app.png
new file mode 100644
index 0000000..2ac0278
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_09_running_app.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_10_after_start.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_10_after_start.png
new file mode 100644
index 0000000..dbfc7ce
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_10_after_start.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_11_during_gps_ride.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_11_during_gps_ride.png
new file mode 100644
index 0000000..b405f5e
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_11_during_gps_ride.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_12_paused_ride.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_12_paused_ride.png
new file mode 100644
index 0000000..e932063
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_12_paused_ride.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_13_after_finish.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_13_after_finish.png
new file mode 100644
index 0000000..22ac104
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_13_after_finish.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_14_after_finalizing.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_14_after_finalizing.png
new file mode 100644
index 0000000..c1f140c
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_14_after_finalizing.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_activity.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_activity.png
new file mode 100644
index 0000000..553c4fc
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_activity.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_back.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_back.png
new file mode 100644
index 0000000..24fae86
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_back.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_filter.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_filter.png
new file mode 100644
index 0000000..a9ea42f
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_filter.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_ride15.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_ride15.png
new file mode 100644
index 0000000..4d876d0
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_ride15.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_scrolled.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_scrolled.png
new file mode 100644
index 0000000..39c80e1
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_15_scrolled.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_16_ride15_detail.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_16_ride15_detail.png
new file mode 100644
index 0000000..fbdeba7
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_16_ride15_detail.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_17_social.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_17_social.png
new file mode 100644
index 0000000..fbbcf72
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_17_social.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_18_hub.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_18_hub.png
new file mode 100644
index 0000000..1f45083
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_18_hub.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_19_group_rides_hub.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_19_group_rides_hub.png
new file mode 100644
index 0000000..a355534
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_19_group_rides_hub.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_20_account.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_20_account.png
new file mode 100644
index 0000000..214055f
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_20_account.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_21_dash_night.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_21_dash_night.png
new file mode 100644
index 0000000..3684bed
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_21_dash_night.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_22_dash_day.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_22_dash_day.png
new file mode 100644
index 0000000..2cfba27
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_22_dash_day.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_22_settings.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_22_settings.png
new file mode 100644
index 0000000..f1367d4
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_22_settings.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_23_dash_day_large_text.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_23_dash_day_large_text.png
new file mode 100644
index 0000000..c09ddd3
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_23_dash_day_large_text.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_24_dash_night_large_text.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_24_dash_night_large_text.png
new file mode 100644
index 0000000..fced6b7
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_24_dash_night_large_text.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_25_hub_organizer_night.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_25_hub_organizer_night.png
new file mode 100644
index 0000000..d2e3017
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_25_hub_organizer_night.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_26_hub_organizer_day.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_26_hub_organizer_day.png
new file mode 100644
index 0000000..8daa002
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_26_hub_organizer_day.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_27_hub_organizer_large_text.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_27_hub_organizer_large_text.png
new file mode 100644
index 0000000..4863ed5
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_27_hub_organizer_large_text.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_28_hub_participant.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_28_hub_participant.png
new file mode 100644
index 0000000..46e1291
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_28_hub_participant.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_loaded_dash.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_loaded_dash.png
new file mode 100644
index 0000000..6a6cc23
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_loaded_dash.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_app_restored.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_app_restored.png
new file mode 100644
index 0000000..38c639e
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_app_restored.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_front.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_front.png
new file mode 100644
index 0000000..1f606fc
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_front.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_now.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_now.png
new file mode 100644
index 0000000..32425a3
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_now.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_running.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_running.png
new file mode 100644
index 0000000..1f606fc
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_main_running.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_launch.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_launch.png
new file mode 100644
index 0000000..73c821d
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_launch.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_loaded.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_loaded.png
new file mode 100644
index 0000000..5f14e7b
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_loaded.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_now.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_now.png
new file mode 100644
index 0000000..06cf27d
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_now.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_ready.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_ready.png
new file mode 100644
index 0000000..5f14e7b
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_ready.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_waited.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_waited.png
new file mode 100644
index 0000000..e2600f2
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_qa_app_waited.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_restarted.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_restarted.png
new file mode 100644
index 0000000..c236caa
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_restarted.png differ
diff --git a/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_tapped.png b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_tapped.png
new file mode 100644
index 0000000..e71368b
Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_tapped.png differ
diff --git a/FIELD_TESTING.md b/docs/guides/field-testing.md
similarity index 100%
rename from FIELD_TESTING.md
rename to docs/guides/field-testing.md
diff --git a/docs/prerelease-v1.0.2-build102-followup-audit.md b/docs/prerelease-v1.0.2-build102-followup-audit.md
new file mode 100644
index 0000000..7b5ea74
--- /dev/null
+++ b/docs/prerelease-v1.0.2-build102-followup-audit.md
@@ -0,0 +1,363 @@
+# MotorSignal Platform — Prerelease Integrity & Evidence Reconciliation Report
+
+**Date:** October 4, 2026
+**Target Release:** `v1.0.2-build102` (Prerelease)
+**Target Git Tag & Commit:** `v1.0.2-build102` (`bd278cd`)
+**Active Branch:** `release/v1.0.2-build102-community-feed` | **HEAD:** `bd278cdfd3619242a3056c6a2738abb0aff70feb` (`bd278cd`)
+**Auditor:** Antigravity Autonomous QA Coordinator & Independent Reviewer Suite
+**Scope:** Final evidence reconciliation, 11-screenshot audit, gap verification, 44-row matrix mapping, and release-readiness review.
+
+---
+
+## 1. Executive Summary & Corrected QA Verdict
+
+### 1.1 Corrected QA Verdict
+**Verdict: CONDITIONAL APPROVAL FOR INTERNAL TESTING ONLY — BLOCK PUBLIC RELEASE CANDIDATE**
+
+> [!NOTE]
+> **Map Defects Remediated & Verified in Runtime:**
+> 1. **Telemetry Replay Map (WEB-05) — VERIFIED PASS:** Resolved React 18 StrictMode unmount race and container destruction on tab switch. Verified live OpenStreetMap raster tiles, street labels (`US 19`, `NC Highway 141`, `Valley River`), multi-color lean angle polyline, START/FINISH badges, and responsive mobile rendering (390x844).
+> 2. **Authenticated Formation Cockpit (WEB-12) — VERIFIED PASS:** Transitioned from abstract SVG graphic to real Web Mercator GIS map with switchable CartoDB Dark, ESRI Satellite, and OpenTopoMap contour tiles. Bound to live Firestore `live_presence` collection with 2 eligible participants (`rider-lead`, `rider-wing`), live position updates, heartbeat freshness lifecycle (<30s Active, 30-120s Stale, >120s Lost Link), and unauthorized access barrier. Public showcase simulation remains clearly labeled.
+>
+> **Remaining Non-Map Scope Gaps (Intentionally Separate Unfinished / Blocked Work):**
+> 1. **Mobile Social Feed (SOC-01..03):** Client-side mock (`_memoryFeed`).
+> 2. **Web Member Chat (CLB-01):** Founder portal admin-only; regular members have no web chat surface.
+> 3. **Sync Integration Fixture:** Uses in-memory SQLite and mock SharedPreferences.
+> 4. **Hardware & External Cloud Boundaries:** ELM327 OBD-II, Google Play Store billing, and Cloudflare R2 worker.
+
+---
+
+## 2. Screenshot & Evidence Audit (Preserved Historical & Current Artifacts)
+
+Every browser screenshot located in `C:\Users\Bill\.gemini\antigravity\brain\96c4edb2-b5ed-4b9c-90e9-6888eb8a767f\` was inspected bit-for-bit. To preserve immutable evidence history without overwriting, all remediated areas maintain distinct Before and After artifacts:
+
+### 2.1 Remediated Map & Cockpit Evidence (Before vs After Pairs)
+
+| Artifact State | File Name | Size (Bytes) | Timestamp | SHA-256 Hash | Observed Rendering & Verdict |
+| :--- | :--- | :---: | :---: | :---: | :--- |
+| **BEFORE** (Failure) | `browser_telemetry_replay_map_before_failure.png` | 218,766 | 10/3/2026 8:46:17 PM | `B834F82127DFAE2A0155EF8480B3296245279AB650EB0404DCBED73EB29D946A` | **DEFECT REPRODUCED:** Black rectangle rendered behind HUD. Zero tile network requests. |
+| **AFTER** (Fixed) | `browser_telemetry_replay_map_after_fixed.png` | 489,370 | 10/3/2026 10:07:56 PM | `C9E5C8588FA7E90288C343855211C8026651A9392918B08E99C4BE63ED9A35F1` | **RUNTIME PASS:** Full OpenStreetMap tiles with road labels (`US 19`, `NC 141`), speed/lean polyline, START/FINISH pins. |
+| **BEFORE** (Failure) | `browser_telemetry_replay_mobile_before.png` | 1,015,601 | 10/3/2026 8:46:41 PM | `39764BC3138A34DB0AEF8C66163483DED13BD25E54A83425984A30101CDC4D81` | **DEFECT REPRODUCED (Mobile):** Pitch-black canvas behind HUD in mobile viewport (390x844). |
+| **AFTER** (Fixed) | `browser_telemetry_replay_mobile_after_fixed.png` | 244,395 | 10/3/2026 10:11:03 PM | `813C2485F2C57C7E0780EFD34E5AD048B120C257B69F55A12046A15C7350DF8D` | **RUNTIME PASS (Mobile):** Responsive mobile modal layout verified; tiles and route geometry rendered cleanly. |
+| **BEFORE** (Graphic) | `browser_radar_cockpit_before_svg.png` | 209,052 | 10/3/2026 8:52:38 PM | `8D0AE0C4D6EF3CC81CF6B27EAF456D78F870581183ED9C28528AB09483D075A3` | **INITIAL IMPLEMENTATION:** Abstract concentric SVG HUD rings with decorative SVG mode toggles. Not a GIS map. |
+| **AFTER** (Fixed) | `browser_radar_cockpit_after_gis.png` | 92,596 | 10/3/2026 10:11:01 PM | `D6627AFB9BF6C937AB3DFE3F6D57989C04DD28AD63210FFD6A4C8C79D4EBD79E` | **RUNTIME PASS (GIS Cockpit):** Web Mercator projection, real CartoDB Dark raster tiles, 2 live Firestore participants. |
+| **BEFORE** (Unclear) | `browser_radar_simulated_ping_before.png` | 207,858 | 10/3/2026 8:52:31 PM | `CCF5DA4FD22207955099A7C561F1378FCADD6CE5A82FAD1DC809A6198C49CABA` | **UNVERIFIED CLAIM:** Button labeled "Ping Swarm" only displayed a toast, implying physical mesh communication. |
+| **AFTER** (Fixed) | `browser_radar_simulated_ping_after_labeled.png` | 187,537 | 10/3/2026 10:04:17 PM | `83B92544E44A3BDF50DC1ECE3B14D9676A58DEA051AA990E0A6005C8C393C079` | **RUNTIME PASS (Demo Labeling):** Labeled "Ping Swarm (Simulate Demo)" with banner disclaiming hardware execution. |
+
+### 2.2 Supporting Browser Feature Artifacts
+
+| Screenshot File | Size (Bytes) | Timestamp | SHA-256 Hash | URL & Action | Observed Outcome & Role | Data Fidelity |
+| :--- | :---: | :---: | :---: | :--- | :--- | :--- |
+| `browser_sessions_paginated.png` | 364,267 | 10/3/2026 8:47:45 PM | `A1E96E79EF624033440C712E133DD7F143090B53B6A694FDAD16D8DB061691E5` | `/account` Clicked "Show more" (+10) | 20 cards rendered reverse-chronological (`ride-01`..`ride-20`). Button reads "Show more (+2 remaining)". Role: `qa-admin` | **PASS:** Verifies client-side 10 -> 20 pagination on seeded array. |
+| `browser_device_sessions_paginated.png` | 290,193 | 10/3/2026 8:48:14 PM | `B5F8C74ACC444F58D21C597C13B8CA3D50EED33932B4C369A94AC8F454349963` | `/account` Clicked "Show more sessions" | Header: "Sessions (Showing 20 of 22)". 20 device rows with "Revoke" button. Role: Admin | **MOCKED / INJECTED:** Injected into React state 22s prior; reverted 26s later. |
+| `browser_billing_fallback_and_watch_status.png` | 398,031 | 10/3/2026 8:48:40 PM | `77C4C0089CF79B23C301D6325E8B7AD3D83C63C489CEF9016C1B65C16A15174F` | `/account` Clicked "Manage Plan & Invoices" | Amber banner: "Web billing portal is currently unavailable (404)...". Watch: "Not Connected (Unavailable on Web)". Role: Signed-in QA user | **PASS:** Validates graceful 404 fallback handling & honest watch status. |
+| `browser_group_ride_create_wizard.png` | 198,395 | 10/3/2026 8:49:32 PM | `D770F62D09C18882787316415AD1FE55B077486DD3F6B8872824A58318FF083A` | `/group-rides/manage` Opened Create Ride modal | Step 1 active: Start Time (+1hr), Paved Asphalt, Brisk Pace, Limit 12 pre-filled. Role: Organizer | **PASS:** Validates sensible defaults and layout. |
+| `browser_group_ride_review_step.png` | 181,124 | 10/3/2026 8:49:46 PM | `6347F620E39E9BFE108C8DCD2A4A8B784B21D3D8C9E6EC601227E564E7666037` | `/group-rides/manage` Filled fields, clicked "Review" | Step 2 active: Pre-Flight Review summary cards with route parameters. Role: Organizer | **PASS:** Validates pre-flight parameter review. |
+| `browser_group_ride_cross_account_discovered.png` | 160,060 | 10/3/2026 8:50:27 PM | `8CC06FAB66BE276C3E38F32F8193698F6E2EC7C33234FDCD36430693A05CADFB` | `/group-rides` Loaded community rides tab | Published "Blood Mountain Canyon Sweep" visible with "Request Pack Slot" CTA. Role: Public visitor | **PASS:** Confirms public discovery of created ride. |
+| `browser_chat_realtime_receipt.png` | 375,867 | 10/3/2026 8:57:39 PM | `D6FD3530D7C909FA14EA4B11C5AFD92539D41C49747058EAC695D0D9B0374FD4` | `/clubs/qa-isolated-riders/manage/chat` Sent unique test messages | Displays 3 messages in `#general-squad`. User badge: "Club Founder - ADMIN". Role: Club Founder/Admin | **ADMIN-ONLY INTERFACE:** All 3 messages sent by QA Admin. Regular members have no web chat surface. |
+
+---
+
+## 3. Resolution of 4 Specific Technical Gaps
+
+### 3.1 Cockpit Radar Map Transitioned to GIS Geographic Map (REMEDIATED & PASS)
+- **Remediation in `website/components/group-rides/live-pack-radar.tsx` & `group-rides-management.tsx`:**
+ - Implemented interactive Web Mercator GIS canvas (`projectMercator`) with real raster tile rendering across 3 tactical modes:
+ - `tactical`: CartoDB Dark Matter raster tiles (`basemaps.cartocdn.com/rastertiles/dark_all/...`).
+ - `satellite`: ESRI World Imagery raster tiles (`server.arcgisonline.com/.../World_Imagery/...`).
+ - `elevation`: OpenTopoMap contour tiles (`tile.opentopomap.org/...`).
+ - Added pan-drag interaction, zoom controls (`+ / -`), and pack centroid recentering.
+ - Bound participant chevrons directly to live Firestore point-read doc streams on `group_rides/{rideId}/live_presence/{uid}`.
+- **Runtime Verification in Browser (`browser_radar_cockpit_map.png`):**
+ - Authenticated as `qa-lead@motorsignal.example` (`rider-lead`).
+ - Navigated to `/group-rides?tab=radar&rideId=ride-blood-mtn-canyon`.
+ - Loaded real geographic map with 2 live pack nodes at true GPS coordinates (`34.7335, -83.9175`).
+ - Live position updates demonstrated real-time telemetry card updates (`52 MPH`, heading 50°).
+ - Freshness lifecycle verified: Active (<30s), Stale (30s-120s with lag callout), Disconnected (>120s with lost link).
+ - Telemetry access barrier verified: unauthorized rider is halted at security boundary (`telemetry-access-barrier`).
+ - Public showcase simulation strictly separated and badged with disclaimer (`browser_radar_simulated_ping.png`).
+- **Verdict:** **REMEDIATED & VERIFIED PASS**. Real geographic map with live participant binding complete.
+
+### 3.2 Device Session Revocation Test Fidelity & Backend Status
+- **Backend Implementation Reality (`backend/motorsignal_api/app/main.py`):**
+ - Backend session revocation is **fully implemented** in FastAPI:
+ - `DELETE /api/v1/auth/sessions/{session_id}` (lines 774–784) verifies authorization, sets `session.revoked_at`, and publishes an `auth.session_revoked` audit event.
+ - `POST /api/v1/auth/logout-all` (lines 787–795) revokes all active sessions for the current user.
+ - Next.js API route `website/app/api/account/sessions/[sessionId]/route.ts` forwards client requests directly to `${MOTORSIGNAL_API_BASE_URL}/api/v1/auth/sessions/${sessionId}`.
+ - The feature is **NOT missing or unimplemented** in production source code.
+- **Execution Reality in QA:**
+ - No FastAPI backend was running during the browser QA session (port 8000 was Docker, without an active python/uvicorn process).
+ - The 22 device sessions in `browser_device_sessions_paginated.png` were injected into React state via DevTools `evaluate_script` with identical timestamps (`8:47:52 PM`).
+ - 26 seconds later (`browser_billing_fallback_and_watch_status.png`), the session list reverted to "0 Sessions".
+- **Resolution:** The browser test verified **client-side state removal and pagination UX only**. The backend implementation exists, but was **UNTESTED against a running FastAPI instance** in this mock browser environment.
+
+### 3.3 Web Chat Surface & Role Boundaries
+- **Code Reality (`website/app/clubs/[clubId]/manage/chat/page.tsx`):**
+ - The only chat route on the website is inside `FounderCommunityPage` at `/clubs/[clubId]/manage/chat`.
+ - In `website/components/founder-portal/founder-community-page.tsx` line 38:
+ ```tsx
+ if (!result.permissions.canManageSettings && !result.isDemoFixture) setAccess('restricted');
+ ```
+ - When a non-admin club member visits this route, lines 50–52 render:
+ > *"Management Access Restricted: Only club Owners and Admins can access this management page."*
+ (Proven by `browser_chat_member_restricted.png`).
+ - There is NO public or regular-member chat page on the website.
+- **Execution Reality:**
+ - In `browser_chat_realtime_receipt.png`, the user is `Club Founder - ADMIN` and all messages displayed were sent by QA Admin.
+ - Cross-account chat delivery between Admin and Member was executed strictly in headless Node.js (`tools/verify_cross_account_chat_live.mjs`), never in the browser UI.
+- **Resolution:** Web chat is strictly an **Admin Founder Portal tool**. Regular members have **ZERO web chat surface**.
+
+### 3.4 Offline Sync Integration Fixture Realism & Architectural Consistency
+- **Code Reality (`test/isolated_sync_integration_fixture_test.dart`):**
+ - Database initialized via `AppDatabase.forTesting(NativeDatabase.memory())` (in-memory SQLite).
+ - Preferences initialized via `SharedPreferences.setMockInitialValues({})` (in-memory mock).
+ - Phase C reconstructs `SyncOutbox(prefs)` and `SyncManager` passing the **exact same in-memory `prefs` and in-memory `db` objects**.
+- **Execution Reality:**
+ - In an OS process termination, all memory heaps and in-memory databases are destroyed. A true restart requires re-reading from disk (`motorsignal.sqlite` and Android XML shared preferences).
+ - The fixture uses `MockClient` for HTTP PUT and `_IsolatedContractSyncProvider`. Zero traffic reaches Cloudflare R2 or Firebase.
+- **Architectural Observation vs Feature Requirement:**
+ - **Distributed Two-Phase Commit (2PC):** Multi-cloud atomicity across Cloudflare R2 and Google Cloud Firestore is an **architectural observation**, not an outstanding or missing feature requirement. The production design deliberately employs safe sequential writes: R2 telemetry bundles must upload successfully before Firestore ride records are created/updated. If R2 fails, Firestore is never touched; if Firestore subsequently fails, the durable `SyncOutbox` retries the Firestore transaction idempotently using stable `cloudRideId`.
+- **Resolution:** `prefs` and `db` in the fixture are **100% in-memory**. The fixture proves Dart state machine logic, queue ordering, and retry idempotency, but is classified as **simulated integration contract testing**, not proof of OS process termination or live cloud provider synchronization.
+
+---
+
+## 4. Authoritative 44-Scenario Reconciliation Matrix
+
+Every single row from the authoritative baseline (`docs/QA_AUDIT_REPORT_2026-09-30.md`) is explicitly accounted for below, alongside the 3 Multi-Role contracts and 8 new Website Issue rows (**55 Total Authoritative Scenarios**):
+
+### 4.1 Original 44 Baseline Rows
+| Original ID | Subsystem | Surface | Baseline Claim | Reconciliation Status | Category | Evidence & Mapping Notes |
+| :--- | :--- | :---: | :---: | :---: | :---: | :--- |
+| **DASH-01** | Dashboard Minimal HUD | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `dash_dark.png` (Pixel 8 Pro smoke check) |
+| **DASH-02** | Dashboard Sport Cockpit | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `dash_cockpit.png` (Pixel 8 Pro smoke check) |
+| **DASH-03** | Dashboard Touring Cockpit | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `dash_touring.png` (Pixel 8 Pro smoke check) |
+| **DASH-04** | Lifecycle Start Session | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_session_started.png` (Pixel 8 Pro smoke check) |
+| **DASH-05** | Lifecycle Stop Session | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_session_stopped.png` (DEFECT-01 fixed) |
+| **PERF-01** | 0–60 Launch Mode | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `perf_launch_armed.png` (Pixel 8 Pro smoke check) |
+| **PERF-02** | 0–100 Launch Mode | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `perf_launch_mode.png` (Pixel 8 Pro smoke check) |
+| **MAP-01** | Tactical Vector Map | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `map_dark.png` (Pixel 8 Pro smoke check) |
+| **MAP-02** | Tactical Layer Sheet | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `tactical_layers_sheet.png` (Pixel 8 Pro smoke check) |
+| **MAP-03** | Road Hazard Report | Mobile | PASS | **RETAINED PASS** | Runtime Pass | DEFECT-03 resolved; `quick_swarm_report_modal_test.dart` passes |
+| **GAR-01** | NHTSA Make Search | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_search_yamaha.png` (Pixel 8 Pro smoke check) |
+| **GAR-02** | Model & Trim Selection | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_select_model.png` (Pixel 8 Pro smoke check) |
+| **GAR-03** | Add to Garage | Mobile | PASS | **RETAINED PASS** | Runtime Pass | DEFECT-02 resolved; attribution HTML sanitized |
+| **GAR-04** | Maintenance Due | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `bike_maint.png` (Pixel 8 Pro smoke check) |
+| **GAR-05** | Log Service Record | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `log_service.png` (Pixel 8 Pro smoke check) |
+| **SOC-01** | Community Feed View | Mobile | PASS | **DOWNGRADED** | Mock / In-Memory | In-memory `_memoryFeed`; resets on app restart |
+| **SOC-02** | Fist Bump Kudos | Mobile | PASS | **DOWNGRADED** | Mock / In-Memory | In-memory counter; resets on app restart |
+| **SOC-03** | Comments Posting | Mobile | PASS | **DOWNGRADED** | Mock / In-Memory | In-memory list; resets on app restart |
+| **SOC-04** | Telemetry Waveform | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `telemetry_graph.png` (Pixel 8 Pro smoke check) |
+| **SOC-05** | Social Hub Navigation | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_social_hub_tapped2.png` (Pixel 8 Pro smoke check) |
+| **SOC-06** | Clubs Auth Gate | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_clubs_auth_required.png` (Pixel 8 Pro smoke check) |
+| **SOC-07** | Group Rides Auth Gate | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_group_rides_auth_required.png` (Pixel 8 Pro smoke check) |
+| **AUTH-01**| Mobile Sign In Form | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_mobile_auth_attempt.png` (Pixel 8 Pro smoke check) |
+| **AUTH-02**| Real Offline SQLite | Mobile | PASS | **RETAINED PASS** | Runtime Pass | 532 samples written to SQLite while offline (`verify_offline_recording.png`) |
+| **SET-01** | Cockpit Dark Mode | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `settings_dark_active.png` (Pixel 8 Pro smoke check) |
+| **SET-02** | Calibrate Mount Angle | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `calibrate_pressed.png` (Pixel 8 Pro smoke check) |
+| **SET-03** | Sensor Diagnostics | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `sensor_diagnostics.png` (Pixel 8 Pro smoke check) |
+| **ACT-01** | Personal Records Trophies | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `activity_dark.png` (Pixel 8 Pro smoke check) |
+| **ACT-02** | Ride History List | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_activity_tab.png` (Pixel 8 Pro smoke check) |
+| **ACT-03** | Ride Detail Telemetry | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `verify_ride_detail.png` (Pixel 8 Pro smoke check) |
+| **ACT-04** | GPX / CSV File Contents | Mobile | PASS | **RETAINED PASS** | Automated Test | Valid GPX 1.1; Pro CSV gating verified in unit test |
+| **ACT-05** | 20Hz Ride Replay | Mobile | PASS | **RETAINED PASS** | Runtime Pass | `replay_ride1.png` (Pixel 8 Pro smoke check) |
+| **WEB-01** | Web Homepage & Features | Web | PASS | **RETAINED PASS** | Runtime Pass | `browser_home.png` (Chromium DevTools E2E) |
+| **WEB-02** | Group Rides Showcase | Web | PASS | **RETAINED PASS** | Runtime Pass | `browser_group_rides_filtered.png` (Chromium DevTools E2E) |
+| **WEB-03** | Web Hub / Clubs Gates | Web | PASS | **RETAINED PASS** | Runtime Pass | `browser_group_rides_manage_gate.png` (Chromium DevTools E2E) |
+| **WEB-04** | Web Account 401 Gate | Web | PASS | **RETAINED PASS** | Runtime Pass | `browser_account_gate.png` (Chromium DevTools E2E) |
+| **OPS-01** | Ops Console 307 Redirect| Web | PASS | **RETAINED PASS** | Runtime Pass | `browser_admin_gate.png` (Chromium DevTools E2E) |
+| **OPS-02** | Dual Speedtest | Web | PASS | **RETAINED PASS** | Automated Test | `tests/operations-speedtest.test.tsx` (6/6 passing) |
+| **OPS-03** | Support Ticket Modal | Web | PASS | **RETAINED PASS** | Automated Test | `tests/support-report-modal.test.tsx` (8/8 passing) |
+| **OPS-04** | CMS Revision Workflow | Web | PASS | **RETAINED PASS** | Automated Test | `website/tests/ops-experience.test.cjs` (44/44 passing) |
+| **HW-01** | BLE OBD-II CAN Bus | Mobile | BLOCKED | **RETAINED BLOCKED** | Blocker | Physical ELM327 Bluetooth adapter required |
+| **HW-02** | Road Vibration Fusion | Mobile | HW REQ | **RETAINED CARRIED** | Hardware Req | Motorcycle dynamic road test required |
+| **HW-03** | Cellular Handoff & GPS | Mobile | HW REQ | **RETAINED CARRIED** | Hardware Req | Field transit required |
+| **HW-04** | Solar Thermal Load | Mobile | HW REQ | **RETAINED CARRIED** | Hardware Req | Outdoor cockpit test required |
+
+### 4.2 Multi-Role Contracts
+| ID | Area | Surface | Status | Category | Evidence & Reality |
+| :--- | :--- | :---: | :---: | :---: | :--- |
+| **GRP-01** | Multi-Role Group Ride | Mobile | **PASS** | Runtime Pass | Join request, approve, decline verified in Pixel 8 Pro emulator |
+| **CLB-01** | Club Member Roster | Direct SDK / Web | **PARTIAL** | Untested / Blocker | SDK rules pass; member web roster interface unbuilt |
+| **CLB-02** | Club Administration | Direct SDK / Web | **PASS** | Runtime Pass | Live chat, route planning, wall posts pass in Founder Portal |
+
+### 4.3 New Website Issues Evaluated (Distinct Rows)
+| ID | Feature / Defect Area | Surface | Status | Category | Evidence & Reality |
+| :--- | :--- | :---: | :---: | :---: | :--- |
+| **WEB-05** | Telemetry Forensic Replay Map | Web | **PASS** | Runtime Pass | `browser_telemetry_replay_map.png` & `browser_telemetry_replay_mobile.png` render live OSM tiles, street labels (`US 19`, `NC 141`), lean heatmap, and pins. |
+| **WEB-06** | Recent Sessions Pagination | Web | **PASS** | Runtime Pass | `browser_sessions_paginated.png` (10 + "Show more" 10 on seeded array) |
+| **WEB-07** | Active Devices Pagination | Web | **PASS (UI Only)** | Mock / In-Memory | `browser_device_sessions_paginated.png` (Synthetic injected sessions) |
+| **WEB-08** | Billing Portal Graceful Fallback| Web | **PASS** | Runtime Pass | `browser_billing_fallback_and_watch_status.png` (Graceful 404 banner) |
+| **WEB-09** | Apple Watch Status Clarity | Web | **PASS** | Runtime Pass | Displays `"Not Connected (Unavailable on Web)"` |
+| **WEB-10** | Group Ride Public Discovery | Web | **PASS** | Runtime Pass | `browser_group_ride_cross_account_discovered.png` (API returns 200) |
+| **WEB-11** | 2-Step Group Ride Create Wizard| Web | **PASS** | Runtime Pass | `browser_group_ride_create_wizard.png` & `browser_group_ride_review_step.png` |
+| **WEB-12** | Live Radar HUD & Formation Map | Web | **PASS** | Runtime Pass | `browser_radar_cockpit_map.png` renders real GIS Web Mercator tiles with 2 live Firestore participants; public demo clearly badged in `browser_radar_simulated_ping.png`. |
+
+### 4.4 Matrix Provenance & Exact Set Differences
+- **Baseline Source vs Current JSON Comparison:**
+ - Original baseline source: [`docs/QA_AUDIT_REPORT_2026-09-30.md`](file:///c:/Users/Bill/Desktop/flutter-app/docs/QA_AUDIT_REPORT_2026-09-30.md) Section 4 contains **exactly 44 unique scenario IDs**:
+ - Mobile: `DASH-01..05`, `PERF-01..02`, `MAP-01..03`, `GAR-01..05`, `SOC-01..07`, `AUTH-01..02`, `SET-01..03`, `ACT-01..05` (32 IDs, including `SOC-06` and `SOC-07`)
+ - Web Browser: `WEB-01..04`, `OPS-01` (5 IDs)
+ - Automated Web: `OPS-02..04` (3 IDs)
+ - Hardware: `HW-01..04` (4 IDs, including `HW-03` and `HW-04`)
+ - Current machine-readable dataset: [`docs/qa-2026-10-01/scenarios_machine_readable.json`](file:///c:/Users/Bill/Desktop/flutter-app/docs/qa-2026-10-01/scenarios_machine_readable.json) contains **exactly 55 unique scenario IDs**.
+- **Exact Set Difference Analysis:**
+ - **Omissions from Original Baseline:** $\text{Original} \setminus \text{Current JSON} = \emptyset$ (**0 IDs omitted**; every single original ID is preserved).
+ - **Additions to Current Scope:** $\text{Current JSON} \setminus \text{Original} = \mathbf{11\text{ IDs}}$:
+ - 3 Multi-Role Group/Club Contracts: `GRP-01`, `CLB-01`, `CLB-02` (promoted from Section 10 narrative into formal scenario rows).
+ - 8 New Website Audit Issues: `WEB-05` (Replay Map), `WEB-06` (Sessions Pagination), `WEB-07` (Devices Pagination), `WEB-08` (Billing Fallback), `WEB-09` (Apple Watch Status), `WEB-10` (Group Ride Discovery), `WEB-11` (Group Ride Wizard), `WEB-12` (Formation Radar Cockpit).
+ - **Renamed IDs:** $\emptyset$ (**0 IDs renamed**; all original IDs preserve identical string identifiers).
+ - **Mathematical Proof:** $\text{Original Baseline (44)} + \text{Additions (11)} - \text{Omissions (0)} = \mathbf{55\text{ Authoritative Scenarios}}$.
+- **Baseline ID Range Analysis (42 vs 44 Reconciliation):**
+ - When baseline ID ranges were summarized with Social scoped to `SOC-01..05` (5 items) instead of `SOC-01..07` (7 items), the enumerated ranges totaled **exactly 42**:
+ $$\text{DASH (5)} + \text{PERF (2)} + \text{MAP (3)} + \text{GAR (5)} + \mathbf{\text{SOC-01..05 (5)}} + \text{AUTH (2)} + \text{SET (3)} + \text{ACT (5)} + \text{WEB (4)} + \text{OPS (4)} + \text{HW (4)} = \mathbf{42}$$
+ - The **genuinely missing IDs** from that 42-count range list are **`SOC-06` (Clubs Auth Gate)** and **`SOC-07` (Group Rides Auth Gate)**.
+ - When `SOC-06` and `SOC-07` are restored (`SOC-01..07`, 7 items), the baseline range sum equals the full authoritative 44 baseline scenarios:
+ $$32\text{ (Mobile)} + 4\text{ (Web Browser)} + 4\text{ (Web Ops)} + 4\text{ (Hardware)} = \mathbf{44}$$
+ - Adding the 11 audited additions (`GRP-01`, `CLB-01`, `CLB-02`, and `WEB-05`..`12`) yields the final complete scope:
+ $$44 + 11 = \mathbf{55\text{ Authoritative Scenarios}}$$
+ - All category classifications, percentages, and totals below are derived directly from the exact 55-element machine-readable dataset ([`scenarios_machine_readable.json`](file:///c:/Users/Bill/Desktop/flutter-app/docs/qa-2026-10-01/scenarios_machine_readable.json)).
+
+### 4.5 Category Tally Directly Derived from Machine-Readable Dataset
+```
+┌────────────────────────────────────────────────────────────────────────────────────────┐
+│ RECONCILED CATEGORY TOTALS │
+├──────────────────────────────────────────┬───────────┬───────────────┬─────────────────┤
+│ Category │ Count │ Percentage │ Status │
+├──────────────────────────────────────────┼───────────┼───────────────┼─────────────────┤
+│ 1. Runtime Pass (Legacy Smoke Evidence) │ 30 / 55 │ 54.5% │ PASS (HISTORIC) │
+│ 2. Runtime Pass (Current Tested Build) │ 12 / 55 │ 21.8% │ PASS (CURRENT) │
+│ 3. Automated Test Suite Passes │ 4 / 55 │ 7.3% │ PASS │
+│ 4. Mocked / In-Memory Functionality │ 4 / 55 │ 7.3% │ MOCKED / IN-MEM │
+│ 5. Hardware Required (Physical Transit) │ 3 / 55 │ 5.5% │ HW REQUIRED │
+│ 6. Blocked External Dependencies (HW-01) │ 1 / 55 │ 1.8% │ BLOCKED │
+│ 7. Untested User Surfaces (CLB-01) │ 1 / 55 │ 1.8% │ UNTESTED │
+├──────────────────────────────────────────┼───────────┼───────────────┼─────────────────┤
+│ Total Scenarios │ 55 / 55 │ 100.0% │ AUDITED │
+└──────────────────────────────────────────┴───────────┴───────────────┴─────────────────┘
+```
+
+*(Supporting Verification Gates: `npm run test:components` 165/165 passed across 16 files, `npm run build` 37/37 routes OK, `flutter test` 1,431 passed, `flutter analyze` 0 issues, `firebase/emulator-tests` 222 passed, `ops-experience` 44 passed).*
+
+---
+
+## 5. Working Tree Governance & Process Separation
+
+### 5.1 Process State vs Clean Working Tree Distinction
+- **Test Processes (Stopped):** All QA test processes have been terminated cleanly:
+ - Node.js Next.js development server on port 3100: **STOPPED**
+ - Firebase emulator suite (ports 9099, 8088, 8085): **STOPPED**
+ - ADB emulator shell sessions and port forwarders: **STOPPED**
+ - No background test listeners or orphaned ports remain active.
+- **Git Working Tree (Preserved Dirty State):** The working tree is deliberately dirty to preserve pre-existing user changes, uncommitted QA tools, test fixtures, and rules enhancements across the repository. In accordance with strict instructions, no files have been committed, reset, staged, or pushed.
+
+### 5.2 Tracked Modified Files (22 Files)
+```text
+docs/qa-2026-10-01/auth.cleanup-results.json
+docs/qa-2026-10-01/auth.extra-results.json
+docs/qa-2026-10-01/auth.firebase.json
+docs/qa-2026-10-01/auth.firestore.rules
+docs/qa-2026-10-01/auth.results.json
+firebase/emulator-tests/club-wall.rules.test.js
+firestore.rules
+lib/features/group_rides/group_ride_detail_screen.dart
+lib/services/clubs/club_providers.dart
+lib/services/group_rides/group_ride_models.dart
+lib/services/group_rides/group_ride_providers.dart
+lib/services/group_rides/group_ride_service.dart
+lib/services/location_service.dart
+test/billing_lifecycle_test.dart
+test/purchase_verification_service_test.dart
+tools/qa_auth_main.dart
+website/app/account/page.tsx
+website/components/app-cloud-data.tsx
+website/components/group-rides/group-rides-management.tsx
+website/components/group-rides/live-pack-radar.tsx
+website/components/web-telemetry-replay-modal.tsx
+website/lib/firebase-admin.ts
+```
+
+### 5.3 Untracked Verification Artifacts (11 Files)
+```text
+docs/prerelease-v1.0.2-build102-followup-audit.md
+docs/qa-2026-10-01/auth.verify-direct-sdk-results.json
+docs/qa-2026-10-01/auth.verify-routes-chat-mobile.json
+docs/qa-2026-10-01/auth.verify-routes-chat-mobile.mjs
+docs/qa-2026-10-01/chat.cross-account-results.json
+firebase/emulator-tests/club-routes-chat.rules.test.js
+test/isolated_sync_integration_fixture_test.dart
+test/location_permission_refresh_test.dart
+test/pro_csv_export_verification_test.dart
+tools/verify_cross_account_chat_live.mjs
+tools/verify_isolated_e2e.mjs
+```
+
+### 5.4 Diagnostic & Scratch Tools (Preserved Non-Production)
+```text
+tools/check_participant.mjs
+tools/check_synced_rides.mjs
+tools/inspect_sqlite.py
+tools/seed_profiles.mjs
+tools/setup_e2e_rides.mjs
+tools/submit_admin_request.mjs
+tools/test_dart_transaction.mjs
+tools/test_join_requests_endpoint.mjs
+tools/test_probe.mjs
+tools/test_verify_token.mjs
+website/check_requests.cjs
+website/test_verify.cjs
+.firebase-tmp/
+```
+
+### 5.5 Test Account Cleanup Status
+- Role promotions (`qa-member` promoted to admin for Founder Portal chat receipt test) must be revoked prior to production deployment.
+- Seeded test rides (`ride-blood-mtn-canyon`) and test chat messages exist strictly within the local Firebase emulator database (`demo-motorsignal-qa`), with **zero contamination of production cloud services**.
+
+---
+
+## 6. Release-Readiness Assessment
+
+### 6.1 Verified Fixes
+- **Web Telemetry Replay Map (WEB-05):** Resolved React 18 StrictMode unmount race, persistent container sizing, CSS hidden/block tab switching, and multi-CDN loader. Verified high-contrast OpenStreetMap raster tiles, street labels (`US 19`, `NC 141`), lean angle heatmap trace, START/FINISH pins, and mobile responsive rendering (390x844).
+- **Authenticated Formation Cockpit & Live Radar (WEB-12):** Transitioned to real Web Mercator GIS map with switchable CartoDB Dark, ESRI Satellite, and OpenTopoMap contour tiles. Bound to live Firestore `live_presence` collection with 2 eligible participants (`rider-lead`, `rider-wing`), live position updates, freshness lifecycle (Active/Stale/Disconnected), and unauthorized access barrier. Public simulation clearly separated and badged.
+- **Recent Telemetry Sessions Pagination:** Capped at 10 initial with "+10" expansion (`visibleRideCount`), reverse-chronological ordering, accurate totals, and actions preserved.
+- **Active Device Sessions UI Pagination:** Sliced at 10 initial with "+10" expansion.
+- **Billing Portal Fallback:** Connected to `/api/billing/portal` with dismissible unconfigured 404 banner.
+- **Apple Watch Companion Honesty:** Replaced placeholder sync with `"Not Connected (Unavailable on Web)"`.
+- **Group Rides Error Banner Elimination:** Replaced floating red error with dedicated in-tab loading skeletons, empty state with filters/CTAs, and retryable error cards.
+- **2-Step Group Ride Create Wizard:** Sensible defaults, plain labels, validation, and pre-flight review.
+- **Billing Test Fixes:** Dynamic relative time fixtures (`add/subtract Duration`) replacing static future years.
+- **Firestore Security Rules:** 222 / 222 rules unit tests passed.
+
+### 6.2 Explicit Feature Gaps & Untested Behavior Distinctions
+
+#### A. Unimplemented Features (Missing Code)
+1. **Mobile Social Persistence (`SOC-01`..`03`):**
+ - *Status:* **UNIMPLEMENTED IN CODE**
+ - *Detail:* `lib/features/social/feed/social_feed_screen.dart` uses an in-memory collection (`_memoryFeed`). Feed posts, comments, and fist-bump reactions operate strictly in local memory and are not backed by Firestore collections or security rules. State resets upon application process restart.
+2. **Regular-Member Web Chat (`CLB-01`):**
+ - *Status:* **UNIMPLEMENTED IN CODE**
+ - *Detail:* The web squad chat interface exists solely within the Founder Portal at `/clubs/[clubId]/manage/chat`. It enforces strict role gates (`isClubAdmin(uid) || isCallerClubOwner()`). No web chat route or component exists for regular club members.
+
+#### B. Implemented Features with Untested Behavior in QA (Verification Gaps)
+3. **Completed Device-to-Backend Cloud Sync:**
+ - *Status:* **SIMULATED INTEGRATION / UNTESTED E2E**
+ - *Detail:* Verified via isolated unit/contract test `test/isolated_sync_integration_fixture_test.dart` using Drift Schema 7 `AppDatabase`, `SyncOutbox`, and `SyncBundleWorker`. However, the test runs with `NativeDatabase.memory()`, mock `SharedPreferences`, and mock HTTP clients. Live end-to-end device-to-backend upload against Cloudflare R2 and production Firestore remains unverified.
+4. **Backend Session Revocation Verification:**
+ - *Status:* **IMPLEMENTED IN BACKEND / UNTESTED IN BROWSER QA**
+ - *Detail:* The session termination endpoints are implemented in the FastAPI backend (`DELETE /api/v1/auth/sessions/{id}` and `POST /api/v1/auth/logout-all` in `backend/motorsignal_api/app/main.py`) and proxied by Next.js (`/api/account/sessions/[sessionId]`). However, during browser QA, the FastAPI backend was not running; session revocation was demonstrated only via client-side state manipulation in the `Active Devices` component list. Server-side session invalidation remains untested in this environment.
+
+#### C. Unconfigured Environment Fallback
+5. **Successful Billing Portal Access:**
+ - *Status:* **UNCONFIGURED PREVIEW / GRACEFUL FALLBACK**
+ - *Detail:* The Next.js endpoint `/api/billing/portal` is wired and tested. In preview and local test environments, Stripe Customer Portal is unprovisioned, so the route returns a graceful HTTP 404 fallback banner (`"Billing portal is currently unavailable in this preview environment"`). Actual redirection to an active Stripe portal session remains unverified.
+
+#### D. Hardware-Dependent Verification (Physical Boundaries)
+6. **Physical Hardware Scenarios (`HW-01`..`HW-04`):**
+ - **`HW-01` (Bluetooth OBD-II CAN Bus Link):** **BLOCKED** — Requires physical ELM327 BLE hardware adapter and motorcycle CAN bus.
+ - **`HW-02` (6-Axis Road Vibration IMU Fusion):** **HARDWARE REQUIRED** — Requires physical motorcycle road test under 100–300 Hz engine harmonics.
+ - **`HW-03` (Cellular Carrier Handoff & GPS Dead-Zones):** **HARDWARE REQUIRED** — Requires physical highway transit through real RF shadow zones.
+ - **`HW-04` (Direct Sunlight Solar Thermal Dissipation):** **HARDWARE REQUIRED** — Requires outdoor motorcycle cockpit mount under sustained direct sunlight and thermal load.
+
+### 6.3 Readiness for Code Review
+**The branch IS READY for code review of all verified fixes (telemetry replay map, authenticated formation cockpit, security rules, 2-step group ride flow, billing relative fixtures, and account fallbacks).**
+However, **PUBLIC RELEASE CANDIDATE MUST REMAIN CONDITIONALLY GATED** until external hardware, production billing provisioning, and cross-account cloud sync dependencies are validated in staging.
diff --git a/MOTORSIGNAL_FREE_PRO_SPEC.md b/docs/product/free-pro-spec.md
similarity index 100%
rename from MOTORSIGNAL_FREE_PRO_SPEC.md
rename to docs/product/free-pro-spec.md
diff --git a/MOTORSIGNAL_PRODUCT_ROADMAP.md b/docs/product/roadmap.md
similarity index 100%
rename from MOTORSIGNAL_PRODUCT_ROADMAP.md
rename to docs/product/roadmap.md
diff --git a/docs/qa-2026-10-01/auth.cleanup-results.json b/docs/qa-2026-10-01/auth.cleanup-results.json
index 2673104..ff302b9 100644
--- a/docs/qa-2026-10-01/auth.cleanup-results.json
+++ b/docs/qa-2026-10-01/auth.cleanup-results.json
@@ -1,6 +1,6 @@
{
"project": "demo-motorsignal-qa",
- "executedAt": "2026-10-01T10:51:32.634Z",
+ "executedAt": "2026-10-03T16:00:31.398Z",
"results": [
{
"service": "auth",
diff --git a/docs/qa-2026-10-01/auth.extra-results.json b/docs/qa-2026-10-01/auth.extra-results.json
index bdad9c1..651712a 100644
--- a/docs/qa-2026-10-01/auth.extra-results.json
+++ b/docs/qa-2026-10-01/auth.extra-results.json
@@ -1,6 +1,6 @@
{
"projectId": "demo-motorsignal-qa",
- "executedAt": "2026-10-01T05:49:09.255Z",
+ "executedAt": "2026-10-03T18:21:35.368Z",
"kind": "Real Auth and Firestore emulator SDK; not UI E2E",
"results": [
{
diff --git a/docs/qa-2026-10-01/auth.firebase.json b/docs/qa-2026-10-01/auth.firebase.json
index 021a44e..34775d4 100644
--- a/docs/qa-2026-10-01/auth.firebase.json
+++ b/docs/qa-2026-10-01/auth.firebase.json
@@ -1,2 +1,2 @@
-{"firestore":{"rules":"auth.firestore.rules"},"emulators":{"auth":{"host":"127.0.0.1","port":9099},"firestore":{"host":"127.0.0.1","port":8088},"ui":{"enabled":false},"singleProjectMode":false}}
+{"firestore":{"rules":"auth.firestore.rules"},"emulators":{"auth":{"host":"0.0.0.0","port":9099},"firestore":{"host":"0.0.0.0","port":8088},"ui":{"enabled":false},"singleProjectMode":false}}
diff --git a/docs/qa-2026-10-01/auth.firestore.rules b/docs/qa-2026-10-01/auth.firestore.rules
index f51b12b..465aba5 100644
--- a/docs/qa-2026-10-01/auth.firestore.rules
+++ b/docs/qa-2026-10-01/auth.firestore.rules
@@ -1254,6 +1254,241 @@ service cloud.firestore {
allow delete: if false;
}
+ // --- CLUB WALL ---
+ // Uses the existing mobile wall_posts model (embedded comments).
+ // Membership gives access to club discussions only, never ride presence.
+ match /wall_posts/{postId} {
+ function canUseWall() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (isCallerClubOwner() || isActiveMember(request.auth.uid));
+ }
+
+ function canModerateWall() {
+ return isCallerClubOwner() || isClubAdmin(request.auth.uid);
+ }
+
+ function validWallAuthor(data) {
+ return data.authorUid == request.auth.uid
+ && data.authorDisplayName is string
+ && data.authorDisplayName.size() > 0 && data.authorDisplayName.size() <= 100
+ && ((isCallerClubOwner() && data.authorRole == 'owner')
+ || (!isCallerClubOwner() && data.authorRole == memberData(request.auth.uid).role))
+ && (!('authorHandle' in data) || (data.authorHandle is string && data.authorHandle.size() <= 100))
+ && (!('authorAvatarUrl' in data) || isValidClubImageUrl(data.authorAvatarUrl))
+ && (!('authorDisplayRank' in data) || (data.authorDisplayRank is string
+ && data.authorDisplayRank == memberData(request.auth.uid).displayRank));
+ }
+
+ function validWallContent(data, maxLength) {
+ return data.content is string && data.content.trim().size() > 0 && data.content.size() <= maxLength;
+ }
+
+ function validPostCreate() {
+ let data = request.resource.data;
+ return data.keys().hasOnly(['id', 'clubId', 'authorUid', 'authorDisplayName',
+ 'authorHandle', 'authorAvatarUrl', 'authorDisplayRank', 'authorRole',
+ 'content', 'createdAt', 'isPinned', 'fistBumpCount', 'fistBumpUserIds', 'comments'])
+ && data.id == postId && data.clubId == clubId
+ && validWallAuthor(data) && validWallContent(data, 5000)
+ && data.createdAt is timestamp
+ && data.isPinned is bool && (!data.isPinned || canModerateWall())
+ && data.fistBumpCount == 0 && data.fistBumpUserIds == [] && data.comments == [];
+ }
+
+ function validReplyAppend() {
+ let old = resource.data.comments;
+ let next = request.resource.data.comments;
+ let reply = next[next.size() - 1];
+ return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['comments'])
+ && old is list && next is list && old.size() < 200
+ && next.size() == old.size() + 1 && next == old.concat([reply])
+ && reply is map
+ && reply.keys().hasOnly(['id', 'postId', 'authorUid', 'authorDisplayName',
+ 'authorHandle', 'authorAvatarUrl', 'authorDisplayRank', 'authorRole', 'content', 'createdAt'])
+ && reply.id is string && reply.id.size() > 0 && reply.id.size() <= 100
+ && reply.postId == postId && validWallAuthor(reply) && validWallContent(reply, 2000)
+ // Mobile serializes embedded comment timestamps as ISO strings.
+ && reply.createdAt is string && reply.createdAt.size() <= 40
+ && reply.createdAt.matches('^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*Z$');
+ }
+
+ function validFistBumpToggle() {
+ let old = resource.data.fistBumpUserIds;
+ let next = request.resource.data.fistBumpUserIds;
+ return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['fistBumpCount', 'fistBumpUserIds'])
+ && next is list && next.size() <= 5000
+ && next.size() == next.toSet().size()
+ && request.resource.data.fistBumpCount == next.size()
+ && ((!(request.auth.uid in old) && next == old.concat([request.auth.uid]))
+ || (request.auth.uid in old && next == old.removeAll([request.auth.uid])));
+ }
+
+ allow read: if canUseWall();
+ allow create: if canUseWall() && validPostCreate();
+ allow update: if canUseWall() && (
+ (canModerateWall()
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isPinned'])
+ && request.resource.data.isPinned is bool)
+ || validReplyAppend() || validFistBumpToggle()
+ );
+ allow delete: if canUseWall() && (canModerateWall() || resource.data.authorUid == request.auth.uid);
+ }
+
+ // --- CLUB ROUTES ---
+ // Curated motorcycle routes created by club members or owner.
+ // Read access is public if club is public; otherwise active members/owner only.
+ // Deletions are restricted to club admins and owner.
+ match /routes/{routeId} {
+ function canReadClubRoutes() {
+ return isRegisteredUser()
+ && !isBanned(request.auth.uid)
+ && (
+ getClubDoc().data.visibility == 'public'
+ || isCallerClubOwner()
+ || isActiveMember(request.auth.uid)
+ );
+ }
+
+ function isValidClubRoute(data) {
+ return data.keys().hasAll([
+ 'id', 'clubId', 'title', 'description', 'distanceMiles',
+ 'estimatedMinutes', 'elevationGainFt', 'curvatureRating',
+ 'surfaceType', 'startLocation', 'endLocation', 'curatorUid',
+ 'curatorName', 'createdAt'
+ ])
+ && data.keys().hasOnly([
+ 'id', 'clubId', 'title', 'description', 'distanceMiles',
+ 'estimatedMinutes', 'elevationGainFt', 'curvatureRating',
+ 'surfaceType', 'startLocation', 'endLocation', 'gpxUrl',
+ 'curatorUid', 'curatorName', 'createdAt'
+ ])
+ && data.id == routeId
+ && data.clubId == clubId
+ && data.curatorUid == request.auth.uid
+ && data.curatorName is string
+ && data.curatorName.size() >= 1 && data.curatorName.size() <= 100
+ && data.title is string
+ && data.title.size() >= 1 && data.title.size() <= 120
+ && data.description is string
+ && data.description.size() <= 1000
+ && (data.distanceMiles is int || data.distanceMiles is float)
+ && data.distanceMiles > 0
+ && (data.estimatedMinutes is int || data.estimatedMinutes is float)
+ && data.estimatedMinutes > 0
+ && (data.elevationGainFt is int || data.elevationGainFt is float)
+ && data.elevationGainFt >= 0
+ && data.curvatureRating in ['High Twisties', 'Moderate', 'Scenic Cruise', 'Track Pace']
+ && data.surfaceType in ['Paved Asphalt', 'Mixed Surface', 'Mountain Pass']
+ && data.startLocation is string
+ && data.startLocation.size() >= 1 && data.startLocation.size() <= 120
+ && data.endLocation is string
+ && data.endLocation.size() >= 1 && data.endLocation.size() <= 120
+ && (!('gpxUrl' in data) || data.gpxUrl == null || (data.gpxUrl is string && data.gpxUrl.size() <= 2048))
+ && data.createdAt is timestamp;
+ }
+
+ function canCreateClubRoute() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (isCallerClubOwner() || isActiveMember(request.auth.uid))
+ && isValidClubRoute(request.resource.data);
+ }
+
+ function canDeleteClubRoute() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (isCallerClubOwner() || isClubAdmin(request.auth.uid));
+ }
+
+ allow read: if canReadClubRoutes();
+ allow create: if canCreateClubRoute();
+ allow update: if false;
+ allow delete: if canDeleteClubRoute();
+ }
+
+ // --- CLUB CHAT MESSAGING ---
+ // Real-time squad chat messaging partitioned by channels.
+ // General channels (general, ride-intel): active members and owner.
+ // Officers channel: club admin and owner only.
+ // Messages are immutable once posted.
+ match /chat_channels/{channelId}/messages/{messageId} {
+ function canReadChatMessage() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (
+ channelId == 'officers'
+ ? (isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ : (isCallerClubOwner() || isActiveMember(request.auth.uid))
+ );
+ }
+
+ function validChatAuthor(data) {
+ return data.authorUid == request.auth.uid
+ && data.authorDisplayName is string
+ && data.authorDisplayName.size() >= 1 && data.authorDisplayName.size() <= 100
+ && ((isCallerClubOwner() && data.authorRole == 'owner')
+ || (!isCallerClubOwner() && data.authorRole == memberData(request.auth.uid).role))
+ && (!('authorHandle' in data) || data.authorHandle == null || (data.authorHandle is string && data.authorHandle.size() <= 100))
+ && (!('authorDisplayRank' in data) || data.authorDisplayRank == null || (
+ data.authorDisplayRank is string && data.authorDisplayRank.size() <= 100
+ && (isCallerClubOwner() || ('displayRank' in memberData(request.auth.uid) && data.authorDisplayRank == memberData(request.auth.uid).displayRank))
+ ));
+ }
+
+ function isValidChatMessage(data) {
+ return data.keys().hasAll([
+ 'id', 'clubId', 'channelId', 'content', 'authorUid',
+ 'authorDisplayName', 'authorRole', 'createdAt'
+ ])
+ && data.keys().hasOnly([
+ 'id', 'clubId', 'channelId', 'content', 'authorUid',
+ 'authorDisplayName', 'authorRole', 'authorHandle',
+ 'authorDisplayRank', 'createdAt'
+ ])
+ && data.id == messageId
+ && data.clubId == clubId
+ && data.channelId == channelId
+ && data.content is string
+ && data.content.trim().size() > 0
+ && data.content.size() <= 1000
+ && data.createdAt is timestamp
+ && validChatAuthor(data);
+ }
+
+ function canCreateChatMessage() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (
+ channelId == 'officers'
+ ? (isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ : (isCallerClubOwner() || isActiveMember(request.auth.uid))
+ )
+ && isValidChatMessage(request.resource.data);
+ }
+
+ function canDeleteChatMessage() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (
+ channelId == 'officers'
+ ? (isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ : (resource.data.authorUid == request.auth.uid || isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ );
+ }
+
+ allow read: if canReadChatMessage();
+ allow create: if canCreateChatMessage();
+ allow update: if false;
+ allow delete: if canDeleteChatMessage();
+ }
+
// --- ADMINISTRATIVE BANS SUBCOLLECTION ---
match /bans/{bannedUid} {
allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid);
diff --git a/docs/qa-2026-10-01/auth.results.json b/docs/qa-2026-10-01/auth.results.json
index ed223c1..7ddb56b 100644
--- a/docs/qa-2026-10-01/auth.results.json
+++ b/docs/qa-2026-10-01/auth.results.json
@@ -3,27 +3,27 @@
"kind": "Real Auth + Firestore emulators via SDK, not UI E2E",
"accounts": {
"owner": {
- "uid": "HMlGy4DNyJUUR4CDr43aThYQX8nd",
+ "uid": "oiTCNDzizWElfSWfa1GYtcDvxWuq",
"email": "qa-owner@motorsignal.example"
},
"admin": {
- "uid": "vgQtjVSuz5aPATM2ghUVxUuProG3",
+ "uid": "Mub6ejs3oIuVyxsXqdJI1TF7XRjz",
"email": "qa-admin@motorsignal.example"
},
"organizer": {
- "uid": "iPE9feykd6rJqs7b7gvxGkhAkJz3",
+ "uid": "e1bEZPhCgWiEEzKgdOhO6qvs0ODp",
"email": "qa-organizer@motorsignal.example"
},
"participant": {
- "uid": "xvMH3PcgmZZeVpvVk3HbUKx5bRzd",
+ "uid": "uVKcwcRlv2nzmNXUtZQCf1KAtWXj",
"email": "qa-participant@motorsignal.example"
},
"member": {
- "uid": "5O24hx3Hvoct5YE0dtT8Q0eMqWen",
+ "uid": "a8DDZOUIDPsEGb29tkaikjSNHGUF",
"email": "qa-member@motorsignal.example"
},
"outsider": {
- "uid": "HcgPrXi4vX8AOvsXXDS856ySWj7G",
+ "uid": "z5Mb3oOVMbNQMYT936S50Kw4fXkC",
"email": "qa-outsider@motorsignal.example"
}
},
diff --git a/docs/qa-2026-10-04-authenticated-website.md b/docs/qa-2026-10-04-authenticated-website.md
new file mode 100644
index 0000000..811083b
--- /dev/null
+++ b/docs/qa-2026-10-04-authenticated-website.md
@@ -0,0 +1,75 @@
+# Authenticated website verification — October 4, 2026
+
+The focused local phase passed 15 browser/backend checks and 42 relevant automated tests. The website compiled successfully, including TypeScript, in one build. These results establish isolated website behavior; they do not establish authenticated production behavior.
+
+| Workflow | Result | Evidence and limits |
+| --- | --- | --- |
+| Telemetry sessions | PASS, isolated Firebase E2E | 25 owned sessions; initial 10, then 20 and 25; newest-first ordering; replay, GPX/CSV downloads, and deletion verified through the browser and backend. The fixtures use the website's supported inline telemetry format; R2 bundle retrieval was not tested. |
+| Device sessions | PASS for website UI/proxy; actual backend unverified | 25 sessions from the existing token-verifying local fixture API; initial 10, then 20 and 25; newest-first ordering, current-session protection, retained revoke buttons, and successful revoke verified. This is not PostgreSQL session-service E2E. |
+| Community discovery | PASS, isolated Firebase E2E | Populated and empty results; absent/null `clubId` personal rides included; private and club rides excluded; both directions of blocking excluded. |
+| Two-step wizard | PASS, isolated Firebase E2E | Browser configuration and review, publication, matching Firestore record and accepted organizer participant, and persistence after reload. |
+| Live radar | PASS, isolated Firebase E2E | Two authorized SDK publishers; inspector coordinates and projected marker positions changed; actual elapsed-time stale/disconnected transitions occurred without writes; empty/error/denied states, revoked access, real Firestore `removeTarget`, and browser Leave Pack persistence verified. |
+| Billing fallback | PASS | The actual local `/api/billing/portal` request returned 404 and the account page displayed the fallback notice. |
+| Actual billing portal | BLOCKED | No portal route or legitimate isolated billing configuration exists in the inspected website/backend. No portal response was mocked or represented as portal verification. |
+| Authenticated production | NOT RUN | This phase was local. No deployment, production fixtures, commits, pushes, or mobile changes were made. |
+
+## Isolation and configuration
+
+The running emulator hub independently reported Auth `127.0.0.1:9199` and Firestore `127.0.0.1:8188`. The dedicated configuration is `firebase.website-qa.json`, which loads the current root `firestore.rules` and indexes. The rules SHA-256 was `4b67b2d16235b8259a57852a386f7c8bf98dddf0c6e0588e51209e819f327cf0` and remained unchanged.
+
+Browser Firebase, website Firebase Admin, fixture Admin, and authorized publisher clients all used `demo-motorsignal-web-qa`. The browser's actual Firebase app options, Auth emulator configuration, and Firestore host were asserted. Authenticated server discovery returned the same seeded project records. The fixture API verified token audience/issuer before issuing isolated opaque sessions. Server HTTP forwarding targeted `http://127.0.0.1:8189`. The browser harness aborted external destinations. No production environment file was overwritten.
+
+The handoff's `NEXT_PUBLIC_USE_FIREBASE_EMULATOR` flag is not the flag used by current source. The actual flag is `NEXT_PUBLIC_FIREBASE_EMULATORS`, together with explicit project and emulator ports. Emulator host variables alone were not used as proof of project isolation.
+
+## Reproduced fixes and changed files
+
+- `website/app/api/group-rides/route.ts`: accept absent/null club association, consistent with the current schema and rules.
+- `website/app/api/account/sessions/route.ts` and `website/app/api/account/sessions/[sessionId]/route.ts`: forward successful 204 revocations with a null body; previously they threw and returned 500.
+- `website/components/group-rides/live-pack-radar.tsx`: clear cached presence and stop listeners on access revocation/errors; reject late callbacks; distinguish stream failures from an empty pack.
+- `website/components/group-rides/group-rides-management.tsx`: keep inaccessible requested ride IDs in real radar mode so reload after Leave Pack shows the clearance barrier rather than a simulated pack; reject a cached summary for a different ride.
+- New focused regression files: `website/tests/community-personal-rides.test.ts`, `website/tests/website-session-proxy.test.ts`, and `website/tests/radar-access-lifecycle.test.tsx`. Failures were observed before the corresponding product fixes.
+- Reused/extended `tools/verify_isolated_e2e.mjs` and `website/test-support/qa-auth-backend.mjs`; added `tools/verify_website_authenticated.mjs`, `tools/website-qa-isolation.mjs`, and `firebase.website-qa.json`.
+
+Earlier harness failures caused by incomplete telemetry fixture fields or an incorrect wizard selector were corrected in the harness, not treated as product defects. Earlier failed runs are retained as evidence.
+
+## Commands and results
+
+All commands ran from `C:\Users\Bill\Desktop\flutter-app`. The focused harness used these process-only settings:
+
+```powershell
+$env:NEXT_PUBLIC_FIREBASE_EMULATORS='true'
+$env:NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID='demo-motorsignal-web-qa'
+$env:NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT='9199'
+$env:NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT='8188'
+$env:FIREBASE_PROJECT_ID='demo-motorsignal-web-qa'
+$env:GCLOUD_PROJECT='demo-motorsignal-web-qa'
+$env:FIRESTORE_EMULATOR_HOST='127.0.0.1:8188'
+$env:FIREBASE_AUTH_EMULATOR_HOST='127.0.0.1:9199'
+$env:MOTORSIGNAL_API_BASE_URL='http://127.0.0.1:8189'
+$env:QA_FOCUSED_WEBSITE='true'
+```
+
+| Command | Result |
+| --- | --- |
+| `firebase emulators:start --config firebase.website-qa.json --only auth,firestore --project demo-motorsignal-web-qa` | Ready; hub verified the configured loopback ports. |
+| `npm --prefix website run dev -- --hostname 127.0.0.1 -p 3100` | Local website ready with the process settings above. |
+| `node website/test-support/qa-auth-backend.mjs` with `QA_DEVICE_SESSION_COUNT=25` | Local token-verifying fixture listening on 8189. |
+| `node tools/verify_isolated_e2e.mjs` with `QA_WEBSITE_MODE=seed` | 5 owned users, 25 telemetry sessions, and 7 scoped group-ride fixtures seeded. |
+| Same entry point with `QA_WEBSITE_MODE=run`, `QA_RUN_LABEL=completed` | Exit 0; 15/15 checks passed. |
+| `node website/node_modules/vitest/vitest.mjs run --root website tests/community-personal-rides.test.ts tests/website-session-proxy.test.ts tests/radar-access-lifecycle.test.tsx tests/group-rides-page.test.tsx tests/telemetry-replay-export.test.tsx tests/firebase-client-emulator.test.ts --maxWorkers=1 --testTimeout=15000 --reporter=default --reporter=json --outputFile=../output/playwright/authenticated-20261004/focused-tests-completed.json` | Exit 0; 6 files, 42 tests passed. |
+| Focused harness with `QA_WEBSITE_MODE=build` | Exit 0; runs the installed Next `build` once in `website`; TypeScript passed. The local artifact is built with demo Firebase configuration. |
+| Focused harness with `QA_WEBSITE_MODE=cleanup` | Owned user and ride trees removed; cleanup assertions passed. |
+| Focused harness with `QA_WEBSITE_MODE=audit` | Preservation and production-target guard evidence in the files below. |
+| Focused harness with `QA_WEBSITE_MODE=service-audit` | All six owned website, fixture, and emulator endpoint ports refused connections after shutdown. |
+
+For the recorded harness/test commands, Node was invoked directly using `C:\Users\Bill\AppData\Roaming\devbox-pro\resources\nodejs\24\win\node.exe`; the available `node.cmd`/npm shim did not reliably propagate failing exit codes. `rtk` was unavailable, so native commands were used.
+
+The build reported three whole-project filesystem-tracing warnings at lines 37, 42, and 71 of the pre-existing, unchanged `website/app/api/account/rides/[rideId]/telemetry/route.ts`. They were not fixed within this phase. Tests also printed the existing Vite configuration warning, jsdom canvas limitation, and legacy test emulator connection warning; no test failed. Deliberate denied-access tests emitted expected Firestore permission errors. Broad Flutter/production guest suites were not restarted.
+
+## Evidence, cleanup, and Git
+
+Evidence directory: `output/playwright/authenticated-20261004/`. Primary files are `results-completed.json`, `focused-tests-completed.json`, `created-ride.json`, `emulator-topology.json`, `website-build.log`, `website-build-result.json`, `guard-results.json`, `cleanup.json`, `service-cleanup.json`, `git-before.json`, and `git-final.json`. GPX/CSV downloads and failure-only screenshots are retained there. Generic unavailable/missing stream states also have focused component coverage; deletion under current rules produces a real permission-denied barrier in the browser.
+
+The final cleanup removed five owned Auth users, eight group-ride trees including the browser-created ride, their presence/participant records, and the owned user trees containing telemetry and blocks. The QA browser and local services were stopped. The local fixture backend's in-memory device sessions disappear when it stops. No pre-existing emulator project was cleared wholesale.
+
+Main branch/HEAD remain `release/v1.0.2-build102-community-feed` / `bd278cdfd3619242a3056c6a2738abb0aff70feb`. The main worktree retains its pre-existing changes plus the scoped files/evidence above, with nothing staged. Next dev temporarily generated development type paths in `website/next-env.d.ts`; the single build returned it to the original clean HEAD content, independently asserted by the audit. All other unrelated baseline hashes remain unchanged. The deployment worktree remains clean on `deploy/website-20261004` / `69662eae46b92b2d824558b40a82fd487d15ba1b`. Complete final status, including untracked files, and preservation hashes are recorded in `git-final.json`.
diff --git a/docs/website-deployment-2026-10-04-scoped.md b/docs/website-deployment-2026-10-04-scoped.md
new file mode 100644
index 0000000..e47df43
--- /dev/null
+++ b/docs/website-deployment-2026-10-04-scoped.md
@@ -0,0 +1,76 @@
+# Scoped website deployment — October 4, 2026
+
+Deployed commit: `8b3a7aa29290b58a9456a564785e9c73cd8e3093` on `deploy/website-20261004`. Only five reviewed product files and three regression files were committed. No push was performed. Website HTTP/artifact smoke passed. A subsequent administrator configuration resolved the credential blocker for the **user-confirmed production authenticated empty-state workflow**; populated production discovery remains untested. This report update did not rebuild, redeploy, restart, rerun configuration, or independently recheck production.
+
+## Included scope
+
+| Fix | Product files |
+| --- | --- |
+| Personal discovery accepts absent/null `clubId` | `website/app/api/group-rides/route.ts` |
+| Successful session revocation forwards 204 without a body | `website/app/api/account/sessions/route.ts`; `website/app/api/account/sessions/[sessionId]/route.ts` |
+| Radar clears cached presence and stops obsolete listeners after errors/revocation | `website/components/group-rides/live-pack-radar.tsx` |
+| Requested ride identity survives Leave Pack/reload; denied rides do not become simulated packs | `website/components/group-rides/group-rides-management.tsx` |
+
+Regression files: `website/tests/community-personal-rides.test.ts`, `website/tests/website-session-proxy.test.ts`, and `website/tests/radar-access-lifecycle.test.tsx`. The commit contains 180 insertions and 16 deletions across these eight files. Product differences are 49 insertions and 16 deletions, with no authentication, rule, entitlement, or dependency-version changes.
+
+QA fixtures, emulator configuration, regression source, test-support files, environment files, evidence, caches, and Windows dependency trees were excluded from the production payload. The regression tests remain in the deployment Git commit but are not runtime package contents. Next's generated external-package alias is recreated as a relative Linux link to the verified existing Firebase Admin package.
+
+## Production configuration and build
+
+- Fresh build from the clean deployment commit; the main workspace's demo `.next` was not used or changed.
+- Browser Firebase project verified at deployment was `motoapp-d22e0`; served client assets contained its production options. At cutover, Firebase Admin retained the same production project fallback with no runtime project override; subsequent administrator runtime configuration is recorded above.
+- Build explicitly set `FIREBASE_PROJECT_ID` and `GCLOUD_PROJECT` to `motoapp-d22e0`; emulator/QA settings were stripped before environment loading. No environment files loaded in the build. At cutover, the production service also had no emulator flags/hosts or environment files. The later runtime configuration does not change this build evidence.
+- Backend URL verified at deployment was `https://api.motorsignal.app`; service listened on `127.0.0.1:3001` under `motorsignal`. Systemd configuration was not modified by the deployment phase; the later administrator configuration was separate.
+- The pre-existing dependency junction caused runner/build-path problems. It and the old deployment build were preserved under the ignored evidence subdirectories. A fresh `npm ci --no-audit --no-fund` installed 344 packages from the unchanged committed lockfile. There were no version upgrades.
+- Fresh Next/Turbopack build and TypeScript passed. Three known tracing warnings remain in the unchanged telemetry route at lines 37, 42, and 71. Earlier junction builds and their warnings were discarded, not deployed.
+- Runtime versions checked on Linux: Next 16.3.4, React/React DOM 19.1.1, Firebase Admin 14.4.0. Firebase browser SDK 12.18.0 is bundled.
+- Build ID: `oo8nnoCyk_My8qXXYMOcr`. Artifact SHA-256: `2d9874f1fb23e8e22e5f700ead1db6ac90882063af4b9d5b82f0ec7a0482c0f9`.
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| Focused Vitest: three regression files plus `group-rides-page.test.tsx` | 31 tests passed, four files, exit 0 |
+| Deployment revision contract test | PASS |
+| Fresh production build, TypeScript, artifact scan | PASS; no isolated project/session/emulator endpoint contamination |
+| Linux candidate on loopback 3102 | PASS; target revision, account/group pages, 13 client assets, protected API 401 responses; process stopped |
+| Public HTTPS `/api/version` | PASS; exact deployed SHA and no-store |
+| Public HTTPS account/group pages and 13 client assets | PASS; every asset hash matches the fresh package |
+| Unauthenticated discovery/session APIs | PASS; both return 401 |
+| Existing authenticated session: My Pack | PASS; ten existing rides displayed; no sign-in or record mutation performed |
+| Existing authenticated session: real radar | PASS for an existing empty ride; authenticated mode, empty-state message, zero simulated-pilot cards; left radar view without leaving the ride |
+| Authenticated community discovery | Initially BLOCKED with credential-loading 500; subsequent production empty state PASS — user-confirmed **No Community Rides Scheduled**. Populated production discovery remains UNTESTED |
+
+The browser restored an existing Firebase session after navigation. Initial HTTP-only evidence saying authentication was not run is superseded by `authenticated-readonly.json`; no credentials were exported or new session sign-in performed. The management route's HTTP 200 credential-check skeleton is expected: its redirect runs only after client Auth resolution. The initial HTTP-redirect assertion was corrected after source inspection.
+
+Backup inspection proved Firebase Admin initialization source was unchanged. Old and new standalone server configurations embedded only the deployment-revision variable. At deployment verification, the service had neither `FIREBASE_SERVICE_ACCOUNT` nor `GOOGLE_APPLICATION_CREDENTIALS` configured. The deployment phase did not modify credential-loading configuration. An authenticated request before cutover was not independently recorded, so the initial credential failure is not falsely claimed as a verified pre-deployment outcome.
+
+On 2026-10-04, the administrator subsequently reported applying the separately prepared runtime configuration: credential probes passed, the website restarted, revision remained `8b3a7aa`, guest discovery returned 401, and post-restart credential/discovery failures were zero. In the administrator's existing signed-in Group Ride Hub, **No Community Rides Scheduled** appeared. These are **user-confirmed production results**, not independent agent retests; no raw authenticated HTTP status/JSON was supplied in this follow-up. Populated production discovery remains untested. See `docs/website-firebase-admin-blocker-2026-10-04.md` for the attributed follow-up and preserved original blocker evidence.
+
+No production fixture creation, ride creation, session revocation, Leave Pack, access revocation, or presence publication was performed. Their behavioral coverage remains the prior isolated verification and current focused regression tests. Billing, PostgreSQL, R2, Android, Firestore rules, and unrelated backend services were not expanded or modified. The existing Tailwind CDN warning was observed and left unchanged.
+
+## Cutover and rollback
+
+The existing website-only packaging/restart process was retained, with shipping-and-launch safeguards: Linux candidate smoke, verified full backup before activation, preserved previous runtime, and automatic rollback on activation failure. The global `/opt/motorsignal/current` link was not moved. Backend and nginx PIDs, active states, restart counters, and backend path remained unchanged. Only `motorsignal-website` restarted; its automatic restart count remains zero. Previous static assets were retained for existing tabs, without overwriting fresh assets; all active server code is from the new build.
+
+Rollback archive:
+
+`/home/motorsignal-admin/rollback-website-69662eae-to-8b3a7aa-20261004.tar.gz`
+
+SHA-256: `6e70037312d16d8967334e790cfad68e2d82df40a943db2261630f1174723712`. Its old server and build-ID contents were compared before cutover. The prior runtime is preserved at `/opt/motorsignal/releases/20260920-master-7efd29a/website/.next.rollback-8b3a7aa29290`.
+
+Rollback command on the verified VPS, if authorized/needed:
+
+```sh
+python3 /home/motorsignal-admin/deploy-scoped-8b3a7aa29290.py rollback
+```
+
+The helper restores the preserved runtime and original five source files, server and package metadata, restarts only the website, and verifies previous revision `69662eae46b92b2d824558b40a82fd487d15ba1b`. Rollback is ready and verified structurally; it was not activated merely to test it.
+
+## Commands, evidence, and preservation
+
+Commands used direct bundled `node.exe`, installed Vitest/Next entry points, `npm ci`, `git diff --check`, explicit eight-file staging and local commit, `scp`, the scoped Python deployment helper, and read-only HTTP/browser checks. No broad QA suites were repeated. Build/deployment helper sources and exact settings are retained in `output/deployment/20261004-scoped/`.
+
+Primary evidence: `focused-tests.json`, `production-config.json`, `production-build-result.json`, `production-build.log`, `artifact-scan.json`, `package.json`, `candidate-smoke.json`, `deployment-result.json`, `production-smoke.json`, `authenticated-readonly.json`, `credential-baseline.json`, `baseline.json`, and `git-final.json`.
+
+The temporary candidate process and agent-created browser tab were closed; the user's existing signed-in session was not signed out. Owned staging directories, failed-build diagnostics, production package, previous local dependency junction/build, rollback archive, and old live runtime remain recoverable. No existing data was deleted. Main branch/HEAD remain `release/v1.0.2-build102-community-feed` / `bd278cdfd3619242a3056c6a2738abb0aff70feb`; preservation audit checks all 117 original dirty/untracked file hashes. Deployment worktree is clean at the scoped commit. Full final status, including untracked evidence additions, is in `git-final.json`.
diff --git a/docs/website-firebase-admin-administrator-commands-2026-10-04.md b/docs/website-firebase-admin-administrator-commands-2026-10-04.md
new file mode 100644
index 0000000..355a267
--- /dev/null
+++ b/docs/website-firebase-admin-administrator-commands-2026-10-04.md
@@ -0,0 +1,182 @@
+# Firebase Admin: administrator command sequence
+
+Prepared only: **none of the privileged commands below has been executed by the agent.** No rebuild is needed. Do not paste passwords, keys, credential JSON, tokens, HAR files, or “Copy as cURL” output into chat. Never enable shell tracing (`set -x`) during this procedure.
+
+This sequence is deliberately scoped to the known credential `/etc/motorsignal/firebase-admin.json`, website user `motorsignal`, and deployed revision `8b3a7aa29290b58a9456a564785e9c73cd8e3093`. It stops rather than overwriting configuration if the known baseline has changed. If a different credential or identity is required, stop for a reviewed provisioning plan; do not create a new key or loosen file permissions.
+
+## 0. Transfer the non-secret probe from your Windows computer
+
+Run in **local PowerShell**, from `C:\Users\Bill\Desktop\flutter-app`. This only uploads diagnostic source; it does not contain credentials or change services.
+
+```powershell
+scp -i C:/Users/Bill/.ssh/motorsignal_prod output/deployment/20261004-firebase-admin/admin/probe-firebase-admin.mjs motorsignal-admin@89.31.239.10:/home/motorsignal-admin/probe-firebase-admin.mjs
+ssh -i C:/Users/Bill/.ssh/motorsignal_prod motorsignal-admin@89.31.239.10
+```
+
+Review the uploaded file, then use your existing secure administrator access on this VPS. `sudo` authentication stays in your own terminal; never send it to the agent. Commands below use ordinary SSH plus interactive sudo, not a new root SSH login or new access grant.
+
+## 1. Read-only validation; no configuration edits or restart
+
+Run in your **VPS SSH shell**:
+
+```bash
+set +x
+systemctl show motorsignal-website.service -p User -p ActiveState -p EnvironmentFiles -p DropInPaths
+sudo stat -c '%U %G %a %n' /etc/motorsignal/firebase-admin.json
+sudo -u motorsignal env -i PATH=/usr/bin:/bin /usr/bin/node --input-type=module - metadata < /home/motorsignal-admin/probe-firebase-admin.mjs
+```
+
+The probe reads the existing file as the website user. It prints only type, project ID, service-account email, mode, and readability. It requires a regular file owned by that user, no world permissions, and no group write/execute permission. It requires project `motoapp-d22e0`. Failure means **STOP**; do not `chmod` the shared credential to bypass it.
+
+### Identity-owner approval and IAM review: separate gates
+
+In your **secure Google Cloud Console administrator session**, select `motoapp-d22e0`, locate the exact service-account email printed above, and verify that the account is enabled. Review its existing project/database/inherited grants and conditions. Confirm effective `datastore.entities.get` and `datastore.entities.list` on the production database. Inspect any unexpectedly broad grants with the owner; do not automatically reuse an unrelated identity merely because it can read data.
+
+Obtain explicit approval from the identity owner for **website runtime reuse of this exact identity**. Record the approver, identity email, purpose, scope, and approval reference in your normal secure operations record. IAM access and a routing-service reference do **not** themselves constitute reuse approval. No command below grants IAM roles, changes rules, or creates a key.
+
+Firestore server SDK reads use IAM, not the website client's security rules. `roles/datastore.viewer` includes document get/list, but it permits reads across Firestore resources; it is a reference for reviewing existing access, **not a command to broaden grants**. [Google Firestore IAM reference](https://docs.cloud.google.com/firestore/native/docs/security/iam)
+
+After owner approval, run in your **VPS SSH shell**. Enter only the non-secret email and approval reference:
+
+```bash
+read -r -p 'Approved service-account email: ' APPROVED_IDENTITY
+read -r -p 'Identity-owner approval reference: ' APPROVAL_REFERENCE
+test -n "$APPROVED_IDENTITY" && test -n "$APPROVAL_REFERENCE"
+sudo -u motorsignal env -i PATH=/usr/bin:/bin /usr/bin/node --input-type=module - probe "$APPROVED_IDENTITY" < /home/motorsignal-admin/probe-firebase-admin.mjs
+```
+
+Require `firestoreReadProbes: PASS`. The probe exercises ADC using the deployed website's installed Firebase Admin SDK: the actual public/scheduled-or-assembling community query shape, limited to one result, plus two document reads on the exact block/participant path shapes. The latter use the fixed name `website-credential-read-probe`; missing documents are acceptable and **no document is created**. No document contents or returned IDs are printed, and no collection-group scans are performed. These probes check get/list access; they do not prove every route authorization branch or establish human approval. A permission, disabled/invalid credential, missing-index, ownership, or timeout error means **STOP**. Review privately; do not create indexes, fixtures, or keys to force success in this task.
+
+The probe uses clean process settings and checks file readability, but the post-restart browser check below remains required to establish access from the actual website process. [Firebase ADC setup](https://firebase.google.com/docs/admin/setup)
+
+## 2. Configuration changes: only after both gates pass
+
+Run this block in your **VPS SSH shell using your secure interactive sudo access**. It rechecks approval and Firestore reads, preserves the baseline, creates only a website-specific environment file/drop-in, and restarts only the website. It does not touch the shared credential, routing environment, backend environment, code, or build assets.
+
+The fixed backup path is `/var/backups/motorsignal-website/firebase-admin-20261004`. If it already exists, the block stops: do not reuse or overwrite it. If any failure occurs after the backup is created, use section 4 instead of blindly rerunning.
+
+```bash
+sudo /bin/bash -s -- "$APPROVED_IDENTITY" "$APPROVAL_REFERENCE" <<'ADMIN'
+set -euo pipefail
+set +x
+umask 077
+test "$#" -eq 2 && test -n "$1" && test -n "$2"
+read -r -p 'Owner approved this exact identity for website reuse? Type APPROVED: ' ANSWER "$BACKUP/prior-state.txt"
+printf 'identity=%s\napproval_reference=%s\n' "$1" "$2" > "$BACKUP/approval.txt"
+printf '%s\n' 'FIREBASE_PROJECT_ID=motoapp-d22e0' 'GOOGLE_APPLICATION_CREDENTIALS=/etc/motorsignal/firebase-admin.json' > "$BACKUP/website.env.applied"
+printf '%s\n' '[Service]' 'EnvironmentFile=/etc/motorsignal/website.env' > "$BACKUP/10-firebase-admin.conf.applied"
+date -u '+%Y-%m-%d %H:%M:%S UTC' > "$BACKUP/restart-since.txt"
+printf 'Protected backup: %s\nRollback: section 4 of the administrator runbook\n' "$BACKUP"
+
+install -m 0640 -o root -g motorsignal "$BACKUP/website.env.applied" "$ENV_FILE"
+install -d -m 0755 -o root -g root "$DROP_DIR"
+install -m 0644 -o root -g root "$BACKUP/10-firebase-admin.conf.applied" "$DROP_FILE"
+sudo -u motorsignal test -r "$ENV_FILE"
+sudo -u motorsignal test -r /etc/motorsignal/firebase-admin.json
+systemctl daemon-reload
+systemctl restart motorsignal-website.service
+systemctl is-active --quiet motorsignal-website.service
+printf '%s\n' 'Website restarted. Authenticated browser and post-restart log checks are still required.'
+ADMIN
+```
+
+Do not run concurrently with another administrator editing website configuration. The prior unit is backed up but never rewritten. The added environment file contains only project/path settings; credential JSON remains in its existing protected file. No Firebase credentials belong in `.next`, `public`, `next.config`, `NEXT_PUBLIC_*`, shell history, or browser assets.
+
+## 3. Read-only post-restart verification
+
+First run in your **VPS SSH shell** (no sudo required for HTTP checks):
+
+```bash
+/usr/bin/node --input-type=module - <<'HTTP'
+const expected = '8b3a7aa29290b58a9456a564785e9c73cd8e3093';
+const version = await fetch('https://motorsignal.app/api/version', { cache:'no-store', signal:AbortSignal.timeout(15000) });
+if (version.status !== 200 || (await version.json()).revision !== expected) throw new Error('revision_check_failed');
+const response = await fetch('https://motorsignal.app/api/group-rides', { cache:'no-store', signal:AbortSignal.timeout(15000) });
+if (response.status !== 401 || (await response.json()).error !== 'unauthorized') throw new Error('unauthenticated_401_failed');
+console.log('PASS: unchanged revision; unauthenticated discovery 401');
+HTTP
+```
+
+Then, in your **existing authorized production browser session**, open `https://motorsignal.app/group-rides/manage` and select **Explore Community Rides** (or **Retry Loading**). In that browser's Network panel, inspect the actual GET `/api/group-rides` response: require HTTP 200 and JSON with `rides` as an array and `nextCursor` as null or a string. Keep request headers/tokens in the browser; do not export them or copy them into a shell. This is the authenticated end-to-end check; the service-account probe is not a substitute.
+
+If the actual eligible result is empty, require **No Community Rides Scheduled**, not **Community rides could not be loaded**. If results are populated, verify that view and record the natural empty-result check as not exercised; do not create/delete rides or mock a production response. If pagination remains, do not claim the entire candidate set is empty from only the first page. A 401, 403, 500, or malformed response means the task is not verified.
+
+After that authenticated request, run in your **VPS SSH shell**:
+
+```bash
+sudo /bin/bash -s <<'LOGS'
+set -euo pipefail
+set +x
+SINCE=$(cat /var/backups/motorsignal-website/firebase-admin-20261004/restart-since.txt)
+systemctl is-active --quiet motorsignal-website.service
+journalctl -u motorsignal-website.service --since "$SINCE" --no-pager | /usr/bin/python3 -c 'import re,sys; s=sys.stdin.read(); assert s.strip() and "-- No entries --" not in s; c=len(re.findall(r"Could not load the default credentials|Failed to parse FIREBASE_SERVICE_ACCOUNT|\[FIREBASE_ADMIN\].*Failed|\[GROUP_RIDE_DISCOVERY\] Failed",s)); print("Post-restart credential/discovery failures:",c); sys.exit(1 if c else 0)'
+LOGS
+```
+
+This prints a count, not arbitrary log lines. Zero failures is meaningful only alongside the successful authenticated request. Review any failure privately with administrator access; do not paste raw secret-bearing logs into chat. No Firestore rules or entitlements should be weakened.
+
+## 4. Exact configuration rollback (website only)
+
+Run in your **VPS SSH shell using secure interactive sudo access**. This also handles partial application. It refuses to overwrite subsequent edits, moves only the two files created above into the protected backup, leaves the shared credential untouched, and restarts only the website. The new empty drop-in directory is intentionally retained; it has no active configuration. No code rollback/rebuild is needed.
+
+```bash
+sudo /bin/bash -s <<'ROLLBACK'
+set -euo pipefail
+set +x
+umask 077
+BACKUP=/var/backups/motorsignal-website/firebase-admin-20261004
+UNIT=/etc/systemd/system/motorsignal-website.service
+ENV_FILE=/etc/motorsignal/website.env
+DROP_FILE=/etc/systemd/system/motorsignal-website.service.d/10-firebase-admin.conf
+test -d "$BACKUP" && test ! -L "$BACKUP"
+test "$(stat -c '%u %a' "$BACKUP")" = '0 700'
+test ! -L "$UNIT"
+test ! -L /etc/motorsignal && test ! -L /etc/systemd/system/motorsignal-website.service.d
+cmp --silent "$UNIT" "$BACKUP/unit.before"
+for NAME in website.env 10-firebase-admin.conf; do
+ if test "$NAME" = website.env; then TARGET="$ENV_FILE"; else TARGET="$DROP_FILE"; fi
+ test ! -L "$TARGET"
+ if test -e "$TARGET"; then
+ cmp --silent "$TARGET" "$BACKUP/$NAME.applied"
+ test ! -e "$BACKUP/$NAME.removed" && test ! -L "$BACKUP/$NAME.removed"
+ fi
+done
+if test -e "$DROP_FILE"; then mv -- "$DROP_FILE" "$BACKUP/10-firebase-admin.conf.removed"; fi
+if test -e "$ENV_FILE"; then mv -- "$ENV_FILE" "$BACKUP/website.env.removed"; fi
+systemctl daemon-reload
+systemctl restart motorsignal-website.service
+systemctl is-active --quiet motorsignal-website.service
+printf '%s\n' 'Prior website configuration restored; shared credential and deployed code unchanged.'
+ROLLBACK
+```
+
+Repeat the revision/unauthenticated HTTP checks after rollback. The prior missing-ADC failure is expected to return; rollback restores configuration, not a working credential setup. Retain the protected backup and approval record. No password, key, token, or credential content is needed in chat.
diff --git a/docs/website-firebase-admin-blocker-2026-10-04.md b/docs/website-firebase-admin-blocker-2026-10-04.md
new file mode 100644
index 0000000..f4c5520
--- /dev/null
+++ b/docs/website-firebase-admin-blocker-2026-10-04.md
@@ -0,0 +1,92 @@
+# Website Firebase Admin runtime configuration investigation
+
+Date: 2026-10-04. Latest result: **USER-CONFIRMED production authenticated community empty state after administrator configuration.** Populated production discovery remains untested. This documentation update did not rebuild, redeploy, restart, rerun configuration, or independently recheck production.
+
+## User-confirmed production follow-up
+
+Evidence source: the administrator's direct report in this conversation on 2026-10-04, not a new agent browser/SSH verification. The administrator reports applying the configuration from `docs/website-firebase-admin-administrator-commands-2026-10-04.md`.
+
+| Check | Latest result and provenance |
+|---|---|
+| Credential probes | PASS — user-confirmed |
+| Administrator configuration and website restart | Completed — user-confirmed; no agent configuration rerun |
+| Deployed revision | Unchanged at `8b3a7aa` — user-confirmed; existing full revision is `8b3a7aa29290b58a9456a564785e9c73cd8e3093` |
+| Guest discovery | 401 — user-confirmed |
+| Post-restart credential/discovery failures | Zero — user-confirmed |
+| Signed-in Group Ride Hub community empty state | PASS — user-confirmed: **No Community Rides Scheduled** appears |
+| Populated production discovery | UNTESTED |
+| Raw authenticated HTTP status/JSON | Not supplied or independently captured in this follow-up; no claim of inspected HTTP 200 or response payload |
+
+This user-confirmed result supersedes the historical missing-credential blocker for the production empty-state workflow. It does not establish populated discovery, ordering, pagination, blocking/exclusions, or other production behaviors. Exact live identity, IAM grants, file contents, and rollback-backup presence were not independently inspected after application. The administrator runbook retains the configuration rollback procedure; it was not exercised here. Existing isolated/regression evidence remains separate from this production confirmation.
+
+## Historical investigation scope
+
+Initial result before administrator application: **BLOCKED on privileged execution and identity approval/permission verification.** During that agent investigation, no production configuration was changed and no rebuild, restart, key generation, credential copy/export, commit, push, fixture, IAM change, or rules change occurred. The sections below preserve the original pre-configuration observations and plan; they are not current post-configuration assertions.
+
+## Initial confirmed cause and provisioning
+
+- Deployed revision remains `8b3a7aa29290b58a9456a564785e9c73cd8e3093` (`GET /api/version`: 200).
+- The deployed server loader supports explicit `FIREBASE_SERVICE_ACCOUNT` or Application Default Credentials, including `GOOGLE_APPLICATION_CREDENTIALS`. Its production project fallback is `motoapp-d22e0`.
+- `motorsignal-website.service` runs as `motorsignal`. It has no EnvironmentFile, no drop-in, no credential environment variable, and no website `.env*` file. Its existing environment preserves port 3001 and production settings.
+- `/etc/motorsignal/firebase-admin.json` exists, mode 0600, owner/group `motorsignal`. The service user can read it by ownership; the SSH inspector cannot.
+- `/etc/motorsignal/routing.env`, root-owned 0640, references that credential and project `motoapp-d22e0`. Active `motorsignal-routing.service` loads this file. This establishes **routing provisioning**, not website reuse approval or adequate Firestore permissions.
+- Backend `production.env` specifies the same Firebase project but no Admin credential reference. It contains unrelated database/mail/session secrets and was not copied.
+- The existing SSH key cannot authenticate as root. `sudo -n true` and a read-only check as `motorsignal` both return `sudo: a password is required`. Passwordless commands include website restart/status, but not credential inspection, systemd configuration edits, or daemon-reload. An opaque legacy staging helper was not invoked.
+
+The security review therefore kept the routing identity untouched. The exact service-account email, credential validity, approved website purpose, IAM grants, and Firestore effective read permissions remain unknown. A filename and matching project variable do not prove them.
+
+## Initial production checks (before administrator application)
+
+| Check | Result |
+|---|---|
+| Existing authorized authenticated session | Restored; no sign-in or token extraction |
+| Community discovery | Reproduced error UI and `server_error` browser warning; website status logs at 06:59:45 UTC show ADC loading failure |
+| Successful structured authenticated response | BLOCKED; no successful response observed |
+| Legitimate empty result display | NOT VERIFIED; failed request is not an empty result |
+| Unauthenticated `GET /api/group-rides` | PASS: 401, `{"error":"unauthorized"}` |
+| Credential-loading failures cleared | FAIL: still present; no fix applied |
+| Revision and website availability | PASS: version 200, exact deployed revision; website service active |
+
+Commands: read-only SSH `systemctl show`, metadata listings, sanitized `inspect-runtime.py`, allowed `sudo -n /usr/bin/systemctl status motorsignal-website`; browser navigation and community tab; local `node output/deployment/20261004-firebase-admin/smoke.mjs`. No broad suites were rerun.
+
+## Original privileged provisioning plan (administrator now reports applied)
+
+An administrator must first inspect **only** credential type, `project_id`, and `client_email` under the service user, without outputting the remaining JSON. For example, this read-only command does not print keys or tokens:
+
+```sh
+sudo -u motorsignal /usr/bin/python3 -c 'import json; d=json.load(open("/etc/motorsignal/firebase-admin.json")); print(json.dumps({k:d.get(k) for k in ("type","project_id","client_email")}))'
+```
+
+The identity owner/project administrator must confirm its intended website use and effective Firestore read permissions for the production database: community `group_rides`, participant documents, and `users/*/blocks` reads. The current website Admin routes are read-only; no Firebase Auth administration grant is requested. The necessary document get/list permissions are `datastore.entities.get` and `datastore.entities.list`; the predefined `roles/datastore.viewer` includes these, but grants access to all Firestore resources and is **not** an instruction to broaden an existing identity. Verify current grants/conditions and least privilege with the project owner. [Google Firestore IAM documentation](https://docs.cloud.google.com/firestore/native/docs/security/iam)
+
+If this routing identity is approved and adequate, **reference the existing file in place**, without copying or changing its permissions. Otherwise provision an already-approved website identity through the same secure `/etc/motorsignal` runtime mechanism, using an existing credential or approved keyless ADC configuration. Do not generate a new service-account key as a workaround. The exact account email cannot be specified until privileged metadata inspection succeeds.
+
+After that validation, use a website-specific runtime environment file (proposed `/etc/motorsignal/website.env`, root-owned 0640, group `motorsignal`), containing only non-secret settings:
+
+```text
+FIREBASE_PROJECT_ID=motoapp-d22e0
+GOOGLE_APPLICATION_CREDENTIALS=/etc/motorsignal/firebase-admin.json
+```
+
+The credential path above is conditional on approval; use the approved identity's path otherwise. Load it from proposed `/etc/systemd/system/motorsignal-website.service.d/10-firebase-admin.conf`:
+
+```ini
+[Service]
+EnvironmentFile=/etc/motorsignal/website.env
+```
+
+Do not point the website at `routing.env` or `production.env`: they include unrelated settings/secrets and routing's port. Do not add credential JSON to Next.js configuration, `.next`, public assets, or any `NEXT_PUBLIC_*` variable. Preserve existing website port/backend settings; no emulator variables are permitted.
+
+Back up the prior website unit, drop-ins, and any existing website-specific environment file into a root-only backup directory **before** edits. Record which files did not exist. Then privileged `systemctl daemon-reload`, followed by restarting **only** `motorsignal-website`, is required. No code rebuild is necessary. These steps were documented, not executed.
+
+Verify after restart with the existing authorized session: actual authenticated response status/JSON, eligible rides or genuine empty UI, unauthenticated 401, post-restart logs free of credential failures, unchanged revision. Any index or IAM failure is a separate reported blocker, not grounds for rules changes or fixtures.
+
+## Historical rollback and preservation notes
+
+No rollback is currently necessary because configuration is unchanged. No new production configuration backup was created.
+
+For a future application of the proposed change, restore the prior website unit/drop-ins/environment files from the protected backup, or move newly added website-only files back into that backup when their previous state was absent. Run privileged daemon-reload and restart only the website. **Do not remove or alter the shared routing credential.** Keep deployed code revision unchanged. The earlier code rollback archive is unrelated to this configuration rollback.
+
+Only new local evidence files and this report were added. Hash verification confirms all 117 files from the existing pre-deployment dirty-file baseline remain unchanged. Existing main-workspace changes remain unstaged; deployment worktree remains clean. The temporary authenticated verification tab was closed without signing out. A non-secret read-only inspector was uploaded to `/home/motorsignal-admin/inspect-website-credentials-20261004.py` and retained for the administrator; it does not contain credentials.
+
+Evidence: `output/deployment/20261004-firebase-admin/runtime-inventory.json`, `authenticated-check.json`, `http-smoke.json`, `git-final.json`, and the corresponding non-secret inspection/smoke/preservation scripts.
diff --git a/docs/website-gps-badge-deployment-2026-10-04.md b/docs/website-gps-badge-deployment-2026-10-04.md
new file mode 100644
index 0000000..5f5d5d6
--- /dev/null
+++ b/docs/website-gps-badge-deployment-2026-10-04.md
@@ -0,0 +1,54 @@
+# GPS badge overlap deployment — 2026-10-04
+
+## Result
+
+Deployed website revision `c7b00a4bd315c9185a01f4f28630f58c9f130cb2`. The replay GPS/source metadata now occupies a normal-flow row below the Leaflet viewport. The telemetry source, map rendering, and controls were not changed.
+
+Changed and committed files:
+
+- `website/components/web-telemetry-replay-modal.tsx`
+- `website/tests/replay-recorded-data.test.tsx`
+
+Commit contains only these two files. The regression asserts the badge is outside the map, visible on the map tab, hidden on the corner tab, and preserves the telemetry source.
+
+## Verification
+
+- Focused replay regression: 5/5 passed in both worktrees.
+- TypeScript check and `git diff --check`: passed.
+- Fresh production build: passed with production project `motoapp-d22e0`, no emulator configuration or demo build reuse. Artifact excludes the regression test and local QA harness.
+- Production signed-in replay: actual existing R2 CSV ride loaded; badge showed `GPS: 34.04784, -85.23513`, source `r2-csv`, and OpenStreetMap label. At desktop width, Leaflet zoom tiles moved from z10 to z11 on `+` and returned to z10 on `−`. DOM geometry showed no overlap with Leaflet controls, no clipping, 11px text, and badge outside the map.
+- At production viewport 390×844, mouse `+` moved tiles z8→z9 and `−` returned z9→z8. Badge remained readable with no control overlap, clipping, or horizontal dialog overflow.
+- At 320×740, DOM checks showed the badge wrapped to 55px height, retained all text, and had no overlap/clipping/overflow. The production zoom level sampled asynchronously during viewport resize and animation, so this width's production zoom transition is not counted as verified.
+- Separate sanitized local Playwright browser with actual Leaflet and touch input: desktop1280×900, mobile320×740, mobile390×844, landscape844×390 all passed `tap +`/`tap −` (z18→19→18), badge/control separation, readable text, and responsive fit. Tile imagery was stubbed locally; no production session/data was used. This is browser emulation, not physical-device testing.
+- Public smoke: `/api/version` 200 with the deployed revision; `/account` 200; guest telemetry request 401.
+
+The authenticated production browser session expired during the final narrow-width checks. A final screenshot showed the replay and badge but the page had returned to its signed-out header. Authenticated checks therefore apply to the observed signed-in period above; no further authenticated check was possible after expiry. No data was changed.
+
+## Deployment and rollback
+
+Fresh artifact SHA-256: `c4420a7cd782fd117168acaec93a234c2f74cf3d0ee9e2c3c065bb12e78eec1e`.
+
+Rollback archive on VPS: `/home/motorsignal-admin/rollback-website-b25b6e5-to-c7b00a4-20261004.tar.gz` (mode 0600). The previous runtime `.next` is also retained at `/opt/motorsignal/releases/20260920-master-7efd29a/website/.next.rollback-c7b00a4bd315`.
+
+As the VPS administrator, run:
+
+```sh
+python3 /home/motorsignal-admin/deploy-badge-c7b00a4bd315.py rollback
+```
+
+This restores the preceding working replay runtime and scoped source, then restarts only the website.
+
+## Evidence
+
+Evidence directory: `C:/Users/Bill/Desktop/flutter-app/output/playwright/badge-20261004/`.
+
+- `local-touch-results.json`, `local-mobile-final.png`, `local-desktop-final.png`: isolated responsive/touch results.
+- `focused-validation.json`, `production-config.json`, `production-build-result.json`, `production-build.log`: regression, configuration, and fresh build.
+- `motorsignal-website-c7b00a4bd315.tar.gz`, `stage-result.json`, `deployment-result.json`: packaged artifact and deployment.
+- `public-smoke.json`: served revision and public/guest checks.
+
+The production screenshot was inspected in the Codex browser and was not saved into this evidence directory.
+
+## Git state and scope
+
+Deployment worktree HEAD is `c7b00a4bd315c9185a01f4f28630f58c9f130cb2`; it was clean after the scoped commit. The main checkout remains on `release/v1.0.2-build102-community-feed` at `bd278cdfd3619242a3056c6a2738abb0aff70feb`, preserving all pre-existing dirty/untracked work. No push, mobile release, backend/rules change, production fixture, or user ride mutation occurred.
diff --git a/docs/website-replay-gps-fix-2026-10-04.md b/docs/website-replay-gps-fix-2026-10-04.md
new file mode 100644
index 0000000..43d71a5
--- /dev/null
+++ b/docs/website-replay-gps-fix-2026-10-04.md
@@ -0,0 +1,136 @@
+# Website recorded-GPS replay fix — 2026-10-04
+
+## Outcome and scope
+
+Deployed commit `b25b6e57a77f35bf6c81d3bfedd605c6f4d8f708` replaces `8b3a7aa29290b58a9456a564785e9c73cd8e3093`. Public `/api/version` confirms the full revision. One existing production ride was replayed through an existing authorized signed-in browser, without production fixtures or exporting its authentication token.
+
+Ride: `ride_d82e047e97c6f7668ad785dd6d8d6467`, September 3, 2026 at 08:52 PM, 2001 Yamaha YZF-R6, 15.66 miles. This existing affected ride was independently reproduced; the user did not explicitly confirm that this ID was their originally open ride.
+
+## Demonstrated root cause
+
+The old browser telemetry request omitted its Firebase bearer token. The old endpoint looked for local GPX/demo files, did not enforce ride ownership, and discarded a successfully retrieved R2 response instead of decoding it. This ride's endpoint returned HTTP 404. The modal ignored that failure and generated 2,400 invented points around `35.18520, -83.92140` from summary statistics. It displayed that invented track as replay telemetry.
+
+Consequently this was not merely a map-tile or latitude/longitude inversion problem: the recorded coordinates never reached the map. An independent screen-space canvas fallback could additionally detach the overlay from map geography. Legacy parsers also lost mixed-order GPX points and recording segment boundaries; ride changes could retain previously fetched data.
+
+## Corrected data path
+
+1. Browser selects the stable ride ID and sends its normal Firebase ID token with a private, uncached telemetry request.
+2. Website Firebase Admin verifies the token, including revocation, then reads `users/{uid}/rides/{rideId}`. The owner, stored ride identity, and bundle object key must match. Server SDK access is not treated as a substitute for those checks.
+3. For bundle-backed rides, the existing authenticated R2 download gateway receives the same token. Its ride-ID header and SHA-256 are checked before ZIP decoding; the optional stored hash is checked when present. Download and selected-entry extraction limits are enforced.
+4. `telemetry.csv` is preferred because it retains GPS-loss rows omitted by legacy GPX. GPX remains a fallback. Inline points/CSV/GPX follow the recorded-data parsing path without R2; both paths reject missing recorded GPS instead of inventing geometry.
+5. Valid geographic latitude/longitude and original timestamps are retained and sorted chronologically. Explicit segments, invalid/missing GPS rows, and time gaps over five seconds break continuity. No road snapping, route reconstruction, coordinate translation, or sensor normalization is performed.
+6. Leaflet receives actual latitude/longitude pairs for bounds, rider markers, and segmented colored polylines. Independent screen-space canvas rendering is removed. Loading/error/ride-switch barriers clear obsolete geometry; closing removes map instances and resize listeners.
+
+The verified production ride reports `r2-csv`, 19,759 recorded GPS points, and a recorded duration of 16:28. Its cloud summary duration is different; replay now uses recorded timestamps rather than manufacturing a summary-length trace.
+
+## Production browser coordinate comparisons
+
+An independent Web Mercator calculation uses an observed OSM tile's `z/x/y` and screen rectangle. It is compared with both the rider icon center and the nearest actual Leaflet SVG geographic trace vertex. GPS HUD precision is five decimal places. All sampled errors are below one pixel; no canvas is present.
+
+| Recorded sample | Index | Latitude, longitude | Marker and trace error |
+| --- | ---: | --- | ---: |
+| Start | 0 | 34.04784, -85.23513 | 0.4035 px |
+| Turn 4 | 2,731 | 34.08204, -85.23273 | 0.4544 px |
+| Turn 11 | 15,324 | 34.22821, -85.16143 | 0.5629 px |
+| Turn 17 | 19,390 | 34.24516, -85.15971 | 0.5570 px |
+| Finish | 19,758 | 34.24507, -85.15964 | 0.4663 px |
+
+Pan, actual keyboard zoom from level 10 to 11, resize to 1280×900, and viewport reset retain alignment. Close leaves zero dialogs and map containers. Reopen first shows loading with no prior geometry, then returns to index zero at the recorded start with 0.4035-pixel alignment error.
+
+## Focused validation
+
+| Check | Result and evidence category |
+| --- | --- |
+| Parser/authentication/R2/replay regressions | 19 tests passed across four focused files; automated, not a broad suite |
+| Sanitized regression data | GPX attribute order/segments, canonical CSV missing GPS/zero coordinates/invalid ranges and times, ZIP retrieval/integrity, exact Leaflet geometry, loading/error/late response/cleanup |
+| TypeScript | `tsc --noEmit --incremental false` passed |
+| Fresh production build | Next 16.3.4 production build passed; 37 generated pages; exit 0 |
+| Candidate staging | Version/account HTTP 200, 10 JavaScript assets HTTP 200, guest telemetry HTTP 401; candidate process stopped |
+| Public production smoke | Full deployed revision HTTP 200; account HTTP 200; structured guest telemetry HTTP 401, private/no-store |
+| Authenticated production replay | Existing R2-backed ride loaded recorded data and geographic overlay; coordinate comparison, pan, keyboard zoom, resize, close/reopen passed |
+
+Commands, from the deployment worktree's `website` directory (using the configured Node 24 binary):
+
+```text
+node node_modules/vitest/vitest.mjs run tests/telemetry-track-regression.test.ts tests/ride-telemetry-api.test.ts tests/replay-recorded-data.test.tsx tests/telemetry-replay-export.test.tsx --maxWorkers=1 --reporter=dot
+node node_modules/typescript/bin/tsc --noEmit --incremental false
+node C:/Users/Bill/Desktop/flutter-app/output/replay-20261004/build-release.mjs
+node C:/Users/Bill/Desktop/flutter-app/output/replay-20261004/package-release.mjs
+```
+
+VPS commands used after artifact verification:
+
+```text
+python3 /home/motorsignal-admin/deploy-replay-b25b6e57a77f.py stage
+python3 /home/motorsignal-admin/deploy-replay-b25b6e57a77f.py deploy
+```
+
+Final secret-free preservation/public smoke:
+
+```text
+node C:/Users/Bill/Desktop/flutter-app/output/replay-20261004/capture-final.mjs
+```
+
+## Production configuration and changed files
+
+Fresh build and existing secure website runtime both target Firebase project `motoapp-d22e0`. Backend remains `https://api.motorsignal.app`. Build loaded no environment files or credential material; emulator flags/hosts were absent. The approved existing runtime EnvironmentFile and ADC file were left unchanged. No demo-configured `.next` was reused. `fflate` was pinned to `0.8.3`; no other dependency versions changed. It is bundled into the server artifact, so no VPS dependency installation was required.
+
+Committed files (website-relative):
+
+```text
+app/api/account/rides/[rideId]/telemetry/route.ts
+components/web-telemetry-replay-modal.tsx
+lib/telemetry-export.ts
+lib/telemetry-track.ts
+package.json
+package-lock.json
+tests/replay-recorded-data.test.tsx
+tests/ride-telemetry-api.test.ts
+tests/telemetry-replay-export.test.tsx
+tests/telemetry-track-regression.test.ts
+```
+
+The production package contains the fresh runtime and product sources, not regression tests, QA fixtures, emulator configuration, evidence, environment files, build caches, or unrelated dirty work. Artifact SHA-256: `452117b11f9ec4e93835dc21e15f32802665f8b18816cc69a54ad291aaef8f29`.
+
+Only the website restarted. Backend and nginx remained active with unchanged PIDs. The release symlink, Firestore rules, mobile, billing, and unrelated backend services were not modified. No push occurred.
+
+## Rollback and cleanup
+
+Rollback archive, mode 0600:
+
+```text
+/home/motorsignal-admin/rollback-website-8b3a7aa-to-b25b6e5-20261004.tar.gz
+```
+
+SHA-256: `7b0cdeeb6743e8bcb270233c308bfc7b5e5616790f26d74d173c873dd62675e5`.
+
+Previous runtime is also retained at:
+
+```text
+/opt/motorsignal/releases/20260920-master-7efd29a/website/.next.rollback-b25b6e57a77f
+```
+
+With secure administrator SSH access to the VPS, exact rollback command:
+
+```text
+python3 /home/motorsignal-admin/deploy-replay-b25b6e57a77f.py rollback
+```
+
+The helper restores the prior website runtime and scoped sources, restarts only the website, and checks revision `8b3a7aa` and website health. Backup contents/hashes were verified; actual rollback was not executed. Candidate process is stopped, temporary viewport override is reset, and old local build/evidence and VPS rollback files are retained recoverably. No production data was created or changed by verification.
+
+Final Git: main remains `release/v1.0.2-build102-community-feed` at `bd278cdfd3619242a3056c6a2738abb0aff70feb`, dirty with existing work plus scoped replay/evidence files, zero staged files. All 150 baseline paths outside the authorized replay file set match their initial hashes; none are missing. Deployment worktree remains `deploy/website-20261004` at `b25b6e57a77f35bf6c81d3bfedd605c6f4d8f708`, clean, zero staged files. Full untracked-inclusive status is recorded in the evidence folder; the preserved previous build accounts for many evidence entries.
+
+## Evidence and limits
+
+Local evidence: `C:/Users/Bill/Desktop/flutter-app/output/replay-20261004/`.
+
+- `baseline.json`, `final-git-status.json`: branches, full HEADs, untracked-inclusive status, and preservation audit.
+- `browser-coordinate-comparisons.json`, `browser-alignment-check.js`: production DOM coordinate results and independent projection method.
+- `production-config.json`, `production-build.log`, `production-build-result.json`, `artifact-scan.json`, `package.json`: fresh build/configuration/package evidence.
+- `stage-result.json`, `deployment-result.json`, `public-smoke.json`: staging, cutover, rollback, service isolation, and public revision/health.
+
+Limits: this verifies one actual R2-backed production ride, not all historical rides or all basemaps. The raw production ZIP was integrity-checked by the server but was not independently retained and cross-exported for a separate source audit; pixel comparisons use the decoded recorded GPS displayed in the HUD. Inline and disconnected-segment cases have focused automated coverage, not newly created production examples. The five-second gap rule is conservative and cannot recover disconnection information absent from historical exports. A CSV browser download was attempted but no new artifact was independently verified; an older local CSV was not used as evidence. Post-deployment server journal contents were not separately audited.
+
+The GPS information badge overlaps the mouse “+” control: clicking it did not change zoom. This separate UI defect remains unresolved; actual keyboard zoom and geographic alignment were verified. The shell staging/deployment JSON's authenticated “not run” field applies only to its credential-free HTTP probes; the separate authenticated browser replay above did run successfully. No authenticated-production claim is based on local fixtures.
+
+The verification/shipping skills influenced the focused red/green gates, fresh production configuration checks, strict package scope, and prepared rollback. No unchanged broad suite was rerun.
diff --git a/firebase.json b/firebase.json
index 6a00fb1..5c3b2e2 100644
--- a/firebase.json
+++ b/firebase.json
@@ -1,8 +1,12 @@
-{
+{
"firestore": {
"database": "(default)",
"location": "nam5",
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
+ },
+ "emulators": {
+ "auth": { "host": "0.0.0.0", "port": 9099 },
+ "firestore": { "host": "0.0.0.0", "port": 8088 }
}
}
diff --git a/firebase/emulator-tests/club-routes-chat.rules.test.js b/firebase/emulator-tests/club-routes-chat.rules.test.js
new file mode 100644
index 0000000..67d5bfa
--- /dev/null
+++ b/firebase/emulator-tests/club-routes-chat.rules.test.js
@@ -0,0 +1,446 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
+import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing';
+import { collection, deleteDoc, doc, getDoc, getDocs, limit, orderBy, query, setDoc, Timestamp, updateDoc } from 'firebase/firestore';
+import fs from 'node:fs';
+import path from 'node:path';
+
+let env;
+const clubId = 'ClubRoutesChatTest';
+const publicClubId = 'ClubPublicRoutesChatTest';
+
+const profile = (cid, uid, role = 'member', status = 'active') => ({
+ uid,
+ clubId: cid,
+ role,
+ status,
+ displayRank: role === 'owner' ? 'President' : role === 'admin' ? 'Vice President' : 'Road Captain',
+});
+
+const sampleRoute = (cid = clubId, routeId = 'rt-1', curatorUid = 'member') => ({
+ id: routeId,
+ clubId: cid,
+ title: 'Blood Mountain Loop',
+ description: 'Fast elevation transitions and pristine sweepers.',
+ distanceMiles: 48.6,
+ estimatedMinutes: 65,
+ elevationGainFt: 3120,
+ curvatureRating: 'High Twisties',
+ surfaceType: 'Paved Asphalt',
+ startLocation: 'Dahlonega Outpost',
+ endLocation: 'Vogel State Park',
+ gpxUrl: '/fixtures/blood-mountain.gpx',
+ curatorUid,
+ curatorName: 'Rider',
+ createdAt: Timestamp.now(),
+});
+
+const sampleMessage = (cid = clubId, channelId = 'general', msgId = 'msg-1', authorUid = 'member', role = 'member') => ({
+ id: msgId,
+ clubId: cid,
+ channelId,
+ content: 'Kickstands up at 09:00 AM sharp at the north staging point.',
+ authorUid,
+ authorDisplayName: authorUid,
+ authorRole: role,
+ authorHandle: `@${authorUid}`,
+ authorDisplayRank: role === 'owner' ? 'President' : role === 'admin' ? 'Vice President' : 'Road Captain',
+ createdAt: Timestamp.now(),
+});
+
+const dbFor = (uid) => env.authenticatedContext(uid).firestore();
+
+beforeAll(async () => {
+ env = await initializeTestEnvironment({
+ projectId: 'demo-motorsignal-club-routes-chat',
+ firestore: {
+ rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8'),
+ },
+ });
+}, 30000);
+
+afterAll(async () => {
+ await env?.cleanup();
+});
+
+beforeEach(async () => {
+ await env.clearFirestore();
+ await env.withSecurityRulesDisabled(async (context) => {
+ const db = context.firestore();
+
+ // 1. Seed private club
+ await setDoc(doc(db, 'clubs', clubId), {
+ id: clubId,
+ ownerUid: 'owner',
+ memberCount: 3,
+ archivedAt: null,
+ visibility: 'private',
+ });
+
+ // Seed members for private club
+ await setDoc(doc(db, 'clubs', clubId, 'members', 'owner'), profile(clubId, 'owner', 'owner', 'active'));
+ await setDoc(doc(db, 'clubs', clubId, 'members', 'admin'), profile(clubId, 'admin', 'admin', 'active'));
+ await setDoc(doc(db, 'clubs', clubId, 'members', 'member'), profile(clubId, 'member', 'member', 'active'));
+ await setDoc(doc(db, 'clubs', clubId, 'members', 'bannedMember'), profile(clubId, 'bannedMember', 'member', 'active'));
+ await setDoc(doc(db, 'clubs', clubId, 'bans', 'bannedMember'), { uid: 'bannedMember' });
+
+ // Seed initial route in private club
+ await setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-initial'), sampleRoute(clubId, 'rt-initial', 'owner'));
+
+ // Seed initial messages in private club
+ await setDoc(
+ doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'),
+ sampleMessage(clubId, 'general', 'msg-gen-1', 'member', 'member')
+ );
+ await setDoc(
+ doc(db, 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'),
+ sampleMessage(clubId, 'officers', 'msg-off-1', 'admin', 'admin')
+ );
+
+ // 2. Seed public club
+ await setDoc(doc(db, 'clubs', publicClubId), {
+ id: publicClubId,
+ ownerUid: 'owner',
+ memberCount: 3,
+ archivedAt: null,
+ visibility: 'public',
+ });
+ await setDoc(doc(db, 'clubs', publicClubId, 'members', 'owner'), profile(publicClubId, 'owner', 'owner', 'active'));
+ await setDoc(doc(db, 'clubs', publicClubId, 'members', 'admin'), profile(publicClubId, 'admin', 'admin', 'active'));
+ await setDoc(doc(db, 'clubs', publicClubId, 'members', 'member'), profile(publicClubId, 'member', 'member', 'active'));
+ await setDoc(doc(db, 'clubs', publicClubId, 'routes', 'rt-pub-1'), sampleRoute(publicClubId, 'rt-pub-1', 'owner'));
+ });
+});
+
+// ============================================================================
+// SUITE 1: Curated Club Routes
+// ============================================================================
+describe('Curated Club Routes Security Rules', () => {
+ test('Active member can create a route in their club', async () => {
+ const db = dbFor('member');
+ await assertSucceeds(
+ setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-member-1'), sampleRoute(clubId, 'rt-member-1', 'member'))
+ );
+ });
+
+ test('Club owner can create a route in their club', async () => {
+ const db = dbFor('owner');
+ await assertSucceeds(
+ setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-owner-1'), sampleRoute(clubId, 'rt-owner-1', 'owner'))
+ );
+ });
+
+ test('Outsider CANNOT create a route in the club', async () => {
+ const db = dbFor('outsider');
+ await assertFails(
+ setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-outsider-1'), sampleRoute(clubId, 'rt-outsider-1', 'outsider'))
+ );
+ });
+
+ test('Banned member CANNOT create a route in the club', async () => {
+ const db = dbFor('bannedMember');
+ await assertFails(
+ setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-banned-1'), sampleRoute(clubId, 'rt-banned-1', 'bannedMember'))
+ );
+ });
+
+ test('Signed-out or anonymous caller CANNOT create a route', async () => {
+ const anonDb = env.authenticatedContext('anon', { firebase: { sign_in_provider: 'anonymous' } }).firestore();
+ const unauthDb = env.unauthenticatedContext().firestore();
+ await assertFails(setDoc(doc(anonDb, 'clubs', clubId, 'routes', 'rt-anon'), sampleRoute(clubId, 'rt-anon', 'anon')));
+ await assertFails(setDoc(doc(unauthDb, 'clubs', clubId, 'routes', 'rt-unauth'), sampleRoute(clubId, 'rt-unauth', 'unauth')));
+ });
+
+ test('Curator UID forgery is strictly rejected', async () => {
+ const db = dbFor('member');
+ await assertFails(
+ setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-forged'), sampleRoute(clubId, 'rt-forged', 'owner'))
+ );
+ });
+
+ test('Archived club rejects route creation, even from club owner', async () => {
+ await env.withSecurityRulesDisabled((ctx) =>
+ updateDoc(doc(ctx.firestore(), 'clubs', clubId), { archivedAt: Timestamp.now() })
+ );
+ const db = dbFor('owner');
+ await assertFails(
+ setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-archived'), sampleRoute(clubId, 'rt-archived', 'owner'))
+ );
+ });
+
+ test('Malformed or schema-polluted route payloads are rejected', async () => {
+ const db = dbFor('member');
+ const valid = sampleRoute(clubId, 'rt-bad', 'member');
+
+ // Missing required field
+ const { title: _, ...missingTitle } = valid;
+ await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), missingTitle));
+
+ // Negative distance
+ await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), { ...valid, distanceMiles: -5 }));
+
+ // Empty startLocation
+ await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), { ...valid, startLocation: '' }));
+
+ // Extra forbidden field
+ await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), { ...valid, extraField: 'exploit' }));
+ });
+
+ test('Route read in public club is allowed for any registered user (including outsider)', async () => {
+ const db = dbFor('outsider');
+ await assertSucceeds(getDoc(doc(db, 'clubs', publicClubId, 'routes', 'rt-pub-1')));
+ await assertSucceeds(getDocs(query(collection(db, 'clubs', publicClubId, 'routes'), limit(10))));
+ });
+
+ test('Route read in private club is allowed for active members and owner', async () => {
+ await assertSucceeds(getDoc(doc(dbFor('member'), 'clubs', clubId, 'routes', 'rt-initial')));
+ await assertSucceeds(getDoc(doc(dbFor('admin'), 'clubs', clubId, 'routes', 'rt-initial')));
+ await assertSucceeds(getDoc(doc(dbFor('owner'), 'clubs', clubId, 'routes', 'rt-initial')));
+ });
+
+ test('Route read in private club is DENIED for outsider', async () => {
+ const db = dbFor('outsider');
+ await assertFails(getDoc(doc(db, 'clubs', clubId, 'routes', 'rt-initial')));
+ await assertFails(getDocs(collection(db, 'clubs', clubId, 'routes')));
+ });
+
+ test('Banned member is DENIED reading routes in any club', async () => {
+ const db = dbFor('bannedMember');
+ await assertFails(getDoc(doc(db, 'clubs', clubId, 'routes', 'rt-initial')));
+ });
+
+ test('Club admin can delete a route; regular member is DENIED deleting a route', async () => {
+ // Regular member attempts delete -> DENIED
+ await assertFails(deleteDoc(doc(dbFor('member'), 'clubs', clubId, 'routes', 'rt-initial')));
+
+ // Outsider attempts delete -> DENIED
+ await assertFails(deleteDoc(doc(dbFor('outsider'), 'clubs', clubId, 'routes', 'rt-initial')));
+
+ // Club admin deletes route -> ALLOWED
+ await assertSucceeds(deleteDoc(doc(dbFor('admin'), 'clubs', clubId, 'routes', 'rt-initial')));
+ });
+
+ test('Club owner can delete a route', async () => {
+ await assertSucceeds(deleteDoc(doc(dbFor('owner'), 'clubs', clubId, 'routes', 'rt-initial')));
+ });
+
+ test('Route updates are DENIED for everyone (immutable routes)', async () => {
+ await assertFails(updateDoc(doc(dbFor('owner'), 'clubs', clubId, 'routes', 'rt-initial'), { title: 'New Name' }));
+ await assertFails(updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'routes', 'rt-initial'), { title: 'New Name' }));
+ await assertFails(updateDoc(doc(dbFor('member'), 'clubs', clubId, 'routes', 'rt-initial'), { title: 'New Name' }));
+ });
+});
+
+// ============================================================================
+// SUITE 2: Squad Live Chat Channels & Messages
+// ============================================================================
+describe('Squad Live Chat Channels & Messages Security Rules', () => {
+ test('Active member can post to general channel; outsider is DENIED', async () => {
+ // Active member post -> ALLOWED
+ await assertSucceeds(
+ setDoc(
+ doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-2'),
+ sampleMessage(clubId, 'general', 'msg-gen-2', 'member', 'member')
+ )
+ );
+
+ // Outsider post -> DENIED
+ await assertFails(
+ setDoc(
+ doc(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-3'),
+ sampleMessage(clubId, 'general', 'msg-gen-3', 'outsider', 'member')
+ )
+ );
+ });
+
+ test('Active member can post to ride-intel channel', async () => {
+ await assertSucceeds(
+ setDoc(
+ doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'ride-intel', 'messages', 'msg-intel-1'),
+ sampleMessage(clubId, 'ride-intel', 'msg-intel-1', 'member', 'member')
+ )
+ );
+ });
+
+ test('Club admin can post to officers channel; regular member is DENIED posting to officers channel', async () => {
+ // Regular member attempts to post to officers channel -> DENIED
+ await assertFails(
+ setDoc(
+ doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-2'),
+ sampleMessage(clubId, 'officers', 'msg-off-2', 'member', 'member')
+ )
+ );
+
+ // Club admin posts to officers channel -> ALLOWED
+ await assertSucceeds(
+ setDoc(
+ doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-2'),
+ sampleMessage(clubId, 'officers', 'msg-off-2', 'admin', 'admin')
+ )
+ );
+ });
+
+ test('Club owner can post to officers channel', async () => {
+ await assertSucceeds(
+ setDoc(
+ doc(dbFor('owner'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-3'),
+ sampleMessage(clubId, 'officers', 'msg-off-3', 'owner', 'owner')
+ )
+ );
+ });
+
+ test('Regular member is DENIED reading messages in officers channel; club admin is allowed', async () => {
+ // Regular member reading officers channel message -> DENIED
+ await assertFails(
+ getDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'))
+ );
+ await assertFails(
+ getDocs(collection(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages'))
+ );
+
+ // Outsider reading officers channel message -> DENIED
+ await assertFails(
+ getDoc(doc(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'))
+ );
+
+ // Club admin reading officers channel message -> ALLOWED
+ await assertSucceeds(
+ getDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'))
+ );
+ await assertSucceeds(
+ getDocs(collection(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages'))
+ );
+
+ // Club owner reading officers channel message -> ALLOWED
+ await assertSucceeds(
+ getDoc(doc(dbFor('owner'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'))
+ );
+ });
+
+ test('Active member can read messages in general channel; outsider is DENIED', async () => {
+ await assertSucceeds(
+ getDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'))
+ );
+ await assertSucceeds(
+ getDocs(collection(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages'))
+ );
+
+ await assertFails(
+ getDoc(doc(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'))
+ );
+ await assertFails(
+ getDocs(collection(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'general', 'messages'))
+ );
+ });
+
+ test('Role spoofing or author forgery in chat message is rejected', async () => {
+ const db = dbFor('member');
+
+ // Member claims role: 'admin'
+ await assertFails(
+ setDoc(
+ doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-spoof-1'),
+ sampleMessage(clubId, 'general', 'msg-spoof-1', 'member', 'admin')
+ )
+ );
+
+ // Member claims role: 'owner'
+ await assertFails(
+ setDoc(
+ doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-spoof-2'),
+ sampleMessage(clubId, 'general', 'msg-spoof-2', 'member', 'owner')
+ )
+ );
+
+ // Member claims someone else is authorUid
+ await assertFails(
+ setDoc(
+ doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-spoof-3'),
+ sampleMessage(clubId, 'general', 'msg-spoof-3', 'owner', 'member')
+ )
+ );
+ });
+
+ test('Chat message content validation limits (1..1000 chars)', async () => {
+ const db = dbFor('member');
+ const valid = sampleMessage(clubId, 'general', 'msg-len', 'member', 'member');
+
+ // Empty content -> DENIED
+ await assertFails(
+ setDoc(doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-len'), {
+ ...valid,
+ content: ' ',
+ })
+ );
+
+ // Exceeds 1000 chars -> DENIED
+ await assertFails(
+ setDoc(doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-len'), {
+ ...valid,
+ content: 'x'.repeat(1001),
+ })
+ );
+
+ // Exactly 1000 chars -> ALLOWED
+ await assertSucceeds(
+ setDoc(doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-len'), {
+ ...valid,
+ content: 'x'.repeat(1000),
+ })
+ );
+ });
+
+ test('Chat messages are immutable (update is DENIED)', async () => {
+ await assertFails(
+ updateDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'), {
+ content: 'Edited content',
+ })
+ );
+ await assertFails(
+ updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'), {
+ content: 'Admin edited content',
+ })
+ );
+ });
+
+ test('Author can delete own message; Admin can delete other messages; Member cannot delete other messages', async () => {
+ // Seed message by member
+ const memberMsgDoc = doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-1');
+ await assertSucceeds(
+ setDoc(memberMsgDoc, sampleMessage(clubId, 'general', 'msg-del-1', 'member', 'member'))
+ );
+
+ // Another non-admin member cannot delete it
+ await env.withSecurityRulesDisabled((ctx) =>
+ setDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'otherMember'), profile(clubId, 'otherMember'))
+ );
+ await assertFails(
+ deleteDoc(doc(dbFor('otherMember'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-1'))
+ );
+
+ // Author can delete their own message
+ await assertSucceeds(deleteDoc(memberMsgDoc));
+
+ // Admin can delete a member message
+ await env.withSecurityRulesDisabled((ctx) =>
+ setDoc(
+ doc(ctx.firestore(), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-2'),
+ sampleMessage(clubId, 'general', 'msg-del-2', 'member', 'member')
+ )
+ );
+ await assertSucceeds(
+ deleteDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-2'))
+ );
+ });
+
+ test('Officers channel deletion: admin can delete, regular member is DENIED', async () => {
+ // Regular member attempts delete in officers channel -> DENIED
+ await assertFails(
+ deleteDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'))
+ );
+
+ // Admin deletes in officers channel -> ALLOWED
+ await assertSucceeds(
+ deleteDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'))
+ );
+ });
+});
diff --git a/firebase/emulator-tests/club-wall.rules.test.js b/firebase/emulator-tests/club-wall.rules.test.js
index 7992672..f1055bc 100644
--- a/firebase/emulator-tests/club-wall.rules.test.js
+++ b/firebase/emulator-tests/club-wall.rules.test.js
@@ -22,7 +22,7 @@ const dbFor = (uid) => env.authenticatedContext(uid).firestore();
beforeAll(async () => {
env = await initializeTestEnvironment({ projectId: 'demo-motorsignal-club-wall',
firestore: { rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8') } });
-});
+}, 30000);
afterAll(async () => { await env?.cleanup(); });
beforeEach(async () => {
await env.clearFirestore();
diff --git a/firestore.rules b/firestore.rules
index 85c70ca..465aba5 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -1336,6 +1336,159 @@ service cloud.firestore {
allow delete: if canUseWall() && (canModerateWall() || resource.data.authorUid == request.auth.uid);
}
+ // --- CLUB ROUTES ---
+ // Curated motorcycle routes created by club members or owner.
+ // Read access is public if club is public; otherwise active members/owner only.
+ // Deletions are restricted to club admins and owner.
+ match /routes/{routeId} {
+ function canReadClubRoutes() {
+ return isRegisteredUser()
+ && !isBanned(request.auth.uid)
+ && (
+ getClubDoc().data.visibility == 'public'
+ || isCallerClubOwner()
+ || isActiveMember(request.auth.uid)
+ );
+ }
+
+ function isValidClubRoute(data) {
+ return data.keys().hasAll([
+ 'id', 'clubId', 'title', 'description', 'distanceMiles',
+ 'estimatedMinutes', 'elevationGainFt', 'curvatureRating',
+ 'surfaceType', 'startLocation', 'endLocation', 'curatorUid',
+ 'curatorName', 'createdAt'
+ ])
+ && data.keys().hasOnly([
+ 'id', 'clubId', 'title', 'description', 'distanceMiles',
+ 'estimatedMinutes', 'elevationGainFt', 'curvatureRating',
+ 'surfaceType', 'startLocation', 'endLocation', 'gpxUrl',
+ 'curatorUid', 'curatorName', 'createdAt'
+ ])
+ && data.id == routeId
+ && data.clubId == clubId
+ && data.curatorUid == request.auth.uid
+ && data.curatorName is string
+ && data.curatorName.size() >= 1 && data.curatorName.size() <= 100
+ && data.title is string
+ && data.title.size() >= 1 && data.title.size() <= 120
+ && data.description is string
+ && data.description.size() <= 1000
+ && (data.distanceMiles is int || data.distanceMiles is float)
+ && data.distanceMiles > 0
+ && (data.estimatedMinutes is int || data.estimatedMinutes is float)
+ && data.estimatedMinutes > 0
+ && (data.elevationGainFt is int || data.elevationGainFt is float)
+ && data.elevationGainFt >= 0
+ && data.curvatureRating in ['High Twisties', 'Moderate', 'Scenic Cruise', 'Track Pace']
+ && data.surfaceType in ['Paved Asphalt', 'Mixed Surface', 'Mountain Pass']
+ && data.startLocation is string
+ && data.startLocation.size() >= 1 && data.startLocation.size() <= 120
+ && data.endLocation is string
+ && data.endLocation.size() >= 1 && data.endLocation.size() <= 120
+ && (!('gpxUrl' in data) || data.gpxUrl == null || (data.gpxUrl is string && data.gpxUrl.size() <= 2048))
+ && data.createdAt is timestamp;
+ }
+
+ function canCreateClubRoute() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (isCallerClubOwner() || isActiveMember(request.auth.uid))
+ && isValidClubRoute(request.resource.data);
+ }
+
+ function canDeleteClubRoute() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (isCallerClubOwner() || isClubAdmin(request.auth.uid));
+ }
+
+ allow read: if canReadClubRoutes();
+ allow create: if canCreateClubRoute();
+ allow update: if false;
+ allow delete: if canDeleteClubRoute();
+ }
+
+ // --- CLUB CHAT MESSAGING ---
+ // Real-time squad chat messaging partitioned by channels.
+ // General channels (general, ride-intel): active members and owner.
+ // Officers channel: club admin and owner only.
+ // Messages are immutable once posted.
+ match /chat_channels/{channelId}/messages/{messageId} {
+ function canReadChatMessage() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (
+ channelId == 'officers'
+ ? (isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ : (isCallerClubOwner() || isActiveMember(request.auth.uid))
+ );
+ }
+
+ function validChatAuthor(data) {
+ return data.authorUid == request.auth.uid
+ && data.authorDisplayName is string
+ && data.authorDisplayName.size() >= 1 && data.authorDisplayName.size() <= 100
+ && ((isCallerClubOwner() && data.authorRole == 'owner')
+ || (!isCallerClubOwner() && data.authorRole == memberData(request.auth.uid).role))
+ && (!('authorHandle' in data) || data.authorHandle == null || (data.authorHandle is string && data.authorHandle.size() <= 100))
+ && (!('authorDisplayRank' in data) || data.authorDisplayRank == null || (
+ data.authorDisplayRank is string && data.authorDisplayRank.size() <= 100
+ && (isCallerClubOwner() || ('displayRank' in memberData(request.auth.uid) && data.authorDisplayRank == memberData(request.auth.uid).displayRank))
+ ));
+ }
+
+ function isValidChatMessage(data) {
+ return data.keys().hasAll([
+ 'id', 'clubId', 'channelId', 'content', 'authorUid',
+ 'authorDisplayName', 'authorRole', 'createdAt'
+ ])
+ && data.keys().hasOnly([
+ 'id', 'clubId', 'channelId', 'content', 'authorUid',
+ 'authorDisplayName', 'authorRole', 'authorHandle',
+ 'authorDisplayRank', 'createdAt'
+ ])
+ && data.id == messageId
+ && data.clubId == clubId
+ && data.channelId == channelId
+ && data.content is string
+ && data.content.trim().size() > 0
+ && data.content.size() <= 1000
+ && data.createdAt is timestamp
+ && validChatAuthor(data);
+ }
+
+ function canCreateChatMessage() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (
+ channelId == 'officers'
+ ? (isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ : (isCallerClubOwner() || isActiveMember(request.auth.uid))
+ )
+ && isValidChatMessage(request.resource.data);
+ }
+
+ function canDeleteChatMessage() {
+ return isRegisteredUser()
+ && getClubDoc().data.archivedAt == null
+ && !isBanned(request.auth.uid)
+ && (
+ channelId == 'officers'
+ ? (isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ : (resource.data.authorUid == request.auth.uid || isCallerClubOwner() || isClubAdmin(request.auth.uid))
+ );
+ }
+
+ allow read: if canReadChatMessage();
+ allow create: if canCreateChatMessage();
+ allow update: if false;
+ allow delete: if canDeleteChatMessage();
+ }
+
// --- ADMINISTRATIVE BANS SUBCOLLECTION ---
match /bans/{bannedUid} {
allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid);
diff --git a/integration_test/patrol_test_bundle.dart b/integration_test/patrol_test_bundle.dart
new file mode 100644
index 0000000..30f3d79
--- /dev/null
+++ b/integration_test/patrol_test_bundle.dart
@@ -0,0 +1,6 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND AND DO NOT COMMIT TO VERSION CONTROL
+// ignore_for_file: type=lint
+
+import '../patrol_test/test_bundle.dart' as bundle;
+
+Future main() => bundle.main();
diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart
index 0eefbdd..184df9e 100644
--- a/lib/core/theme/app_colors.dart
+++ b/lib/core/theme/app_colors.dart
@@ -79,4 +79,39 @@ abstract final class AppColors {
static const Color alertRed = Color(0xFFEF4444); // Urgent collision/hazard
static const Color hazardAmber = Color(0xFFF59E0B); // Road debris / speed traps
static const Color leanGreen = Color(0xFF10B981); // Optimal lean / GPS lock
+
+ // MotorSignal Hybrid v1 Canonical Tokens
+ // Night Mode (Canonical Canvas: #0B1326)
+ static const Color hybridNightCanvas = Color(0xFF0B1326);
+ static const Color hybridNightSurfaceCardPrimary = Color(0xFF111C38);
+ static const Color hybridNightSurfaceCardSecondary = Color(0xFF17223B);
+ static const Color hybridNightSurfaceCardLowest = Color(0xFF060E20);
+ static const Color hybridNightBorder = Color(0xFF1E293B);
+ static const Color hybridNightBorderSubtle = Color(0xFF24324D);
+ static const Color hybridNightTextPrimary = Color(0xFFDAE2FD);
+ static const Color hybridNightTextMuted = Color(0xFF869397);
+ static const Color hybridNightBrandCyan = Color(0xFF06B6D4);
+ static const Color hybridNightBrandCyanBright = Color(0xFF22D3EE);
+ static const Color hybridNightBrandCyanContainer = Color(0xFF00424F);
+ static const Color hybridNightRecGreen = Color(0xFF10B981);
+ static const Color hybridNightRecGreenBg = Color(0xFF064E3B);
+ static const Color hybridNightCautionAmber = Color(0xFFF59E0B);
+ static const Color hybridNightDangerRed = Color(0xFFEF4444);
+
+ // Day Mode (Canonical Canvas: #F8FAFC)
+ static const Color hybridDayCanvas = Color(0xFFF8FAFC);
+ static const Color hybridDaySurfaceCardPrimary = Color(0xFFFFFFFF);
+ static const Color hybridDaySurfaceCardSecondary = Color(0xFFF1F5F9);
+ static const Color hybridDaySurfaceCardLowest = Color(0xFFE2E8F0);
+ static const Color hybridDayBorder = Color(0xFFCBD5E1);
+ static const Color hybridDayBorderSubtle = Color(0xFFE2E8F0);
+ static const Color hybridDayTextPrimary = Color(0xFF0F172A);
+ static const Color hybridDayTextMuted = Color(0xFF64748B);
+ static const Color hybridDayBrandCyan = Color(0xFF0284C7);
+ static const Color hybridDayBrandCyanBright = Color(0xFF0EA5E9);
+ static const Color hybridDayBrandCyanContainer = Color(0xFFE0F2FE);
+ static const Color hybridDayRecGreen = Color(0xFF10B981);
+ static const Color hybridDayRecGreenBg = Color(0xFFD1FAE5);
+ static const Color hybridDayCautionAmber = Color(0xFFD97706);
+ static const Color hybridDayDangerRed = Color(0xFFDC2626);
}
diff --git a/lib/core/theme/app_spacing.dart b/lib/core/theme/app_spacing.dart
index d1fb91f..bea3442 100644
--- a/lib/core/theme/app_spacing.dart
+++ b/lib/core/theme/app_spacing.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:flutter/material.dart';
/// Authoritative spatial, radius, and dimension tokens for MotorSignal.
///
@@ -14,6 +14,12 @@ abstract final class AppSpacing {
/// Minimum interactive target size for motorcycle/touch interaction (48dp).
static const double minTouchTarget = 48.0;
+
+ /// Canonical primary riding action button height (56dp).
+ static const double primaryRidingCtaHeight = 56.0;
+
+ /// Canonical bottom navigation bar height (72dp).
+ static const double navBarHeight = 72.0;
}
/// Authoritative corner radius tokens for cards, buttons, badges, and dials.
diff --git a/lib/core/theme/theme_extensions.dart b/lib/core/theme/theme_extensions.dart
index 98c6290..57e2085 100644
--- a/lib/core/theme/theme_extensions.dart
+++ b/lib/core/theme/theme_extensions.dart
@@ -35,6 +35,23 @@ class MotorSignalThemeColors extends ThemeExtension {
final Color hazardAmber;
final Color leanGreen;
+ // MotorSignal Hybrid v1 Canonical Tokens
+ final Color surfaceCanvas;
+ final Color surfaceCardPrimary;
+ final Color surfaceCardSecondary;
+ final Color surfaceCardLowest;
+ final Color surfaceBorder;
+ final Color surfaceBorderSubtle;
+ final Color textPrimary;
+ final Color textMuted;
+ final Color brandCyan;
+ final Color brandCyanBright;
+ final Color brandCyanContainer;
+ final Color recStaticGreen;
+ final Color recStaticGreenBg;
+ final Color cautionAmber;
+ final Color dangerRed;
+
const MotorSignalThemeColors({
required this.cyberCyan,
required this.velocityBlue,
@@ -64,6 +81,21 @@ class MotorSignalThemeColors extends ThemeExtension {
this.alertRed = AppColors.alertRed,
this.hazardAmber = AppColors.hazardAmber,
this.leanGreen = AppColors.leanGreen,
+ this.surfaceCanvas = AppColors.hybridNightCanvas,
+ this.surfaceCardPrimary = AppColors.hybridNightSurfaceCardPrimary,
+ this.surfaceCardSecondary = AppColors.hybridNightSurfaceCardSecondary,
+ this.surfaceCardLowest = AppColors.hybridNightSurfaceCardLowest,
+ this.surfaceBorder = AppColors.hybridNightBorder,
+ this.surfaceBorderSubtle = AppColors.hybridNightBorderSubtle,
+ this.textPrimary = AppColors.hybridNightTextPrimary,
+ this.textMuted = AppColors.hybridNightTextMuted,
+ this.brandCyan = AppColors.hybridNightBrandCyan,
+ this.brandCyanBright = AppColors.hybridNightBrandCyanBright,
+ this.brandCyanContainer = AppColors.hybridNightBrandCyanContainer,
+ this.recStaticGreen = AppColors.hybridNightRecGreen,
+ this.recStaticGreenBg = AppColors.hybridNightRecGreenBg,
+ this.cautionAmber = AppColors.hybridNightCautionAmber,
+ this.dangerRed = AppColors.hybridNightDangerRed,
});
/// Dark Mode default telemetry tokens (Cockpit Precision).
@@ -96,6 +128,21 @@ class MotorSignalThemeColors extends ThemeExtension {
alertRed: AppColors.alertRed,
hazardAmber: AppColors.hazardAmber,
leanGreen: AppColors.leanGreen,
+ surfaceCanvas: AppColors.hybridNightCanvas,
+ surfaceCardPrimary: AppColors.hybridNightSurfaceCardPrimary,
+ surfaceCardSecondary: AppColors.hybridNightSurfaceCardSecondary,
+ surfaceCardLowest: AppColors.hybridNightSurfaceCardLowest,
+ surfaceBorder: AppColors.hybridNightBorder,
+ surfaceBorderSubtle: AppColors.hybridNightBorderSubtle,
+ textPrimary: AppColors.hybridNightTextPrimary,
+ textMuted: AppColors.hybridNightTextMuted,
+ brandCyan: AppColors.hybridNightBrandCyan,
+ brandCyanBright: AppColors.hybridNightBrandCyanBright,
+ brandCyanContainer: AppColors.hybridNightBrandCyanContainer,
+ recStaticGreen: AppColors.hybridNightRecGreen,
+ recStaticGreenBg: AppColors.hybridNightRecGreenBg,
+ cautionAmber: AppColors.hybridNightCautionAmber,
+ dangerRed: AppColors.hybridNightDangerRed,
);
/// Light Mode default telemetry tokens (Daylight HUD).
@@ -118,6 +165,21 @@ class MotorSignalThemeColors extends ThemeExtension {
outline: AppColors.lightOutline,
outlineVariant: AppColors.lightOutlineVariant,
glowShadow: Color(0x14000000),
+ surfaceCanvas: AppColors.hybridDayCanvas,
+ surfaceCardPrimary: AppColors.hybridDaySurfaceCardPrimary,
+ surfaceCardSecondary: AppColors.hybridDaySurfaceCardSecondary,
+ surfaceCardLowest: AppColors.hybridDaySurfaceCardLowest,
+ surfaceBorder: AppColors.hybridDayBorder,
+ surfaceBorderSubtle: AppColors.hybridDayBorderSubtle,
+ textPrimary: AppColors.hybridDayTextPrimary,
+ textMuted: AppColors.hybridDayTextMuted,
+ brandCyan: AppColors.hybridDayBrandCyan,
+ brandCyanBright: AppColors.hybridDayBrandCyanBright,
+ brandCyanContainer: AppColors.hybridDayBrandCyanContainer,
+ recStaticGreen: AppColors.hybridDayRecGreen,
+ recStaticGreenBg: AppColors.hybridDayRecGreenBg,
+ cautionAmber: AppColors.hybridDayCautionAmber,
+ dangerRed: AppColors.hybridDayDangerRed,
);
Color get brandBlue => cyberCyan;
@@ -160,6 +222,21 @@ class MotorSignalThemeColors extends ThemeExtension {
Color? alertRed,
Color? hazardAmber,
Color? leanGreen,
+ Color? surfaceCanvas,
+ Color? surfaceCardPrimary,
+ Color? surfaceCardSecondary,
+ Color? surfaceCardLowest,
+ Color? surfaceBorder,
+ Color? surfaceBorderSubtle,
+ Color? textPrimary,
+ Color? textMuted,
+ Color? brandCyan,
+ Color? brandCyanBright,
+ Color? brandCyanContainer,
+ Color? recStaticGreen,
+ Color? recStaticGreenBg,
+ Color? cautionAmber,
+ Color? dangerRed,
}) {
return MotorSignalThemeColors(
cyberCyan: cyberCyan ?? this.cyberCyan,
@@ -190,6 +267,21 @@ class MotorSignalThemeColors extends ThemeExtension {
alertRed: alertRed ?? this.alertRed,
hazardAmber: hazardAmber ?? this.hazardAmber,
leanGreen: leanGreen ?? this.leanGreen,
+ surfaceCanvas: surfaceCanvas ?? this.surfaceCanvas,
+ surfaceCardPrimary: surfaceCardPrimary ?? this.surfaceCardPrimary,
+ surfaceCardSecondary: surfaceCardSecondary ?? this.surfaceCardSecondary,
+ surfaceCardLowest: surfaceCardLowest ?? this.surfaceCardLowest,
+ surfaceBorder: surfaceBorder ?? this.surfaceBorder,
+ surfaceBorderSubtle: surfaceBorderSubtle ?? this.surfaceBorderSubtle,
+ textPrimary: textPrimary ?? this.textPrimary,
+ textMuted: textMuted ?? this.textMuted,
+ brandCyan: brandCyan ?? this.brandCyan,
+ brandCyanBright: brandCyanBright ?? this.brandCyanBright,
+ brandCyanContainer: brandCyanContainer ?? this.brandCyanContainer,
+ recStaticGreen: recStaticGreen ?? this.recStaticGreen,
+ recStaticGreenBg: recStaticGreenBg ?? this.recStaticGreenBg,
+ cautionAmber: cautionAmber ?? this.cautionAmber,
+ dangerRed: dangerRed ?? this.dangerRed,
);
}
@@ -227,6 +319,21 @@ class MotorSignalThemeColors extends ThemeExtension {
alertRed: Color.lerp(alertRed, other.alertRed, t) ?? alertRed,
hazardAmber: Color.lerp(hazardAmber, other.hazardAmber, t) ?? hazardAmber,
leanGreen: Color.lerp(leanGreen, other.leanGreen, t) ?? leanGreen,
+ surfaceCanvas: Color.lerp(surfaceCanvas, other.surfaceCanvas, t) ?? surfaceCanvas,
+ surfaceCardPrimary: Color.lerp(surfaceCardPrimary, other.surfaceCardPrimary, t) ?? surfaceCardPrimary,
+ surfaceCardSecondary: Color.lerp(surfaceCardSecondary, other.surfaceCardSecondary, t) ?? surfaceCardSecondary,
+ surfaceCardLowest: Color.lerp(surfaceCardLowest, other.surfaceCardLowest, t) ?? surfaceCardLowest,
+ surfaceBorder: Color.lerp(surfaceBorder, other.surfaceBorder, t) ?? surfaceBorder,
+ surfaceBorderSubtle: Color.lerp(surfaceBorderSubtle, other.surfaceBorderSubtle, t) ?? surfaceBorderSubtle,
+ textPrimary: Color.lerp(textPrimary, other.textPrimary, t) ?? textPrimary,
+ textMuted: Color.lerp(textMuted, other.textMuted, t) ?? textMuted,
+ brandCyan: Color.lerp(brandCyan, other.brandCyan, t) ?? brandCyan,
+ brandCyanBright: Color.lerp(brandCyanBright, other.brandCyanBright, t) ?? brandCyanBright,
+ brandCyanContainer: Color.lerp(brandCyanContainer, other.brandCyanContainer, t) ?? brandCyanContainer,
+ recStaticGreen: Color.lerp(recStaticGreen, other.recStaticGreen, t) ?? recStaticGreen,
+ recStaticGreenBg: Color.lerp(recStaticGreenBg, other.recStaticGreenBg, t) ?? recStaticGreenBg,
+ cautionAmber: Color.lerp(cautionAmber, other.cautionAmber, t) ?? cautionAmber,
+ dangerRed: Color.lerp(dangerRed, other.dangerRed, t) ?? dangerRed,
);
}
}
diff --git a/lib/core/widgets/hybrid_card.dart b/lib/core/widgets/hybrid_card.dart
new file mode 100644
index 0000000..dbc8f31
--- /dev/null
+++ b/lib/core/widgets/hybrid_card.dart
@@ -0,0 +1,66 @@
+import 'package:flutter/material.dart';
+import '../theme/app_spacing.dart';
+import '../theme/theme_extensions.dart';
+
+/// Canonical cockpit telemetry card container for MotorSignal Hybrid v1.
+///
+/// Features 1px high-contrast border and layered surface background
+/// according to the canonical day/night token hierarchy.
+class HybridCard extends StatelessWidget {
+ final Widget child;
+ final EdgeInsetsGeometry padding;
+ final EdgeInsetsGeometry? margin;
+ final bool secondary;
+ final VoidCallback? onTap;
+ final double borderRadius;
+ final Color? borderColor;
+
+ const HybridCard({
+ super.key,
+ required this.child,
+ this.padding = const EdgeInsets.all(AppSpacing.md),
+ this.margin,
+ this.secondary = false,
+ this.onTap,
+ this.borderRadius = AppRadius.lg,
+ this.borderColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.motorColors;
+ final backgroundColor =
+ secondary ? colors.surfaceCardSecondary : colors.surfaceCardPrimary;
+ final effectiveBorderColor = borderColor ?? colors.surfaceBorder;
+
+ final shape = RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(borderRadius),
+ side: BorderSide(color: effectiveBorderColor, width: 1.0),
+ );
+
+ Widget content = Container(
+ margin: margin,
+ decoration: ShapeDecoration(
+ color: backgroundColor,
+ shape: shape,
+ ),
+ child: Padding(
+ padding: padding,
+ child: child,
+ ),
+ );
+
+ if (onTap != null) {
+ content = Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(borderRadius),
+ onTap: onTap,
+ child: content,
+ ),
+ );
+ }
+
+ return content;
+ }
+}
diff --git a/lib/core/widgets/hybrid_metric_tile.dart b/lib/core/widgets/hybrid_metric_tile.dart
new file mode 100644
index 0000000..b1af2e8
--- /dev/null
+++ b/lib/core/widgets/hybrid_metric_tile.dart
@@ -0,0 +1,97 @@
+import 'package:flutter/material.dart';
+import '../theme/app_typography.dart';
+import '../theme/theme_extensions.dart';
+
+/// Reusable telemetry metric tile for MotorSignal Hybrid v1.
+///
+/// Features high-contrast tabular typography, uppercase tracking-wider
+/// unit and label tags, and motorcycle cockpit glanceability.
+class HybridMetricTile extends StatelessWidget {
+ final String label;
+ final String value;
+ final String? unit;
+ final IconData? icon;
+ final Color? accentColor;
+ final bool compact;
+
+ const HybridMetricTile({
+ super.key,
+ required this.label,
+ required this.value,
+ this.unit,
+ this.icon,
+ this.accentColor,
+ this.compact = false,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.motorColors;
+ final primaryTextColor = accentColor ?? colors.textPrimary;
+
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(
+ icon,
+ size: compact ? 12 : 14,
+ color: colors.textMuted,
+ ),
+ const SizedBox(width: 4),
+ ],
+ Flexible(
+ child: Text(
+ label.toUpperCase(),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: compact ? 10 : 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1.0,
+ color: colors.textMuted,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 2),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ Text(
+ value,
+ style: TextStyle(
+ fontFamily: AppTypography.fontTelemetry,
+ fontSize: compact ? 22 : 26,
+ fontWeight: FontWeight.w700,
+ color: primaryTextColor,
+ letterSpacing: -0.5,
+ ),
+ ),
+ if (unit != null) ...[
+ const SizedBox(width: 4),
+ Text(
+ unit!.toUpperCase(),
+ style: TextStyle(
+ fontFamily: AppTypography.fontTelemetry,
+ fontSize: compact ? 10 : 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.5,
+ color: colors.textMuted,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/core/widgets/hybrid_riding_action_bar.dart b/lib/core/widgets/hybrid_riding_action_bar.dart
new file mode 100644
index 0000000..f7735d2
--- /dev/null
+++ b/lib/core/widgets/hybrid_riding_action_bar.dart
@@ -0,0 +1,163 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../theme/app_spacing.dart';
+import '../theme/app_typography.dart';
+import '../theme/theme_extensions.dart';
+import '../../features/rides/ride_state_machine.dart';
+
+/// Pinned riding action controls for MotorSignal Hybrid v1.
+///
+/// Provides 56dp minimum height buttons for glove-friendly cockpit control.
+/// In active ride state, provides high-contrast Pause Ride (Amber) and
+/// Finish Ride (Dark/Slate with Cyan/Green accent) action controls.
+class HybridRidingActionBar extends ConsumerWidget {
+ final bool compact;
+ final String? finishLabel;
+ final String? logTag;
+
+ const HybridRidingActionBar({
+ super.key,
+ this.compact = false,
+ this.finishLabel,
+ this.logTag,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final state = ref.watch(
+ rideStateMachineProvider.select((s) => s.state),
+ );
+ final colors = context.motorColors;
+ final isRiding = state == RideState.riding;
+ final isPaused = state == RideState.paused;
+ final notifier = ref.read(rideStateMachineProvider.notifier);
+
+ final double buttonHeight = compact ? AppSpacing.minTouchTarget : AppSpacing.primaryRidingCtaHeight;
+
+ if (isRiding || isPaused) {
+ return Row(
+ children: [
+ // Pause / Resume button
+ Expanded(
+ flex: 1,
+ child: SizedBox(
+ height: buttonHeight,
+ child: ElevatedButton.icon(
+ key: const ValueKey('hybrid_pause_resume_ride_button'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.cautionAmber.withValues(alpha: 0.16),
+ foregroundColor: colors.cautionAmber,
+ side: BorderSide(color: colors.cautionAmber, width: 1.2),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ elevation: 0,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ ),
+ onPressed: () {
+ if (isPaused) {
+ notifier.resumeRide();
+ } else {
+ notifier.pauseRide();
+ }
+ },
+ icon: Icon(
+ isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
+ size: 24,
+ color: colors.cautionAmber,
+ ),
+ label: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ isPaused ? 'Resume' : 'Pause Ride',
+ style: const TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.3,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ // Finish Ride button
+ Expanded(
+ flex: 1,
+ child: SizedBox(
+ height: buttonHeight,
+ child: ElevatedButton.icon(
+ key: const ValueKey('hybrid_finish_ride_button'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.surfaceCardPrimary,
+ foregroundColor: colors.brandCyan,
+ side: BorderSide(color: colors.surfaceBorder, width: 1.2),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ elevation: 0,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ ),
+ onPressed: () {
+ final effectiveTag = logTag ?? 'HybridRidingActionBar';
+ notifier.finishRide().catchError((Object error) {
+ debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}');
+ });
+ },
+ icon: Icon(
+ Icons.stop_rounded,
+ size: 24,
+ color: colors.brandCyan,
+ ),
+ label: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ finishLabel ?? 'Finish Ride',
+ style: const TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.3,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ // Ready / Standby state: Single prominent start button
+ return SizedBox(
+ width: double.infinity,
+ height: buttonHeight,
+ child: ElevatedButton.icon(
+ key: const ValueKey('hybrid_start_ride_button'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.brandCyan.withValues(alpha: 0.16),
+ foregroundColor: colors.brandCyan,
+ side: BorderSide(color: colors.brandCyan, width: 1.2),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ elevation: 0,
+ ),
+ onPressed: () {
+ notifier.startRide();
+ },
+ icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan),
+ label: const Text(
+ 'Start Session',
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.5,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/core/widgets/hybrid_static_rec_badge.dart b/lib/core/widgets/hybrid_static_rec_badge.dart
new file mode 100644
index 0000000..5b1908c
--- /dev/null
+++ b/lib/core/widgets/hybrid_static_rec_badge.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+import '../theme/app_typography.dart';
+import '../theme/theme_extensions.dart';
+
+/// 100% static recording status badge for MotorSignal Hybrid v1.
+///
+/// Strictly non-pulsing and non-animating to prevent rider distraction
+/// during high-speed operation.
+class HybridStaticRecBadge extends StatelessWidget {
+ final bool isRecording;
+ final bool isPaused;
+ final String? customLabel;
+ final Color? customColor;
+
+ const HybridStaticRecBadge({
+ super.key,
+ required this.isRecording,
+ this.isPaused = false,
+ this.customLabel,
+ this.customColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.motorColors;
+
+ final Color dotColor;
+ final Color textColor;
+ final Color bgColor;
+ final Color borderColor;
+ final String label;
+
+ if (customColor != null) {
+ dotColor = customColor!;
+ textColor = customColor!;
+ bgColor = customColor!.withValues(alpha: isRecording ? 0.2 : 0.12);
+ borderColor = customColor!.withValues(alpha: isRecording ? 0.6 : 0.35);
+ label = customLabel ?? (isRecording ? 'REC • LIVE' : (isPaused ? 'PAUSED' : 'STANDBY'));
+ } else if (isRecording) {
+ dotColor = colors.recStaticGreen;
+ textColor = colors.recStaticGreen;
+ bgColor = colors.recStaticGreenBg;
+ borderColor = colors.recStaticGreen.withValues(alpha: 0.4);
+ label = customLabel ?? 'REC • LIVE';
+ } else if (isPaused) {
+ dotColor = colors.cautionAmber;
+ textColor = colors.cautionAmber;
+ bgColor = colors.cautionAmber.withValues(alpha: 0.15);
+ borderColor = colors.cautionAmber.withValues(alpha: 0.4);
+ label = customLabel ?? 'PAUSED';
+ } else {
+ dotColor = colors.textMuted;
+ textColor = colors.textMuted;
+ bgColor = colors.surfaceBorder.withValues(alpha: 0.3);
+ borderColor = colors.surfaceBorder;
+ label = customLabel ?? 'STANDBY';
+ }
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ color: bgColor,
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: borderColor, width: 1),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Container(
+ width: 7,
+ height: 7,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: dotColor,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ label,
+ style: TextStyle(
+ fontFamily: AppTypography.fontTelemetry,
+ fontSize: 11,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.8,
+ color: textColor,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/core/widgets/main_layout.dart b/lib/core/widgets/main_layout.dart
index cf95536..fd9a1c7 100644
--- a/lib/core/widgets/main_layout.dart
+++ b/lib/core/widgets/main_layout.dart
@@ -5,6 +5,7 @@ import '../../features/rides/ride_state_machine.dart';
import '../../services/location_service.dart';
import '../responsive/viewport_size.dart';
import '../theme/app_typography.dart';
+import '../theme/theme_extensions.dart';
/// Single authoritative navigation destination definition.
class AppNavDestination {
@@ -117,8 +118,7 @@ class _MainLayoutState extends ConsumerState {
}
});
- final theme = Theme.of(context);
- final colorScheme = theme.colorScheme;
+ final colors = context.motorColors;
final selectedIndex = widget.navigationShell.currentIndex;
return LayoutBuilder(
@@ -143,31 +143,32 @@ class _MainLayoutState extends ConsumerState {
groupAlignment: 0.0,
selectedIndex: selectedIndex,
onDestinationSelected: _onDestinationSelected,
- backgroundColor: colorScheme.surface,
- indicatorColor: colorScheme.primary.withValues(alpha: 0.14),
+ backgroundColor: colors.surfaceCanvas,
+ indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
selectedIconTheme: IconThemeData(
- color: colorScheme.primary,
- size: 20,
+ color: colors.brandCyan,
+ size: 21,
),
unselectedIconTheme: IconThemeData(
- color: colorScheme.onSurfaceVariant,
+ color: colors.textMuted,
size: 19,
),
- selectedLabelTextStyle: const TextStyle(
+ selectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
- fontSize: 9.5,
- fontWeight: FontWeight.w600,
+ fontSize: 10.0,
+ fontWeight: FontWeight.w700,
+ color: colors.brandCyan,
),
unselectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
- fontSize: 9.0,
+ fontSize: 9.5,
fontWeight: FontWeight.w500,
- color: colorScheme.onSurfaceVariant,
+ color: colors.textMuted,
),
labelType: NavigationRailLabelType.all,
destinations: kAppNavDestinations.map((dest) {
return NavigationRailDestination(
- padding: const EdgeInsets.symmetric(vertical: 3),
+ padding: const EdgeInsets.symmetric(vertical: 4),
icon: Icon(dest.icon, key: dest.railKey),
selectedIcon: dest.selectedIcon != null
? Icon(dest.selectedIcon, key: dest.railKey)
@@ -183,7 +184,7 @@ class _MainLayoutState extends ConsumerState {
VerticalDivider(
width: 1,
thickness: 1,
- color: colorScheme.outlineVariant,
+ color: colors.surfaceBorder,
),
Expanded(child: widget.navigationShell),
],
@@ -193,38 +194,46 @@ class _MainLayoutState extends ConsumerState {
return Scaffold(
body: widget.navigationShell,
- bottomNavigationBar: BottomNavigationBar(
- key: const ValueKey('main_bottom_nav_bar'),
- currentIndex: selectedIndex,
- onTap: _onDestinationSelected,
- selectedItemColor: colorScheme.primary,
- unselectedItemColor: colorScheme.onSurfaceVariant,
- backgroundColor: colorScheme.surface,
- type: BottomNavigationBarType.fixed,
- iconSize: 20,
- selectedFontSize: 10,
- unselectedFontSize: 9.5,
- selectedLabelStyle: const TextStyle(
- fontFamily: AppTypography.fontHeadline,
- fontSize: 10,
- fontWeight: FontWeight.w600,
+ bottomNavigationBar: Container(
+ decoration: BoxDecoration(
+ border: Border(
+ top: BorderSide(color: colors.surfaceBorder, width: 1.0),
+ ),
),
- unselectedLabelStyle: TextStyle(
- fontFamily: AppTypography.fontHeadline,
- fontSize: 9.5,
- fontWeight: FontWeight.w500,
- color: colorScheme.onSurfaceVariant,
+ child: BottomNavigationBar(
+ key: const ValueKey('main_bottom_nav_bar'),
+ currentIndex: selectedIndex,
+ onTap: _onDestinationSelected,
+ selectedItemColor: colors.brandCyan,
+ unselectedItemColor: colors.textMuted,
+ backgroundColor: colors.surfaceCanvas,
+ type: BottomNavigationBarType.fixed,
+ iconSize: 22,
+ selectedFontSize: 10.5,
+ unselectedFontSize: 10.0,
+ selectedLabelStyle: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 10.5,
+ fontWeight: FontWeight.w700,
+ color: colors.brandCyan,
+ ),
+ unselectedLabelStyle: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 10.0,
+ fontWeight: FontWeight.w500,
+ color: colors.textMuted,
+ ),
+ items: kAppNavDestinations.map((dest) {
+ return BottomNavigationBarItem(
+ icon: Icon(dest.icon, key: dest.navKey),
+ activeIcon: dest.selectedIcon != null
+ ? Icon(dest.selectedIcon, key: dest.navKey)
+ : null,
+ label: dest.label,
+ tooltip: dest.label,
+ );
+ }).toList(),
),
- items: kAppNavDestinations.map((dest) {
- return BottomNavigationBarItem(
- icon: Icon(dest.icon, key: dest.navKey),
- activeIcon: dest.selectedIcon != null
- ? Icon(dest.selectedIcon, key: dest.navKey)
- : null,
- label: dest.label,
- tooltip: dest.label,
- );
- }).toList(),
),
);
},
diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart
index 92f496b..8387a74 100644
--- a/lib/features/dashboard/widgets/lean_angle_indicator.dart
+++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart
@@ -19,17 +19,17 @@ class LeanAngleIndicator extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final leanAngle = ref.watch(
- telemetryStreamProvider.select((s) => s.value?.leanAngle ?? 0.0),
- );
- final speed = ref.watch(
- telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0),
- );
+ final sample = ref.watch(telemetryStreamProvider).value;
+ final leanAngle = sample?.leanAngle;
+ final isCalibrated = sample?.quality?.isMountCalibrated ?? (leanAngle != null);
+ final isAvailable = leanAngle != null && isCalibrated;
+
+ final double displayLean = isAvailable ? leanAngle : 0.0;
final (maxLeft, maxRight) = ref.watch(
rideStateMachineProvider.select((s) => (s.maxLeanLeft, s.maxLeanRight)),
);
- final currentLeft = leanAngle < 0 ? leanAngle.abs() : 0.0;
- final currentRight = leanAngle > 0 ? leanAngle : 0.0;
+ final currentLeft = displayLean < 0 ? displayLean.abs() : 0.0;
+ final currentRight = displayLean > 0 ? displayLean : 0.0;
final colors = context.motorColors;
final theme = Theme.of(context);
@@ -39,14 +39,18 @@ class LeanAngleIndicator extends ConsumerWidget {
// Explicit directional semantics
final String direction;
- if (leanAngle < -0.5) {
+ if (!isAvailable) {
+ direction = 'UNCALIBRATED';
+ } else if (displayLean < -0.5) {
direction = 'LEFT';
- } else if (leanAngle > 0.5) {
+ } else if (displayLean > 0.5) {
direction = 'RIGHT';
} else {
direction = 'CENTER';
}
+ final String angleText = isAvailable ? '${displayLean.abs().toStringAsFixed(0)}°' : '--°';
+
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
@@ -54,7 +58,9 @@ class LeanAngleIndicator extends ConsumerWidget {
final double gaugeHeight = compact ? 52.0 : 68.0;
return Semantics(
- label: 'Lean angle: ${leanAngle.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
+ label: isAvailable
+ ? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.'
+ : 'Lean angle: uncalibrated. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -78,11 +84,11 @@ class LeanAngleIndicator extends ConsumerWidget {
child: CustomPaint(
size: Size(gaugeWidth, gaugeHeight),
painter: _MotorcycleLeanPainter(
- leanAngle: leanAngle,
+ leanAngle: displayLean,
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
- accentColor: colors.brandBlue,
+ accentColor: colors.brandCyan,
horizonColor: isDark ? Colors.white24 : Colors.black26,
- tickColor: colors.brandBlue,
+ tickColor: colors.brandCyan,
isDark: isDark,
),
),
@@ -99,7 +105,7 @@ class LeanAngleIndicator extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
- '${leanAngle.abs().toStringAsFixed(0)}°',
+ angleText,
style: TextStyle(
fontSize: compact ? 18 : 22,
fontWeight: FontWeight.w900,
@@ -114,7 +120,9 @@ class LeanAngleIndicator extends ConsumerWidget {
fontSize: compact ? 11 : 13,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
- color: direction != 'CENTER' ? colors.brandBlue : onSurfaceVariant,
+ color: direction != 'CENTER' && direction != 'UNCALIBRATED'
+ ? colors.brandCyan
+ : colors.textMuted,
),
),
],
@@ -123,7 +131,7 @@ class LeanAngleIndicator extends ConsumerWidget {
SizedBox(height: compact ? 1 : 3),
- // Peak Session Metrics: MAX L and MAX R
+ // Peak Session Metrics: MAX L and MAX R in neutral muted styling
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
@@ -134,7 +142,7 @@ class LeanAngleIndicator extends ConsumerWidget {
style: TextStyle(
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w700,
- color: speed < 3.0 ? onSurfaceVariant : colors.brandBlue,
+ color: colors.textMuted,
),
),
SizedBox(width: compact ? 8 : 12),
@@ -143,7 +151,7 @@ class LeanAngleIndicator extends ConsumerWidget {
style: TextStyle(
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w700,
- color: speed < 3.0 ? onSurfaceVariant : colors.brandBlue,
+ color: colors.textMuted,
),
),
],
diff --git a/lib/features/dashboard/widgets/lean_visualizer.dart b/lib/features/dashboard/widgets/lean_visualizer.dart
index a52493b..44590df 100644
--- a/lib/features/dashboard/widgets/lean_visualizer.dart
+++ b/lib/features/dashboard/widgets/lean_visualizer.dart
@@ -3,13 +3,26 @@ import 'package:flutter/material.dart';
import '../../rides/ride_state_machine.dart';
class LeanVisualizer extends StatelessWidget {
- final double lean;
+ final double? lean;
final RideSession session;
+ final bool isCalibrated;
- const LeanVisualizer({super.key, required this.lean, required this.session});
+ const LeanVisualizer({
+ super.key,
+ required this.lean,
+ required this.session,
+ this.isCalibrated = true,
+ });
@override
Widget build(BuildContext context) {
+ final effectiveLean = (isCalibrated && lean != null) ? lean! : 0.0;
+ final isAvailable = isCalibrated && lean != null;
+ final String angleText = isAvailable ? '${effectiveLean.abs().toStringAsFixed(0)}°' : '--°';
+ final String directionText = isAvailable
+ ? (effectiveLean > 1.0 ? 'RIGHT' : (effectiveLean < -1.0 ? 'LEFT' : 'UPRIGHT'))
+ : 'UNCALIBRATED';
+
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -31,13 +44,13 @@ class LeanVisualizer extends StatelessWidget {
children: [
CustomPaint(
size: const Size(110, 110),
- painter: _LeanDialPainter(leanAngle: lean),
+ painter: _LeanDialPainter(leanAngle: effectiveLean),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
- '${lean.abs().toStringAsFixed(0)}°',
+ angleText,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
@@ -46,11 +59,11 @@ class LeanVisualizer extends StatelessWidget {
),
),
Text(
- lean > 1.0 ? 'RIGHT' : (lean < -1.0 ? 'LEFT' : 'UPRIGHT'),
+ directionText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
- color: lean.abs() > 1.0
+ color: isAvailable && effectiveLean.abs() > 1.0
? Theme.of(context).colorScheme.primary
: Colors.white38,
),
diff --git a/lib/features/dashboard/widgets/metric_tile.dart b/lib/features/dashboard/widgets/metric_tile.dart
index ccb25ce..4e74c02 100644
--- a/lib/features/dashboard/widgets/metric_tile.dart
+++ b/lib/features/dashboard/widgets/metric_tile.dart
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
-import '../../../core/theme/app_colors.dart';
-import '../../../core/theme/app_typography.dart';
+import '../../../core/widgets/hybrid_metric_tile.dart';
/// Standardized high-contrast metric card/tile for rider dashboard.
+///
+/// Directly delegates rendering to [HybridMetricTile] from MotorSignal Hybrid v1.
class MetricTile extends StatelessWidget {
final String label;
final String value;
@@ -21,67 +22,15 @@ class MetricTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final labelColor = isDark ? Colors.white54 : AppColors.lightOnSurfaceVariant;
- final valueColor = isDark ? Colors.white : AppColors.lightOnSurface;
- final unitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
-
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (icon != null) ...[
- Icon(icon, size: compact ? 11 : 13, color: labelColor),
- const SizedBox(width: 4),
- ],
- Text(
- label.toUpperCase(),
- style: TextStyle(
- fontFamily: AppTypography.fontBody,
- fontSize: compact ? 9 : 11,
- fontWeight: FontWeight.w800,
- letterSpacing: 1.0,
- color: labelColor,
- ),
- ),
- ],
- ),
- const SizedBox(height: 2),
- Row(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.baseline,
- textBaseline: TextBaseline.alphabetic,
- children: [
- Text(
- value,
- style: TextStyle(
- fontFamily: AppTypography.fontTelemetry,
- fontSize: compact ? 16 : 20,
- fontWeight: FontWeight.w800,
- color: valueColor,
- ),
- ),
- if (unit != null) ...[
- const SizedBox(width: 3),
- Text(
- unit!,
- style: TextStyle(
- fontFamily: AppTypography.fontTelemetry,
- fontSize: compact ? 10 : 12,
- fontWeight: FontWeight.w700,
- color: unitColor,
- ),
- ),
- ],
- ],
- ),
- ],
+ child: HybridMetricTile(
+ label: label,
+ value: value,
+ unit: unit,
+ icon: icon,
+ compact: compact,
),
);
}
diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart
index 88d0764..6921452 100644
--- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart
+++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/responsive/viewport_size.dart';
-import '../../../core/theme/app_colors.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../performance/models/performance_launch_models.dart';
import '../../performance/performance_providers.dart';
@@ -19,6 +18,7 @@ import 'performance_summary.dart';
import 'ride_action_button.dart';
import 'ride_status_chip.dart';
import 'speed_readout.dart';
+import '../../../core/widgets/hybrid_card.dart';
import '../../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../social/feed/widgets/quick_squad_feed_button.dart';
@@ -122,12 +122,11 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
const Spacer(flex: 2)
else
const Spacer(flex: 1),
- Container(
+ HybridCard(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isShortPortrait ? 4 : 10,
),
- decoration: _boxDecoration(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -169,12 +168,11 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
SizedBox(height: isShortPortrait ? 4 : 8),
const Expanded(flex: 5, child: DashboardMapPanel()),
SizedBox(height: isShortPortrait ? 4 : 10),
- Container(
+ HybridCard(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: isShortPortrait ? 4 : 6,
),
- decoration: _boxDecoration(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -247,12 +245,11 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
],
),
SizedBox(height: isShortPortrait ? 4 : 14),
- Container(
+ HybridCard(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: isShortPortrait ? 4 : 8,
),
- decoration: _boxDecoration(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -518,9 +515,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: SpeedReadout(size: SpeedReadoutSize.regular),
),
const Spacer(),
- Container(
+ HybridCard(
padding: const EdgeInsets.all(14),
- decoration: _boxDecoration(context),
child: Column(
children: [
Row(
@@ -577,9 +573,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
children: [
const GForceIndicator(),
const SizedBox(height: 16),
- Container(
+ HybridCard(
padding: const EdgeInsets.all(12),
- decoration: _boxDecoration(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -664,9 +659,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
children: [
const LeanAngleIndicator(),
const SizedBox(height: 16),
- Container(
+ HybridCard(
padding: const EdgeInsets.all(12),
- decoration: _boxDecoration(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -696,23 +690,6 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
],
);
}
-
- BoxDecoration _boxDecoration(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final colors = context.motorColors;
- return BoxDecoration(
- color: isDark
- ? colors.surfaceCard
- : AppColors.lightSurfaceContainer,
- borderRadius: BorderRadius.circular(14),
- border: Border.all(
- color: isDark
- ? colors.slateMuted
- : AppColors.lightOutlineVariant,
- width: 1.0,
- ),
- );
- }
}
/// Fast dashboard entry point for the launch timer used before a run.
@@ -792,14 +769,10 @@ class PerformanceLaunchShortcut extends ConsumerWidget {
vertical: compact ? 5 : 7,
),
decoration: BoxDecoration(
- color: Theme.of(context).brightness == Brightness.dark
- ? colors.surfaceCard
- : colors.surfaceContainer,
+ color: colors.surfaceCardPrimary,
borderRadius: BorderRadius.circular(10),
border: Border.all(
- color: Theme.of(context).brightness == Brightness.dark
- ? colors.slateMuted
- : colors.outlineVariant,
+ color: colors.surfaceBorder,
width: 1.0,
),
),
diff --git a/lib/features/dashboard/widgets/ride_action_button.dart b/lib/features/dashboard/widgets/ride_action_button.dart
index ac74c1a..92ae166 100644
--- a/lib/features/dashboard/widgets/ride_action_button.dart
+++ b/lib/features/dashboard/widgets/ride_action_button.dart
@@ -1,14 +1,11 @@
import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../../core/theme/app_colors.dart';
-import '../../../core/theme/app_typography.dart';
-import '../../rides/ride_state_machine.dart';
+import '../../../core/widgets/hybrid_riding_action_bar.dart';
/// Primary start/stop recording control button for the rider dashboard.
///
-/// Subscribes strictly to [rideStateMachineProvider.select((s) => s.state)]
-/// to prevent rebuilding on high-frequency telemetry samples.
-class RideActionButton extends ConsumerWidget {
+/// Wraps [HybridRidingActionBar] from MotorSignal Hybrid v1, preserving
+/// standard responsive heights, RID-2 error containment, and test hooks.
+class RideActionButton extends StatelessWidget {
final bool compact;
const RideActionButton({
@@ -17,72 +14,11 @@ class RideActionButton extends ConsumerWidget {
});
@override
- Widget build(BuildContext context, WidgetRef ref) {
- final state = ref.watch(
- rideStateMachineProvider.select((s) => s.state),
- );
-
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final isRiding = state == RideState.riding;
-
- final Color buttonColor;
- final String label;
- final IconData icon;
-
- if (isRiding) {
- buttonColor = AppColors.raceRed;
- label = 'Stop Session';
- icon = Icons.stop_circle_outlined;
- } else {
- buttonColor = isDark ? AppColors.electricCyan : AppColors.lightPrimary;
- label = 'Start Session';
- icon = Icons.play_circle_outlined;
- }
-
- return SizedBox(
- width: double.infinity,
- height: compact ? 48.0 : 56.0,
- child: ElevatedButton.icon(
- style: ElevatedButton.styleFrom(
- backgroundColor: buttonColor.withValues(alpha: isRiding ? 0.16 : 0.12),
- foregroundColor: buttonColor,
- side: BorderSide(color: buttonColor, width: 1.0),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(14),
- ),
- elevation: 0,
- ),
- onPressed: () {
- final notifier = ref.read(rideStateMachineProvider.notifier);
- if (isRiding) {
- // RID-2: the Stop action is fire-and-forget. finishRide() retains
- // its Future error semantics for callers that deliberately await
- // it, so the failure is contained here at the UI boundary using the
- // project's existing catchError convention
- // (create_group_ride_screen.dart) and its runtimeType-only
- // diagnostic convention (account_screen.dart, sync_observer.dart).
- // Only the exception class is logged - never its payload - so
- // tokens and ride content cannot leak, and output is bounded to one
- // line per tap. No success is fabricated, no error silently
- // swallowed.
- notifier.finishRide().catchError((Object error) {
- debugPrint('[RideStop] finish failed class=${error.runtimeType}');
- });
- } else {
- notifier.startRide();
- }
- },
- icon: Icon(icon, size: compact ? 20 : 24, color: buttonColor),
- label: Text(
- label,
- style: TextStyle(
- fontFamily: AppTypography.fontHeadline,
- fontSize: compact ? 15 : 18,
- fontWeight: FontWeight.w700,
- letterSpacing: 0.5,
- ),
- ),
- ),
+ Widget build(BuildContext context) {
+ return HybridRidingActionBar(
+ compact: compact,
+ finishLabel: 'Stop Session',
+ logTag: 'RideStop',
);
}
}
diff --git a/lib/features/dashboard/widgets/ride_status_chip.dart b/lib/features/dashboard/widgets/ride_status_chip.dart
index 5e9b341..d2ade02 100644
--- a/lib/features/dashboard/widgets/ride_status_chip.dart
+++ b/lib/features/dashboard/widgets/ride_status_chip.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
-import '../../../core/theme/app_typography.dart';
+import '../../../core/widgets/hybrid_static_rec_badge.dart';
import '../../rides/ride_state_machine.dart';
import '../../../models/recording_health_status.dart';
@@ -33,102 +33,63 @@ class RideStatusChip extends ConsumerWidget {
final isActiveRide = state == RideState.riding;
final isPaused = state == RideState.paused;
- // The glow/emphasis treatment belongs to a genuine healthy recording claim
- // only, never to a warm-up or degraded ride.
final claimsRecording =
isActiveRide && health == RecordingHealthStatus.recordingActive;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
- final Color badgeColor;
+ final Color? customColor;
final String label;
if (isActiveRide) {
switch (health) {
case RecordingHealthStatus.recordingActive:
- // Existing healthy/active treatment for this chip, preserved.
- badgeColor = AppColors.raceRed;
+ // Healthy active recording: uses canonical recStaticGreen (#10B981)
+ customColor = null;
label = 'RECORDING';
break;
case RecordingHealthStatus.recordingArmed:
case RecordingHealthStatus.recordingStarting:
// Ride exists but required evidence has not completed yet. Neutral /
// informational; the Stop action remains available elsewhere.
- badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
+ customColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'WARMING UP';
break;
case RecordingHealthStatus.recordingFinalizing:
// RID-3B-1 finalization has priority and is never shown as RECORDING.
- badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
+ customColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'FINALIZING';
break;
case RecordingHealthStatus.recordingDegraded:
case RecordingHealthStatus.recordingFailed:
// Warning, NOT danger: DEGRADED means the ride is still active and still
// stoppable - not ended, discarded, failed or service-stopped.
- badgeColor = AppColors.warningAmber;
+ customColor = AppColors.warningAmber;
label = 'DEGRADED';
break;
case RecordingHealthStatus.recordingComplete:
// Unreachable alongside an active ride; must not present as recording.
- badgeColor = theme.colorScheme.onSurfaceVariant;
+ customColor = null;
label = 'STANDBY';
break;
}
} else if (isPaused) {
- badgeColor = AppColors.warningAmber;
+ customColor = null;
label = 'PAUSED';
} else if (state == RideState.ready) {
- badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
+ customColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'READY';
} else {
- badgeColor = theme.colorScheme.onSurfaceVariant;
+ customColor = null;
label = 'STANDBY';
}
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
- decoration: BoxDecoration(
- color: badgeColor.withValues(alpha: claimsRecording ? 0.18 : 0.10),
- borderRadius: BorderRadius.circular(6),
- border: Border.all(
- color: badgeColor.withValues(alpha: claimsRecording ? 0.8 : 0.35),
- width: 1.0,
- ),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- width: 7,
- height: 7,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: badgeColor,
- boxShadow: claimsRecording
- ? [
- BoxShadow(
- color: badgeColor.withValues(alpha: 0.6),
- blurRadius: 6,
- ),
- ]
- : null,
- ),
- ),
- const SizedBox(width: 6),
- Text(
- label,
- style: TextStyle(
- fontFamily: AppTypography.fontTelemetry,
- fontSize: 10,
- fontWeight: FontWeight.w800,
- color: badgeColor,
- letterSpacing: 0.6,
- ),
- ),
- ],
- ),
+ return HybridStaticRecBadge(
+ isRecording: claimsRecording,
+ isPaused: isPaused,
+ customLabel: label,
+ customColor: customColor,
);
}
}
diff --git a/lib/features/garage/garage_screen.dart b/lib/features/garage/garage_screen.dart
index 52bc735..9b1eaef 100644
--- a/lib/features/garage/garage_screen.dart
+++ b/lib/features/garage/garage_screen.dart
@@ -10,6 +10,7 @@ import 'garage_providers.dart';
import 'widgets/motorcycle_photo.dart';
import '../../services/entitlements/entitlement_providers.dart';
import '../../services/entitlements/feature_access_service.dart';
+import '../../services/sync/sync_providers.dart';
import '../entitlements/widgets/pro_lock_badge.dart';
class GarageScreen extends ConsumerWidget {
@@ -26,13 +27,24 @@ class GarageScreen extends ConsumerWidget {
'My Garage',
style: TextStyle(fontWeight: FontWeight.bold),
),
-
+ actions: [
+ IconButton(
+ tooltip: 'Sync Garage with Cloud',
+ icon: const Icon(Icons.cloud_sync_outlined),
+ onPressed: () async {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Syncing garage with cloud...')),
+ );
+ await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
+ },
+ ),
+ ],
),
body: bikesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error loading garage: $err')),
data: (bikes) {
- if (bikes.isEmpty) return _buildEmptyState(context, colors);
+ if (bikes.isEmpty) return _buildEmptyState(context, colors, ref);
return ResponsiveLayout(
builder: (context, responsive) {
@@ -102,7 +114,11 @@ class GarageScreen extends ConsumerWidget {
context.push('/garage/add');
}
- Widget _buildEmptyState(BuildContext context, MotorSignalThemeColors colors) {
+ Widget _buildEmptyState(
+ BuildContext context,
+ MotorSignalThemeColors colors,
+ WidgetRef ref,
+ ) {
final theme = Theme.of(context);
return Center(
child: Padding(
@@ -159,6 +175,24 @@ class GarageScreen extends ConsumerWidget {
),
onPressed: () => context.push('/garage/add'),
),
+ const SizedBox(height: 12),
+ OutlinedButton.icon(
+ key: const ValueKey('garage_sync_cloud_button'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: colors.cyberCyan,
+ side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)),
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ ),
+ icon: const Icon(Icons.cloud_sync_outlined, size: 18),
+ label: const Text('Restore from Cloud / Sync'),
+ onPressed: () async {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Checking cloud for motorcycles...')),
+ );
+ await ref.read(firebaseAccountSyncServiceProvider).syncAccount();
+ },
+ ),
],
),
),
diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart
index 057adf1..d7ec7cd 100644
--- a/lib/features/group_rides/group_ride_detail_screen.dart
+++ b/lib/features/group_rides/group_ride_detail_screen.dart
@@ -11,6 +11,7 @@ import '../../services/group_rides/group_ride_models.dart';
import '../../services/group_rides/group_ride_providers.dart';
import '../../services/group_rides/live_broadcast_controller.dart';
import '../../services/social/social_providers.dart';
+import '../../services/social/rider_profile_models.dart';
import '../../services/routes/route_providers.dart';
import '../../services/clubs/club_models.dart';
import '../../services/clubs/club_providers.dart';
@@ -901,8 +902,31 @@ class _GroupRideDetailScreenState extends ConsumerState {
onPressed: _isProcessing
? null
: () async {
- final profile = ref.read(myRiderProfileProvider).value;
- if (profile == null) return;
+ var profile = ref.read(myRiderProfileProvider).valueOrNull;
+ if (profile == null) {
+ try {
+ profile = await ref.read(myRiderProfileProvider.future);
+ } catch (_) {}
+ }
+ if (profile == null) {
+ final user = ref.read(currentUserProvider);
+ if (user != null && !user.isAnonymous) {
+ profile = RiderProfile(
+ uid: user.uid,
+ handle: user.email?.split('@').first ?? 'rider',
+ displayName: user.displayName ?? (user.email?.split('@').first ?? 'Rider'),
+ createdAt: DateTime.now(),
+ updatedAt: DateTime.now(),
+ );
+ }
+ }
+ if (profile == null) {
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Please sign in or wait for profile to load.')),
+ );
+ return;
+ }
setState(() => _isProcessing = true);
try {
await service.requestToJoin(
diff --git a/lib/features/group_rides/group_rides_hub_screen.dart b/lib/features/group_rides/group_rides_hub_screen.dart
index 8bd2b66..946a67e 100644
--- a/lib/features/group_rides/group_rides_hub_screen.dart
+++ b/lib/features/group_rides/group_rides_hub_screen.dart
@@ -3,10 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/responsive/viewport_size.dart';
+import '../../core/theme/app_typography.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/auth/auth_providers.dart';
+import '../../services/auth/auth_user.dart';
+import '../../services/entitlements/entitlement_providers.dart';
import '../../services/group_rides/group_ride_models.dart';
import '../../services/group_rides/group_ride_providers.dart';
+import '../../services/social/social_providers.dart';
class GroupRidesHubScreen extends ConsumerStatefulWidget {
const GroupRidesHubScreen({super.key});
@@ -39,20 +43,21 @@ class _GroupRidesHubScreenState extends ConsumerState
final isAuthed = user != null && !user.isAnonymous;
return Scaffold(
- backgroundColor: colors.surfaceContainer,
+ backgroundColor: colors.surfaceCanvas,
appBar: AppBar(
- backgroundColor: colors.surfaceContainerHigh,
+ backgroundColor: colors.surfaceCardPrimary,
elevation: 0,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
- Icon(Icons.two_wheeler, color: colors.cyberCyan, size: 22),
+ Icon(Icons.two_wheeler, color: colors.brandCyan, size: 22),
const SizedBox(width: 8),
const Flexible(
child: Text(
'GROUP RIDES',
overflow: TextOverflow.ellipsis,
style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
fontSize: 15,
@@ -61,34 +66,44 @@ class _GroupRidesHubScreenState extends ConsumerState
),
],
),
- bottom: TabBar(
- controller: _tabController,
- indicatorColor: colors.cyberCyan,
- indicatorWeight: 3,
- labelColor: colors.cyberCyan,
- unselectedLabelColor:
- Theme.of(context).colorScheme.onSurfaceVariant,
- labelStyle: const TextStyle(
- fontWeight: FontWeight.bold,
- letterSpacing: 0.5,
- fontSize: 12,
+ bottom: PreferredSize(
+ preferredSize: const Size.fromHeight(48),
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
+ ),
+ ),
+ child: TabBar(
+ controller: _tabController,
+ indicatorColor: colors.brandCyan,
+ indicatorWeight: 3,
+ labelColor: colors.brandCyan,
+ unselectedLabelColor: colors.textMuted,
+ labelStyle: const TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 0.5,
+ fontSize: 12,
+ ),
+ tabs: const [
+ Tab(height: 48, text: 'UPCOMING RIDES'),
+ Tab(height: 48, text: 'MY RIDES'),
+ ],
+ ),
),
- tabs: const [
- Tab(text: 'UPCOMING RIDES'),
- Tab(text: 'MY RIDES'),
- ],
),
actions: [
if (isAuthed) ...[
IconButton(
key: const ValueKey('group_rides_join_appbar_button'),
- icon: Icon(Icons.group_add_outlined, color: colors.cyberCyan),
+ icon: Icon(Icons.group_add_outlined, color: colors.brandCyan),
tooltip: 'Join with Ride ID',
onPressed: () => _showJoinRideDialog(context, colors),
),
IconButton(
key: const ValueKey('group_rides_create_appbar_button'),
- icon: Icon(Icons.add, color: colors.cyberCyan),
+ icon: Icon(Icons.add, color: colors.brandCyan),
tooltip: 'Create Group Ride',
onPressed: () => context.push('/group-rides/create'),
),
@@ -100,11 +115,18 @@ class _GroupRidesHubScreenState extends ConsumerState
if (!isAuthed) {
return _buildAccountRequiredNotice(context, colors);
}
- return TabBarView(
- controller: _tabController,
+ return Column(
children: [
- _buildUpcomingTab(context, ref, user.uid, snapshot, colors),
- _buildMyRidesTab(context, ref, user.uid, snapshot, colors),
+ _buildMemberProfileBanner(context, ref, user, colors),
+ Expanded(
+ child: TabBarView(
+ controller: _tabController,
+ children: [
+ _buildUpcomingTab(context, ref, user.uid, snapshot, colors),
+ _buildMyRidesTab(context, ref, user.uid, snapshot, colors),
+ ],
+ ),
+ ),
],
);
},
@@ -112,7 +134,7 @@ class _GroupRidesHubScreenState extends ConsumerState
floatingActionButton: isAuthed
? FloatingActionButton.extended(
key: const ValueKey('create_group_ride_fab'),
- backgroundColor: colors.cyberCyan,
+ backgroundColor: colors.brandCyan,
foregroundColor: colors.onBrand,
icon: const Icon(Icons.add_road),
label: const Text(
@@ -125,6 +147,170 @@ class _GroupRidesHubScreenState extends ConsumerState
);
}
+ Widget _buildMemberProfileBanner(
+ BuildContext context,
+ WidgetRef ref,
+ AuthUser user,
+ MotorSignalThemeColors colors,
+ ) {
+ final profileAsync = ref.watch(myRiderProfileProvider);
+ final isPro = ref.watch(isProSubscriberProvider);
+ final profile = profileAsync.valueOrNull;
+
+ final displayName = (profile != null && profile.displayName.isNotEmpty)
+ ? profile.displayName
+ : (user.email != null && user.email!.isNotEmpty
+ ? user.email!.split('@').first
+ : 'Rider');
+ final handle = (profile != null && profile.handle.isNotEmpty)
+ ? profile.handle
+ : (user.email != null && user.email!.isNotEmpty
+ ? user.email!.split('@').first
+ : 'rider');
+ final avatarInitial =
+ displayName.isNotEmpty ? displayName[0].toUpperCase() : 'R';
+
+ return Container(
+ decoration: BoxDecoration(
+ color: colors.surfaceCardPrimary,
+ border: Border(
+ bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
+ ),
+ ),
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 20,
+ backgroundColor: colors.brandCyan.withValues(alpha: 0.15),
+ foregroundColor: colors.brandCyan,
+ child: Text(
+ avatarInitial,
+ style: const TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontWeight: FontWeight.w800,
+ fontSize: 16,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ displayName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 15,
+ fontWeight: FontWeight.w800,
+ color: colors.textPrimary,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Wrap(
+ spacing: 6,
+ runSpacing: 2,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: isPro
+ ? colors.brandCyan.withValues(alpha: 0.15)
+ : colors.surfaceBorder.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ isPro ? 'PRO MEMBER' : 'FREE RIDER',
+ style: TextStyle(
+ fontSize: 9,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.5,
+ color: isPro ? colors.brandCyan : colors.textMuted,
+ ),
+ ),
+ ),
+ Text(
+ '@$handle',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 12,
+ color: colors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 48, minWidth: 48),
+ child: OutlinedButton.icon(
+ key: const ValueKey('group_rides_account_profile_button'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: colors.brandCyan,
+ side: BorderSide(color: colors.surfaceBorder),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 8,
+ ),
+ ),
+ onPressed: () => context.push('/account'),
+ icon: const Icon(Icons.person_outline, size: 16),
+ label: const Text(
+ 'Account',
+ style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ SizedBox(
+ width: double.infinity,
+ height: 48,
+ child: ElevatedButton.icon(
+ key: const ValueKey('create_group_ride_cta_banner'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.brandCyan,
+ foregroundColor: colors.onBrand,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ elevation: 0,
+ ),
+ onPressed: () => context.push('/group-rides/create'),
+ icon: const Icon(Icons.add_road, size: 18),
+ label: const Text(
+ 'CREATE GROUP RIDE',
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontWeight: FontWeight.w800,
+ fontSize: 13,
+ letterSpacing: 0.5,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
Widget _buildAccountRequiredNotice(
BuildContext context,
MotorSignalThemeColors colors,
@@ -229,7 +415,7 @@ class _GroupRidesHubScreenState extends ConsumerState
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
- mainAxisExtent: 185,
+ mainAxisExtent: 260,
),
itemCount: rides.length,
itemBuilder: (context, index) {
@@ -249,7 +435,7 @@ class _GroupRidesHubScreenState extends ConsumerState
);
},
loading: () => Center(
- child: CircularProgressIndicator(color: colors.cyberCyan),
+ child: CircularProgressIndicator(color: colors.brandCyan),
),
error: (e, _) => _buildErrorState(
colors: colors,
@@ -293,7 +479,7 @@ class _GroupRidesHubScreenState extends ConsumerState
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
- mainAxisExtent: 185,
+ mainAxisExtent: 260,
),
itemCount: myRides.length,
itemBuilder: (context, index) {
@@ -313,7 +499,7 @@ class _GroupRidesHubScreenState extends ConsumerState
);
},
loading: () => Center(
- child: CircularProgressIndicator(color: colors.cyberCyan),
+ child: CircularProgressIndicator(color: colors.brandCyan),
),
error: (e, _) => _buildErrorState(
colors: colors,
@@ -516,18 +702,18 @@ class _GroupRidesHubScreenState extends ConsumerState
String currentUid,
MotorSignalThemeColors colors,
) {
- final theme = Theme.of(context);
final isOrganizer = ride.organizerUid == currentUid;
+ final isParticipant = ride.accessUids.contains(currentUid);
return Card(
margin: const EdgeInsets.only(bottom: 12),
- color: colors.surfaceContainerHigh,
+ color: colors.surfaceCardPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isOrganizer
- ? colors.cyberCyan.withValues(alpha: 0.4)
- : colors.outlineVariant,
+ ? colors.brandCyan.withValues(alpha: 0.5)
+ : colors.surfaceBorder,
),
),
child: InkWell(
@@ -539,64 +725,78 @@ class _GroupRidesHubScreenState extends ConsumerState
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Row(
+ // Header: status, organizer badge, participant counter
+ Wrap(
+ spacing: 8,
+ runSpacing: 6,
+ alignment: WrapAlignment.spaceBetween,
+ crossAxisAlignment: WrapCrossAlignment.center,
children: [
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: ride.isCanceled
- ? colors.raceRed.withValues(alpha: 0.2)
- : colors.cyberCyan.withValues(alpha: 0.15),
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- ride.status.label.toUpperCase(),
- style: TextStyle(
- fontSize: 10,
- fontWeight: FontWeight.w900,
- color: ride.isCanceled
- ? colors.raceRed
- : colors.cyberCyan,
- ),
- ),
- ),
- const SizedBox(width: 8),
- if (isOrganizer)
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 6,
- vertical: 2,
- ),
- decoration: BoxDecoration(
- color: colors.warningAmber.withValues(alpha: 0.2),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(
- 'ORGANIZER',
- style: TextStyle(
- fontSize: 9,
- fontWeight: FontWeight.bold,
- color: colors.warningAmber,
+ Wrap(
+ spacing: 8,
+ runSpacing: 4,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: ride.isCanceled
+ ? colors.dangerRed.withValues(alpha: 0.15)
+ : colors.brandCyan.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Text(
+ ride.status.label.toUpperCase(),
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 10,
+ fontWeight: FontWeight.w900,
+ color: ride.isCanceled
+ ? colors.dangerRed
+ : colors.brandCyan,
+ ),
),
),
- ),
- const Spacer(),
+ if (isOrganizer)
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: colors.cautionAmber.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ 'ORGANIZER',
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 9,
+ fontWeight: FontWeight.bold,
+ color: colors.cautionAmber,
+ ),
+ ),
+ ),
+ ],
+ ),
Row(
+ mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 16,
- color: theme.colorScheme.onSurfaceVariant,
+ color: colors.textMuted,
),
const SizedBox(width: 4),
Text(
'${ride.participantCount}${ride.participantLimit != null ? '/${ride.participantLimit}' : ''}',
style: TextStyle(
+ fontFamily: AppTypography.fontTelemetry,
fontSize: 12,
- color: theme.colorScheme.onSurfaceVariant,
+ color: colors.textPrimary,
fontWeight: FontWeight.bold,
),
),
@@ -605,76 +805,245 @@ class _GroupRidesHubScreenState extends ConsumerState
],
),
const SizedBox(height: 10),
+ // Title
Text(
ride.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
fontSize: 17,
fontWeight: FontWeight.bold,
- color: theme.colorScheme.onSurface,
+ color: colors.textPrimary,
),
),
- const SizedBox(height: 6),
- Row(
- children: [
- Icon(
- Icons.schedule,
- size: 14,
- color: colors.cyberCyan,
+ const SizedBox(height: 8),
+ // Dedicated Full-Width Logistics Container
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: colors.surfaceCardSecondary,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: colors.surfaceBorderSubtle,
+ width: 0.8,
),
- const SizedBox(width: 6),
- Expanded(
- child: Text(
- 'Meet: ${_formatDateTime(ride.scheduledStartAt)} • KSU: ${_formatTime(ride.departureAt)}',
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- fontSize: 12,
- color: theme.colorScheme.onSurfaceVariant,
- fontWeight: FontWeight.w500,
- ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ Icons.schedule,
+ size: 14,
+ color: colors.brandCyan,
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ 'Meet: ${_formatDateTime(ride.scheduledStartAt)} • KSU: ${_formatTime(ride.departureAt)}',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 12,
+ color: colors.textPrimary,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
),
- ),
- ],
- ),
- const SizedBox(height: 4),
- Row(
- children: [
- Icon(
- Icons.place_outlined,
- size: 14,
- color: theme.colorScheme.onSurfaceVariant,
- ),
- const SizedBox(width: 6),
- Expanded(
- child: Text(
- ride.meetingPoint.displayName,
- style: TextStyle(
- fontSize: 12,
- color: theme.colorScheme.onSurfaceVariant,
- overflow: TextOverflow.ellipsis,
- ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Icon(
+ Icons.place_outlined,
+ size: 14,
+ color: colors.textMuted,
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ ride.meetingPoint.displayName,
+ style: TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 12,
+ color: colors.textMuted,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ ],
),
- ),
- ],
+ if (ride.destination != null) ...[
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Icon(
+ Icons.flag_outlined,
+ size: 14,
+ color: colors.cautionAmber,
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ 'Destination: ${ride.destination!.displayName}',
+ style: TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 12,
+ color: colors.textMuted,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ],
+ ),
),
- if (ride.destination != null) ...[
- const SizedBox(height: 4),
+ const SizedBox(height: 12),
+ // Isolated Action Buttons Row (min 48dp height)
+ if (isOrganizer) ...[
Row(
children: [
- Icon(
- Icons.flag_outlined,
- size: 14,
- color: colors.warningAmber,
- ),
- const SizedBox(width: 6),
Expanded(
- child: Text(
- 'Destination: ${ride.destination!.displayName}',
- style: TextStyle(
- fontSize: 12,
- color: theme.colorScheme.onSurfaceVariant,
- overflow: TextOverflow.ellipsis,
+ child: SizedBox(
+ height: 48,
+ child: OutlinedButton(
+ key: ValueKey('manage_ride_${ride.id}'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: colors.brandCyan,
+ side: BorderSide(
+ color: colors.brandCyan.withValues(alpha: 0.5),
+ ),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: () => context.push('/group-rides/${ride.id}'),
+ child: const Text(
+ 'MANAGE RIDE',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: SizedBox(
+ height: 48,
+ child: ElevatedButton(
+ key: ValueKey('open_hub_${ride.id}'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.brandCyan,
+ foregroundColor: colors.onBrand,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: () => context.push('/group-rides/${ride.id}'),
+ child: const Text(
+ 'OPEN HUB',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ] else if (isParticipant) ...[
+ Row(
+ children: [
+ Expanded(
+ child: SizedBox(
+ height: 48,
+ child: ElevatedButton(
+ key: ValueKey('details_ride_${ride.id}'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.surfaceCardSecondary,
+ foregroundColor: colors.textPrimary,
+ side: BorderSide(color: colors.surfaceBorder),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: () => context.push('/group-rides/${ride.id}'),
+ child: const Text(
+ 'RIDE DETAILS',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: SizedBox(
+ height: 48,
+ child: OutlinedButton(
+ key: ValueKey('leave_pack_${ride.id}'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: colors.cautionAmber,
+ side: BorderSide(
+ color: colors.cautionAmber.withValues(alpha: 0.5),
+ ),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: () => context.push('/group-rides/${ride.id}'),
+ child: const Text(
+ 'LEAVE PACK',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ] else ...[
+ Row(
+ children: [
+ Expanded(
+ child: SizedBox(
+ height: 48,
+ child: OutlinedButton(
+ key: ValueKey('details_ride_${ride.id}'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: colors.textPrimary,
+ side: BorderSide(color: colors.surfaceBorder),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: () => context.push('/group-rides/${ride.id}'),
+ child: const Text(
+ 'RIDE DETAILS',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: SizedBox(
+ height: 48,
+ child: ElevatedButton(
+ key: ValueKey('join_ride_${ride.id}'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colors.brandCyan,
+ foregroundColor: colors.onBrand,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: () => context.push('/group-rides/${ride.id}'),
+ child: const Text(
+ 'JOIN RIDE',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
+ ),
),
),
),
diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart
index fd12c66..01daf66 100644
--- a/lib/features/history/history_screen.dart
+++ b/lib/features/history/history_screen.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:math' as math;
import 'package:intl/intl.dart';
import '../../database/database.dart';
+import 'package:drift/drift.dart' as drift;
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import '../garage/garage_providers.dart';
@@ -44,7 +45,9 @@ class _HistoryScreenState extends ConsumerState {
_buildFilterBar(context, garageBikesAsync.value ?? [], defaultBike),
Expanded(
child: StreamBuilder>(
- stream: database.select(database.rides).watch(),
+ stream: (database.select(database.rides)
+ ..orderBy([(t) => drift.OrderingTerm(expression: t.id, mode: drift.OrderingMode.desc)]))
+ .watch(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
@@ -54,9 +57,32 @@ class _HistoryScreenState extends ConsumerState {
return Center(child: Text('Error: ${snapshot.error}'));
}
- var rides = snapshot.data ?? [];
+ var rides = List.from(snapshot.data ?? []);
- // Filter logic
+ // Normalize legacy milliseconds timestamps vs Drift seconds timestamps
+ DateTime normalizeEpoch(DateTime dt) {
+ if (dt.year > 3000) {
+ return DateTime.fromMillisecondsSinceEpoch(
+ dt.millisecondsSinceEpoch ~/ 1000,
+ isUtc: true,
+ );
+ }
+ return dt.toUtc();
+ }
+
+ // Strict descending chronological sort (newest rides first, tie-break on id)
+ rides.sort((a, b) {
+ final timeA = normalizeEpoch(a.startTime);
+ final timeB = normalizeEpoch(b.startTime);
+ final cmp = timeB.compareTo(timeA);
+ if (cmp != 0) return cmp;
+ return b.id.compareTo(a.id);
+ });
+
+ // Filter logic:
+ // null = All Motorcycles (show all rides, including rides with no motorcycle)
+ // -1 = Current default motorcycle
+ // > 0 = Specific motorcycle ID
if (_selectedMotorcycleId == -1 && defaultBike != null) {
rides = rides
.where((r) => r.motorcycleId == defaultBike.id)
@@ -68,9 +94,6 @@ class _HistoryScreenState extends ConsumerState {
.toList();
}
- // Sort by start time descending
- rides.sort((a, b) => b.startTime.compareTo(a.startTime));
-
if (rides.isEmpty) {
return Center(
child: Column(
@@ -139,7 +162,10 @@ class _HistoryScreenState extends ConsumerState {
: null;
final theme = Theme.of(context);
- final dateFormatted = DateFormat('yyyy-MM-dd HH:mm').format(ride.startTime.toLocal());
+ final normalizedDate = ride.startTime.year > 3000
+ ? DateTime.fromMillisecondsSinceEpoch(ride.startTime.millisecondsSinceEpoch ~/ 1000)
+ : ride.startTime;
+ final dateFormatted = DateFormat('yyyy-MM-dd HH:mm').format(normalizedDate.toLocal());
return Card(
elevation: 2,
diff --git a/lib/features/history/ride_detail_screen.dart b/lib/features/history/ride_detail_screen.dart
index 2930350..c952e5e 100644
--- a/lib/features/history/ride_detail_screen.dart
+++ b/lib/features/history/ride_detail_screen.dart
@@ -397,26 +397,88 @@ class _RideDetailScreenState extends ConsumerState {
),
),
const SizedBox(height: 4),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 6,
- vertical: 2,
- ),
- decoration: BoxDecoration(
- color: colors.cyberCyan.withValues(alpha: 0.15),
- borderRadius: BorderRadius.circular(6),
- ),
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- 'VIEW MAP >',
- style: TextStyle(
- fontFamily: 'JetBrains Mono',
- fontSize: 8,
- fontWeight: FontWeight.w800,
- color: colors.cyberCyan,
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: colors.cyberCyan.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Text(
+ 'VIEW MAP',
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ fontSize: 8,
+ fontWeight: FontWeight.w800,
+ color: colors.cyberCyan,
+ ),
+ ),
),
- ),
+ const SizedBox(width: 4),
+ InkWell(
+ onTap: () {
+ if (ride.distanceMiles <= 0.05) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: const Text('Stationary session — no moving route recorded to replay.'),
+ duration: const Duration(seconds: 2),
+ backgroundColor: colors.surfaceContainer,
+ ),
+ );
+ return;
+ }
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ReplayScreen(rideId: ride.id, autoPlay: true),
+ ),
+ );
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: (ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant)
+ .withValues(alpha: 0.2),
+ borderRadius: BorderRadius.circular(6),
+ border: Border.all(
+ color: (ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant)
+ .withValues(alpha: 0.6),
+ width: 0.8,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ ride.distanceMiles > 0.05 ? Icons.play_arrow_rounded : Icons.pause_circle_outline,
+ size: 10,
+ color: ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant,
+ ),
+ const SizedBox(width: 2),
+ Text(
+ ride.distanceMiles > 0.05 ? 'PLAY >' : '0.0 MI',
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ fontSize: 8,
+ fontWeight: FontWeight.w800,
+ color: ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
),
),
],
@@ -472,7 +534,15 @@ class _RideDetailScreenState extends ConsumerState {
'TOP SPEED:',
'${ride.topSpeedMph.toStringAsFixed(1)} mph',
'DATE:',
- DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal()),
+ DateFormat('MM/dd/yyyy').format(
+ (ride.startTime.year > 3000
+ ? DateTime.fromMillisecondsSinceEpoch(
+ ride.startTime.millisecondsSinceEpoch ~/ 1000,
+ isUtc: true,
+ )
+ : ride.startTime)
+ .toLocal(),
+ ),
theme,
colors,
),
diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart
index c035dad..19d40eb 100644
--- a/lib/features/map/map_screen.dart
+++ b/lib/features/map/map_screen.dart
@@ -15,6 +15,7 @@ import '../../services/alerts/road_alert_service.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/map/map_layer_settings.dart';
import 'widgets/tactical_layers_bottom_sheet.dart';
+import '../replay/replay_screen.dart';
class MapScreen extends ConsumerStatefulWidget {
final int? rideId;
@@ -546,6 +547,18 @@ class _MapScreenState extends ConsumerState {
icon: Icon(Icons.layers_outlined, color: colors.cyberCyan),
onPressed: () => TacticalLayersBottomSheet.show(context),
),
+ IconButton(
+ key: const ValueKey('play_replay_appbar_button'),
+ tooltip: 'Fire Off Replay',
+ icon: Icon(Icons.play_circle_fill, color: colors.cyberCyan),
+ onPressed: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ReplayScreen(rideId: widget.rideId!, autoPlay: true),
+ ),
+ );
+ },
+ ),
],
),
@@ -603,6 +616,48 @@ class _MapScreenState extends ConsumerState {
),
),
+ // Bottom Center: FIRE OFF REPLAY floating button
+ if (_routePoints.isNotEmpty)
+ Positioned(
+ bottom: 24,
+ left: 20,
+ right: 80,
+ child: Center(
+ child: FilledButton.icon(
+ key: const ValueKey('fire_off_ride_preview_button'),
+ onPressed: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ReplayScreen(
+ rideId: widget.rideId!,
+ autoPlay: true,
+ ),
+ ),
+ );
+ },
+ icon: const Icon(Icons.play_arrow_rounded, size: 22),
+ label: const Text(
+ 'FIRE OFF REPLAY',
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ fontSize: 12.5,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.6,
+ ),
+ ),
+ style: FilledButton.styleFrom(
+ backgroundColor: colors.cyberCyan,
+ foregroundColor: Colors.black,
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
+ elevation: 4,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(24),
+ ),
+ ),
+ ),
+ ),
+ ),
+
// Right HUD zoom controls on history preview
Positioned(
right: 14,
diff --git a/lib/features/map/widgets/tactical_layers_bottom_sheet.dart b/lib/features/map/widgets/tactical_layers_bottom_sheet.dart
index afd8014..14bd295 100644
--- a/lib/features/map/widgets/tactical_layers_bottom_sheet.dart
+++ b/lib/features/map/widgets/tactical_layers_bottom_sheet.dart
@@ -87,15 +87,20 @@ class TacticalLayersBottomSheet extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
+ mainAxisSize: MainAxisSize.min,
children: [
- Text(
- 'TACTICAL MAP LAYERS',
- style: TextStyle(
- fontFamily: 'Inter',
- fontSize: 15,
- fontWeight: FontWeight.w800,
- letterSpacing: 0.8,
- color: theme.colorScheme.onSurface,
+ Flexible(
+ child: Text(
+ 'TACTICAL MAP LAYERS',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 15,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.8,
+ color: theme.colorScheme.onSurface,
+ ),
),
),
const SizedBox(width: 8),
diff --git a/lib/features/replay/replay_screen.dart b/lib/features/replay/replay_screen.dart
index baac162..e6d9cf8 100644
--- a/lib/features/replay/replay_screen.dart
+++ b/lib/features/replay/replay_screen.dart
@@ -54,8 +54,9 @@ class CornerAnalysis {
class ReplayScreen extends ConsumerStatefulWidget {
final int rideId;
+ final bool autoPlay;
- const ReplayScreen({super.key, required this.rideId});
+ const ReplayScreen({super.key, required this.rideId, this.autoPlay = false});
@override
ConsumerState createState() => _ReplayScreenState();
@@ -91,7 +92,7 @@ class _ReplayScreenState extends ConsumerState {
var targetRideId = widget.rideId;
if (targetRideId == 0) {
final latestRide = await (db.select(db.rides)
- ..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)])
+ ..orderBy([(r) => OrderingTerm(expression: r.id, mode: OrderingMode.desc)])
..limit(1))
.getSingleOrNull();
if (latestRide != null) {
@@ -119,6 +120,13 @@ class _ReplayScreenState extends ConsumerState {
_telemetry = telemetry;
_isLoading = false;
});
+ if (widget.autoPlay && telemetry.isNotEmpty) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted && !_isPlaying) {
+ _togglePlayback();
+ }
+ });
+ }
}
}
@@ -475,41 +483,56 @@ class _ReplayScreenState extends ConsumerState {
zoom: 17.0,
markers: allMarkers,
polylines: polylines,
+ isFollowModeOverride: true,
),
// Telemetry Overlay
Positioned(
top: 14,
left: 14,
right: 14,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- _buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
- _buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
- _buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
- ],
+ child: Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ _buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
+ const SizedBox(width: 8),
+ _buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
+ const SizedBox(width: 8),
+ _buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
+ ],
+ ),
+ ),
),
),
// Heatmap Legend Overlay
Positioned(
bottom: 10,
left: 14,
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
- decoration: BoxDecoration(
- color: Colors.black.withValues(alpha: 0.8),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: Colors.white24),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- _buildLegendItem(Colors.amberAccent, '≤20°'),
- const SizedBox(width: 8),
- _buildLegendItem(Colors.cyanAccent, '20°-35°'),
- const SizedBox(width: 8),
- _buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
- ],
+ right: 14,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.8),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.white24),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildLegendItem(Colors.amberAccent, '≤20°'),
+ const SizedBox(width: 8),
+ _buildLegendItem(Colors.cyanAccent, '20°-35°'),
+ const SizedBox(width: 8),
+ _buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
+ ],
+ ),
+ ),
),
),
),
@@ -527,15 +550,19 @@ class _ReplayScreenState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Text(
- currentSample.timestamp.toLocal().toString().split('.')[0],
- style: const TextStyle(
- color: Colors.cyanAccent,
- fontWeight: FontWeight.bold,
- fontSize: 12,
- fontFamily: 'JetBrains Mono',
+ Flexible(
+ child: Text(
+ currentSample.timestamp.toLocal().toString().split('.')[0],
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ color: Colors.cyanAccent,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ fontFamily: 'JetBrains Mono',
+ ),
),
),
+ const SizedBox(width: 8),
Text(
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
style: TextStyle(
@@ -604,16 +631,20 @@ class _ReplayScreenState extends ConsumerState {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Text(
- 'CORNER APEX BREAKDOWN & GRADING',
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.bold,
- fontFamily: 'JetBrains Mono',
- color: colors.cyberCyan,
- letterSpacing: 0.8,
+ Flexible(
+ child: Text(
+ 'CORNER APEX BREAKDOWN & GRADING',
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ fontFamily: 'JetBrains Mono',
+ color: colors.cyberCyan,
+ letterSpacing: 0.8,
+ ),
),
),
+ const SizedBox(width: 8),
Text(
'Tap row to seek apex',
style: TextStyle(
diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart
index b3183ad..bc397ed 100644
--- a/lib/features/rides/ride_state_machine.dart
+++ b/lib/features/rides/ride_state_machine.dart
@@ -478,7 +478,15 @@ class RideStateMachine extends StateNotifier {
);
} catch (_) {}
- final defaultBike = ref.read(defaultMotorcycleProvider);
+ var defaultBike = ref.read(defaultMotorcycleProvider);
+ if (defaultBike == null) {
+ try {
+ final bikes = await (db.select(db.garageMotorcycles)
+ ..orderBy([(t) => drift.OrderingTerm(expression: t.isDefault, mode: drift.OrderingMode.desc)]))
+ .get();
+ if (bikes.isNotEmpty) defaultBike = bikes.first;
+ } catch (_) {}
+ }
final startTime = DateTime.now();
final id = await db.startRide(
startTime: startTime,
diff --git a/lib/features/settings/diagnostics_screen.dart b/lib/features/settings/diagnostics_screen.dart
index e465cd2..dd9551d 100644
--- a/lib/features/settings/diagnostics_screen.dart
+++ b/lib/features/settings/diagnostics_screen.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/responsive/viewport_size.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/telemetry_service.dart';
+import '../../services/sensor_fusion_service.dart';
/// Canonical responsive diagnostics screen displaying sensor health, GNSS fidelity, and raw IMU readings.
class DiagnosticsScreen extends ConsumerWidget {
@@ -410,6 +411,7 @@ class _ImuGForceCard extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final telemetry = ref.watch(telemetryStreamProvider).value;
+ final diag = ref.watch(sensorDiagnosticsStreamProvider).value;
return Material(
color: colors.surfaceContainer,
@@ -453,6 +455,53 @@ class _ImuGForceCard extends ConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
+ _buildMetricRow(
+ context,
+ label: 'Mount Orientation',
+ value: diag?.mountOrientation ?? 'Portrait Upright',
+ ),
+ _buildMetricRow(
+ context,
+ label: 'Mount Calibration',
+ value: (diag?.isCalibrated ?? false) ? 'CALIBRATED' : 'UNCALIBRATED',
+ ),
+ _buildMetricRow(
+ context,
+ label: 'Calibration Offsets (R / P)',
+ value: diag != null
+ ? '${diag.rollOffset.toStringAsFixed(1)}° / ${diag.pitchOffset.toStringAsFixed(1)}°'
+ : '0.0° / 0.0°',
+ ),
+ const Divider(height: 12),
+ _buildMetricRow(
+ context,
+ label: 'Raw Accel (X, Y, Z)',
+ value: diag != null
+ ? '${diag.rawAccelX.toStringAsFixed(2)}, ${diag.rawAccelY.toStringAsFixed(2)}, ${diag.rawAccelZ.toStringAsFixed(2)} m/s²'
+ : 'N/A',
+ ),
+ _buildMetricRow(
+ context,
+ label: 'Raw Gyro (X, Y, Z)',
+ value: diag != null
+ ? '${diag.rawGyroX.toStringAsFixed(3)}, ${diag.rawGyroY.toStringAsFixed(3)}, ${diag.rawGyroZ.toStringAsFixed(3)} rad/s'
+ : 'N/A',
+ ),
+ const Divider(height: 12),
+ _buildMetricRow(
+ context,
+ label: 'Transformed Roll / Pitch',
+ value: diag != null
+ ? '${diag.transformedRoll.toStringAsFixed(1)}° / ${diag.transformedPitch.toStringAsFixed(1)}°'
+ : 'N/A',
+ ),
+ _buildMetricRow(
+ context,
+ label: 'Dashboard Lean Angle',
+ value: lean != null
+ ? '${lean.abs().toStringAsFixed(1)}° (${lean < 0 ? "L" : (lean > 0 ? "R" : "Center")})'
+ : '--° (UNCALIBRATED)',
+ ),
_buildMetricRow(
context,
label: 'Linear Accel X (Lat)',
@@ -479,14 +528,6 @@ class _ImuGForceCard extends ConsumerWidget {
label: 'Lateral G-Force',
value: latG != null ? '${latG.toStringAsFixed(2)} G' : 'N/A',
),
- const Divider(height: 12),
- _buildMetricRow(
- context,
- label: 'Derived Lean Angle',
- value: lean != null
- ? '${lean.abs().toStringAsFixed(1)}° (${lean < 0 ? "L" : (lean > 0 ? "R" : "Center")})'
- : 'N/A',
- ),
_buildMetricRow(
context,
label: 'Derived Pitch Angle',
diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart
index e1ac2bf..e1c88ba 100644
--- a/lib/features/social/feed/social_feed_screen.dart
+++ b/lib/features/social/feed/social_feed_screen.dart
@@ -18,15 +18,40 @@ import 'widgets/share_session_modal.dart';
/// Implements the Stitch canonical Community Social Feed HUD with telemetry
/// snapshot cards, custom route vector track visualization, apex angle markers,
/// glove-friendly fist bump kudos reactions, comment drawers, and session publishing.
-class SocialFeedScreen extends ConsumerWidget {
+/// glove-friendly fist bump kudos reactions, comment drawers, and session publishing.
+class SocialFeedScreen extends ConsumerStatefulWidget {
final bool showAppBar;
const SocialFeedScreen({super.key, this.showAppBar = true});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState createState() => _SocialFeedScreenState();
+}
+
+class _SocialFeedScreenState extends ConsumerState {
+ bool _isSearchExpanded = false;
+ late final TextEditingController _searchController;
+
+ @override
+ void initState() {
+ super.initState();
+ _searchController = TextEditingController(
+ text: ref.read(feedSearchQueryProvider),
+ );
+ }
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
final colors = context.motorColors;
final activeFilter = ref.watch(feedFilterStateProvider);
+ final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
+ final searchQuery = ref.watch(feedSearchQueryProvider);
final items = ref.watch(socialFeedNotifierProvider);
final content = SafeArea(
@@ -40,16 +65,28 @@ class SocialFeedScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- // Top Context Header & Filter Bar
- _buildTopFilterSection(context, ref, activeFilter, colors),
+ // Top Context Header, Multi-Stream Bar & Discipline Chips
+ _buildTopFilterSection(
+ context,
+ activeFilter,
+ activeDiscipline,
+ searchQuery,
+ colors,
+ ),
// Feed List
Expanded(
child: items.isEmpty
- ? _buildEmptyFeedState(context, activeFilter, colors)
+ ? _buildEmptyFeedState(
+ context,
+ activeFilter,
+ activeDiscipline,
+ searchQuery,
+ colors,
+ )
: ListView.separated(
key: const ValueKey('social_feed_list_view'),
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
+ padding: const EdgeInsets.fromLTRB(16, 10, 16, 100),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 16),
itemBuilder: (context, index) {
@@ -83,17 +120,17 @@ class SocialFeedScreen extends ConsumerWidget {
),
);
- if (!showAppBar) {
+ if (!widget.showAppBar) {
return Container(
- color: const Color(0xFF101419),
+ color: const Color(0xFF0D1117),
child: content,
);
}
return Scaffold(
- backgroundColor: const Color(0xFF101419),
+ backgroundColor: const Color(0xFF0D1117),
appBar: AppBar(
- backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.9),
+ backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.95),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
@@ -144,7 +181,7 @@ class SocialFeedScreen extends ConsumerWidget {
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
- color: colors.outline,
+ color: const Color(0xFF94A3B8),
),
),
],
@@ -154,7 +191,25 @@ class SocialFeedScreen extends ConsumerWidget {
),
actions: [
IconButton(
- icon: const Icon(Icons.refresh_rounded, color: Color(0xFF849396)),
+ icon: Icon(
+ _isSearchExpanded ? Icons.search_off_rounded : Icons.search_rounded,
+ color: _isSearchExpanded
+ ? AppColors.electricCyan
+ : const Color(0xFF94A3B8),
+ ),
+ tooltip: 'Search Routes',
+ onPressed: () {
+ setState(() {
+ _isSearchExpanded = !_isSearchExpanded;
+ if (!_isSearchExpanded) {
+ _searchController.clear();
+ ref.read(feedSearchQueryProvider.notifier).state = '';
+ }
+ });
+ },
+ ),
+ IconButton(
+ icon: const Icon(Icons.refresh_rounded, color: Color(0xFF94A3B8)),
tooltip: 'Refresh Feed',
onPressed: () {
ref.invalidate(socialFeedNotifierProvider);
@@ -170,28 +225,29 @@ class SocialFeedScreen extends ConsumerWidget {
Widget _buildTopFilterSection(
BuildContext context,
- WidgetRef ref,
FeedFilter activeFilter,
+ RidingDiscipline activeDiscipline,
+ String searchQuery,
MotorSignalThemeColors colors,
) {
return Container(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
- color: const Color(0xFF101419),
+ padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
+ color: const Color(0xFF0D1117),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- // Status LED and Live Metric Row
+ // Row 1: Status LED, Active metric & Search toggle
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 6,
children: [
- // Status LED pill
+ // Status LED pill (Required by test suite)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
- color: const Color(0xFF181C21),
+ color: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.25),
@@ -230,11 +286,11 @@ class SocialFeedScreen extends ConsumerWidget {
),
),
- // Live sensor metric pill
+ // Live sensor metric pill (Required by test suite)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
- color: const Color(0xFF272A30),
+ color: const Color(0xFF1E2530),
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -261,91 +317,283 @@ class SocialFeedScreen extends ConsumerWidget {
),
],
),
+
+ // Optional Search Input Field
+ if (_isSearchExpanded) ...[
+ const SizedBox(height: 8),
+ Container(
+ height: 42,
+ decoration: BoxDecoration(
+ color: const Color(0xFF161B22),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: AppColors.electricCyan.withValues(alpha: 0.3),
+ ),
+ ),
+ child: TextField(
+ controller: _searchController,
+ autofocus: true,
+ style: const TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 13,
+ color: Colors.white,
+ ),
+ decoration: InputDecoration(
+ hintText: 'Search rider, route, bike, or region...',
+ hintStyle: const TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 13,
+ color: Color(0xFF64748B),
+ ),
+ prefixIcon: const Icon(
+ Icons.search_rounded,
+ size: 18,
+ color: Color(0xFF94A3B8),
+ ),
+ suffixIcon: _searchController.text.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.close_rounded, size: 16),
+ color: const Color(0xFF94A3B8),
+ onPressed: () {
+ _searchController.clear();
+ ref.read(feedSearchQueryProvider.notifier).state = '';
+ setState(() {});
+ },
+ )
+ : null,
+ border: InputBorder.none,
+ contentPadding: const EdgeInsets.symmetric(vertical: 10),
+ ),
+ onChanged: (val) {
+ ref.read(feedSearchQueryProvider.notifier).state = val;
+ setState(() {});
+ },
+ ),
+ ),
+ ],
+
const SizedBox(height: 10),
- // Segmented Filter Control (3 tabs)
- Container(
- padding: const EdgeInsets.all(4),
- decoration: BoxDecoration(
- color: const Color(0xFF0B0E13),
- borderRadius: BorderRadius.circular(14),
- border: Border.all(color: const Color(0xFF1D2025)),
+ // Stream Selector (Scrollable horizontally to guarantee zero RenderFlex overflow)
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Container(
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: const Color(0xFF0B0E13),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: const Color(0xFF1E2633)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildFilterTab(
+ tab: FeedFilter.allActivity,
+ title: 'All Activity',
+ isSelected: activeFilter == FeedFilter.allActivity,
+ ),
+ const SizedBox(width: 4),
+ _buildFilterTab(
+ tab: FeedFilter.clubOnly,
+ title: 'Club Only',
+ isSelected: activeFilter == FeedFilter.clubOnly,
+ ),
+ const SizedBox(width: 4),
+ _buildFilterTab(
+ tab: FeedFilter.nearbyPacks,
+ title: 'Nearby Packs',
+ isSelected: activeFilter == FeedFilter.nearbyPacks,
+ ),
+ const SizedBox(width: 4),
+ _buildFilterTab(
+ tab: FeedFilter.forYou,
+ title: 'For You',
+ isSelected: activeFilter == FeedFilter.forYou,
+ ),
+ const SizedBox(width: 4),
+ _buildFilterTab(
+ tab: FeedFilter.following,
+ title: 'Following',
+ isSelected: activeFilter == FeedFilter.following,
+ ),
+ ],
+ ),
),
+ ),
+
+ const SizedBox(height: 8),
+
+ // Riding Discipline Filter Chips
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
child: Row(
children: [
- _buildFilterTab(
- context,
- ref,
- tab: FeedFilter.allActivity,
- title: 'All Activity',
- isSelected: activeFilter == FeedFilter.allActivity,
+ _buildDisciplineChip(
+ discipline: RidingDiscipline.all,
+ label: 'All Styles',
+ isSelected: activeDiscipline == RidingDiscipline.all,
),
- const SizedBox(width: 4),
- _buildFilterTab(
- context,
- ref,
- tab: FeedFilter.clubOnly,
- title: 'Club Only',
- isSelected: activeFilter == FeedFilter.clubOnly,
+ const SizedBox(width: 6),
+ _buildDisciplineChip(
+ discipline: RidingDiscipline.canyonSport,
+ label: '⚡ Canyon',
+ isSelected: activeDiscipline == RidingDiscipline.canyonSport,
),
- const SizedBox(width: 4),
- _buildFilterTab(
- context,
- ref,
- tab: FeedFilter.nearbyPacks,
- title: 'Nearby Packs',
- isSelected: activeFilter == FeedFilter.nearbyPacks,
+ const SizedBox(width: 6),
+ _buildDisciplineChip(
+ discipline: RidingDiscipline.advDualSport,
+ label: '🌲 ADV & Dirt',
+ isSelected: activeDiscipline == RidingDiscipline.advDualSport,
+ ),
+ const SizedBox(width: 6),
+ _buildDisciplineChip(
+ discipline: RidingDiscipline.touring,
+ label: '🛣️ Touring',
+ isSelected: activeDiscipline == RidingDiscipline.touring,
+ ),
+ const SizedBox(width: 6),
+ _buildDisciplineChip(
+ discipline: RidingDiscipline.trackDay,
+ label: '🏁 Track Day',
+ isSelected: activeDiscipline == RidingDiscipline.trackDay,
+ ),
+ const SizedBox(width: 6),
+ _buildDisciplineChip(
+ discipline: RidingDiscipline.cruiser,
+ label: '🏍️ Cruiser',
+ isSelected: activeDiscipline == RidingDiscipline.cruiser,
),
],
),
),
+
+ // Filter Summary & Reset Bar (When disciplined or searched)
+ if (activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty) ...[
+ const SizedBox(height: 6),
+ Row(
+ children: [
+ const Icon(
+ Icons.filter_alt_outlined,
+ size: 13,
+ color: AppColors.electricCyan,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ 'Filtered by: ${activeDiscipline != RidingDiscipline.all ? activeDiscipline.displayName : ''}${activeDiscipline != RidingDiscipline.all && searchQuery.isNotEmpty ? ', ' : ''}${searchQuery.isNotEmpty ? '"$searchQuery"' : ''}',
+ style: const TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 11,
+ color: Color(0xFF94A3B8),
+ ),
+ ),
+ const Spacer(),
+ GestureDetector(
+ onTap: () {
+ ref.read(feedDisciplineFilterProvider.notifier).state =
+ RidingDiscipline.all;
+ ref.read(feedSearchQueryProvider.notifier).state = '';
+ _searchController.clear();
+ setState(() {
+ _isSearchExpanded = false;
+ });
+ },
+ child: const Text(
+ 'Reset',
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ color: AppColors.electricCyan,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
],
),
);
}
- Widget _buildFilterTab(
- BuildContext context,
- WidgetRef ref, {
+ Widget _buildFilterTab({
required FeedFilter tab,
required String title,
required bool isSelected,
}) {
- return Expanded(
- child: InkWell(
- borderRadius: BorderRadius.circular(10),
- onTap: () {
- ref.read(feedFilterStateProvider.notifier).state = tab;
- },
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 180),
- curve: Curves.easeInOut,
- height: 42,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: isSelected ? AppColors.electricCyan : Colors.transparent,
- borderRadius: BorderRadius.circular(10),
- boxShadow: isSelected
- ? [
- BoxShadow(
- color: AppColors.electricCyan.withValues(alpha: 0.35),
- blurRadius: 8,
- offset: const Offset(0, 2),
- ),
- ]
- : null,
+ return InkWell(
+ borderRadius: BorderRadius.circular(10),
+ onTap: () {
+ ref.read(feedFilterStateProvider.notifier).state = tab;
+ },
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeInOut,
+ height: 36,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: isSelected ? AppColors.electricCyan : Colors.transparent,
+ borderRadius: BorderRadius.circular(10),
+ boxShadow: isSelected
+ ? [
+ BoxShadow(
+ color: AppColors.electricCyan.withValues(alpha: 0.35),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ]
+ : null,
+ ),
+ child: Text(
+ title,
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 11.5,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.1,
+ color: isSelected
+ ? const Color(0xFF00363D)
+ : const Color(0xFFCBD5E1),
),
- child: Text(
- title,
- style: TextStyle(
- fontFamily: AppTypography.fontHeadline,
- fontSize: 12,
- fontWeight: FontWeight.w700,
- letterSpacing: 0.4,
- color: isSelected
- ? const Color(0xFF00363D)
- : const Color(0xFFBAC9CC),
- ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDisciplineChip({
+ required RidingDiscipline discipline,
+ required String label,
+ required bool isSelected,
+ }) {
+ final chipColor = _getDisciplineColor(discipline);
+
+ return InkWell(
+ borderRadius: BorderRadius.circular(8),
+ onTap: () {
+ ref.read(feedDisciplineFilterProvider.notifier).state = discipline;
+ },
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 160),
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ color: isSelected
+ ? chipColor.withValues(alpha: 0.22)
+ : const Color(0xFF161B22),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: isSelected
+ ? chipColor.withValues(alpha: 0.7)
+ : const Color(0xFF263238),
+ width: isSelected ? 1.2 : 1.0,
+ ),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 11.5,
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
+ color: isSelected ? Colors.white : const Color(0xFF94A3B8),
),
),
),
@@ -355,8 +603,13 @@ class SocialFeedScreen extends ConsumerWidget {
Widget _buildEmptyFeedState(
BuildContext context,
FeedFilter activeFilter,
+ RidingDiscipline activeDiscipline,
+ String searchQuery,
MotorSignalThemeColors colors,
) {
+ final hasCustomFilters =
+ activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
+
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -366,11 +619,13 @@ class SocialFeedScreen extends ConsumerWidget {
Icon(
Icons.dynamic_feed_outlined,
size: 56,
- color: colors.outline.withValues(alpha: 0.4),
+ color: const Color(0xFF64748B),
),
const SizedBox(height: 16),
Text(
- 'No posts in ${activeFilter.displayName}',
+ hasCustomFilters
+ ? 'No matching ride sessions'
+ : 'No posts in ${activeFilter.displayName}',
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
@@ -380,14 +635,38 @@ class SocialFeedScreen extends ConsumerWidget {
),
const SizedBox(height: 6),
Text(
- 'Hit "Share Recent Session" below to publish telemetry from your latest ride.',
+ hasCustomFilters
+ ? 'Try relaxing your style filters or search query to discover more community rides.'
+ : 'Hit "Share Recent Session" below to publish telemetry from your latest ride.',
textAlign: TextAlign.center,
- style: TextStyle(
+ style: const TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
- color: colors.outline,
+ color: Color(0xFF94A3B8),
),
),
+ if (hasCustomFilters) ...[
+ const SizedBox(height: 16),
+ OutlinedButton.icon(
+ icon: const Icon(Icons.refresh_rounded, size: 16),
+ label: const Text('Reset All Filters'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: AppColors.electricCyan,
+ side: BorderSide(
+ color: AppColors.electricCyan.withValues(alpha: 0.5),
+ ),
+ ),
+ onPressed: () {
+ ref.read(feedDisciplineFilterProvider.notifier).state =
+ RidingDiscipline.all;
+ ref.read(feedSearchQueryProvider.notifier).state = '';
+ _searchController.clear();
+ setState(() {
+ _isSearchExpanded = false;
+ });
+ },
+ ),
+ ],
],
),
),
@@ -398,11 +677,14 @@ class SocialFeedScreen extends ConsumerWidget {
return Container(
height: 52,
decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(14),
+ borderRadius: BorderRadius.circular(16),
+ gradient: const LinearGradient(
+ colors: [Color(0xFF00E5FF), Color(0xFF00B0FF)],
+ ),
boxShadow: [
BoxShadow(
- color: AppColors.electricCyan.withValues(alpha: 0.45),
- blurRadius: 20,
+ color: const Color(0xFF00E5FF).withValues(alpha: 0.35),
+ blurRadius: 16,
offset: const Offset(0, 4),
),
],
@@ -410,10 +692,11 @@ class SocialFeedScreen extends ConsumerWidget {
child: ElevatedButton(
key: const ValueKey('feed_share_session_button'),
style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.electricCyan,
- foregroundColor: const Color(0xFF00363D),
+ backgroundColor: Colors.transparent,
+ shadowColor: Colors.transparent,
+ foregroundColor: const Color(0xFF00272E),
shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(14),
+ borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
@@ -429,7 +712,7 @@ class SocialFeedScreen extends ConsumerWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
- fontSize: 13,
+ fontSize: 13.5,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
@@ -442,6 +725,23 @@ class SocialFeedScreen extends ConsumerWidget {
}
}
+Color _getDisciplineColor(RidingDiscipline discipline) {
+ switch (discipline) {
+ case RidingDiscipline.canyonSport:
+ return const Color(0xFFF59E0B);
+ case RidingDiscipline.advDualSport:
+ return const Color(0xFF10B981);
+ case RidingDiscipline.touring:
+ return const Color(0xFF38BDF8);
+ case RidingDiscipline.trackDay:
+ return const Color(0xFFF43F5E);
+ case RidingDiscipline.cruiser:
+ return const Color(0xFFA855F7);
+ case RidingDiscipline.all:
+ return AppColors.electricCyan;
+ }
+}
+
/// Authoritative Feed Item Card (`SocialFeedCard`).
class _SocialFeedCard extends ConsumerStatefulWidget {
final SocialFeedItem item;
@@ -533,7 +833,6 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
@override
Widget build(BuildContext context) {
- final colors = context.motorColors;
final item = widget.item;
final user = ref.watch(currentUserProvider);
final isBumped =
@@ -542,9 +841,9 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
return Container(
decoration: BoxDecoration(
- color: const Color(0xFF1D2025),
+ color: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(18),
- border: Border.all(color: const Color(0xFF272A30)),
+ border: Border.all(color: const Color(0xFF263238)),
boxShadow: const [
BoxShadow(
color: Colors.black45,
@@ -556,7 +855,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- // Card Header: Author info, badge, club, overflow
+ // Card Header: Author info, badge, discipline, club, overflow
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),
child: Row(
@@ -567,7 +866,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
children: [
CircleAvatar(
radius: 22,
- backgroundColor: const Color(0xFF272A30),
+ backgroundColor: const Color(0xFF222936),
child: const Icon(
Icons.sports_motorsports_rounded,
color: AppColors.electricCyan,
@@ -584,7 +883,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
color: AppColors.electricCyan,
shape: BoxShape.circle,
border: Border.all(
- color: const Color(0xFF1D2025), width: 1.5),
+ color: const Color(0xFF161B22), width: 1.5),
boxShadow: const [
BoxShadow(
color: AppColors.electricCyan,
@@ -598,27 +897,26 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
),
const SizedBox(width: 12),
- // Author handle, badge, club, timestamp
+ // Author handle, badge, discipline badge, club, timestamp
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Row(
+ Wrap(
+ crossAxisAlignment: WrapCrossAlignment.center,
+ spacing: 6,
+ runSpacing: 4,
children: [
- Flexible(
- child: Text(
- '@${item.authorHandle}',
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontFamily: AppTypography.fontHeadline,
- fontSize: 15,
- fontWeight: FontWeight.w700,
- color: Color(0xFFE0E2EA),
- ),
+ Text(
+ '@${item.authorHandle}',
+ style: const TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFFF1F5F9),
),
),
if (item.authorBadge != null) ...[
- const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
@@ -639,6 +937,32 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
),
),
],
+ if (item.discipline != RidingDiscipline.all) ...[
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: _getDisciplineColor(item.discipline)
+ .withValues(alpha: 0.18),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(
+ color: _getDisciplineColor(item.discipline)
+ .withValues(alpha: 0.35),
+ width: 0.8,
+ ),
+ ),
+ child: Text(
+ item.discipline.displayName.toUpperCase(),
+ style: TextStyle(
+ fontFamily: AppTypography.fontTelemetry,
+ fontSize: 8.5,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.5,
+ color: _getDisciplineColor(item.discipline),
+ ),
+ ),
+ ),
+ ],
],
),
if (item.clubName != null) ...[
@@ -649,17 +973,18 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
style: const TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
- color: Color(0xFFBAC9CC),
+ color: Color(0xFF94A3B8),
),
),
],
const SizedBox(height: 2),
Text(
'${_formatRelativeTime(item.timestamp)} • ${item.locationName}',
- style: TextStyle(
+ style: const TextStyle(
fontFamily: AppTypography.fontBody,
- fontSize: 11,
- color: colors.outline,
+ fontSize: 12.5,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFFCBD5E1),
),
),
],
@@ -719,7 +1044,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
- color: Color(0xFFC3F5FF),
+ color: Colors.white,
),
),
const SizedBox(height: 6),
@@ -727,9 +1052,9 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
- color: const Color(0xFF181C21),
+ color: const Color(0xFF12161D),
borderRadius: BorderRadius.circular(6),
- border: Border.all(color: const Color(0xFF272A30)),
+ border: Border.all(color: const Color(0xFF263238)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -747,12 +1072,32 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
- fontSize: 11,
+ fontSize: 11.5,
fontWeight: FontWeight.w600,
- color: Color(0xFFE0E2EA),
+ color: Color(0xFFE2E8F0),
),
),
),
+ if (item.regionName != null) ...[
+ const SizedBox(width: 6),
+ Container(
+ width: 3,
+ height: 3,
+ decoration: const BoxDecoration(
+ color: Color(0xFF64748B),
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ item.regionName!,
+ style: const TextStyle(
+ fontFamily: AppTypography.fontBody,
+ fontSize: 11,
+ color: Color(0xFF94A3B8),
+ ),
+ ),
+ ],
],
),
),
@@ -767,9 +1112,9 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
- color: const Color(0xFF0B0E13),
+ color: const Color(0xFF0F1318),
borderRadius: BorderRadius.circular(14),
- border: Border.all(color: const Color(0xFF181C21)),
+ border: Border.all(color: const Color(0xFF1E2633)),
),
child: Column(
children: [
@@ -1025,10 +1370,11 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
}) {
return Expanded(
child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
- color: const Color(0xFF181C21),
+ color: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: const Color(0xFF222936), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1042,10 +1388,10 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
- fontSize: 8.5,
+ fontSize: 10.5,
fontWeight: FontWeight.w700,
- letterSpacing: 0.5,
- color: Color(0xFF849396),
+ letterSpacing: 0.6,
+ color: Color(0xFF94A3B8),
),
),
),
@@ -1073,23 +1419,23 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
- fontSize: 14,
+ fontSize: 15.5,
fontWeight: FontWeight.w800,
color: valueColor,
),
),
),
if (unit.isNotEmpty) ...[
- const SizedBox(width: 3),
+ const SizedBox(width: 4),
Text(
unit,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
- fontSize: 9,
- fontWeight: FontWeight.w600,
- color: Color(0xFFBAC9CC),
+ fontSize: 10,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFFCBD5E1),
),
),
],
diff --git a/lib/features/social/feed/widgets/share_session_modal.dart b/lib/features/social/feed/widgets/share_session_modal.dart
index 3c99cbb..ab54daf 100644
--- a/lib/features/social/feed/widgets/share_session_modal.dart
+++ b/lib/features/social/feed/widgets/share_session_modal.dart
@@ -39,6 +39,7 @@ class _ShareSessionModalState extends ConsumerState {
final _maxLeanController = TextEditingController(text: '49');
String _selectedLeanSide = 'RIGHT';
String _selectedPace = '1.25x Spirited';
+ RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
bool _isSubmitting = false;
@override
@@ -104,6 +105,7 @@ class _ShareSessionModalState extends ConsumerState {
paceFactor: _selectedPace,
hazardCount: 0,
elevationFt: 1840,
+ discipline: _selectedDiscipline,
snapshotType: FeedSnapshotType.routeTrack,
routePoints: const [
AppLatLng(37.3861, -122.2641),
@@ -280,6 +282,43 @@ class _ShareSessionModalState extends ConsumerState {
),
const SizedBox(height: 14),
+ // Riding Discipline / Style Field
+ _buildFieldLabel('RIDING DISCIPLINE / STYLE', colors),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ decoration: BoxDecoration(
+ color: const Color(0xFF161B22),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: const Color(0xFF263238)),
+ ),
+ child: DropdownButtonHideUnderline(
+ child: DropdownButton(
+ value: _selectedDiscipline,
+ isExpanded: true,
+ dropdownColor: const Color(0xFF161B22),
+ style: const TextStyle(
+ fontFamily: AppTypography.fontHeadline,
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: Colors.white,
+ ),
+ items: RidingDiscipline.values
+ .where((d) => d != RidingDiscipline.all)
+ .map((d) => DropdownMenuItem(
+ value: d,
+ child: Text(d.displayName),
+ ))
+ .toList(),
+ onChanged: (val) {
+ if (val != null) {
+ setState(() => _selectedDiscipline = val);
+ }
+ },
+ ),
+ ),
+ ),
+ const SizedBox(height: 14),
+
// Location Field
_buildFieldLabel('LOCATION / CANYON', colors),
TextFormField(
@@ -510,12 +549,12 @@ class _ShareSessionModalState extends ConsumerState {
padding: const EdgeInsets.only(bottom: 6),
child: Text(
label,
- style: TextStyle(
+ style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
- fontSize: 10,
+ fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
- color: colors.outline,
+ color: Color(0xFF94A3B8),
),
),
);
@@ -527,18 +566,18 @@ class _ShareSessionModalState extends ConsumerState {
hintStyle: const TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
- color: Color(0xFF849396),
+ color: Color(0xFF64748B),
),
filled: true,
- fillColor: const Color(0xFF181C21),
+ fillColor: const Color(0xFF161B22),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
- borderSide: const BorderSide(color: Color(0xFF272A30)),
+ borderSide: const BorderSide(color: Color(0xFF263238)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
- borderSide: const BorderSide(color: Color(0xFF272A30)),
+ borderSide: const BorderSide(color: Color(0xFF263238)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
diff --git a/lib/main.dart b/lib/main.dart
index d9829a9..457cc9a 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -17,7 +17,13 @@ import 'services/sync/sync_observer.dart';
Future main() async {
final isFlutterTest =
!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST');
- if (kDebugMode && !isFlutterTest) {
+ var hasBinding = false;
+ try {
+ WidgetsBinding.instance;
+ hasBinding = true;
+ } catch (_) {}
+
+ if (kDebugMode && !isFlutterTest && !hasBinding) {
MarionetteBinding.ensureInitialized();
} else {
WidgetsFlutterBinding.ensureInitialized();
diff --git a/lib/models/telemetry_sample.dart b/lib/models/telemetry_sample.dart
index 0bac587..ab6715b 100644
--- a/lib/models/telemetry_sample.dart
+++ b/lib/models/telemetry_sample.dart
@@ -90,12 +90,12 @@ class TelemetrySample {
bool get isSpeedFresh {
if (speed == null) return false;
if (gpsAgeMs != null) {
- return gpsAgeMs! >= 0 &&
+ return gpsAgeMs! >= -2000 &&
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;
}
if (gpsTimestamp != null) {
final age = timestamp.difference(gpsTimestamp!).inMilliseconds;
- return age >= 0 &&
+ return age >= -2000 &&
age <= RecordingHealthContract.evidenceStaleAfterMs;
}
// Synthetic test samples without GPS age/timestamp retain speed validity.
@@ -121,12 +121,12 @@ class TelemetrySample {
return false;
}
if (gpsAgeMs != null) {
- return gpsAgeMs! >= 0 &&
+ return gpsAgeMs! >= -2000 &&
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;
}
if (gpsTimestamp != null) {
final age = timestamp.difference(gpsTimestamp!).inMilliseconds;
- return age >= 0 &&
+ return age >= -2000 &&
age <= RecordingHealthContract.evidenceStaleAfterMs;
}
// Synthetic test samples without GPS age/timestamp retain validity.
diff --git a/lib/services/calibration_service.dart b/lib/services/calibration_service.dart
index cdeccfc..5608eaa 100644
--- a/lib/services/calibration_service.dart
+++ b/lib/services/calibration_service.dart
@@ -11,6 +11,12 @@ class CalibrationService {
static const _gyroBiasXKey = 'calibration_gyro_bias_x';
static const _gyroBiasYKey = 'calibration_gyro_bias_y';
static const _gyroBiasZKey = 'calibration_gyro_bias_z';
+ static const _isCalibratedKey = 'calibration_is_calibrated';
+ static const _accelCalXKey = 'calibration_accel_x';
+ static const _accelCalYKey = 'calibration_accel_y';
+ static const _accelCalZKey = 'calibration_accel_z';
+ static const _mountModeKey = 'calibration_mount_mode';
+ static const _calibratedAtKey = 'calibration_timestamp_ms';
Future saveCalibrationOffsets(
double pitch,
@@ -18,15 +24,27 @@ class CalibrationService {
double gyroBiasX = 0.0,
double gyroBiasY = 0.0,
double gyroBiasZ = 0.0,
+ double accelCalX = 0.0,
+ double accelCalY = 0.0,
+ double accelCalZ = 0.0,
+ String mountMode = 'portrait',
+ bool isCalibrated = true,
int? motorcycleId,
}) async {
final prefs = await SharedPreferences.getInstance();
+ final nowMs = DateTime.now().millisecondsSinceEpoch;
if (motorcycleId != null) {
await prefs.setDouble('calibration_pitch_$motorcycleId', pitch);
await prefs.setDouble('calibration_roll_$motorcycleId', roll);
await prefs.setDouble('calibration_gyro_bias_x_$motorcycleId', gyroBiasX);
await prefs.setDouble('calibration_gyro_bias_y_$motorcycleId', gyroBiasY);
await prefs.setDouble('calibration_gyro_bias_z_$motorcycleId', gyroBiasZ);
+ await prefs.setBool('calibration_is_calibrated_$motorcycleId', isCalibrated);
+ await prefs.setDouble('calibration_accel_x_$motorcycleId', accelCalX);
+ await prefs.setDouble('calibration_accel_y_$motorcycleId', accelCalY);
+ await prefs.setDouble('calibration_accel_z_$motorcycleId', accelCalZ);
+ await prefs.setString('calibration_mount_mode_$motorcycleId', mountMode);
+ await prefs.setInt('calibration_timestamp_ms_$motorcycleId', nowMs);
}
// Also save global fallback
await prefs.setDouble(_pitchKey, pitch);
@@ -34,6 +52,30 @@ class CalibrationService {
await prefs.setDouble(_gyroBiasXKey, gyroBiasX);
await prefs.setDouble(_gyroBiasYKey, gyroBiasY);
await prefs.setDouble(_gyroBiasZKey, gyroBiasZ);
+ await prefs.setBool(_isCalibratedKey, isCalibrated);
+ await prefs.setDouble(_accelCalXKey, accelCalX);
+ await prefs.setDouble(_accelCalYKey, accelCalY);
+ await prefs.setDouble(_accelCalZKey, accelCalZ);
+ await prefs.setString(_mountModeKey, mountMode);
+ await prefs.setInt(_calibratedAtKey, nowMs);
+ }
+
+ Future clearCalibration({int? motorcycleId}) async {
+ final prefs = await SharedPreferences.getInstance();
+ if (motorcycleId != null) {
+ await prefs.setBool('calibration_is_calibrated_$motorcycleId', false);
+ await prefs.setDouble('calibration_pitch_$motorcycleId', 0.0);
+ await prefs.setDouble('calibration_roll_$motorcycleId', 0.0);
+ await prefs.setDouble('calibration_accel_x_$motorcycleId', 0.0);
+ await prefs.setDouble('calibration_accel_y_$motorcycleId', 0.0);
+ await prefs.setDouble('calibration_accel_z_$motorcycleId', 0.0);
+ }
+ await prefs.setBool(_isCalibratedKey, false);
+ await prefs.setDouble(_pitchKey, 0.0);
+ await prefs.setDouble(_rollKey, 0.0);
+ await prefs.setDouble(_accelCalXKey, 0.0);
+ await prefs.setDouble(_accelCalYKey, 0.0);
+ await prefs.setDouble(_accelCalZKey, 0.0);
}
Future