diff --git a/.gitignore b/.gitignore index 1797d50..885f93d 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,21 @@ scratch_*.py 1.29 graphify-out/ devtools_options.yaml + +# Temporary outputs, root screenshots, and emulator caches +/screen*.png +/emulator_screenshot.png +/.firebase-tmp/ +/output/ +firebase.website-qa.json + +# Scratch tools and ephemeral test runs +tools/*.py +tools/*.mjs +tools/*.cjs +tools/*.js +docs/qa-2026-10-01/auth.verify-*.json +docs/qa-2026-10-01/auth.verify-*.mjs +docs/qa-2026-10-01/chat.cross-account-results.json +docs/qa-2026-10-01/scenarios_machine_readable.json + diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..2f94eba --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,18 @@ +\## Android Emulator / Device Policy + + + +\- For all Android emulator testing, UI validation, screenshots, integration testing, and interactive app launches, ALWAYS use the AVD named `avdslim`. + +\- Treat `avdslim` as the canonical MotorSignal Android test emulator. + +\- Do not create, select, launch, or switch to another Android Virtual Device unless the user explicitly instructs you to do so. + +\- Before running emulator-dependent tests, verify that `avdslim` is running and available through `adb devices`. + +\- If `avdslim` is not running, start `avdslim` rather than choosing another emulator. + +\- When multiple Android devices/emulators are connected, explicitly target `avdslim` for Flutter/ADB commands instead of relying on automatic device selection. + +\- Physical devices should only be used when the user explicitly requests physical-device testing. + diff --git a/README.md b/README.md index b438f67..1ba4181 100644 --- a/README.md +++ b/README.md @@ -26,5 +26,9 @@ A high-performance, dark-themed telemetry and analytics dashboard for motorcycle * **Database:** Drift (SQLite) for robust, local storage. * **Sensor Fusion:** Complementary filter combining accelerometer and gyroscope for precise lean angles. +## Documentation + +The active documentation map and dated evidence archive are indexed in [docs/README.md](docs/README.md). + ## Disclaimer This application relies on the accuracy of your smartphone's internal sensors. It should be securely mounted to the motorcycle using a rigid mount. Do not interact with the screen while riding. Ride safely! diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c8a0414..53c2824 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -48,6 +48,8 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "false" } signingConfigs { diff --git a/android/app/src/androidTest/java/app/motorsignal/motorcycle/MainActivityTest.java b/android/app/src/androidTest/java/app/motorsignal/motorcycle/MainActivityTest.java new file mode 100644 index 0000000..59f40b2 --- /dev/null +++ b/android/app/src/androidTest/java/app/motorsignal/motorcycle/MainActivityTest.java @@ -0,0 +1,31 @@ +package app.motorsignal.motorcycle; + +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; + +@RunWith(Parameterized.class) +public class MainActivityTest { + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f860949 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Documentation + +This index separates active reference material from dated planning, audit, QA, and deployment evidence. `AGENTS.md`, current code, schemas, and tests remain the authority when they conflict with documentation. + +## Repository entrypoints + +- [Repository README](../README.md): setup and application overview. +- [Agent rules](../AGENTS.md): mandatory development boundaries and verification rules. +- [Product brief](../PRODUCT.md): product intent and constraints. +- [Changelog](../CHANGELOG.md): canonical release history. + +## Active reference + +- [Architecture](architecture/): telemetry, cloud, and data-model references. +- [Design](design/): interface language and visual tokens. +- [Product](product/): entitlement baseline and roadmap. +- [Guides](guides/): field-testing procedure. +- [Release operations](RELEASING.md) and [versioning](VERSIONING.md). +- [Agent domain map](agents/domain.md): documentation authority and subsystem routing. + +## Operational and historical material + +- [Website and production records](#website-and-production-records): current scoped deployment and credential evidence remains at this level. +- [Archive](archive/): preserved dated planning, audit, QA, and legacy summaries. These documents are evidence, not current implementation proof. + +## Website and production records + +- `website-*.md`: scoped website verification, deployment, replay, and credential records dated 2026-10-04. +- `qa-*.md` and `QA_AUDIT_REPORT_2026-09-30.md`: dated QA outcomes and follow-up evidence. +- `OPS4_*.md`, `PRODUCTION_DEPLOYMENT_CHECKLIST.md`, and `WEBSITE_ADMIN_CURRENT_STATE.md`: operational references that require validation against the live configuration before use. diff --git a/docs/agents/domain.md b/docs/agents/domain.md index 3538ad1..b9fc35d 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -13,13 +13,13 @@ When documents conflict, verify behavior in code and tests. Do not revive obsole ## Documentation Map -- Telemetry and field validation: `TELEMETRY_ARCHITECTURE.md`, `TELEMETRY_VALIDATION.md`, and `FIELD_TESTING.md`. -- Cloud and sync: `MOTORSIGNAL_FINAL_CLOUD_DATA_MODEL.md`, `MOTORSIGNAL_CLOUD_ARCHITECTURE.md`, and `cloudflare/motorsignal-r2-worker/README.md`. -- Product and entitlement intent: `MOTORSIGNAL_FREE_PRO_SPEC.md` and `MOTORSIGNAL_PRODUCT_ROADMAP.md`. These are planning baselines, not proof that features exist. -- Interface language and visual tokens: `DESIGN.md`. Validate behavioral and timing claims against current code. +- Telemetry and field validation: `docs/architecture/telemetry-architecture.md`, `docs/architecture/telemetry-validation.md`, and `docs/guides/field-testing.md`. +- Cloud and sync: `docs/architecture/cloud-data-model.md`, `docs/architecture/cloud-architecture.md`, and `cloudflare/motorsignal-r2-worker/README.md`. +- Product and entitlement intent: `docs/product/free-pro-spec.md` and `docs/product/roadmap.md`. These are planning baselines, not proof that features exist. +- Interface language and visual tokens: `docs/design/design-system.md`. Validate behavioral and timing claims against current code. - Releases and versions: `docs/RELEASING.md` and `docs/VERSIONING.md`. Preserve Semantic Versioning and monotonically increasing build numbers; use current `AGENTS.md` verification gates and current schema values where old examples conflict. - Waze relay operation: `server/waze_relay/README.md`. -- `CHANGELOG.md`, `FEATURES_CHANGELOG.md`, `DEVELOPMENT_HISTORY.md`, `MOTORSIGNAL_AI_ENVIRONMENT_AUDIT.md`, phase reports, discovery documents, and gap audits are dated evidence. Never treat their old test counts, release status, schema numbers, or pending physical-validation claims as current proof. +- `CHANGELOG.md` is the canonical release record. `docs/archive/` contains dated feature histories, environment audits, phase reports, discovery documents, and gap audits. Never treat their old test counts, release status, schema numbers, or pending physical-validation claims as current proof. ## Stable Vocabulary diff --git a/MOTORSIGNAL_CLOUD_ARCHITECTURE.md b/docs/architecture/cloud-architecture.md similarity index 100% rename from MOTORSIGNAL_CLOUD_ARCHITECTURE.md rename to docs/architecture/cloud-architecture.md diff --git a/MOTORSIGNAL_FINAL_CLOUD_DATA_MODEL.md b/docs/architecture/cloud-data-model.md similarity index 100% rename from MOTORSIGNAL_FINAL_CLOUD_DATA_MODEL.md rename to docs/architecture/cloud-data-model.md diff --git a/TELEMETRY_ARCHITECTURE.md b/docs/architecture/telemetry-architecture.md similarity index 100% rename from TELEMETRY_ARCHITECTURE.md rename to docs/architecture/telemetry-architecture.md diff --git a/TELEMETRY_VALIDATION.md b/docs/architecture/telemetry-validation.md similarity index 100% rename from TELEMETRY_VALIDATION.md rename to docs/architecture/telemetry-validation.md diff --git a/MOTORSIGNAL_AI_ENVIRONMENT_AUDIT.md b/docs/archive/2026-08/audits/ai-environment-audit.md similarity index 100% rename from MOTORSIGNAL_AI_ENVIRONMENT_AUDIT.md rename to docs/archive/2026-08/audits/ai-environment-audit.md diff --git a/docs/archive/2026-08/legacy/README.md b/docs/archive/2026-08/legacy/README.md new file mode 100644 index 0000000..d2ff9a1 --- /dev/null +++ b/docs/archive/2026-08/legacy/README.md @@ -0,0 +1,7 @@ +# Legacy summaries + +This directory consolidates discovery for the overlapping August 2026 historical summaries while preserving each original record and its unique details. Use the root [CHANGELOG](../../../../CHANGELOG.md) for release history and current code for current behavior. + +- [Development history](development-history.md): commit and phase chronology. +- [Features changelog](features-changelog.md): historical feature inventory. +- [Project summary](project-summary.md): historical architecture overview. diff --git a/DEVELOPMENT_HISTORY.md b/docs/archive/2026-08/legacy/development-history.md similarity index 98% rename from DEVELOPMENT_HISTORY.md rename to docs/archive/2026-08/legacy/development-history.md index ac9d4e4..40c5774 100644 --- a/DEVELOPMENT_HISTORY.md +++ b/docs/archive/2026-08/legacy/development-history.md @@ -91,4 +91,4 @@ f7fbe71 (2026-08-27) - fix(telemetry): solve sensor fusion false lean, 3D G-forc * **Landscape UI Optimization**: Compacted toolbars and AppBars to **36px** in landscape mode to maximize map and gauge visibility. * **TomTom v5 API Upgrade**: Migrated to official TomTom query-parameter v5 endpoints. * **Waze Alert Relay Server**: Built and dockerized self-hosted Node.js relay backend in `server/waze_relay/`. - * **Markdown Documentation**: Generated `FEATURES_CHANGELOG.md` and `DEVELOPMENT_HISTORY.md`. + * **Markdown Documentation**: Generated `features-changelog.md` and `development-history.md`. diff --git a/FEATURES_CHANGELOG.md b/docs/archive/2026-08/legacy/features-changelog.md similarity index 100% rename from FEATURES_CHANGELOG.md rename to docs/archive/2026-08/legacy/features-changelog.md diff --git a/project_summary.md b/docs/archive/2026-08/legacy/project-summary.md similarity index 100% rename from project_summary.md rename to docs/archive/2026-08/legacy/project-summary.md diff --git a/MOTORSIGNAL_PHASE_2_CLOUD_SYNC_DISCOVERY.md b/docs/archive/2026-08/phase-2/cloud-sync-discovery.md similarity index 100% rename from MOTORSIGNAL_PHASE_2_CLOUD_SYNC_DISCOVERY.md rename to docs/archive/2026-08/phase-2/cloud-sync-discovery.md diff --git a/MOTORSIGNAL_PHASE_2B_FIREBASE_SETUP.md b/docs/archive/2026-08/phase-2/firebase-setup.md similarity index 100% rename from MOTORSIGNAL_PHASE_2B_FIREBASE_SETUP.md rename to docs/archive/2026-08/phase-2/firebase-setup.md diff --git a/MOTORSIGNAL_PHASE_2E_PRODUCTION_SETUP.md b/docs/archive/2026-08/phase-2/production-setup.md similarity index 100% rename from MOTORSIGNAL_PHASE_2E_PRODUCTION_SETUP.md rename to docs/archive/2026-08/phase-2/production-setup.md diff --git a/MOTORSIGNAL_PHASE_2D_R2_MIGRATION.md b/docs/archive/2026-08/phase-2/r2-migration.md similarity index 100% rename from MOTORSIGNAL_PHASE_2D_R2_MIGRATION.md rename to docs/archive/2026-08/phase-2/r2-migration.md diff --git a/PHASE10_FIELD_TEST_REPORT.md b/docs/archive/2026-08/qa/phase-10-field-test-report.md similarity index 100% rename from PHASE10_FIELD_TEST_REPORT.md rename to docs/archive/2026-08/qa/phase-10-field-test-report.md diff --git a/PHASE11_5_TORTURE_SOAK_AUDIT_REPORT.md b/docs/archive/2026-08/qa/phase-11-5-torture-soak-audit.md similarity index 100% rename from PHASE11_5_TORTURE_SOAK_AUDIT_REPORT.md rename to docs/archive/2026-08/qa/phase-11-5-torture-soak-audit.md diff --git a/PHASE11_FIELD_TEST_READINESS_REPORT.md b/docs/archive/2026-08/qa/phase-11-field-test-readiness.md similarity index 100% rename from PHASE11_FIELD_TEST_READINESS_REPORT.md rename to docs/archive/2026-08/qa/phase-11-field-test-readiness.md diff --git a/PHASE9_AUDIT_REPORT.md b/docs/archive/2026-08/qa/phase-9-field-readiness-audit.md similarity index 100% rename from PHASE9_AUDIT_REPORT.md rename to docs/archive/2026-08/qa/phase-9-field-readiness-audit.md diff --git a/MOTORSIGNAL_SYNC_ARCHITECTURE_GAP_AUDIT.md b/docs/archive/2026-09/audits/sync-architecture-gap-audit.md similarity index 100% rename from MOTORSIGNAL_SYNC_ARCHITECTURE_GAP_AUDIT.md rename to docs/archive/2026-09/audits/sync-architecture-gap-audit.md diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..965e9af --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,14 @@ +# Documentation Archive + +These files preserve dated evidence and planning context. They are not current specifications. Validate present behavior against `AGENTS.md`, source, schemas, and tests. + +- [2026-08](2026-08/): Phase 2 planning, field-test/QA reports, legacy summaries, and the environment audit. +- [2026-09](2026-09/): dated architecture gap audit. + +## Consolidated legacy discovery + +The following overlapping historical summaries are retained as separate source records because each has unique detail. `CHANGELOG.md` at the repository root is the canonical release record. + +- [Development history](2026-08/legacy/development-history.md): commit and phase chronology. +- [Features changelog](2026-08/legacy/features-changelog.md): historical feature inventory. +- [Project summary](2026-08/legacy/project-summary.md): historical architecture overview. diff --git a/DESIGN.md b/docs/design/design-system.md similarity index 100% rename from DESIGN.md rename to docs/design/design-system.md diff --git a/docs/design/motorsignal-hybrid-v1/DESIGN.md b/docs/design/motorsignal-hybrid-v1/DESIGN.md new file mode 100644 index 0000000..f374910 --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/DESIGN.md @@ -0,0 +1,103 @@ +# DESIGN.md — MotorSignal Hybrid v1 Canonical Tokens & Components + +## 1. Visual Language & Principles +- **Theme Name**: MotorSignal Precision Telemetry & Community (Hybrid v1) +- **Visual Direction**: Conventional cockpit layout clarity, high-contrast anti-glare data hierarchy, restrained cyan branding accents, and strict zero-clutter riding ergonomics. +- **Static Recording Discipline**: Active recording indicators (`REC • LIVE`) must remain 100% static (no pulsating, flashing, or shifting animations) to eliminate rider distraction during high-speed operation. +- **Physical Touch Enforcement**: Strict minimum `48×48 CSS px` (48×48dp) touch boundaries across all buttons, segmented toggles, navigation tabs, and modal triggers. Primary riding controls enforce `56dp` minimum height. +- **Platform Inset Rules**: Dynamic runtime safe-area clearance (`SafeArea` / `MediaQuery.of(context).padding` in Flutter; `env(safe-area-inset-*)` in Web/PWA). +- **Accessibility & Runtime Verification**: Accessibility scaling (200% text scale) and runtime performance claims are treated as unverified targets until proven via emulator/device test execution. + +--- + +## 2. Canonical Token Set (Synchronized Day & Night Modes) + +> [!NOTE] +> Canonical Canvas Token Reconciliation: `#0B1326` is the canonical night canvas background. Hex variants like `#081326` or `#0B111E` appearing in intermediate drafts are reconciled to canonical `#0B1326`. + +```json +{ + "theme": "MotorSignal Hybrid v1", + "modes": { + "night": { + "surface_canvas": "#0B1326", + "surface_card_primary": "#111C38", + "surface_card_secondary": "#17223B", + "surface_card_lowest": "#060E20", + "surface_border": "#1E293B", + "surface_border_subtle": "#24324D", + "text_primary": "#DAE2FD", + "text_muted": "#869397", + "brand_cyan": "#06B6D4", + "brand_cyan_bright": "#22D3EE", + "brand_cyan_container": "#00424F", + "rec_static_green": "#10B981", + "rec_static_green_bg": "#064E3B", + "caution_amber": "#F59E0B", + "danger_red": "#EF4444" + }, + "day": { + "surface_canvas": "#F8FAFC", + "surface_card_primary": "#FFFFFF", + "surface_card_secondary": "#F1F5F9", + "surface_card_lowest": "#E2E8F0", + "surface_border": "#CBD5E1", + "surface_border_subtle": "#E2E8F0", + "text_primary": "#0F172A", + "text_muted": "#64748B", + "brand_cyan": "#0284C7", + "brand_cyan_bright": "#0EA5E9", + "brand_cyan_container": "#E0F2FE", + "rec_static_green": "#10B981", + "rec_static_green_bg": "#D1FAE5", + "caution_amber": "#D97706", + "danger_red": "#DC2626" + } + }, + "typography": { + "font_primary": "Inter, -apple-system, BlinkMacSystemFont, sans-serif", + "font_telemetry": "JetBrains Mono, monospace", + "scale": { + "display_speed": "58px–72px (font-black / tabular)", + "telemetry_primary": "24px–28px (font-bold / tabular)", + "telemetry_unit": "10px–12px (uppercase, tracking-wider)", + "headline_lg": "24px–28px (font-bold)", + "headline_md": "18px–20px (font-semibold)", + "body_regular": "14px (line-height: 20px)", + "label_nav": "10px–11px (font-medium / bold)" + } + }, + "touch_targets": { + "standard_minimum": "48px × 48px", + "primary_riding_cta": "56px height", + "nav_bar_height": "72px" + } +} +``` + +--- + +## 3. Reusable Component Guidelines + +1. **Persistent 6-Destination Bottom Nav**: + - Items: `Dash` (1), `Map` (2), `Activity` (3), `Garage` (4), `Social` (5), `Settings` (6). + - Target bounding box: `≥48×48dp` per tab icon & label. + +2. **Riding Dashboard Telemetry Cards**: + - Speed Hero: High-contrast large numeral with instant unit label (`mph` or `km/h`). + - Secondary Telemetry: Distance, Elapsed Time, Lean Dynamics (neutral max lean styling, live lean in cyan), Elevation Gain, and Braking G. + - Pinned Action Bar: `Pause Ride` (Amber, min 56dp) and `Finish Ride` (Dark/Slate with Cyan/Green accent, min 56dp), pinned to screen base with scrollable telemetry above in accessibility text scales. + - Non-pulsing `REC • LIVE` status badge. + +3. **Group Ride Cards & Hub**: + - Decoupled Logistics: Date, time, and staging location placed on a dedicated full-width row with clock icon to avoid button/text truncation. + - Hitboxes: Segmented switcher (Feed vs Hub) and ride card buttons (`Manage Ride`, `Open Hub`, `Ride Details`, `Leave Pack`) sized to minimum `48dp` height. + - Visibility & Join Policies: Strict preservation of existing permissions, host/organizer badges, and join policies. + +4. **Replay HUD**: + - Dedicated map controls locked to Top-Right quadrant, isolated from scrub timeline and lower telemetry gauges. + - Telemetry loading/status reflects real storage retrieval (e.g. SQLite database cache or cloud bundle retrieval), replacing speculative "100% buffered in-memory" labels. + - Scoped deletion options (*Local Device Cache Only* vs *Cloud & Local Records*). Never preselected. + +5. **Account & System Status**: + - Reflects actual device sensors (Internal Phone IMU at authoritative 20Hz pipeline). Discards unsupported 100Hz claims and invalid Pro restrictions on basic telemetry acquisition. diff --git a/docs/design/motorsignal-hybrid-v1/HANDOFF.md b/docs/design/motorsignal-hybrid-v1/HANDOFF.md new file mode 100644 index 0000000..6e24638 --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/HANDOFF.md @@ -0,0 +1,72 @@ +# HANDOFF.md — MotorSignal Hybrid v1 Architecture & Implementation Notice + +## 1. Prototype Scope & Verification Disclaimer +This handoff package contains **visual and interaction design prototypes** retrieved from Stitch project `5138779337547529796` ("MotorSignal UI/UX Redesign Exploration"). These specifications establish layout hierarchy, typography, touch target boundaries, and user experience flows for the MotorSignal mobile client (target viewport: `390 × 844 px`). + +**CRITICAL IMPLEMENTATION PRINCIPLE:** +Mockups represent target client UI. **Repository code and backend APIs are authoritative** regarding: +1. Actual binary telemetry storage formats (authoritative SQLite Drift Schema 7, R2 telemetry bundles). +2. Authoritative Telemetry Acquisition (frozen at 20 Hz / 50 ms via `TelemetryService`). +3. Sensor fusion (frozen in `SensorFusionService`; no independent lean-angle calculations). +4. Ride lifecycle and motion semantics (frozen in `RideStateMachine`; 3.0 MPH moving gate, kickstand isolation). +5. Cloud synchronization and offline sync architecture (`SyncManager`, `SyncOutbox`, `FirebaseSyncProvider`). +6. Authentication providers and entitlements (free vs pro capabilities). +7. Existing group ride visibility, roles, and join policies. + +Do **not** implement speculative backend mechanisms or invent hardware capabilities based on UI mockups. + +--- + +## 2. Inconsistency Resolutions & Corrections + +1. **Replay Telemetry Retrieval Wording**: + - Stitch Replay mockups contain the label `"100% Buffered (Local cache)"` or references to `"100% buffered in-memory"`. + - *Resolution*: In production code and documentation, loading and status wording must reflect the actual telemetry retrieval pipeline (retrieving points from the local SQLite database or streaming/downloading the complete bundle from cloud storage/R2). Do not present speculative "100% buffered in-memory" messaging. + +2. **Account Screen Sample Rates and Pro Restrictions**: + - An older mockup displayed `Internal Phone IMU: Active (100Hz Gyro / Accel)` and claimed `Multi-sensor telemetry sync requires Pro subscription`. + - *Resolution*: Discarded. The authoritative acquisition cadence is 20 Hz (50 ms). Basic telemetry acquisition and synchronization are not restricted by unsupported Pro paywalls; current entitlements and free/pro models documented in the repository are strictly preserved. + +3. **Ride Visibility and Join Policies**: + - Preserve all existing group ride visibility, membership, and join policies (public, unlisted, club-only, invite-only, host-approved join requests, participant lists, and role permissions). UI components adapt to these existing models without redefining policies. + +4. **Canonical Token Reconciliation (#0B1326 vs #081326)**: + - Stitch HTML templates occasionally used `#081326` or `#0B111E` for dark backgrounds. + - *Resolution*: Canonical night surface canvas is strictly `#0B1326` as defined in `DESIGN.md`. + +5. **Accessibility and Runtime Claims**: + - Visual mockups demonstrate 200% text scaling and proclaim "Contrast Ratio: 18.2:1 (Calc sRGB)" and "Design Target: 48×48px Minimum Target Discipline". + - *Resolution*: All accessibility, contrast, and runtime performance claims are treated as unverified until validated with unit/widget tests and on-device / emulator verification. + +--- + +## 3. Phased Implementation Roadmap + +### Phase 1: Mobile App Only (Current Implementation Target) +1. **Shared Day/Night Theme and Reusable Components**: + - Canonical tokens (`#0B1326` night canvas, `#F8FAFC` day canvas, `#06B6D4` / `#0284C7` brand cyan, `#10B981` static green, `#F59E0B` / `#D97706` amber, `#EF4444` / `#DC2626` red). + - Reusable cockpit telemetry card containers with 1px border. + - Pinned riding action buttons with minimum 56dp height and 48dp touch bounds. + - 6-destination bottom navigation bar with ≥48×48dp hitboxes. + - Static (non-pulsing) recording status indicator. +2. **Riding Dashboard**: + - High-contrast Speed hero with unit pill. + - Telemetry metrics (Trip Distance, Elapsed Time, Lean Dynamics with neutral max lean, Elevation Gain, Braking G). + - Live binding to authoritative `telemetryStreamProvider`, `rideStateMachineProvider`, and vehicle profile. + - Scrollable telemetry container for accessibility text scales, maintaining pinned Pause and Finish controls. +3. **Authenticated Group Ride Hub**: + - Decoupled meeting logistics row (Date, Time, Staging point) to prevent collision with action buttons. + - Segmented Feed / Hub switcher with ≥48dp touch targets. + - Ride cards supporting Host ("Manage Ride", "Open Hub") and Member ("Ride Details", "Leave Pack") states. + - Binding to live `groupRideServiceProvider` and user state. + +### Phase 2: Planned Client Roadmap (Future Implementation) +- 2-step Create Group Ride wizard (staging typeahead, date/time pickers, access review, QR rendezvous code). +- Route calculation engine loading and fallback states ("direct distance—not a road route"). +- Share ride sheet with recipient dispatch (Clubs, rider DMs, GPX export). +- Explicit scoped deletion dialog (Local cache only vs Cloud & local deletion). + +### Phase 3: Capabilities Requiring Further Discovery +- External BLE sensor GATT protocol integration. +- Two-factor authentication (2FA) setup flows. +- Dynamic telemetry streaming throttles. diff --git a/docs/design/motorsignal-hybrid-v1/SCREEN_INDEX.md b/docs/design/motorsignal-hybrid-v1/SCREEN_INDEX.md new file mode 100644 index 0000000..ffefe0a --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/SCREEN_INDEX.md @@ -0,0 +1,46 @@ +# SCREEN_INDEX.md — MotorSignal Hybrid v1 Final Design Handoff + +This index maps the 14 required screen flows (and companion accessibility views) to their canonical Stitch project (`projects/5138779337547529796`) screen IDs, composite frame breakdowns, and implementation status. + +## Screen Catalog & Resolved Stitch Identifiers + +| # | Screen / State Name | Actual Stitch Screen ID | Composite Frame / Viewport | Status / Verification Scope | Description & Key Corrections | +|---|---|---|---|---|---| +| **01** | Riding Dashboard — Day | `fbbd68740d11447e8035cd0776133053` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Anti-glare daylight contrast (`#F8FAFC`), neutral max lean, static REC pill, pinned 56dp Pause/Finish controls, 6-tab nav. | +| **01b**| Riding Dashboard — Day (200% Text Scale Companion) | `fbbd68740d11447e8035cd0776133053` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | Simulated 200% scale showing pinned controls and scrollable telemetry container. Unverified until tested. | +| **02** | Riding Dashboard — Night | `8f336a84e92c44d8aea012b2c6703b55` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Deep slate `#0B1326` canvas, neutral 27° max lean readout, static REC pill, zero-scroll phone fit. | +| **02b**| Riding Dashboard — Night (200% Text Scale Companion) | `8f336a84e92c44d8aea012b2c6703b55` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | 200% scale demonstration with ≥48dp physical target boundary indicators. Unverified until tested. | +| **03** | Authenticated Group Ride Hub | `a661c1c9c82843a2bcb6b84d6d7855e1` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Dedicated logistics row, min-48dp Feed/Hub switch, 48dp action buttons. Preserves existing join/visibility policies. | +| **03b**| Group Ride Hub (200% Text Scale Companion) | `a661c1c9c82843a2bcb6b84d6d7855e1` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | Validates text wrapping on meeting details without action button overlap. Unverified until tested. | +| **04** | Ride History & Archive | `934e8a33a5fa44009d40c292aacc630e` | Viewport 1 (Left Frame, 390×844) | **Tier 1 (UI) + Tier 2 (Sync UX)** | Sample data labeled, offline queued sync banner (2 rides queued), 3×2 telemetry card grid. | +| **05** | Ride Replay HUD | `934e8a33a5fa44009d40c292aacc630e` | Viewport 2 (Right Frame, 390×844) | **Tier 1 (UI) + Tier 2 (Proposed HUD)** | Top-right map controls, telemetry retrieval status follows actual database/bundle retrieval (discards '100% buffered in-memory'), 56dp playback controls. | +| **06** | Community Feed | `17a5177fab12466da2dea6639c8fd88f` | Frame 1 (390×844) | **Tier 1 (UI) + Tier 2 (Moderation)** | 4 feed tabs: Following, Clubs, Nearby, Discover. Generic community moderation card. Pro = subscription badge. | +| **07** | Create Group Ride — Step 1: Details & Staging | `17a5177fab12466da2dea6639c8fd88f` | Frame 2a (390×844) | **Tier 2: Proposed Feature** | Staging location typeahead, calendar/time pickers, route preview teaser. | +| **08** | Route Calculation Engine — Loading & Failure | `17a5177fab12466da2dea6639c8fd88f` | Frame 2b (390×844) | **Tier 2 / Tier 3 (Engine UX)** | Calculating state (spinner) vs Fallback state (GPX upload / "direct distance—not a road route"). | +| **09** | Create Group Ride — Step 2: Review & Access | `17a5177fab12466da2dea6639c8fd88f` | Frame 2c (390×844) | **Tier 2: Proposed Feature** | Ride summary recap, Public vs Invite-Only access toggles, pace selector, safety notes. Preserves existing policies. | +| **10** | Ride Created — Join Code & Dynamic QR | `17a5177fab12466da2dea6639c8fd88f` | Frame 2d (390×844) | **Tier 2: Proposed Feature** | High-contrast staging QR code, `#SKY92` rendezvous code, share invite triggers. | +| **11** | Account, Security & Sensor Connections | `fd0e6370af424ff4becfdaecda7f29de` | Frame 3 (390×844) | **Tier 1 (UI) + Tier 3 (Backend/Sensors)** | Internal Phone IMU active (20Hz authoritative pipeline; discarded older 100Hz and Pro restriction assumptions); truthful BLE disconnected state. | +| **12** | Share Ride — Recipient Selection Dialog | `17a5177fab12466da2dea6639c8fd88f` | Modal 1 (Overlay) | **Tier 2: Proposed Feature** | Scoped modal: Club channel, Rider DM, or native GPX export. | +| **13** | Delete Ride — Explicit Scope Selection | `17a5177fab12466da2dea6639c8fd88f` | Modal 2 (Overlay) | **Tier 2: Proposed Feature** | Explicit scope required: "Delete Local Cache Only" vs "Request Cloud & Local Deletion". No preselection. | +| **14** | Supporting System States Matrix | `17a5177fab12466da2dea6639c8fd88f` | States Grid (5 Cards) | **Tier 2: System UX** | Comprehensive previews: Loading/Downloading, Empty Filter, Sync Failure, Offline Queued, Location Denied. | + +--- + +## Historical & Superseded Iterations + +The following screens in project `5138779337547529796` were intermediate drafts or superseded revisions: +- `2cd85dda96ea48ff99a03395c432bfa1`: MotorSignal A/B/C Comparative Exploration (Early 3-way layout benchmark) +- `456a0df6c5a2486b8192c77f8f297c1c`: Hybrid Riding Dashboard (Night Mode) — Initial draft +- `dcad02763b2b499e845aac50dc5126c2`: Revised Authenticated Group Ride Hub — Initial draft +- `a82d7b2aced7407cb43dc0cd2501d690`: Hybrid Riding Dashboard (Day Mode) — Initial draft +- `5e0a7784ecec46eaad2f505a43d96576`: Hybrid Riding Dashboard - Night (Default vs Enlarged Text) — Intermediate draft +- `f9faeec4eec142349267ebb8d4c9a484`: Hybrid Riding Dashboard - Day (Default vs Enlarged Text) — Intermediate draft +- `2f39581f42734138991a2199601038ae`: Revised Group Ride Hub (Default vs Enlarged Text) — Intermediate draft +- `aa6aed675a3044e7a3025333cf732c2d`: MotorSignal - Ride History & Replay HUD — Redundant instance of `934e8a33a5fa44009d40c292aacc630e` + +Uploaded Reference Screenshots from physical test runs: +- `7166114457503287420`: `Screenshot_20261006-020215.png` (Riding Dashboard night dark state) +- `7166114457503285870`: `Screenshot_20261006-020217.png` (Map and live cluster state) +- `7166114457503288416`: `Screenshot_20261006-020220.png` (Activity log & telemetry cards) +- `7166114457503286424`: `Screenshot_20261006-020224.png` (Group ride listing & cards) +- `7166114457503288970`: `Screenshot_20261006-020229.png` (Settings and sensor connection status) diff --git a/docs/design/motorsignal-hybrid-v1/manifest.json b/docs/design/motorsignal-hybrid-v1/manifest.json new file mode 100644 index 0000000..2b302aa --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/manifest.json @@ -0,0 +1,254 @@ +{ + "handoff_package": "MotorSignal Hybrid v1 — Final Design Handoff", + "project_id": "5138779337547529796", + "project_title": "MotorSignal UI/UX Redesign Exploration", + "canonical_tokens": { + "night": { + "surface_canvas": "#0B1326", + "surface_card_primary": "#111C38", + "surface_card_secondary": "#17223B", + "surface_card_lowest": "#060E20", + "surface_border": "#1E293B", + "surface_border_subtle": "#24324D", + "text_primary": "#DAE2FD", + "text_muted": "#869397", + "brand_cyan": "#06B6D4", + "brand_cyan_bright": "#22D3EE", + "brand_cyan_container": "#00424F", + "rec_static_green": "#10B981", + "rec_static_green_bg": "#064E3B", + "caution_amber": "#F59E0B", + "danger_red": "#EF4444" + }, + "day": { + "surface_canvas": "#F8FAFC", + "surface_card_primary": "#FFFFFF", + "surface_card_secondary": "#F1F5F9", + "surface_card_lowest": "#E2E8F0", + "surface_border": "#CBD5E1", + "surface_border_subtle": "#E2E8F0", + "text_primary": "#0F172A", + "text_muted": "#64748B", + "brand_cyan": "#0284C7", + "brand_cyan_bright": "#0EA5E9", + "brand_cyan_container": "#E0F2FE", + "rec_static_green": "#10B981", + "rec_static_green_bg": "#D1FAE5", + "caution_amber": "#D97706", + "danger_red": "#DC2626" + } + }, + "screens": [ + { + "index": "01", + "name": "Riding Dashboard — Day", + "screen_id": "fbbd68740d11447e8035cd0776133053", + "composite_frame": "Left Frame (Default Scale)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1: Verified UI", + "reference_files": [ + "references/screen_day_dashboard.png", + "references/screen_day_dashboard.html" + ] + }, + { + "index": "01b", + "name": "Riding Dashboard — Day (200% Text Scale Companion)", + "screen_id": "fbbd68740d11447e8035cd0776133053", + "composite_frame": "Right Frame (200% Accessibility Scale)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Accessibility Target (Unverified)", + "reference_files": [ + "references/screen_day_dashboard.png", + "references/screen_day_dashboard.html" + ] + }, + { + "index": "02", + "name": "Riding Dashboard — Night", + "screen_id": "8f336a84e92c44d8aea012b2c6703b55", + "composite_frame": "Left Frame (Default Scale)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1: Verified UI", + "reference_files": [ + "references/screen_night_dashboard.png", + "references/screen_night_dashboard.html" + ] + }, + { + "index": "02b", + "name": "Riding Dashboard — Night (200% Text Scale Companion)", + "screen_id": "8f336a84e92c44d8aea012b2c6703b55", + "composite_frame": "Right Frame (200% Accessibility Scale)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Accessibility Target (Unverified)", + "reference_files": [ + "references/screen_night_dashboard.png", + "references/screen_night_dashboard.html" + ] + }, + { + "index": "03", + "name": "Authenticated Group Ride Hub", + "screen_id": "a661c1c9c82843a2bcb6b84d6d7855e1", + "composite_frame": "Left Frame (Default Scale)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1: Verified UI", + "reference_files": [ + "references/screen_group_ride_hub.png", + "references/screen_group_ride_hub.html" + ] + }, + { + "index": "03b", + "name": "Group Ride Hub (200% Text Scale Companion)", + "screen_id": "a661c1c9c82843a2bcb6b84d6d7855e1", + "composite_frame": "Right Frame (200% Accessibility Scale)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Accessibility Target (Unverified)", + "reference_files": [ + "references/screen_group_ride_hub.png", + "references/screen_group_ride_hub.html" + ] + }, + { + "index": "04", + "name": "Ride History & Archive", + "screen_id": "934e8a33a5fa44009d40c292aacc630e", + "composite_frame": "Viewport 1 (Left Frame)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1 (UI) + Tier 2 (Sync UX)", + "reference_files": [ + "references/screen_replay_hud.png", + "references/screen_replay_hud.html" + ] + }, + { + "index": "05", + "name": "Ride Replay HUD", + "screen_id": "934e8a33a5fa44009d40c292aacc630e", + "composite_frame": "Viewport 2 (Right Frame)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1 (UI) + Tier 2 (Proposed HUD)", + "reference_files": [ + "references/screen_replay_hud.png", + "references/screen_replay_hud.html" + ] + }, + { + "index": "06", + "name": "Community Feed", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Frame 1", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1 (UI) + Tier 2 (Moderation)", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "07", + "name": "Create Group Ride — Step 1: Details & Staging", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Frame 2a", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 2: Proposed Feature", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "08", + "name": "Route Calculation Engine — Loading & Failure", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Frame 2b", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 2 / Tier 3 (Engine UX)", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "09", + "name": "Create Group Ride — Step 2: Review & Access", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Frame 2c", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 2: Proposed Feature", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "10", + "name": "Ride Created — Join Code & Dynamic QR", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Frame 2d", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 2: Proposed Feature", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "11", + "name": "Account, Security & Sensor Connections", + "screen_id": "fd0e6370af424ff4becfdaecda7f29de", + "composite_frame": "Frame 3", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 1 (UI) + Tier 3 (Backend/Sensors)", + "reference_files": [ + "references/screen_remaining_flows.png", + "references/screen_remaining_flows.html" + ] + }, + { + "index": "12", + "name": "Share Ride — Recipient Selection Dialog", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Modal 1 (Overlay)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 2: Proposed Feature", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "13", + "name": "Delete Ride — Explicit Scope Selection", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "Modal 2 (Overlay)", + "viewport": { "width": 390, "height": 844 }, + "tier": "Tier 2: Proposed Feature", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + }, + { + "index": "14", + "name": "Supporting System States Matrix", + "screen_id": "17a5177fab12466da2dea6639c8fd88f", + "composite_frame": "System States Grid (5 Cards)", + "viewport": { "width": 1280, "height": 800 }, + "tier": "Tier 2: System UX", + "reference_files": [ + "references/screen_consolidated_flows.png", + "references/screen_consolidated_flows.html" + ] + } + ], + "inconsistencies_resolved": { + "replay_telemetry_retrieval_wording": "Loading/status wording must follow actual database/bundle retrieval rather than speculative '100% buffered in-memory' label.", + "account_screen_assumptions": "Discarded unsupported 100Hz IMU claims and Pro restrictions on multi-sensor telemetry; conforms to frozen 20Hz pipeline and current free/pro model.", + "ride_visibility_and_join_policies": "Preserved all existing membership, host approval, unlisted/public/club-only/invite-only policies.", + "night_surface_canvas_reconciliation": "Reconciled to canonical #0B1326.", + "accessibility_and_runtime_claims": "Treated as unverified targets until tested on device/emulator." + } +} diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_comparative_exploration.png b/docs/design/motorsignal-hybrid-v1/references/screen_comparative_exploration.png new file mode 100644 index 0000000..fa21fc6 Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_comparative_exploration.png differ diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_consolidated_flows.html b/docs/design/motorsignal-hybrid-v1/references/screen_consolidated_flows.html new file mode 100644 index 0000000..8a5ecf9 --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/references/screen_consolidated_flows.html @@ -0,0 +1,1123 @@ + + + + + +MotorSignal - Consolidated Architecture Handoff & Specifications + + + + + + + + + + + + + +
+
+
+
+dataset +
+
+
+Engineering Spec v3.0 +MotorSignal Architecture Handoff +Design Mockups — Non-Verified Backend +
+

Consolidated Mobile Flows & System Architecture Handoff

+
+
+
+Target Frame: 390 × 844 CSS px +Touch Target Minimum: ≥48×48dp + + REC: Static (No Pulse) + +
+
+
+
+ + + +
+
+
+

+ + Core Screen Flows & Create Ride Lifecycle (390×844 Viewports) +

+

High-fidelity 390×844 prototype viewports with persistent 6-destination navigation, compliant moderation, and complete multi-step group ride flow.

+
+
+ 5 Viewports Spec +
+
+
+ + + +
+
+ + 1. Community Feed + +Social > Feed +
+ +
+ +
+09:41 +
+ + +
+
+signal_cellular_alt +wifi +battery_full +
+
+ +
+
+
+Social Network +Sample Data +
+
+
+ + +
+
+
+ +
+ + + + +
+
+ +
+ +
+ +
+
+
AM
+
+
+@ApexMarcus + +PRO (Subscription) +
+Bay Area Twisty Club • 2h ago +
+
+ + +
+ +
+

Sunday Skyline & Pescadero Carve

+

Clean coastal run before fog rolled into Pescadero. 2024 Ducati Streetfighter V4 feeling dialed on new rubber.

+
+ +
+
+Distance +

68.4 MI

+
+
+Peak Lean +

51° R

+
+
+Duration +

1h 14m

+
+
+ +
+
+ + +
+ +
+
+ +
+
+
+
+flag +Content Flagged by Community +
+ + +
+
+

+ This post has been reported for review under community safety guidelines. GPS telemetry map and replay hidden pending moderation review. +

+ +
+ + +
+
+
+ +
+Proposed Flow: Pack Live Beacon +

Broadcast real-time pack proximity during scheduled weekend group rides.

+Implementation decision requiring repository inspection. +
+
+ + + +
+
+
+
+
+ + + +
+
+ + 2a. Create Ride: Step 1 + +Inputs & Route Calc +
+ +
+ +
+09:42 +
+ + +
+
+signal_cellular_alt +wifi +battery_full +
+
+ +
+
+ +
+

Create Group Ride

+STEP 1: ROUTE & LOGISTICS +
+1/2 +
+ +
+
+
+
+
+ +
+ +
+ + +
+ +
+ +
+ +trip_origin +
+ +
+
+
+

Alice's Restaurant

+

17288 Skyline Blvd, Woodside, CA

+
+Selected +
+
+

Alice's Mountain Market

+

Woodside, CA

+
+
+
+ +
+ +
+ +location_on +
+
+ +
+ +
+
+ +calendar_today +
+
+ +schedule +
+
+
+ +
+
+Route Engine Preview +Calculation Ready +
+
+
+ + + + + +
+ Skyline → Hwy 84 +
+
+ 68.4 MI Calculated +
+
+
+
+

Pescadero Loop

+

Elevation +2,140 ft • Est. 1h 45m

+
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+ + + +
+
+ + 2b. Route Calc States + +Loading & Fallback +
+ +
+ +
+09:42 +
+ + +
+
+signal_cellular_alt +wifi +battery_full +
+
+ +
+
+
+route +

Route Engine Testing

+
+Step 1 States +
+
+ +
+ +
+
+ +progress_activity + State A: Loading Route Preview + +Solving Waypoints +
+
+
+

Calculating Optimal Moto Vector...

+

Querying elevation deltas & twisty segment bias

+
+
+ +
+
+ +error + State B: Calculation Failed (Fallback) + +Fallback Active +
+
+
+warning +
+

Cannot Solve Road Routing

+

Selected waypoints traverse private ranch roads without public routing graph.

+
+
+ +
+

Fallback Actions:

+ + +
+
+
+ +
+Architecture Decision +

Routing engine provider and offline tile calculation capabilities require repository inspection.

+
+
+
+ +
+
+
+
+
+
+ + + +
+
+ + 2c. Step 2: Review & Access + +Step 2 of 2 +
+ +
+ +
+09:43 +
+ + +
+
+signal_cellular_alt +wifi +battery_full +
+
+ +
+
+ +
+

Create Group Ride

+STEP 2: REVIEW & ACCESS +
+2/2 +
+ +
+
+
+
+
+ +
+ +
+Ride Summary +

Sunday Skyline & Pescadero Carve

+

Sun, Oct 12 • 09:00 AM KSU • 68.4 MI

+
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+ +
+ +
+
+
+
+
+
+ + + +
+
+ + 2d. Post-Creation Screen + +Join Code & QR +
+ +
+ +
+09:44 +
+ + +
+
+signal_cellular_alt +wifi +battery_full +
+
+ +
+Group Ride Created + +
+ +
+ +
+
+done_all +
+
+

Ride Successfully Created!

+

Pack roster initialized • Ready for invites

+
+
+ +
+Pack Join Code +
+#SKY92 + +
+

Riders enter this code in their app to instantly join pack roster.

+
+ +
+Scan at Staging Area + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Encrypted Pack Token: moto://ride/sky92 +
+ +
+ + +
+
+
+
+
+
+
+
+
+ + + +
+
+

+ + Interactive Modal Architecture & System Protocol Decisions +

+

Strict non-preselected scoped deletion modal, verified sharing sheets, and explicit labeling of unresolved protocol decisions.

+
+
+ +
+
+
+share +

Share Telemetry Replay

+
+Proposed Feature +
+

Select specific riders, pack channels, or external platforms to dispatch session telemetry.

+ +
+ + + +
+
+ + +
+
+ +
+
+
+delete_forever +

Delete Ride Session

+
+Explicit Selection Req. +
+

+ Specify deletion scope. No default option is preselected to protect cloud telemetry from inadvertent destruction. +

+ +
+ + +
+ +
+ + +
+
+ +
+
+
+developer_board +

Telemetry Pipeline Protocols

+
+Unverified in Repo +
+
+
+ + Storage & Packet Structures + +

Implementation Decision Requiring Repository Inspection

+

Specific binary formats, compression schemes, or database engines (e.g. SQLite / protobuf) are unverified.

+
+
+ + IMU Sensor Sampling Frequencies + +

Implementation Decision Requiring Repository Inspection

+

Sensor polling rates (e.g. 100Hz vs 50Hz) and real-time buffer memory requirements must be confirmed against hardware.

+
+
+ + Sensor Sync & Feature Gating + +

Implementation Decision Requiring Repository Inspection

+

Bluetooth Low Energy (BLE) sync protocols and tier gating policies are architectural backlog proposals.

+
+
+
+
+
+ + + +
+
+

+ + Three-Tier Feature Taxonomy & Verification Matrix +

+

Authoritative categorization distinguishing verified client features, design proposals, and unverified backend/hardware capabilities.

+
+
+ +
+
+
+ +

Tier 1: Existing Client Features

+
+Verified in UI +
+

Functionality currently constructed and rendered in mobile prototypes:

+
    +
  • +check_circle +Live Speed HUD: Digital display & current velocity readouts. +
  • +
  • +check_circle +Lean Angle Readout: Degrees and direction indicators (L/R). +
  • +
  • +check_circle +Ride List: Historical session cards with distance and time. +
  • +
  • +check_circle +Offline Recording Indicator: Static (non-pulsing) REC badge. +
  • +
  • +check_circle +Basic Group Cards: Club preview cards and member counters. +
  • +
+
+ +
+
+
+ +

Tier 2: Proposed Features

+
+Planned UX +
+

Validated workflow proposals awaiting backend contract finalization:

+
    +
  • +sync +Auto-Sync on Connection: Background uplink upon WiFi/Cellular. +
  • +
  • +share +Recipient Selection Sheet: Club/DM telemetry dispatcher. +
  • +
  • +qr_code_2 +Join Code & QR Generation: Pack rendezvous tokens (#SKY92). +
  • +
  • +alt_route +Route Calculation Engine: Multi-point waypoint moto routing. +
  • +
  • +radar +Pack Live Beacon: Real-time distance proximity tracking. +
  • +
+
+ +
+
+
+ +

Tier 3: Unknown Capabilities

+
+Repo Inspection Req. +
+

Core decisions that must NOT be assumed until repository analysis:

+
    +
  • +bluetooth +BLE External Sensor Sync Protocol: GATT characteristics unknown. +
  • +
  • +cloud_sync +Cloud Endpoints & Conflict Resolution: Merge logic unverified. +
  • +
  • +lock_clock +2FA & Passkey Implementation: Auth provider not specified. +
  • +
  • +storage +Storage Format & Sampling Rate: No verified .pb/SQLite/100Hz. +
  • +
  • +workspace_premium +Subscription Entitlements: Feature-gating rules unconfirmed. +
  • +
+
+
+
+ + + +
+
+

+ + Consolidated Design Tokens & Multi-Platform Handoff (Night & Day Modes) +

+

Authoritative token specifications, static recording discipline, and touch geometry across lighting themes.

+
+
+ +
+
+

+dark_mode + Night Mode Tokens (Default) +

+Dark Canvas +
+
+
+Surface Canvas +#0B1326 +
+
+Card Primary +#111C38 +
+
+Card Secondary +#17223B +
+
+Border / Outline +#1E293B +
+
+Cyan Accent +#06B6D4 +
+
+ + + REC Static Green + +#10B981 (Static) +
+
+Caution Amber +#F59E0B +
+
+Danger Red +#EF4444 +
+
+
+ +
+
+

+light_mode + Day Mode Tokens (High Sun) +

+Day Canvas +
+
+
+Surface Canvas +#F8FAFC +
+
+Card Primary +#FFFFFF +
+
+Card Secondary +#F1F5F9 +
+
+Border / Stroke +#CBD5E1 +
+
+Cyan Accent +#0284C7 +
+
+ + + REC Static Green + +#10B981 (Static) +
+
+Caution Amber +#D97706 +
+
+Danger Red +#DC2626 +
+
+
+ +
+

+touch_app + Touch Boundaries & Safe Area Rules +

+
+
+ +Touch Minimum: +≥48×48dp Enforced + +

All tabs, icon actions, menu triggers, and buttons strictly honor 48×48dp physical target bounding boxes.

+
+
+ +Static REC Rule: +No Animated Pulsing + +

Recording indicators must remain completely static in the UI to prevent distraction during high-speed operation.

+
+
+Multi-Platform Inset Handling: +

Flutter: SafeArea + MediaQuery. Web: env(safe-area-inset-top/bottom).

+
+
+
+
+
+
+ \ 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 + + + + + + +
+ +
+
+
DESIGN SYSTEM SPECIFICATIONDesign Target: 48×48px Minimum Target Discipline• Evaluation: Visual Specification & Prototype Sizing
+

Group Ride Hub & Touch Architecture

+

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.

+
+
+
+ +≥48dp Target Zone +
+
+ +Dedicated Logistics Row +
+
+
+ +
+ +
+
+
+ +Default Text & Touch Layout +
+390 × 844 px +
+ +
+ +
09:41
MotorSignal•Online
+ +
+

Group Rides

Packs, route planning & community sessions

FeedHub
+
+ +
+ +
+
+
+ B +
+
+
+Bill +Free Tier +
+
@bponders12 • '01 R6
+
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+
Bay Area Twisty Club • Organizing
+

Sunday Skyline & Pescadero Carve

+
+ + #SKY92 + +
+ +
+
+
Distance
+
68.4 mi
+
+
+
Pace
+
Spirited
+
+
+
Riders
+
8 Confirmed
+
+
+ +
+ + + +Sun, 9:00 AM • Alice's Restaurant, Woodside +
+ +
+
+ +
+
+
+
NorCal Sportbikes • Joined
+

Mt. Tamalpais Twilight Run

+
+ + #TAM71 + +
+ +
+
+
Distance
+
42.0 mi
+
+
+
Pace
+
Moderate
+
+
+
Riders
+
14 Riders
+
+
+ +
+ + + +Sat, 5:30 PM • Mill Valley Depot, Marin +
+ +
+
+
+
+ + +
+
+ +
+
200% Body & UI Text Scale Demonstration (Simulated Large Font Mode)
≥48dp Inspect
+ +
+ +
09:41
MotorSignal•Online
+ +

Group Rides

Packs, route planning & community sessions

48dp boxFeedHub
+ +
B
BillFree Tier
@bponders12 • Yamaha R6
48dp min
48dp Primary Action
Tabs ≥48dp
Bay Area Twisty • Organizing

Sunday Skyline & Pescadero Carve

#SKY92
Distance
68.4 mi
Pace
Spirited
Riders
8 Confirmed
DEDICATED LOGISTICS ROW
Sun, 9:00 AM • Alice's Restaurant, Woodside CA
NorCal Sportbikes • Joined

Mt. Tamalpais Twilight Run

#TAM71
Distance
42.0 mi
Pace
Moderate
Riders
14 Confirmed
DEDICATED LOGISTICS ROW
Sat, 5:30 PM • Mill Valley Depot, Marin County
+ + +
+
+
+
+ \ No newline at end of file diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_group_ride_hub.png b/docs/design/motorsignal-hybrid-v1/references/screen_group_ride_hub.png new file mode 100644 index 0000000..bcd58ab Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_group_ride_hub.png differ diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_night_dashboard.html b/docs/design/motorsignal-hybrid-v1/references/screen_night_dashboard.html new file mode 100644 index 0000000..fbbab27 --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/references/screen_night_dashboard.html @@ -0,0 +1,37 @@ + + + + + + +MotorSignal - Hybrid Riding Dashboard (Night Mode) + + + + + + +

MotorSignal — Hybrid Riding Dashboard (Night Mode)

Accessibility inspection: Default Layout vs. Enlarged Text & 48dp+ Touch Target Bounds (390 × 844 px phone frames)

Night Theme StandardDesign Target: High GlanceabilityContrast Ratio: 18.2:1 (Calc sRGB)
Standard Viewport (Default Scale)
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 Angle DynamicsPhone IMU
Current Lean
18° R
Live tilt reading
Max Lean
27° R
Session Max
Elev. Gain:+69 m
Hard Braking:0.49 G
200% Text Scale Demonstration (Simulated Accessibility Scale) • 48dp+ Tap Targets
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 Angle DynamicsPhone IMU
Current Lean
18° R
Live tilt reading
Max Lean
27° R
Session Max (Neutral)
Elev. Gain:+69 m
Hard Braking:0.49 G
56dp (min 48)
56dp (min 48)
+ \ No newline at end of file diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_night_dashboard.png b/docs/design/motorsignal-hybrid-v1/references/screen_night_dashboard.png new file mode 100644 index 0000000..4152d8f Binary files /dev/null and b/docs/design/motorsignal-hybrid-v1/references/screen_night_dashboard.png differ diff --git a/docs/design/motorsignal-hybrid-v1/references/screen_remaining_flows.html b/docs/design/motorsignal-hybrid-v1/references/screen_remaining_flows.html new file mode 100644 index 0000000..f95b0c8 --- /dev/null +++ b/docs/design/motorsignal-hybrid-v1/references/screen_remaining_flows.html @@ -0,0 +1,994 @@ + + + + + +MotorSignal - Remaining Flows & Consolidated Architecture Handoff + + + + + + + + + + + + + + +
+
+
+
+ dataset +
+
+
+ Prototype Spec v2.8 + MotorSignal UI Engineering Package + Design Mockups — Non-Verified Backend +
+

Consolidated Mobile Flows & Architecture Handoff

+
+
+
+ Target Frame: 390 × 844 CSS px + Touch Target Minimum: ≥48×48dp +
+
+
+ +
+ + + + +
+
+
+

+ + Core Remaining Screen Flows (Default Viewports) +

+

High-fidelity 390×844 prototype viewports with persistent 6-destination navigation and explicit sample-data notation.

+
+
+ Frames 1–3 of 3 +
+
+ +
+ + + + +
+
+ + 1. Community Feed & Moderation + + Social > Feed +
+ + +
+ +
+ 09:41 +
+ + +
+
+ signal_cellular_alt + wifi + battery_full +
+
+ + +
+
+
+ Social Network + Sample Data +
+
+ +
+ + +
+
+
+ + +
+ + + + +
+
+ + +
+ + +
+ +
+
+
AM
+
+
+ @ApexMarcus + PRO +
+ Bay Area Twisty Club • 2h ago +
+
+ + +
+ + +
+

Sunday Skyline & Pescadero Carve

+

Clean coastal run before fog rolled into Pescadero. 2024 Ducati Streetfighter V4 feeling dialed on new rubber.

+
+ + +
+
+ Distance +

68.4 MI

+
+
+ Peak Lean +

51° R

+
+
+ Duration +

1h 14m

+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
+
+ flag + Flagged by Community Review +
+ + +
+ +
+
+ speed +
+

Aggressive Speed Content Hidden

+

Public highway speed exceeded community safety guideline (+40% above zone speed ceiling). GPS telemetry map blurred.

+
+
+ + +
+ + +
+
+
+ + +
+ Proposed Flow: Pack Live Beacon +

Broadcast real-time pack proximity during scheduled weekend group rides.

+ Requires field connectivity testing before production release. +
+ +
+ + + + +
+
+
+
+
+ + + + +
+
+ + 2. Two-Step Create Group Ride + + Step 1 of 2 +
+ + +
+ +
+ 09:42 +
+ + +
+
+ signal_cellular_alt + wifi + battery_full +
+
+ + +
+
+ +
+

Create Group Ride

+ STEP 1: LOGISTICS & ROUTE +
+ + 1/2 +
+ + +
+
+
+
+
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ +
+ + + + + +
+ Skyline Blvd → Hwy 84 +
+
+ 68.4 MI Calculated +
+
+ +
+
+

Pescadero Coastal Loop

+

Elevation: +2,140 ft • Est. 1h 45m

+
+ +
+
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + pin_drop +
+
+
+ + +
+ +
+ + + +
+
+ + +
+
+ qr_code_2 +
+
+

Next Step: Join Code & Invites

+

Step 2 generates your pack join code (#SKY92) and shareable QR code.

+
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + +
+
+ + 3. Account, Security & Sensors + + Truthful States +
+ + +
+ +
+ 09:43 +
+ + +
+
+ signal_cellular_alt + wifi + battery_full +
+
+ + +
+
+
+ Account & System +
+ Build 2.8.4 +
+
+ + +
+ + +
+
+
+
B
+
+

Bill Ponders

+

bponders12@gmail.com

+
+
+ +
+ + +
+
+ Tier: MotorSignal Free + Active +
+

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.

+
+ + State: Denied +
+
+
+ + + + +
+
+

+ + Consolidated Design Tokens & Multi-Platform Handoff +

+

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)

+
+
+ + Spec v2.6.4 Live + + + Target: 390x844 @3x + +
+
+ +
+ + + +
+ +
+ + + VIEWPORT 1: Session Archive + +Screen: Activity.History +
+
+ +
+ +
+env(safe-area-inset-top) +
+ +
+2:02 +
+ + +
+
+
+
+
+ +
+

Ride History

+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+Offline Mode: 2 rides queued locally (auto-sync on connection). +
+
+ +
+
+
+

Ride 12

+ + 2001 YAMAHA Y... + +
+ +
+ +

Skyline Blvd & Pescadero Carve

+ +
+
+Trip +15.7 mi +
+
+Time +16 min +
+
+Top Spd +84 mph +
+
+Max Lean +27° R +
+
+Hard Brk +0.49 G +
+
+Elev Gain +69 m +
+
+ + +
+ +
+
+
+

Ride 11

+ + DUCATI V4 + +
+ + Synced + +
+
+
+TRIP +42.1 mi +
+
+TIME +54 min +
+
+TOP SPD +72 mph +
+
+
+ +
+
+ +
+

No rides found for Track Mode

+

Change telemetry preset or switch filter above.

+
+
+ + + + +
+env(safe-area-inset-bottom) +
+
+
+ + + +
+ +
+ + + VIEWPORT 2: Telemetry Replay HUD + +Screen: Replay.ActiveHUD +
+
+ +
+ +
+env(safe-area-inset-top) +
+ +
+2:02 +
+ +
+
+
+
+
+ +
+ + + +
+

Ride 12 • Skyline & Pescadero

+GPS Telemetry Synced +
+ +
+ + + + +
+
+ +
+ +
+ + + 100% Buffered (Local cache) + +
+ +
+ + + +
+ + + + + + + + + + +51° APEX + + + + + + + + + +
+ + + ELV +2,140 FT • GRADE 6.2% + +
+
+ + +
+ +
+ +
+Live Speed +
58 MPH
+
+ +
+Lean Angle +
+ + + + +21° R +
+
+ +
+Lateral G +
0.38 G
+
+
+ +
+
+08:42 +SESSION PROGRESS +16:00 +
+ +
+
+
+
+ +
+
+
+ +
+ + + + + + + + +
+
+ + +
+env(safe-area-inset-bottom) +
+
+
+
+ + + + + +
+
+

+ + 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> loadCalibrationOffsets({int? motorcycleId}) async { @@ -44,6 +86,10 @@ class CalibrationService { final bikeGbx = prefs.getDouble('calibration_gyro_bias_x_$motorcycleId') ?? 0.0; final bikeGby = prefs.getDouble('calibration_gyro_bias_y_$motorcycleId') ?? 0.0; final bikeGbz = prefs.getDouble('calibration_gyro_bias_z_$motorcycleId') ?? 0.0; + final isCal = prefs.getBool('calibration_is_calibrated_$motorcycleId'); + final ax = prefs.getDouble('calibration_accel_x_$motorcycleId') ?? 0.0; + final ay = prefs.getDouble('calibration_accel_y_$motorcycleId') ?? 0.0; + final az = prefs.getDouble('calibration_accel_z_$motorcycleId') ?? 0.0; if (bikePitch != null && bikeRoll != null) { return { 'pitch': bikePitch, @@ -51,6 +97,10 @@ class CalibrationService { 'gyroBiasX': bikeGbx, 'gyroBiasY': bikeGby, 'gyroBiasZ': bikeGbz, + 'isCalibrated': (isCal == true) ? 1.0 : 0.0, + 'accelCalX': ax, + 'accelCalY': ay, + 'accelCalZ': az, }; } } @@ -59,12 +109,29 @@ class CalibrationService { final gbx = prefs.getDouble(_gyroBiasXKey) ?? 0.0; final gby = prefs.getDouble(_gyroBiasYKey) ?? 0.0; final gbz = prefs.getDouble(_gyroBiasZKey) ?? 0.0; + final isCal = prefs.getBool(_isCalibratedKey); + final ax = prefs.getDouble(_accelCalXKey) ?? 0.0; + final ay = prefs.getDouble(_accelCalYKey) ?? 0.0; + final az = prefs.getDouble(_accelCalZKey) ?? 0.0; return { 'pitch': pitch, 'roll': roll, 'gyroBiasX': gbx, 'gyroBiasY': gby, 'gyroBiasZ': gbz, + 'isCalibrated': (isCal == true) ? 1.0 : 0.0, + 'accelCalX': ax, + 'accelCalY': ay, + 'accelCalZ': az, }; } + + Future loadMountMode({int? motorcycleId}) async { + final prefs = await SharedPreferences.getInstance(); + if (motorcycleId != null) { + final mode = prefs.getString('calibration_mount_mode_$motorcycleId'); + if (mode != null) return mode; + } + return prefs.getString(_mountModeKey); + } } diff --git a/lib/services/cloud/firebase_account_sync_service.dart b/lib/services/cloud/firebase_account_sync_service.dart index 4366476..a27559b 100644 --- a/lib/services/cloud/firebase_account_sync_service.dart +++ b/lib/services/cloud/firebase_account_sync_service.dart @@ -1,4 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:drift/drift.dart'; import 'package:firebase_core/firebase_core.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; @@ -8,13 +9,13 @@ import 'cloud_models.dart'; /// ride upload pipeline. This keeps identity data small and queryable while /// leaving high-frequency telemetry in the existing ride/R2 path. class FirebaseAccountSyncService { - final AppDatabase _db; - final AuthService _auth; + final AppDatabase db; + final AuthService auth; final FirebaseFirestore? _firestore; FirebaseAccountSyncService({ - required this._db, - required this._auth, + required this.db, + required this.auth, FirebaseFirestore? firestore, }) : _firestore = firestore ?? _resolveFirestore(); @@ -27,8 +28,16 @@ class FirebaseAccountSyncService { bool get isAvailable => _firestore != null && Firebase.apps.isNotEmpty; + DateTime? _parseDateTime(dynamic value) { + if (value == null) return null; + if (value is Timestamp) return value.toDate(); + if (value is String) return DateTime.tryParse(value); + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + return null; + } + Future syncAccount({DateTime? now}) async { - final user = _auth.currentUser; + final user = auth.currentUser; if (user == null || user.isAnonymous || !isAvailable) return; final timestamp = (now ?? DateTime.now()).toUtc(); @@ -43,8 +52,57 @@ class FirebaseAccountSyncService { final userRef = _firestore!.collection('users').doc(user.uid); await userRef.set(profile.toJson(), SetOptions(merge: true)); - final motorcycles = await _db.select(_db.garageMotorcycles).get(); - for (final motorcycle in motorcycles) { + // 1. Fetch remote motorcycles from Firestore and reconcile into local Drift DB + try { + final remoteSnapshot = await userRef.collection('motorcycles').get(); + final localBikes = await db.select(db.garageMotorcycles).get(); + final localById = {for (final b in localBikes) b.id.toString(): b}; + final localByKey = {for (final b in localBikes) '${b.year}_${b.makeName}_${b.modelName}'.toLowerCase(): b}; + + for (final doc in remoteSnapshot.docs) { + final data = doc.data(); + final docId = doc.id; + final year = (data['year'] as num?)?.toInt() ?? 2000; + final makeName = (data['makeName'] ?? '').toString(); + final modelName = (data['modelName'] ?? '').toString(); + final key = '${year}_${makeName}_$modelName'.toLowerCase(); + + final existing = localById[docId] ?? localByKey[key]; + if (existing == null) { + final parsedId = int.tryParse(docId); + await db.into(db.garageMotorcycles).insert( + GarageMotorcyclesCompanion( + id: parsedId != null ? Value(parsedId) : const Value.absent(), + year: Value(year), + makeId: Value((data['makeId'] ?? parsedId?.toString() ?? '1').toString()), + makeName: Value(makeName), + modelId: Value((data['modelId'] ?? parsedId?.toString() ?? '1').toString()), + modelName: Value(modelName), + variantId: Value(data['variantId']?.toString()), + variantName: Value(data['variantName']?.toString()), + nickname: Value(data['nickname']?.toString()), + isDefault: Value(data['isDefault'] == true), + dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp), + lastUpdated: Value(_parseDateTime(data['updatedAt']) ?? timestamp), + specificationJson: Value(data['specificationJson']?.toString()), + ), + mode: InsertMode.insertOrReplace, + ); + } else if (existing.specificationJson == null && data['specificationJson'] != null) { + await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing.id))) + .write(GarageMotorcyclesCompanion( + specificationJson: Value(data['specificationJson']?.toString()), + lastUpdated: Value(timestamp), + )); + } + } + } catch (_) { + // Remote fetch error should not block local operations + } + + // 2. Upload any local motorcycles to Firestore + final currentLocal = await db.select(db.garageMotorcycles).get(); + for (final motorcycle in currentLocal) { final cloud = CloudMotorcycle( id: motorcycle.id.toString(), ownerId: user.uid, @@ -66,5 +124,67 @@ class FirebaseAccountSyncService { SetOptions(merge: true), ); } + + // 3. Fallback synthesis: If garage is empty but restored rides reference a motorcycle + if (currentLocal.isEmpty) { + await _synthesizeMotorcycleFromRidesIfEmpty(user.uid, userRef, timestamp); + } + } + + Future _synthesizeMotorcycleFromRidesIfEmpty( + String uid, + DocumentReference userRef, + DateTime timestamp, + ) async { + try { + final ridesWithBikes = await (db.select(db.rides) + ..where((r) => r.motorcycleMake.isNotNull() & r.motorcycleModel.isNotNull()) + ..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)]) + ..limit(1)) + .get(); + + if (ridesWithBikes.isNotEmpty) { + final r = ridesWithBikes.first; + final bikeId = r.motorcycleId ?? 1; + final year = r.motorcycleYear ?? 2001; + final make = r.motorcycleMake ?? 'Unknown'; + final model = r.motorcycleModel ?? 'Unknown'; + final variant = r.motorcycleVariant; + + await db.into(db.garageMotorcycles).insert( + GarageMotorcyclesCompanion( + id: Value(bikeId), + year: Value(year), + makeId: Value(bikeId.toString()), + makeName: Value(make), + modelId: Value(bikeId.toString()), + modelName: Value(model), + variantName: Value(variant), + isDefault: const Value(true), + dateAdded: Value(timestamp), + lastUpdated: Value(timestamp), + ), + mode: InsertMode.insertOrReplace, + ); + + final cloud = CloudMotorcycle( + id: bikeId.toString(), + ownerId: uid, + year: year, + makeId: bikeId.toString(), + makeName: make, + modelId: bikeId.toString(), + modelName: model, + variantName: variant, + isDefault: true, + createdAt: timestamp, + updatedAt: timestamp, + ); + await userRef.collection('motorcycles').doc(cloud.id).set( + cloud.toJson(), + SetOptions(merge: true), + ); + } + } catch (_) {} } } diff --git a/lib/services/clubs/club_providers.dart b/lib/services/clubs/club_providers.dart index cd3a26b..d32f0d8 100644 --- a/lib/services/clubs/club_providers.dart +++ b/lib/services/clubs/club_providers.dart @@ -79,5 +79,7 @@ final publicClubsProvider = FutureProvider>((ref) { final clubDiscoveryRidesProvider = FutureProvider.family, String>((ref, clubId) async { final service = ref.watch(clubServiceProvider); - return service.fetchClubDiscoveryRides(clubId); + const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL'); + final baseUrl = definedUrl.isNotEmpty ? definedUrl : null; + return service.fetchClubDiscoveryRides(clubId, baseUrl: baseUrl); }); diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index 24e972a..19b677d 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -1,5 +1,16 @@ import 'package:flutter/foundation.dart'; +DateTime? _parseModelDateTime(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + try { + return (value as dynamic).toDate() as DateTime; + } catch (_) { + return null; + } +} + /// Type of group ride location / stop. enum GroupRideLocationType { meetup, @@ -261,12 +272,8 @@ class GroupRideParticipant { }; factory GroupRideParticipant.fromJson(Map json) { - final created = json['createdAt'] != null - ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() - : DateTime.now(); - final updated = json['updatedAt'] != null - ? DateTime.tryParse(json['updatedAt'] as String) ?? created - : created; + final created = _parseModelDateTime(json['createdAt']) ?? DateTime.now(); + final updated = _parseModelDateTime(json['updatedAt']) ?? created; return GroupRideParticipant( userId: json['userId'] as String? ?? '', @@ -385,9 +392,7 @@ class GroupRideJoinRequest { riderHandle: json['riderHandle'] as String?, riderAvatarPreset: json['riderAvatarPreset'] as String?, riderAvatarUrl: json['riderAvatarUrl'] as String?, - createdAt: json['createdAt'] != null - ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() - : DateTime.now(), + createdAt: _parseModelDateTime(json['createdAt']) ?? DateTime.now(), ); } @@ -541,21 +546,11 @@ class GroupRide { }; factory GroupRide.fromJson(Map json) { - final created = json['createdAt'] != null - ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() - : DateTime.now(); - final updated = json['updatedAt'] != null - ? DateTime.tryParse(json['updatedAt'] as String) ?? created - : created; - final scheduled = json['scheduledStartAt'] != null - ? DateTime.tryParse(json['scheduledStartAt'] as String) ?? created - : created; - final departure = json['departureAt'] != null - ? DateTime.tryParse(json['departureAt'] as String) ?? scheduled - : scheduled; - final canceled = json['canceledAt'] != null - ? DateTime.tryParse(json['canceledAt'] as String) - : null; + final created = _parseModelDateTime(json['createdAt']) ?? DateTime.now(); + final updated = _parseModelDateTime(json['updatedAt']) ?? created; + final scheduled = _parseModelDateTime(json['scheduledStartAt']) ?? created; + final departure = _parseModelDateTime(json['departureAt']) ?? scheduled; + final canceled = _parseModelDateTime(json['canceledAt']); final stopsList = (json['stops'] as List?) ?.map((s) => GroupRideLocation.fromJson(s as Map)) @@ -807,13 +802,9 @@ class LiveGroupPresence { }; factory LiveGroupPresence.fromJson(Map json) { - final updated = json['updatedAt'] != null - ? DateTime.tryParse(json['updatedAt'] as String) ?? DateTime.now() - : DateTime.now(); - final expires = json['expiresAt'] != null - ? DateTime.tryParse(json['expiresAt'] as String) ?? - updated.add(const Duration(seconds: 120)) - : updated.add(const Duration(seconds: 120)); + final updated = _parseModelDateTime(json['updatedAt']) ?? DateTime.now(); + final expires = _parseModelDateTime(json['expiresAt']) ?? + updated.add(const Duration(seconds: 120)); return LiveGroupPresence( userId: json['userId'] as String? ?? '', diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart index 92f32df..b2064cc 100644 --- a/lib/services/group_rides/group_ride_providers.dart +++ b/lib/services/group_rides/group_ride_providers.dart @@ -118,8 +118,14 @@ final groupRideJoinRequestsProvider = final authService = ref.watch(authServiceProvider); final token = await authService.getIdToken(); final service = ref.watch(groupRideServiceProvider); + const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL'); + final baseUrl = definedUrl.isNotEmpty ? definedUrl : null; try { - return await service.fetchJoinRequests(groupRideId: rideId, idToken: token); + return await service.fetchJoinRequests( + groupRideId: rideId, + idToken: token, + baseUrl: baseUrl, + ); } catch (e) { return const []; } diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index a27858c..01c5091 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -341,14 +341,18 @@ class GroupRideService { throw StateError('Cannot invite a blocked rider.'); } - final targetBlock = await firestore - .collection('users') - .doc(friendProfile.uid) - .collection('blocks') - .doc(callerUid) - .get(); - if (targetBlock.exists) { - throw StateError('Cannot invite this rider.'); + try { + final targetBlock = await firestore + .collection('users') + .doc(friendProfile.uid) + .collection('blocks') + .doc(callerUid) + .get(); + if (targetBlock.exists) { + throw StateError('Cannot invite this rider.'); + } + } on FirebaseException catch (_) { + // Direct read restricted by security rules; enforced server-side. } final participantRef = rideRef.collection('participants').doc(friendProfile.uid); @@ -462,6 +466,59 @@ class GroupRideService { final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(caller.uid); + final initialSnap = await rideRef.get(); + if (!initialSnap.exists || initialSnap.data() == null) { + throw StateError('Group ride not found.'); + } + final initialRide = GroupRide.fromJson(initialSnap.data()!); + if (initialRide.organizerUid == caller.uid) { + throw ArgumentError('You are the organizer of this ride.'); + } + if (initialRide.status != GroupRideStatus.scheduled && + initialRide.status != GroupRideStatus.assembling) { + throw StateError('Cannot request to join a ride that is underway, completed, or canceled.'); + } + if (initialRide.joinPolicy != GroupRideJoinPolicy.requestToJoin) { + throw StateError('This ride does not accept join requests.'); + } + if (initialRide.accessUids.contains(caller.uid)) { + throw StateError('You are already a participant in this ride.'); + } + + // Check mutual blocks outside transaction (avoids method-channel collision on permission-denied) + final callerBlock = await firestore + .collection('users') + .doc(caller.uid) + .collection('blocks') + .doc(initialRide.organizerUid) + .get(); + if (callerBlock.exists) throw StateError('Unable to request to join.'); + + try { + final orgBlock = await firestore + .collection('users') + .doc(initialRide.organizerUid) + .collection('blocks') + .doc(caller.uid) + .get(); + if (orgBlock.exists) throw StateError('Unable to request to join.'); + } on FirebaseException catch (_) { + // Direct read of another user's blocks is restricted by firestore.rules; + // server-side security rules enforce mutual block via areMutuallyBlocked(). + } + + if (initialRide.clubId != null) { + final memberSnap = await firestore + .collection('clubs') + .doc(initialRide.clubId!) + .collection('members') + .doc(caller.uid) + .get(); + if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') { + throw StateError('Active club membership is required to join this club ride.'); + } + } + await firestore.runTransaction((transaction) async { final rideSnap = await transaction.get(rideRef); if (!rideSnap.exists || rideSnap.data() == null) { @@ -469,9 +526,6 @@ class GroupRideService { } final ride = GroupRide.fromJson(rideSnap.data()!); - if (ride.organizerUid == caller.uid) { - throw ArgumentError('You are the organizer of this ride.'); - } if (ride.status != GroupRideStatus.scheduled && ride.status != GroupRideStatus.assembling) { throw StateError('Cannot request to join a ride that is underway, completed, or canceled.'); @@ -497,23 +551,6 @@ class GroupRideService { } } - // Check mutual blocks - final callerBlock = await firestore - .collection('users') - .doc(caller.uid) - .collection('blocks') - .doc(ride.organizerUid) - .get(); - if (callerBlock.exists) throw StateError('Unable to request to join.'); - - final orgBlock = await firestore - .collection('users') - .doc(ride.organizerUid) - .collection('blocks') - .doc(caller.uid) - .get(); - if (orgBlock.exists) throw StateError('Unable to request to join.'); - final pDoc = await transaction.get(participantRef); if (pDoc.exists && pDoc.data() != null) { final existing = GroupRideParticipant.fromJson(pDoc.data()!); @@ -640,6 +677,35 @@ class GroupRideService { final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(requesterUid); + // Mutual block check between organizer and requester (approval only; decline must always be permitted) + if (approve) { + final initialRideDoc = await rideRef.get(); + if (!initialRideDoc.exists || initialRideDoc.data() == null) { + throw StateError('Group ride not found.'); + } + final initialRide = GroupRide.fromJson(initialRideDoc.data()!); + final orgBlock = await firestore + .collection('users') + .doc(initialRide.organizerUid) + .collection('blocks') + .doc(requesterUid) + .get(); + if (orgBlock.exists) throw StateError('Cannot approve mutually blocked requester.'); + + try { + final reqBlock = await firestore + .collection('users') + .doc(requesterUid) + .collection('blocks') + .doc(initialRide.organizerUid) + .get(); + if (reqBlock.exists) throw StateError('Cannot approve mutually blocked requester.'); + } on FirebaseException catch (_) { + // Direct read of requester's blocks is restricted by firestore.rules; + // server-side security rules enforce mutual block via areMutuallyBlocked(). + } + } + await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) { @@ -680,25 +746,6 @@ class GroupRideService { } } - // Mutual block check between organizer and requester (approval only; decline must always be permitted) - if (approve) { - final orgBlock = await firestore - .collection('users') - .doc(ride.organizerUid) - .collection('blocks') - .doc(requesterUid) - .get(); - if (orgBlock.exists) throw StateError('Cannot approve mutually blocked requester.'); - - final reqBlock = await firestore - .collection('users') - .doc(requesterUid) - .collection('blocks') - .doc(ride.organizerUid) - .get(); - if (reqBlock.exists) throw StateError('Cannot approve mutually blocked requester.'); - } - final pDoc = await transaction.get(participantRef); if (!pDoc.exists || pDoc.data() == null) { throw StateError('Request not found.'); diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 230ad0e..6d76252 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter/widgets.dart'; + final locationServiceProvider = Provider((ref) { final service = LocationService(); ref.onDispose(() => service.dispose()); @@ -15,8 +17,32 @@ final locationStreamProvider = StreamProvider((ref) { return service.getFilteredPositionStream(); }); +class _PermissionLifecycleObserver with WidgetsBindingObserver { + final VoidCallback onResumed; + _PermissionLifecycleObserver(this.onResumed); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + onResumed(); + } + } +} + final locationPermissionGrantedProvider = FutureProvider((ref) async { final service = ref.watch(locationServiceProvider); + + // Automatically re-evaluate permission status whenever the app resumes + // (e.g. returning from the system permission dialog or Android Settings). + final observer = _PermissionLifecycleObserver(() { + ref.invalidateSelf(); + }); + final binding = WidgetsBinding.instance; + binding.addObserver(observer); + ref.onDispose(() { + binding.removeObserver(observer); + }); + return await service.hasPermissions(); }); @@ -67,10 +93,19 @@ class LocationService { final geoPermission = await Geolocator.checkPermission(); if (geoPermission == LocationPermission.whileInUse || geoPermission == LocationPermission.always) { + if (_rawPositionSub == null && !_isDisposed) { + _startListening(); + } return true; } final status = await Permission.locationWhenInUse.status; - return status.isGranted; + if (status.isGranted) { + if (_rawPositionSub == null && !_isDisposed) { + _startListening(); + } + return true; + } + return false; } catch (_) { return false; } diff --git a/lib/services/map/unified_map_view.dart b/lib/services/map/unified_map_view.dart index 2d24906..e095c1c 100644 --- a/lib/services/map/unified_map_view.dart +++ b/lib/services/map/unified_map_view.dart @@ -12,6 +12,7 @@ class UnifiedMapView extends ConsumerWidget { final ValueChanged? onPositionChanged; final VoidCallback? onUserInteraction; final MapConfig? configOverride; + final bool? isFollowModeOverride; const UnifiedMapView({ super.key, @@ -22,6 +23,7 @@ class UnifiedMapView extends ConsumerWidget { this.onPositionChanged, this.onUserInteraction, this.configOverride, + this.isFollowModeOverride, }); @override @@ -67,7 +69,7 @@ class UnifiedMapView extends ConsumerWidget { } } - final isFollowMode = ref.watch(mapFollowStateProvider); + final bool isFollowMode = isFollowModeOverride ?? ref.watch(mapFollowStateProvider); return provider.buildMapView( context: context, diff --git a/lib/services/sensor_fusion_service.dart b/lib/services/sensor_fusion_service.dart index cd59078..ca96ea0 100644 --- a/lib/services/sensor_fusion_service.dart +++ b/lib/services/sensor_fusion_service.dart @@ -20,6 +20,67 @@ final gForceStreamProvider = StreamProvider((ref) { return ref.watch(sensorFusionServiceProvider).gForceStream; }); +final sensorDiagnosticsStreamProvider = StreamProvider((ref) { + return ref.watch(sensorFusionServiceProvider).diagnosticsStream; +}); + +enum MountOrientation { + portraitUpright, + landscapeLeftUpright, + landscapeRightUpright, + flat, +} + +class SensorFusionDiagnostics { + final double rawAccelX; + final double rawAccelY; + final double rawAccelZ; + final double rawGyroX; + final double rawGyroY; + final double rawGyroZ; + final double transformedRoll; + final double transformedPitch; + final double rollOffset; + final double pitchOffset; + final double finalLeanAngle; + final double dashboardDisplayedAngle; + final String dashboardDirection; + final bool isCalibrated; + final String mountOrientation; + + const SensorFusionDiagnostics({ + required this.rawAccelX, + required this.rawAccelY, + required this.rawAccelZ, + required this.rawGyroX, + required this.rawGyroY, + required this.rawGyroZ, + required this.transformedRoll, + required this.transformedPitch, + required this.rollOffset, + required this.pitchOffset, + required this.finalLeanAngle, + required this.dashboardDisplayedAngle, + required this.dashboardDirection, + required this.isCalibrated, + required this.mountOrientation, + }); + + Map toMap() => { + 'rawAccel': [rawAccelX, rawAccelY, rawAccelZ], + 'rawGyro': [rawGyroX, rawGyroY, rawGyroZ], + 'transformedRoll': transformedRoll, + 'transformedPitch': transformedPitch, + 'rollOffset': rollOffset, + 'pitchOffset': pitchOffset, + 'finalLeanAngle': finalLeanAngle, + 'dashboardDisplayedAngle': dashboardDisplayedAngle, + 'dashboardDirection': dashboardDirection, + 'isCalibrated': isCalibrated, + 'mountOrientation': mountOrientation, + }; +} + class GForceData { final double longitudinal; // Accel/braking (G's) final double lateral; // Left/right (G's) @@ -54,10 +115,12 @@ class SensorFusionService with WidgetsBindingObserver { final _leanAngleController = StreamController.broadcast(); final _pitchAngleController = StreamController.broadcast(); final _gForceController = StreamController.broadcast(); + final _diagnosticsController = StreamController.broadcast(); Stream get leanAngleStream => _leanAngleController.stream; Stream get pitchAngleStream => _pitchAngleController.stream; Stream get gForceStream => _gForceController.stream; + Stream get diagnosticsStream => _diagnosticsController.stream; StreamSubscription? _accelSub; StreamSubscription? _gyroSub; @@ -69,8 +132,16 @@ class SensorFusionService with WidgetsBindingObserver { double _gyroBiasX = 0.0; double _gyroBiasY = 0.0; double _gyroBiasZ = 0.0; + bool _isCalibrated = false; + bool get isCalibrated => _isCalibrated; + int _remountAnomalyCount = 0; - // Raw sensor caches (unfiltered for forensics) + // Stored baseline gravity vector at calibration for remount detection + double _calGravityX = 0.0; + double _calGravityY = 0.0; + double _calGravityZ = 0.0; + + // Raw sensor caches (unfiltered for forensics and diagnostics) double _latestRawAccelX = 0.0; double _latestRawAccelY = 0.0; double _latestRawAccelZ = 0.0; @@ -88,25 +159,144 @@ class SensorFusionService with WidgetsBindingObserver { double get latestRollRateDegPerSec => _latestRollRateDegPerSec; // Sensor fusion state - double _currentRoll = 0.0; // The lean angle + double _currentRoll = 0.0; double _currentPitch = 0.0; DateTime? _lastGyroTime; + // 3D Orthonormal Basis for Vehicle Frame: + // _rightVector: points to motorcycle right (+X_v) + // _forwardVector: points to motorcycle forward (+Y_v) + // _upVector: points to motorcycle up (+Z_v) + List _rightVector = [1.0, 0.0, 0.0]; + List _forwardVector = [0.0, 0.0, -1.0]; + List _upVector = [0.0, 1.0, 0.0]; + + // Low-pass filtered accelerometer values in vehicle frame to reject engine vibration + double _filteredVx = 0.0; + double _filteredVy = 0.0; + double _filteredVz = 0.0; + bool _hasInitialAccel = false; + bool _isLandscape = false; bool _isLandscapeLeft = true; + MountOrientation _mountOrientation = MountOrientation.portraitUpright; + MountOrientation get mountOrientation => _mountOrientation; static const double _gravityConst = 9.80665; - // Low-pass filter for G-Force to suppress motorcycle vibration spikes + // Low-pass filter for G-Force double _filteredLongitudinalG = 0.0; double _filteredLateralG = 0.0; - static const double _gAlpha = 0.40; // Fast responsive dynamic smoothing + static const double _gAlpha = 0.40; + bool _hasReceivedUserAccel = false; - SensorFusionService(this._calibrationService) { + // Diagnostic throttling + DateTime? _lastDiagnosticLogTime; + SensorFusionDiagnostics? _latestDiagnostics; + SensorFusionDiagnostics? get latestDiagnostics => _latestDiagnostics; + + int _calibrationVersion = 0; + Future? _initializationFuture; + Future get initializationDone => _initializationFuture ?? Future.value(); + + SensorFusionService( + this._calibrationService, { + Stream? accelerometerStream, + Stream? gyroscopeStream, + Stream? userAccelerometerStream, + MountOrientation defaultOrientation = MountOrientation.portraitUpright, + bool autoStartSensors = true, + }) { + _mountOrientation = defaultOrientation; + _recomputeBasisVectors(); WidgetsBinding.instance.addObserver(this); - _updateDisplayOrientation(); - _loadInitialCalibration(); - _startSensors(); + _initializationFuture = _loadInitialCalibration(); + if (autoStartSensors || accelerometerStream != null || gyroscopeStream != null) { + _startSensors( + accelStream: accelerometerStream, + gyroStream: gyroscopeStream, + userAccelStream: userAccelerometerStream, + ); + } + } + + void _recomputeBasisVectors() { + if (_isCalibrated && + (_calGravityX != 0.0 || _calGravityY != 0.0 || _calGravityZ != 0.0)) { + final calNorm = math.sqrt(_calGravityX * _calGravityX + + _calGravityY * _calGravityY + + _calGravityZ * _calGravityZ); + if (calNorm > 3.0) { + _upVector = [ + _calGravityX / calNorm, + _calGravityY / calNorm, + _calGravityZ / calNorm, + ]; + + // Determine nominal right vector based on mount orientation + List nominalRight; + switch (_mountOrientation) { + case MountOrientation.portraitUpright: + nominalRight = [1.0, 0.0, 0.0]; + break; + case MountOrientation.landscapeLeftUpright: + nominalRight = [0.0, -1.0, 0.0]; + break; + case MountOrientation.landscapeRightUpright: + nominalRight = [0.0, 1.0, 0.0]; + break; + case MountOrientation.flat: + nominalRight = [1.0, 0.0, 0.0]; + break; + } + + // Project nominalRight to be strictly orthogonal to _upVector + final dot = nominalRight[0] * _upVector[0] + + nominalRight[1] * _upVector[1] + + nominalRight[2] * _upVector[2]; + final rx = nominalRight[0] - dot * _upVector[0]; + final ry = nominalRight[1] - dot * _upVector[1]; + final rz = nominalRight[2] - dot * _upVector[2]; + final rNorm = math.sqrt(rx * rx + ry * ry + rz * rz); + if (rNorm > 0.1) { + _rightVector = [rx / rNorm, ry / rNorm, rz / rNorm]; + } else { + _rightVector = nominalRight; + } + + // Forward vector = Up x Right + _forwardVector = [ + _upVector[1] * _rightVector[2] - _upVector[2] * _rightVector[1], + _upVector[2] * _rightVector[0] - _upVector[0] * _rightVector[2], + _upVector[0] * _rightVector[1] - _upVector[1] * _rightVector[0], + ]; + return; + } + } + + // Nominal orthogonal basis for uncalibrated state + switch (_mountOrientation) { + case MountOrientation.portraitUpright: + _rightVector = [1.0, 0.0, 0.0]; + _forwardVector = [0.0, 0.0, -1.0]; + _upVector = [0.0, 1.0, 0.0]; + break; + case MountOrientation.landscapeLeftUpright: + _rightVector = [0.0, -1.0, 0.0]; + _forwardVector = [0.0, 0.0, -1.0]; + _upVector = [1.0, 0.0, 0.0]; + break; + case MountOrientation.landscapeRightUpright: + _rightVector = [0.0, 1.0, 0.0]; + _forwardVector = [0.0, 0.0, -1.0]; + _upVector = [-1.0, 0.0, 0.0]; + break; + case MountOrientation.flat: + _rightVector = [1.0, 0.0, 0.0]; + _forwardVector = [0.0, 1.0, 0.0]; + _upVector = [0.0, 0.0, 1.0]; + break; + } } @override @@ -117,9 +307,18 @@ class SensorFusionService with WidgetsBindingObserver { void _updateDisplayOrientation() { try { final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull; - if (view != null) { + if (view != null && view.physicalSize.height > 0) { final size = view.physicalSize; - _isLandscape = size.width > size.height; + // Only adapt automatically if display dimensions are realistic mobile dimensions + if (size.width > size.height && size.width < 1500) { + _isLandscape = true; + _updateMountOrientation(); + _recomputeBasisVectors(); + } else if (size.height >= size.width) { + _isLandscape = false; + _updateMountOrientation(); + _recomputeBasisVectors(); + } } } catch (_) {} } @@ -127,198 +326,483 @@ class SensorFusionService with WidgetsBindingObserver { void setOrientation(bool isLandscape, {bool isLandscapeLeft = true}) { _isLandscape = isLandscape; _isLandscapeLeft = isLandscapeLeft; + _updateMountOrientation(); + _recomputeBasisVectors(); + } + + void _updateMountOrientation() { + if (!_isLandscape) { + _mountOrientation = MountOrientation.portraitUpright; + } else if (_isLandscapeLeft) { + _mountOrientation = MountOrientation.landscapeLeftUpright; + } else { + _mountOrientation = MountOrientation.landscapeRightUpright; + } } Future _loadInitialCalibration() async { - try { - final offsets = await _calibrationService.loadCalibrationOffsets(); - _pitchOffset = offsets['pitch'] ?? 0.0; - _rollOffset = offsets['roll'] ?? 0.0; - _gyroBiasX = offsets['gyroBiasX'] ?? 0.0; - _gyroBiasY = offsets['gyroBiasY'] ?? 0.0; - _gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0; - } catch (_) {} + await loadCalibrationForMotorcycle(null); } - void _startSensors() { - _gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) { - final now = DateTime.now(); - _latestRawGyroX = event.x; - _latestRawGyroY = event.y; - _latestRawGyroZ = event.z; + void _checkRemountValidity(double ax, double ay, double az) { + if (!_isCalibrated) return; + final calNorm = math.sqrt(_calGravityX * _calGravityX + _calGravityY * _calGravityY + _calGravityZ * _calGravityZ); + final curNorm = math.sqrt(ax * ax + ay * ay + az * az); + if (calNorm < 3.0 || curNorm < 3.0) return; - if (_lastGyroTime == null) { - _lastGyroTime = now; - return; + // Normalize current acceleration vector + final nx = ax / curNorm; + final ny = ay / curNorm; + final nz = az / curNorm; + + // Project current vector onto 3D Vehicle Coordinate Frame: + // vUp: component along calibrated upright axis + // vForward: component along motorcycle longitudinal/forward axis + final vUp = (nx * _upVector[0] + ny * _upVector[1] + nz * _upVector[2]).clamp(-1.0, 1.0); + final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0); + + // Total 3D angular deviation from calibrated upright gravity + final totalAngleDiffDeg = math.acos(vUp) * (180.0 / math.pi); + + // Pitch tilt (tilt out of motorcycle roll plane) + final pitchTiltDeg = (math.asin(vForward)).abs() * (180.0 / math.pi); + + // Motorcycle Lean & Remount Physics: + // 1. Roll-axis rotation (up to 65°) is normal motorcycle riding, cornering, and kickstand parking (20°-35°). + // Deviations within the roll plane with pitchTiltDeg <= 35° and totalAngleDiffDeg <= 68° + // must NEVER invalidate calibration! + // 2. True remount / phone removal: + // - Placed flat on desk (screen up): totalAngleDiffDeg ~ 90°, pitchTiltDeg ~ 90° + // - Placed in pocket/bag: totalAngleDiffDeg >= 90° + // - Mount rotated or twisted: totalAngleDiffDeg > 68° or pitchTiltDeg > 38° + final isImmediateRemount = totalAngleDiffDeg >= 75.0 || pitchTiltDeg >= 42.0; + final isSuspectedRemount = totalAngleDiffDeg > 68.0 || pitchTiltDeg > 35.0; + + if (isImmediateRemount) { + _invalidateRemount(totalAngleDiffDeg); + } else if (isSuspectedRemount) { + _remountAnomalyCount++; + // Require 5 consecutive stationary samples (100ms) to filter out transient bumps or noise + if (_remountAnomalyCount >= 5) { + _invalidateRemount(totalAngleDiffDeg); } + } else { + _remountAnomalyCount = 0; + } + } - double dt = now.difference(_lastGyroTime!).inMicroseconds / 1000000.0; + void _invalidateRemount(double angleDiffDeg) { + debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating calibration.'); + _calibrationVersion++; + _isCalibrated = false; + _remountAnomalyCount = 0; + _rollOffset = 0.0; + _pitchOffset = 0.0; + _calGravityX = 0.0; + _calGravityY = 0.0; + _calGravityZ = 0.0; + _recomputeBasisVectors(); + _calibrationService.clearCalibration(); + _emitDiagnostics(0.0); + } + + void _startSensors({ + Stream? accelStream, + Stream? gyroStream, + Stream? userAccelStream, + }) { + try { + final gStream = gyroStream ?? gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)); + _gyroSub = gStream.listen( + handleGyroscopeEvent, + onError: (err) { + debugPrint('[SensorFusion] Gyroscope stream error: $err'); + }, + cancelOnError: false, + ); + } catch (err) { + debugPrint('[SensorFusion] Gyroscope initialization skipped (headless test runner): $err'); + } + + try { + final aStream = accelStream ?? accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)); + _accelSub = aStream.listen( + handleAccelerometerEvent, + onError: (err) { + debugPrint('[SensorFusion] Accelerometer stream error: $err'); + }, + cancelOnError: false, + ); + } catch (err) { + debugPrint('[SensorFusion] Accelerometer initialization skipped (headless test runner): $err'); + } + + try { + final uStream = userAccelStream ?? userAccelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)); + _userAccelSub = uStream.listen( + handleUserAccelerometerEvent, + onError: (err) { + debugPrint('[SensorFusion] UserAccelerometer stream error: $err'); + }, + cancelOnError: false, + ); + } catch (err) { + debugPrint('[SensorFusion] UserAccelerometer initialization skipped (headless test runner): $err'); + } + } + + void handleGyroscopeEvent(GyroscopeEvent event) { + final now = DateTime.now(); + _latestRawGyroX = event.x; + _latestRawGyroY = event.y; + _latestRawGyroZ = event.z; + + if (_lastGyroTime == null) { _lastGyroTime = now; + return; + } - // Clamp dt to prevent runaway integration during gaps/app pauses - if (dt > 0.1) dt = 0.1; - if (dt <= 0.0) return; + double dt = now.difference(_lastGyroTime!).inMicroseconds / 1000000.0; + _lastGyroTime = now; - // Compensate for static zero-rate gyro bias - final unbiasedGx = event.x - _gyroBiasX; - final unbiasedGy = event.y - _gyroBiasY; + if (dt > 0.1) dt = 0.1; + if (dt <= 0.0) return; - // Transform gyro rates into Vehicle Coordinate Frame based on screen orientation - double vGyroRollRate; // Rotation around vehicle forward axis (lean rate) - double vGyroPitchRate; // Rotation around vehicle transverse axis (pitch rate) + // Compensate for static zero-rate gyro bias + final unbiasedGx = event.x - _gyroBiasX; + final unbiasedGy = event.y - _gyroBiasY; + final unbiasedGz = event.z - _gyroBiasZ; - if (!_isLandscape) { - // Portrait - vGyroPitchRate = unbiasedGx * (180.0 / math.pi); - vGyroRollRate = -unbiasedGy * (180.0 / math.pi); - } else if (_isLandscapeLeft) { - // Landscape Left - vGyroPitchRate = unbiasedGy * (180.0 / math.pi); - vGyroRollRate = unbiasedGx * (180.0 / math.pi); - } else { - // Landscape Right - vGyroPitchRate = -unbiasedGy * (180.0 / math.pi); - vGyroRollRate = -unbiasedGx * (180.0 / math.pi); + // Project device angular rates into 3D Vehicle Coordinate Frame: + // Roll rate is rotation around the motorcycle forward axis (_forwardVector) + // Pitch rate is rotation around the motorcycle transverse right axis (_rightVector) + final vGyroRollRate = (unbiasedGx * _forwardVector[0] + + unbiasedGy * _forwardVector[1] + + unbiasedGz * _forwardVector[2]) * + (180.0 / math.pi); + + final vGyroPitchRate = (unbiasedGx * _rightVector[0] + + unbiasedGy * _rightVector[1] + + unbiasedGz * _rightVector[2]) * + (180.0 / math.pi); + + _latestRollRateDegPerSec = vGyroRollRate; + + // Numerical integration of gyro rate + _currentPitch = (_currentPitch + vGyroPitchRate * dt).clamp(-90.0, 90.0); + _currentRoll = (_currentRoll + vGyroRollRate * dt).clamp(-90.0, 90.0); + } + + void handleAccelerometerEvent(AccelerometerEvent event) { + _latestRawAccelX = event.x; + _latestRawAccelY = event.y; + _latestRawAccelZ = event.z; + + // Check remount validity when device is relatively stationary + final rawNorm = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z); + if ((rawNorm - _gravityConst).abs() < 1.0) { + _checkRemountValidity(event.x, event.y, event.z); + } else { + _remountAnomalyCount = 0; + } + + // Auto-detect landscape subtype when in landscape + if (_isLandscape && event.x.abs() > 3.0) { + final wasLeft = _isLandscapeLeft; + _isLandscapeLeft = event.x > 0; + if (wasLeft != _isLandscapeLeft) { + _updateMountOrientation(); + _recomputeBasisVectors(); } + } - _latestRollRateDegPerSec = vGyroRollRate; + // Map raw accelerometer to Vehicle Frame using the 3D basis: + // vx = Right, vy = Forward, vz = Up + final vx = event.x * _rightVector[0] + event.y * _rightVector[1] + event.z * _rightVector[2]; + final vy = event.x * _forwardVector[0] + event.y * _forwardVector[1] + event.z * _forwardVector[2]; + final vz = event.x * _upVector[0] + event.y * _upVector[1] + event.z * _upVector[2]; - _currentPitch = (_currentPitch + vGyroPitchRate * dt).clamp(-90.0, 90.0); - _currentRoll = (_currentRoll + vGyroRollRate * dt).clamp(-90.0, 90.0); - }); + // Low-pass filter to reject engine vibration (e.g. Yamaha R6 high-RPM inline-4 vibration) + if (!_hasInitialAccel) { + _filteredVx = vx; + _filteredVy = vy; + _filteredVz = vz; + _hasInitialAccel = true; + } else { + const accelFilterAlpha = 0.25; + _filteredVx = accelFilterAlpha * vx + (1.0 - accelFilterAlpha) * _filteredVx; + _filteredVy = accelFilterAlpha * vy + (1.0 - accelFilterAlpha) * _filteredVy; + _filteredVz = accelFilterAlpha * vz + (1.0 - accelFilterAlpha) * _filteredVz; + } - _accelSub = accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((AccelerometerEvent event) { - _latestRawAccelX = event.x; - _latestRawAccelY = event.y; - _latestRawAccelZ = event.z; + final norm = math.sqrt(_filteredVx * _filteredVx + _filteredVy * _filteredVy + _filteredVz * _filteredVz); + if (norm < 0.1) return; - // Auto-detect landscape sub-type if in landscape - if (_isLandscape && event.x.abs() > 3.0) { - _isLandscapeLeft = event.x > 0; - } + // Compute static gravity-based angles in vehicle frame + // In vehicle frame: vx = Right, vy = Forward, vz = Up + // When leaning right: gravity reaction tilts to vehicle left (vx < 0, vz > 0) + // -atan2(vx, sqrt(vy^2 + vz^2)) gives positive angle for right lean, negative for left lean + final accelRoll = -math.atan2(_filteredVx, math.sqrt(_filteredVy * _filteredVy + _filteredVz * _filteredVz)) * (180.0 / math.pi); + final accelPitch = math.atan2(_filteredVy, _filteredVz) * (180.0 / math.pi); - // Map raw accelerometer to Vehicle Frame (vx = Right, vy = Forward, vz = Up) - double vx, vy, vz; - if (!_isLandscape) { - vx = event.x; - vy = event.y; - vz = event.z; - } else if (_isLandscapeLeft) { - vx = -event.y; - vy = event.x; - vz = event.z; - } else { - vx = event.y; - vy = -event.x; - vz = event.z; - } + final gMag = norm / _gravityConst; + final dynamicDelta = (gMag - 1.0).abs(); - // Calculate total acceleration magnitude - final norm = math.sqrt(vx * vx + vy * vy + vz * vz); - if (norm < 0.1) return; + // Complementary filter: + // - Static / resting: alpha = 0.88 (converges quickly to gravity reference) + // - Dynamic cruising: alpha = 0.95 (smooth vibration rejection) + // - Dynamic cornering: alpha transitions up to 0.995 max (relies on gyro rate during turns) + // Alpha is strictly capped at 0.995, preventing runaway integration or pinning. + double dynamicAlpha = 0.88; + final rollRateAbs = _latestRollRateDegPerSec.abs(); + if (rollRateAbs > 2.0 || dynamicDelta > 0.04) { + final dynamicFactor = math.max(rollRateAbs / 15.0, dynamicDelta / 0.35).clamp(0.0, 1.0); + dynamicAlpha = (0.95 + 0.045 * dynamicFactor).clamp(0.95, 0.995); + } - // Compute static gravity-based angles in vehicle frame - final accelRoll = -math.atan2(vx, math.sqrt(vy * vy + vz * vz)) * (180.0 / math.pi); - final accelPitch = math.atan2(-vy, vz) * (180.0 / math.pi); + _currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0); + _currentPitch = (dynamicAlpha * _currentPitch + (1.0 - dynamicAlpha) * accelPitch).clamp(-90.0, 90.0); - final gMag = norm / _gravityConst; - final dynamicDelta = (gMag - 1.0).abs(); + // Resting convergence: when stationary and upright, gently pull residual drift to 0.0 + if (rollRateAbs < 0.5 && dynamicDelta < 0.03 && accelRoll.abs() < 1.0) { + _currentRoll = _currentRoll * 0.92; + } - // Continuous dynamic adaptation: smoothly shifts reliance to gyro when total G deviates from 1.0g - final dynamicAlpha = (0.94 + 1.2 * dynamicDelta).clamp(0.94, 0.998); + final calibratedLean = (_currentRoll - _rollOffset).clamp(-65.0, 65.0); + final calibratedPitch = (_currentPitch - _pitchOffset).clamp(-45.0, 45.0); - _currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0); - _currentPitch = (dynamicAlpha * _currentPitch + (1.0 - dynamicAlpha) * accelPitch).clamp(-90.0, 90.0); + _leanAngleController.add(calibratedLean); + _pitchAngleController.add(calibratedPitch); - final calibratedLean = (_currentRoll - _rollOffset).clamp(-65.0, 65.0); - final calibratedPitch = (_currentPitch - _pitchOffset).clamp(-45.0, 45.0); + // Diagnostics + _emitDiagnostics(calibratedLean); - _leanAngleController.add(calibratedLean); - _pitchAngleController.add(calibratedPitch); - }); + // Fallback linear acceleration when userAccelerometer is unavailable + if (!_hasReceivedUserAccel) { + _deriveFallbackLinearG(vx, vy, vz, norm, accelRoll, accelPitch); + } + } - _userAccelSub = userAccelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((UserAccelerometerEvent event) { - double vxUser, vyUser, vzUser; - if (!_isLandscape) { - vxUser = event.x; - vyUser = event.y; - vzUser = event.z; - } else if (_isLandscapeLeft) { - vxUser = -event.y; - vyUser = event.x; - vzUser = event.z; - } else { - vxUser = event.y; - vyUser = -event.x; - vzUser = event.z; - } + void _deriveFallbackLinearG(double vx, double vy, double vz, double norm, double accelRoll, double accelPitch) { + final gx = -norm * math.sin(accelRoll * math.pi / 180.0); + final gy = -norm * math.sin(accelPitch * math.pi / 180.0) * math.cos(accelRoll * math.pi / 180.0); + final gz = norm * math.cos(accelPitch * math.pi / 180.0) * math.cos(accelRoll * math.pi / 180.0); - // Project phone linear acceleration vector into motorcycle body frame - final pitchRad = (_currentPitch - _pitchOffset) * (math.pi / 180.0); - final rollRad = (_currentRoll - _rollOffset) * (math.pi / 180.0); + final userVx = vx - gx; + final userVy = vy - gy; + final userVz = vz - gz; - // Forward/longitudinal acceleration (along motorcycle centerline) - final rawLongitudinal = (vyUser * math.cos(pitchRad) - vzUser * math.sin(pitchRad)); - // Lateral acceleration (cornering) - final rawLateral = (vxUser * math.cos(rollRad) + vzUser * math.sin(rollRad)); + final pitchRad = _currentPitch * (math.pi / 180.0); + final rollRad = _currentRoll * (math.pi / 180.0); - double rawLongitudinalG = rawLongitudinal / _gravityConst; - double rawLateralG = rawLateral / _gravityConst; + final rawLongitudinal = (userVy * math.cos(pitchRad) - userVz * math.sin(pitchRad)); + final rawLateral = (userVx * math.cos(rollRad) + userVz * math.sin(rollRad)); - // Deadband / noise gate for resting phone on table - if (rawLongitudinalG.abs() < 0.02) rawLongitudinalG = 0.0; - if (rawLateralG.abs() < 0.02) rawLateralG = 0.0; + double rawLongitudinalG = rawLongitudinal / _gravityConst; + double rawLateralG = rawLateral / _gravityConst; - // Dynamic smoothing: suppress engine high-frequency vibration while preserving vehicle dynamics - _filteredLongitudinalG = _gAlpha * rawLongitudinalG + (1.0 - _gAlpha) * _filteredLongitudinalG; - _filteredLateralG = _gAlpha * rawLateralG + (1.0 - _gAlpha) * _filteredLateralG; + if (rawLongitudinalG.abs() < 0.02) rawLongitudinalG = 0.0; + if (rawLateralG.abs() < 0.02) rawLateralG = 0.0; - if (_filteredLongitudinalG.abs() < 0.008) _filteredLongitudinalG = 0.0; - if (_filteredLateralG.abs() < 0.008) _filteredLateralG = 0.0; + _filteredLongitudinalG = _gAlpha * rawLongitudinalG + (1.0 - _gAlpha) * _filteredLongitudinalG; + _filteredLateralG = _gAlpha * rawLateralG + (1.0 - _gAlpha) * _filteredLateralG; - _gForceController.add(GForceData( - longitudinal: _filteredLongitudinalG, - lateral: _filteredLateralG, - accelX: event.x, - accelY: event.y, - accelZ: event.z, - rawAccelX: _latestRawAccelX, - rawAccelY: _latestRawAccelY, - rawAccelZ: _latestRawAccelZ, - rawGyroX: _latestRawGyroX, - rawGyroY: _latestRawGyroY, - rawGyroZ: _latestRawGyroZ, - )); - }); + if (_filteredLongitudinalG.abs() < 0.008) _filteredLongitudinalG = 0.0; + if (_filteredLateralG.abs() < 0.008) _filteredLateralG = 0.0; + + _gForceController.add(GForceData( + longitudinal: _filteredLongitudinalG, + lateral: _filteredLateralG, + accelX: userVx, + accelY: userVy, + accelZ: userVz, + rawAccelX: _latestRawAccelX, + rawAccelY: _latestRawAccelY, + rawAccelZ: _latestRawAccelZ, + rawGyroX: _latestRawGyroX, + rawGyroY: _latestRawGyroY, + rawGyroZ: _latestRawGyroZ, + )); + } + + void handleUserAccelerometerEvent(UserAccelerometerEvent event) { + _hasReceivedUserAccel = true; + final vxUser = event.x * _rightVector[0] + event.y * _rightVector[1] + event.z * _rightVector[2]; + final vyUser = event.x * _forwardVector[0] + event.y * _forwardVector[1] + event.z * _forwardVector[2]; + final vzUser = event.x * _upVector[0] + event.y * _upVector[1] + event.z * _upVector[2]; + + final pitchRad = _currentPitch * (math.pi / 180.0); + final rollRad = _currentRoll * (math.pi / 180.0); + + final rawLongitudinal = (vyUser * math.cos(pitchRad) - vzUser * math.sin(pitchRad)); + final rawLateral = (vxUser * math.cos(rollRad) + vzUser * math.sin(rollRad)); + + double rawLongitudinalG = rawLongitudinal / _gravityConst; + double rawLateralG = rawLateral / _gravityConst; + + if (rawLongitudinalG.abs() < 0.02) rawLongitudinalG = 0.0; + if (rawLateralG.abs() < 0.02) rawLateralG = 0.0; + + _filteredLongitudinalG = _gAlpha * rawLongitudinalG + (1.0 - _gAlpha) * _filteredLongitudinalG; + _filteredLateralG = _gAlpha * rawLateralG + (1.0 - _gAlpha) * _filteredLateralG; + + if (_filteredLongitudinalG.abs() < 0.008) _filteredLongitudinalG = 0.0; + if (_filteredLateralG.abs() < 0.008) _filteredLateralG = 0.0; + + _gForceController.add(GForceData( + longitudinal: _filteredLongitudinalG, + lateral: _filteredLateralG, + accelX: event.x, + accelY: event.y, + accelZ: event.z, + rawAccelX: _latestRawAccelX, + rawAccelY: _latestRawAccelY, + rawAccelZ: _latestRawAccelZ, + rawGyroX: _latestRawGyroX, + rawGyroY: _latestRawGyroY, + rawGyroZ: _latestRawGyroZ, + )); + } + + void _emitDiagnostics(double finalLean) { + final now = DateTime.now(); + final direction = finalLean < -0.5 ? 'LEFT' : (finalLean > 0.5 ? 'RIGHT' : 'CENTER'); + final displayedAngle = finalLean.abs(); + + final diag = SensorFusionDiagnostics( + rawAccelX: _latestRawAccelX, + rawAccelY: _latestRawAccelY, + rawAccelZ: _latestRawAccelZ, + rawGyroX: _latestRawGyroX, + rawGyroY: _latestRawGyroY, + rawGyroZ: _latestRawGyroZ, + transformedRoll: _currentRoll, + transformedPitch: _currentPitch, + rollOffset: _rollOffset, + pitchOffset: _pitchOffset, + finalLeanAngle: finalLean, + dashboardDisplayedAngle: displayedAngle, + dashboardDirection: direction, + isCalibrated: _isCalibrated, + mountOrientation: _mountOrientation.name, + ); + + _latestDiagnostics = diag; + _diagnosticsController.add(diag); + + // Throttled debugPrint logging (every 1.5 seconds) + if (_lastDiagnosticLogTime == null || now.difference(_lastDiagnosticLogTime!).inMilliseconds >= 1500) { + _lastDiagnosticLogTime = now; + debugPrint('[SensorFusion Diag] Accel: (${_latestRawAccelX.toStringAsFixed(2)}, ${_latestRawAccelY.toStringAsFixed(2)}, ${_latestRawAccelZ.toStringAsFixed(2)}) ' + 'Gyro: (${_latestRawGyroX.toStringAsFixed(3)}, ${_latestRawGyroY.toStringAsFixed(3)}, ${_latestRawGyroZ.toStringAsFixed(3)}) ' + 'Roll: ${_currentRoll.toStringAsFixed(1)}° Offset: ${_rollOffset.toStringAsFixed(1)}° Lean: ${finalLean.toStringAsFixed(1)}° ' + 'Display: ${displayedAngle.toStringAsFixed(0)}° $direction Calibrated: $_isCalibrated'); + } } Future loadCalibrationForMotorcycle(int? motorcycleId) async { + final version = ++_calibrationVersion; try { final offsets = await _calibrationService.loadCalibrationOffsets(motorcycleId: motorcycleId); + final mountMode = await _calibrationService.loadMountMode(motorcycleId: motorcycleId); + if (version != _calibrationVersion) return; + _pitchOffset = offsets['pitch'] ?? 0.0; _rollOffset = offsets['roll'] ?? 0.0; _gyroBiasX = offsets['gyroBiasX'] ?? 0.0; _gyroBiasY = offsets['gyroBiasY'] ?? 0.0; _gyroBiasZ = offsets['gyroBiasZ'] ?? 0.0; - } catch (_) {} + final isCal = (offsets['isCalibrated'] ?? 0.0) > 0.5; + _calGravityX = offsets['accelCalX'] ?? 0.0; + _calGravityY = offsets['accelCalY'] ?? 0.0; + _calGravityZ = offsets['accelCalZ'] ?? 0.0; + + if (mountMode != null) { + for (final m in MountOrientation.values) { + if (m.name == mountMode) { + _mountOrientation = m; + break; + } + } + } + + _isCalibrated = isCal; + _remountAnomalyCount = 0; + _recomputeBasisVectors(); + } catch (_) { + if (version != _calibrationVersion) return; + _isCalibrated = false; + _recomputeBasisVectors(); + } } - void calibrateUpright({int? motorcycleId}) { - _rollOffset = _currentRoll; - _pitchOffset = _currentPitch; + Future calibrateUpright({int? motorcycleId}) async { + ++_calibrationVersion; _gyroBiasX = _latestRawGyroX; _gyroBiasY = _latestRawGyroY; _gyroBiasZ = _latestRawGyroZ; - _calibrationService.saveCalibrationOffsets( - _pitchOffset, - _rollOffset, + _calGravityX = _latestRawAccelX; + _calGravityY = _latestRawAccelY; + _calGravityZ = _latestRawAccelZ; + _isCalibrated = true; + _remountAnomalyCount = 0; + + // Auto-detect mount orientation from gravity + final ax = _calGravityX.abs(); + final ay = _calGravityY.abs(); + final az = _calGravityZ.abs(); + if (ax > ay && ax > az) { + _mountOrientation = _calGravityX > 0 + ? MountOrientation.landscapeLeftUpright + : MountOrientation.landscapeRightUpright; + } else if (_calGravityZ > 7.5 && ay < 4.0) { + _mountOrientation = MountOrientation.flat; + } else { + _mountOrientation = MountOrientation.portraitUpright; + } + + _recomputeBasisVectors(); + + // In newly calibrated frame, current lean and pitch start at zero + _currentRoll = 0.0; + _currentPitch = 0.0; + _rollOffset = 0.0; + _pitchOffset = 0.0; + + // Immediately re-emit calibrated 0.0° lean + _leanAngleController.add(0.0); + _pitchAngleController.add(0.0); + _emitDiagnostics(0.0); + + await _calibrationService.saveCalibrationOffsets( + 0.0, + 0.0, gyroBiasX: _gyroBiasX, gyroBiasY: _gyroBiasY, gyroBiasZ: _gyroBiasZ, + accelCalX: _calGravityX, + accelCalY: _calGravityY, + accelCalZ: _calGravityZ, + mountMode: _mountOrientation.name, + isCalibrated: true, motorcycleId: motorcycleId, ); } + Future resetCalibration({int? motorcycleId}) async { + _calibrationVersion++; + _isCalibrated = false; + _remountAnomalyCount = 0; + _rollOffset = 0.0; + _pitchOffset = 0.0; + _calGravityX = 0.0; + _calGravityY = 0.0; + _calGravityZ = 0.0; + _recomputeBasisVectors(); + await _calibrationService.clearCalibration(motorcycleId: motorcycleId); + _emitDiagnostics(0.0); + } + void dispose() { WidgetsBinding.instance.removeObserver(this); _accelSub?.cancel(); @@ -327,5 +811,6 @@ class SensorFusionService with WidgetsBindingObserver { _leanAngleController.close(); _pitchAngleController.close(); _gForceController.close(); + _diagnosticsController.close(); } } diff --git a/lib/services/social/social_feed_models.dart b/lib/services/social/social_feed_models.dart index 0a55a40..366d717 100644 --- a/lib/services/social/social_feed_models.dart +++ b/lib/services/social/social_feed_models.dart @@ -16,20 +16,70 @@ dynamic _dateTimeToFirestore(DateTime? dt) { return Timestamp.fromDate(dt); } -/// Filter for the community social feed. +/// Filter for the community social feed stream. enum FeedFilter { allActivity, + forYou, + nearbyPacks, clubOnly, - nearbyPacks; + following; String get displayName { switch (this) { case FeedFilter.allActivity: return 'All Activity'; - case FeedFilter.clubOnly: - return 'Club Only'; + case FeedFilter.forYou: + return 'For You'; case FeedFilter.nearbyPacks: return 'Nearby Packs'; + case FeedFilter.clubOnly: + return 'Club Only'; + case FeedFilter.following: + return 'Following'; + } + } +} + +/// Motorcycle riding discipline / style categorization. +enum RidingDiscipline { + all, + canyonSport, + advDualSport, + touring, + trackDay, + cruiser; + + String get displayName { + switch (this) { + case RidingDiscipline.all: + return 'All Styles'; + case RidingDiscipline.canyonSport: + return '⚡ Canyon / Sport'; + case RidingDiscipline.advDualSport: + return '🌲 ADV & Dirt'; + case RidingDiscipline.touring: + return '🛣️ Touring'; + case RidingDiscipline.trackDay: + return '🏁 Track Day'; + case RidingDiscipline.cruiser: + return '🏍️ Cruiser'; + } + } + + String get shortTag { + switch (this) { + case RidingDiscipline.all: + return 'ALL'; + case RidingDiscipline.canyonSport: + return 'CANYON'; + case RidingDiscipline.advDualSport: + return 'ADV'; + case RidingDiscipline.touring: + return 'TOURING'; + case RidingDiscipline.trackDay: + return 'TRACK'; + case RidingDiscipline.cruiser: + return 'CRUISE'; } } } @@ -108,6 +158,11 @@ class SocialFeedItem { final int hazardCount; // e.g. 0 final int elevationFt; // e.g. +2140 + // Quality & Discipline Scoping + final RidingDiscipline discipline; + final String? regionName; + final bool isFeatured; + // Visual Snapshot Module final FeedSnapshotType snapshotType; final List routePoints; @@ -139,6 +194,9 @@ class SocialFeedItem { this.paceFactor = '1.2x Spirited', this.hazardCount = 0, this.elevationFt = 1200, + this.discipline = RidingDiscipline.canyonSport, + this.regionName, + this.isFeatured = false, this.snapshotType = FeedSnapshotType.routeTrack, this.routePoints = const [], this.photoUrl, @@ -173,6 +231,9 @@ class SocialFeedItem { String? paceFactor, int? hazardCount, int? elevationFt, + RidingDiscipline? discipline, + String? regionName, + bool? isFeatured, FeedSnapshotType? snapshotType, List? routePoints, String? photoUrl, @@ -201,6 +262,9 @@ class SocialFeedItem { paceFactor: paceFactor ?? this.paceFactor, hazardCount: hazardCount ?? this.hazardCount, elevationFt: elevationFt ?? this.elevationFt, + discipline: discipline ?? this.discipline, + regionName: regionName ?? this.regionName, + isFeatured: isFeatured ?? this.isFeatured, snapshotType: snapshotType ?? this.snapshotType, routePoints: routePoints ?? this.routePoints, photoUrl: photoUrl ?? this.photoUrl, @@ -231,6 +295,9 @@ class SocialFeedItem { 'paceFactor': paceFactor, 'hazardCount': hazardCount, 'elevationFt': elevationFt, + 'discipline': discipline.name, + if (regionName != null) 'regionName': regionName, + 'isFeatured': isFeatured, 'snapshotType': snapshotType.name, 'routePoints': routePoints.map((p) => {'lat': p.latitude, 'lng': p.longitude}).toList(), if (photoUrl != null) 'photoUrl': photoUrl, @@ -279,6 +346,14 @@ class SocialFeedItem { paceFactor: json['paceFactor'] as String? ?? '1.0x', hazardCount: (json['hazardCount'] as num?)?.toInt() ?? 0, elevationFt: (json['elevationFt'] as num?)?.toInt() ?? 0, + discipline: json['discipline'] != null + ? RidingDiscipline.values.firstWhere( + (d) => d.name == json['discipline'], + orElse: () => RidingDiscipline.canyonSport, + ) + : RidingDiscipline.canyonSport, + regionName: json['regionName'] as String?, + isFeatured: json['isFeatured'] as bool? ?? false, snapshotType: json['snapshotType'] == 'scenicPhoto' ? FeedSnapshotType.scenicPhoto : FeedSnapshotType.routeTrack, diff --git a/lib/services/social/social_feed_providers.dart b/lib/services/social/social_feed_providers.dart index f7b6762..0be4b9b 100644 --- a/lib/services/social/social_feed_providers.dart +++ b/lib/services/social/social_feed_providers.dart @@ -22,20 +22,68 @@ final socialFeedServiceProvider = Provider((ref) { return service; }); -/// State provider for the active feed filter tab. +/// State provider for the active feed stream filter tab. final feedFilterStateProvider = StateProvider((ref) { return FeedFilter.allActivity; }); +/// State provider for the active riding discipline filter chip. +final feedDisciplineFilterProvider = StateProvider((ref) { + return RidingDiscipline.all; +}); + +/// State provider for searching route titles, canyons, bikes, or handles. +final feedSearchQueryProvider = StateProvider((ref) { + return ''; +}); + +/// State provider for minimum ride distance threshold. +final feedMinDistanceFilterProvider = StateProvider((ref) { + return null; +}); + +/// State provider for GPS verified only filter toggle. +final feedVerifiedGpsOnlyProvider = StateProvider((ref) { + return false; +}); + /// StateNotifier providing instant synchronous initial feed items and reacting to changes. class SocialFeedNotifier extends StateNotifier> { final SocialFeedService _service; final FeedFilter _filter; + final RidingDiscipline _discipline; + final String? _searchQuery; + final double? _minDistanceMiles; + final bool? _verifiedGpsOnly; StreamSubscription>? _sub; - SocialFeedNotifier(this._service, this._filter) - : super(_service.getFilteredItems(filter: _filter)) { - _sub = _service.getFeedStream(filter: _filter).listen((items) { + SocialFeedNotifier( + this._service, + this._filter, { + RidingDiscipline discipline = RidingDiscipline.all, + String? searchQuery, + double? minDistanceMiles, + bool? verifiedGpsOnly, + }) : _discipline = discipline, + _searchQuery = searchQuery, + _minDistanceMiles = minDistanceMiles, + _verifiedGpsOnly = verifiedGpsOnly, + super(_service.getFilteredItems( + filter: _filter, + discipline: discipline, + searchQuery: searchQuery, + minDistanceMiles: minDistanceMiles, + verifiedGpsOnly: verifiedGpsOnly, + )) { + _sub = _service + .getFeedStream( + filter: _filter, + discipline: _discipline, + searchQuery: _searchQuery, + minDistanceMiles: _minDistanceMiles, + verifiedGpsOnly: _verifiedGpsOnly, + ) + .listen((items) { state = items; }); } @@ -53,7 +101,19 @@ final socialFeedNotifierProvider = (ref) { final service = ref.watch(socialFeedServiceProvider); final filter = ref.watch(feedFilterStateProvider); - return SocialFeedNotifier(service, filter); + final discipline = ref.watch(feedDisciplineFilterProvider); + final searchQuery = ref.watch(feedSearchQueryProvider); + final minDistance = ref.watch(feedMinDistanceFilterProvider); + final verifiedGpsOnly = ref.watch(feedVerifiedGpsOnlyProvider); + + return SocialFeedNotifier( + service, + filter, + discipline: discipline, + searchQuery: searchQuery, + minDistanceMiles: minDistance, + verifiedGpsOnly: verifiedGpsOnly, + ); }); /// Stream provider for the filtered community social feed (preserved for backwards compatibility). @@ -61,6 +121,13 @@ final socialFeedStreamProvider = StreamProvider.autoDispose>((ref) { final service = ref.watch(socialFeedServiceProvider); final filter = ref.watch(feedFilterStateProvider); - return service.getFeedStream(filter: filter); + final discipline = ref.watch(feedDisciplineFilterProvider); + final searchQuery = ref.watch(feedSearchQueryProvider); + + return service.getFeedStream( + filter: filter, + discipline: discipline, + searchQuery: searchQuery, + ); }); diff --git a/lib/services/social/social_feed_service.dart b/lib/services/social/social_feed_service.dart index e42781f..b43068d 100644 --- a/lib/services/social/social_feed_service.dart +++ b/lib/services/social/social_feed_service.dart @@ -40,6 +40,9 @@ class SocialFeedService { paceFactor: '1.32x Spirited', hazardCount: 0, elevationFt: 2140, + discipline: RidingDiscipline.canyonSport, + regionName: 'Bay Area, CA', + isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: true, fistBumpCount: 42, @@ -81,6 +84,9 @@ class SocialFeedService { paceFactor: '1.25x Swift', hazardCount: 0, elevationFt: 1850, + discipline: RidingDiscipline.canyonSport, + regionName: 'SoCal, CA', + isFeatured: false, snapshotType: FeedSnapshotType.scenicPhoto, photoUrl: 'assets/images/rock_store_lookout.png', photoCaption: 'Rock Store Lookout', @@ -108,6 +114,9 @@ class SocialFeedService { paceFactor: '1.45x Fast', hazardCount: 1, elevationFt: 5300, + discipline: RidingDiscipline.trackDay, + regionName: 'Angeles Crest, CA', + isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: true, fistBumpCount: 65, @@ -119,6 +128,72 @@ class SocialFeedService { ], comments: const [], ), + SocialFeedItem( + id: 'feed_post_4', + authorUid: 'uid_alex', + authorDisplayName: 'Alex Mercer', + authorHandle: 'TrailScout', + authorAvatarPreset: 'helmet_carbon_cyan', + authorBadge: 'ADV LEAD', + clubName: 'Blue Ridge Trail Explorers', + locationName: 'Blood Mountain Gap', + timestamp: DateTime.now().subtract(const Duration(hours: 7)), + rideTitle: 'Chattahoochee Forest Pass & Water Crossings', + bikeModel: 'KTM 890 Adventure R', + distanceMiles: 58.6, + durationSeconds: 6100, + maxLeanAngleDeg: 34.0, + maxLeanSide: 'PEAK', + paceFactor: '1.15x Trail Pace', + hazardCount: 2, + elevationFt: 3420, + discipline: RidingDiscipline.advDualSport, + regionName: 'North Georgia, GA', + isFeatured: true, + snapshotType: FeedSnapshotType.routeTrack, + isGpsVerified: true, + fistBumpCount: 39, + fistBumpUserIds: const ['uid_marcus'], + routePoints: const [ + AppLatLng(34.7330, -83.9180), + AppLatLng(34.7500, -83.9000), + AppLatLng(34.7800, -83.8800), + ], + comments: const [], + ), + SocialFeedItem( + id: 'feed_post_5', + authorUid: 'uid_ben', + authorDisplayName: 'Ben Holloway', + authorHandle: 'IronButtBen', + authorAvatarPreset: 'helmet_aerodynamic_grey', + authorBadge: 'TOUR MASTER', + clubName: 'Smoky Mountain Cruisers', + locationName: 'Cherohala Skyway Overlook', + timestamp: DateTime.now().subtract(const Duration(hours: 11)), + rideTitle: 'Cherohala Skyway Endurance Sweep', + bikeModel: 'BMW R1250GS Adventure', + distanceMiles: 148.2, + durationSeconds: 11200, + maxLeanAngleDeg: 39.5, + maxLeanSide: 'RIGHT', + paceFactor: '1.20x Touring', + hazardCount: 0, + elevationFt: 5400, + discipline: RidingDiscipline.touring, + regionName: 'Tellico Plains, TN', + isFeatured: true, + snapshotType: FeedSnapshotType.routeTrack, + isGpsVerified: true, + fistBumpCount: 51, + fistBumpUserIds: const ['uid_sarah', 'uid_alex'], + routePoints: const [ + AppLatLng(35.3500, -84.1500), + AppLatLng(35.3700, -84.0500), + AppLatLng(35.3900, -83.9500), + ], + comments: const [], + ), ]; _localFeedController.add(List.unmodifiable(_memoryFeed)); @@ -129,23 +204,59 @@ class SocialFeedService { List getFilteredItems({ FeedFilter filter = FeedFilter.allActivity, String? userClubName, + RidingDiscipline discipline = RidingDiscipline.all, + String? searchQuery, + double? minDistanceMiles, + bool? verifiedGpsOnly, + String? userUid, + List? followingUids, }) { - return _filterItems(_memoryFeed, filter, userClubName); + return _filterItems( + _memoryFeed, + filter, + userClubName, + discipline: discipline, + searchQuery: searchQuery, + minDistanceMiles: minDistanceMiles, + verifiedGpsOnly: verifiedGpsOnly, + userUid: userUid, + followingUids: followingUids, + ); } Stream> getFeedStream({ FeedFilter filter = FeedFilter.allActivity, String? userClubName, + RidingDiscipline discipline = RidingDiscipline.all, + String? searchQuery, + double? minDistanceMiles, + bool? verifiedGpsOnly, + String? userUid, + List? followingUids, }) { late StreamController> controller; StreamSubscription>? sub; + List filterCurrent(List list) { + return _filterItems( + list, + filter, + userClubName, + discipline: discipline, + searchQuery: searchQuery, + minDistanceMiles: minDistanceMiles, + verifiedGpsOnly: verifiedGpsOnly, + userUid: userUid, + followingUids: followingUids, + ); + } + controller = StreamController>( onListen: () { - controller.add(_filterItems(_memoryFeed, filter, userClubName)); + controller.add(filterCurrent(_memoryFeed)); sub = _localFeedController.stream.listen((list) { if (!controller.isClosed) { - controller.add(_filterItems(list, filter, userClubName)); + controller.add(filterCurrent(list)); } }); }, @@ -160,19 +271,76 @@ class SocialFeedService { List _filterItems( List items, FeedFilter filter, - String? userClubName, - ) { + String? userClubName, { + RidingDiscipline discipline = RidingDiscipline.all, + String? searchQuery, + double? minDistanceMiles, + bool? verifiedGpsOnly, + String? userUid, + List? followingUids, + }) { + var result = List.from(items); + + // 1. Primary Stream / Tab Scoping switch (filter) { case FeedFilter.allActivity: - return items; + break; // All posts + case FeedFilter.forYou: + // Featured & high-signal runs: featured, or lean >= 40°, or distance >= 20 mi, or elevation >= 1500, or fist bumps >= 20 + result = result + .where((i) => + i.isFeatured || + i.maxLeanAngleDeg >= 40.0 || + i.distanceMiles >= 20.0 || + i.elevationFt >= 1500 || + i.fistBumpCount >= 20) + .toList(); + break; case FeedFilter.clubOnly: if (userClubName == null || userClubName.isEmpty) { - return items.where((i) => i.clubName != null && i.clubName!.isNotEmpty).toList(); + result = result.where((i) => i.clubName != null && i.clubName!.isNotEmpty).toList(); + } else { + result = result.where((i) => i.clubName?.toLowerCase() == userClubName.toLowerCase()).toList(); } - return items.where((i) => i.clubName?.toLowerCase() == userClubName.toLowerCase()).toList(); + break; case FeedFilter.nearbyPacks: - return items.where((i) => i.distanceMiles < 60.0).toList(); + result = result.where((i) => i.distanceMiles < 60.0).toList(); + break; + case FeedFilter.following: + if (followingUids != null && followingUids.isNotEmpty) { + result = result.where((i) => followingUids.contains(i.authorUid)).toList(); + } else { + // Fallback to recognized squad/badge riders if user has no following graph yet + result = result.where((i) => i.authorBadge != null && i.authorBadge!.isNotEmpty).toList(); + } + break; } + + // 2. Discipline / Riding Style Filter + if (discipline != RidingDiscipline.all) { + result = result.where((i) => i.discipline == discipline).toList(); + } + + // 3. Search Query Filter (road, canyon, bike, rider) + if (searchQuery != null && searchQuery.trim().isNotEmpty) { + final q = searchQuery.trim().toLowerCase(); + result = result.where((i) => + i.rideTitle.toLowerCase().contains(q) || + i.locationName.toLowerCase().contains(q) || + i.bikeModel.toLowerCase().contains(q) || + i.authorHandle.toLowerCase().contains(q) || + (i.regionName?.toLowerCase().contains(q) ?? false)).toList(); + } + + // 4. Quality Thresholds + if (minDistanceMiles != null && minDistanceMiles > 0) { + result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList(); + } + if (verifiedGpsOnly == true) { + result = result.where((i) => i.isGpsVerified).toList(); + } + + return result; } /// Toggle fist bump on a post. @@ -256,6 +424,9 @@ class SocialFeedService { String paceFactor = '1.2x Spirited', int hazardCount = 0, int elevationFt = 1500, + RidingDiscipline discipline = RidingDiscipline.canyonSport, + String? regionName, + bool isFeatured = false, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, List routePoints = const [], }) async { @@ -278,6 +449,9 @@ class SocialFeedService { paceFactor: paceFactor, hazardCount: hazardCount, elevationFt: elevationFt, + discipline: discipline, + regionName: regionName, + isFeatured: isFeatured, snapshotType: snapshotType, routePoints: routePoints, isGpsVerified: true, diff --git a/lib/services/sync/cloud_restore_service.dart b/lib/services/sync/cloud_restore_service.dart index 5293168..9232693 100644 --- a/lib/services/sync/cloud_restore_service.dart +++ b/lib/services/sync/cloud_restore_service.dart @@ -7,6 +7,7 @@ import 'package:drift/drift.dart' as drift; import 'cloud_reconciliation_service.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; +import '../cloud/firebase_account_sync_service.dart'; import 'r2_download_service.dart'; class CloudRestoreResult { @@ -93,6 +94,7 @@ class CloudRestoreService { final CloudReconciliationService _metadata; final R2DownloadService _r2DownloadService; final CloudRideMetadataStore _metadataStore; + final FirebaseAccountSyncService? accountSyncService; CloudRestoreService({ required AppDatabase db, @@ -100,6 +102,7 @@ class CloudRestoreService { CloudReconciliationService? metadata, R2DownloadService? r2DownloadService, CloudRideMetadataStore? metadataStore, + this.accountSyncService, }) : _db = db, _authService = authService, _metadata = @@ -131,6 +134,9 @@ class CloudRestoreService { } final metadataResult = await _metadata.reconcileMetadata(); + try { + await accountSyncService?.syncAccount(); + } catch (_) {} final documents = await _metadataStore.listRideDocuments(user.uid); var telemetryRidesRestored = 0; diff --git a/lib/services/sync/sync_providers.dart b/lib/services/sync/sync_providers.dart index efba735..d682603 100644 --- a/lib/services/sync/sync_providers.dart +++ b/lib/services/sync/sync_providers.dart @@ -29,6 +29,7 @@ final cloudRestoreServiceProvider = Provider( (ref) => CloudRestoreService( db: ref.watch(databaseProvider), authService: ref.watch(authServiceProvider), + accountSyncService: ref.watch(firebaseAccountSyncServiceProvider), ), ); diff --git a/lib/services/telemetry_service.dart b/lib/services/telemetry_service.dart index 0154eff..132deb2 100644 --- a/lib/services/telemetry_service.dart +++ b/lib/services/telemetry_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../models/telemetry_sample.dart'; @@ -51,6 +52,10 @@ class TelemetryService { double _latestCompassHeading = 0.0; DateTime? _latestImuTimestamp; + // GPS Kinematics state + double _gpsLongitudinalG = 0.0; + double _gpsLateralG = 0.0; + // Monotonic authoritative clock final Stopwatch _monotonicStopwatch = Stopwatch(); int _sequenceCounter = 0; @@ -85,6 +90,32 @@ class TelemetryService { }; _locationSub = _locationService.getFilteredPositionStream().listen((pos) { + if (_latestPosition != null) { + final dt = pos.timestamp.difference(_latestPosition!.timestamp).inMicroseconds.abs() / 1000000.0; + if (dt >= 0.05 && dt <= 3.0) { + final currentSpeedMps = pos.speed; + final prevSpeedMps = _latestPosition!.speed; + final accelMps2 = (currentSpeedMps - prevSpeedMps) / dt; + _gpsLongitudinalG = (accelMps2 / 9.80665).clamp(-1.5, 1.5); + + final speedMph = pos.speed * 2.23694; + if (speedMph >= 2.5 && pos.heading >= 0 && _latestPosition!.heading >= 0) { + var dHeading = pos.heading - _latestPosition!.heading; + while (dHeading > 180) { + dHeading -= 360; + } + while (dHeading < -180) { + dHeading += 360; + } + final omegaRadS = (dHeading * math.pi / 180.0) / dt; + final centripetalMps2 = currentSpeedMps * omegaRadS; + final latG = (centripetalMps2 / 9.80665).clamp(-1.5, 1.5); + _gpsLateralG = latG; + } else { + _gpsLateralG = 0.0; + } + } + } _latestPosition = pos; }); @@ -123,7 +154,7 @@ class TelemetryService { final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0; final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null; final isGpsFresh = gpsAgeMs != null && - gpsAgeMs >= 0 && + gpsAgeMs >= -2000 && gpsAgeMs <= RecordingHealthContract.evidenceStaleAfterMs; // Use GPS heading if moving with fresh GPS evidence, otherwise fallback to compass @@ -132,6 +163,26 @@ class TelemetryService { finalHeading = _latestPosition!.heading; } + // Reset GPS kinematics to zero if vehicle is stationary or GPS is stale + if (!isGpsFresh || speedMph < 2.5) { + _gpsLongitudinalG = 0.0; + _gpsLateralG = 0.0; + } + + final hasActiveImuG = _latestGForce != null && + (_latestGForce!.longitudinal.abs() > 0.01 || _latestGForce!.lateral.abs() > 0.01); + final effectiveLongitudinalG = hasActiveImuG ? _latestGForce!.longitudinal : _gpsLongitudinalG; + final effectiveLateralG = hasActiveImuG ? _latestGForce!.lateral : _gpsLateralG; + + bool isImuCalibrated = false; + try { + isImuCalibrated = _sensorFusionService.isCalibrated; + } catch (_) { + isImuCalibrated = false; + } + + final double? effectiveLean = isImuCalibrated ? _latestLean : null; + final rawSample = TelemetrySample( timestamp: now, sequenceNumber: sequence, @@ -145,14 +196,14 @@ class TelemetryService { gpsTimestamp: _latestPosition?.timestamp, gpsAgeMs: gpsAgeMs, imuTimestamp: _latestImuTimestamp, - leanAngle: _latestLean, + leanAngle: effectiveLean, pitchAngle: _latestPitch, rollRateDegPerSec: _sensorFusionService.latestRollRateDegPerSec, accelX: _latestGForce?.accelX, accelY: _latestGForce?.accelY, accelZ: _latestGForce?.accelZ, - longitudinalG: _latestGForce?.longitudinal, - lateralG: _latestGForce?.lateral, + longitudinalG: effectiveLongitudinalG, + lateralG: effectiveLateralG, rawAccelX: _latestGForce?.rawAccelX ?? _sensorFusionService.latestRawAccelX, rawAccelY: _latestGForce?.rawAccelY ?? _sensorFusionService.latestRawAccelY, rawAccelZ: _latestGForce?.rawAccelZ ?? _sensorFusionService.latestRawAccelZ, @@ -164,7 +215,7 @@ class TelemetryService { _lastEmitMonotonicMs = monotonicMs; // Validate sample - final quality = _validator.validate(rawSample); + final quality = _validator.validate(rawSample, isMountCalibrated: isImuCalibrated); final validatedSample = rawSample.copyWith(quality: quality); // Log diagnostic if sample is suspect or rejected during an active field test (throttled for suspect) diff --git a/lib/services/validation/telemetry_validator.dart b/lib/services/validation/telemetry_validator.dart index ed81d53..4ca8bb1 100644 --- a/lib/services/validation/telemetry_validator.dart +++ b/lib/services/validation/telemetry_validator.dart @@ -87,11 +87,12 @@ class TelemetryValidator { _timestampGapCount = 0; } - TelemetryQuality validate(TelemetrySample sample) { + TelemetryQuality validate(TelemetrySample sample, {bool? isMountCalibrated}) { _totalValidated++; ValidationStatus status = ValidationStatus.valid; final List reasons = []; final currentSpeed = sample.speed ?? 0.0; + final effectiveIsMountCalibrated = isMountCalibrated ?? sample.quality?.isMountCalibrated ?? true; // 1. Numeric Sanity (NaN / Infinity) if (_hasInvalidNumeric(sample)) { @@ -235,7 +236,9 @@ class TelemetryValidator { // 5. Lean Angle Plausibility & Confidence double leanConf = 1.0; final currentLean = sample.leanAngle ?? 0.0; - if (currentLean.abs() > 65.0) { + if (sample.leanAngle != null && !effectiveIsMountCalibrated) { + leanConf = 0.5; + } else if (currentLean.abs() > 65.0) { status = ValidationStatus.suspect; leanConf = 0.3; reasons.add('Extreme lean angle beyond physical motorcycle limit (${currentLean.toStringAsFixed(1)}° > 65°)'); @@ -303,7 +306,7 @@ class TelemetryValidator { isStationary: isStationary, isAccelerating: isAccelerating, isBraking: isBraking, - isMountCalibrated: true, + isMountCalibrated: effectiveIsMountCalibrated, isSensorSaturated: isSaturated, timestampDeltaMs: deltaMs, ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f88f581..96deae7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import geolocator_apple import google_sign_in_ios import in_app_purchase_storekit import package_info_plus +import patrol import share_plus import shared_preferences_foundation import url_launcher_macos @@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PatrolPlugin.register(with: registry.registrar(forPlugin: "PatrolPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/patrol_test/social_feed_patrol_test.dart b/patrol_test/social_feed_patrol_test.dart new file mode 100644 index 0000000..be7fdd2 --- /dev/null +++ b/patrol_test/social_feed_patrol_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:motorcycle_dash/main.dart' as app; + +void main() { + patrolTest( + 'verify social community feed multi-stream and navigation on emulator', + ($) async { + await app.main(); + await $.pumpAndSettle(); + + // 1. Navigate to Social section via bottom nav bar + final socialNav = find.byKey(const ValueKey('nav_social_icon')); + expect(socialNav, findsOneWidget); + await $(socialNav).tap(); + await $.pumpAndSettle(); + + // 2. Switch from Hub to Feed tab + final feedToggle = find.byKey(const ValueKey('social_tab_feed')); + expect(feedToggle, findsOneWidget); + await $(feedToggle).tap(); + await $.pumpAndSettle(); + + // 3. Verify multi-stream tabs are present + expect(find.text('All Activity'), findsWidgets); + expect(find.text('Club Only'), findsWidgets); + expect(find.text('Nearby Packs'), findsWidgets); + expect(find.text('For You'), findsWidgets); + expect(find.text('Following'), findsWidgets); + + // 4. Verify Riding Discipline filter chips are present + expect(find.text('All Styles'), findsWidgets); + expect(find.text('⚡ Canyon'), findsWidgets); + + // 5. Select Canyon discipline filter + await $(find.text('⚡ Canyon')).tap(); + await $.pumpAndSettle(); + + // 6. Verify active filter summary bar displays + expect(find.textContaining('Filtered by:'), findsOneWidget); + expect(find.text('Reset'), findsOneWidget); + + // 7. Reset filters + await $(find.text('Reset')).tap(); + await $.pumpAndSettle(); + expect(find.textContaining('Filtered by:'), findsNothing); + }, + ); +} diff --git a/patrol_test/test_bundle.dart b/patrol_test/test_bundle.dart new file mode 100644 index 0000000..e108532 --- /dev/null +++ b/patrol_test/test_bundle.dart @@ -0,0 +1,129 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND AND DO NOT COMMIT TO VERSION CONTROL +// ignore_for_file: type=lint, invalid_use_of_internal_member + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:patrol/src/constants.dart' as constants; +import 'package:patrol/src/manifest_writer.dart'; +import 'package:patrol/src/platform/contracts/contracts.dart'; +import 'package:patrol/src/platform/mobile/patrol_runtime_ports.dart'; +import 'package:test_api/src/backend/invoker.dart'; + +// START: GENERATED TEST IMPORTS +import 'social_feed_patrol_test.dart' as social_feed_patrol_test; +// END: GENERATED TEST IMPORTS + +Future main() async { + // This is the entrypoint of the bundled Dart test. + // + // Its responsibilities are: + // * Running a special Dart test that runs before all the other tests and + // explores the hierarchy of groups and tests. + // * Hosting a PatrolAppService, which the native side of Patrol uses to get + // the Dart tests, and to request execution of a specific Dart test. + // + // When running on Android, the Android Test Orchestrator, before running the + // tests, makes an initial run to gather the tests that it will later run. The + // native side of Patrol (specifically: PatrolJUnitRunner class) is hooked + // into the Android Test Orchestrator lifecycle and knows when that initial + // run happens. When it does, PatrolJUnitRunner makes an RPC call to + // PatrolAppService and asks it for Dart tests. + // + // When running on iOS, the native side of Patrol (specifically: the + // PATROL_INTEGRATION_TEST_IOS_RUNNER macro) makes an initial run to gather + // the tests that it will later run (same as the Android). During that initial + // run, it makes an RPC call to PatrolAppService and asks it for Dart tests. + // + // Once the native runner has the list of Dart tests, it dynamically creates + // native test cases from them. On Android, this is done using the + // Parametrized JUnit runner. On iOS, new test case methods are swizzled into + // the RunnerUITests class, taking advantage of the very dynamic nature of + // Objective-C runtime. + // + // Execution of these dynamically created native test cases is then fully + // managed by the underlying native test framework (JUnit on Android, XCTest + // on iOS). The native test cases do only one thing - request execution of the + // Dart test (out of which they had been created) and wait for it to complete. + // The result of running the Dart test is the result of the native test case. + + final testExplorationCompleter = Completer(); + + // In build-time discovery mode the bundle is driven by a host `flutter test` + // run: we only register tests to build the group tree, then serialize it to a + // manifest. PatrolBinding (a Live binding) must NOT be initialized here - it's + // incompatible with the automated binding `flutter test` installs on the host. + late final PlatformAutomator platformAutomator; + late final PatrolBinding binding; + if (!constants.testDiscoveryEnabled) { + platformAutomator = PlatformAutomator( + config: PlatformAutomatorConfig.defaultConfig(), + ); + await platformAutomator.initialize(); + binding = PatrolBinding.ensureInitialized(platformAutomator); + } + + // A special test to explore the hierarchy of groups and tests. This is a hack + // around https://github.com/dart-lang/test/issues/1998. + // + // This test must be the first to run. If not, the native side likely won't + // receive any tests, and everything will fall apart. + test('patrol_test_explorer', () { + // Maybe somewhat counterintuitively, this callback runs *after* the calls + // to group() below. + final topLevelGroup = Invoker.current!.liveTest.groups.first; + final dartTestGroup = createDartTestGroup( + topLevelGroup, + tags: null, + excludeTags: null, + ); + if (constants.testDiscoveryEnabled) { + // Build-time discovery: serialize the WHOLE tree (same shape as + // ListDartTestsResponse.group) so the native side can flatten it with its + // existing listTestsFlat logic - selectors come out byte-identical to + // runtime discovery. + // + // The manifest is written from inside this test (not from main()) on + // purpose: under the host automated binding the framework runs tests only + // *after* main() finishes declaring, so awaiting the tree in main() would + // deadlock. + const manifestOutput = String.fromEnvironment('PATROL_MANIFEST_OUTPUT'); + if (manifestOutput.isNotEmpty) { + writeTestManifest( + manifestOutput, + jsonEncode(ListDartTestsResponse(group: dartTestGroup).toJson()), + ); + } + return; + } + + testExplorationCompleter.complete(dartTestGroup); + print('patrol_test_explorer: obtained Dart-side test hierarchy:'); + reportGroupStructure(dartTestGroup); + }); + +// START: GENERATED TEST GROUPS + group('social_feed_patrol_test', social_feed_patrol_test.main); +// END: GENERATED TEST GROUPS + + if (constants.testDiscoveryEnabled) { + // Returning finishes declaration; `flutter test` then runs the explorer + // test (which writes the manifest) plus the registered no-op bodies. + return; + } + + final dartTestGroup = await testExplorationCompleter.future; + final appService = PatrolAppService(topLevelDartTestGroup: dartTestGroup); + binding.patrolAppService = appService; + await PatrolRuntimePorts.ensureLoaded(); + await runAppService(appService); + + // Until now, the native test runner was waiting for us, the Dart side, to + // come alive. Now that we did, let's tell it that we're ready to be asked + // about Dart tests. + await platformAutomator.markPatrolAppServiceReady(); + + await appService.testExecutionCompleted; +} diff --git a/pubspec.lock b/pubspec.lock index 5de8e93..4939145 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.59" + adb: + dependency: transitive + description: + name: adb + sha256: ff7324138e3df9dcd8fab54050f9f74a224928321ae50161256109414a87f1d4 + url: "https://pub.dev" + source: hosted + version: "0.6.1" analyzer: dependency: transitive description: @@ -25,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.0.0" + ansi_styles: + dependency: transitive + description: + name: ansi_styles + sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" + url: "https://pub.dev" + source: hosted + version: "0.3.2+1" ansicolor: dependency: transitive description: @@ -185,6 +201,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_completion: + dependency: transitive + description: + name: cli_completion + sha256: d61bbbfccabe02fdad54eeb275586e15e31abdeb35ec7a0d68832fae473cc298 + url: "https://pub.dev" + source: hosted + version: "0.6.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -249,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" cross_file: dependency: transitive description: @@ -313,6 +361,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.15" + dispose_scope: + dependency: transitive + description: + name: dispose_scope + sha256: "48ec38ca2631c53c4f8fa96b294c801e55c335db5e3fb9f82cede150cfe5a2af" + url: "https://pub.dev" + source: hosted + version: "2.1.0" drift: dependency: "direct main" description: @@ -973,6 +1029,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + mason_logger: + dependency: transitive + description: + name: mason_logger + sha256: "495a2732647b1c861f75c8bcc36766b8af4c59999a6c8562d7e604cc6fba0fa3" + url: "https://pub.dev" + source: hosted + version: "0.3.6" matcher: dependency: transitive description: @@ -997,6 +1061,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + mcp_dart: + dependency: transitive + description: + name: mcp_dart + sha256: "5a185c1c02e90239e6f3ebca05ef51e04ac0f9c2b1b7ec7ffbb1a671a6379729" + url: "https://pub.dev" + source: hosted + version: "2.4.2" meta: dependency: transitive description: @@ -1133,6 +1205,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: "662c50d517b2f159fedbc359a7fb8d0d30cbefea6d5c2a08392a0593079d64d1" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + patrol_cli: + dependency: transitive + description: + name: patrol_cli + sha256: "920e62775db68c5d01ba90103d21f893635a195c5c4dba2ecff5218622934eaa" + url: "https://pub.dev" + source: hosted + version: "4.8.0" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "8608cdacaab90a3b00ecd7b09df11f13a62b01dea13c211b9f13fd86854e103a" + url: "https://pub.dev" + source: hosted + version: "3.6.0" + patrol_log: + dependency: transitive + description: + name: patrol_log + sha256: "3d91c93e8d0ed19cb12d4b27aa24eca7294e0a48d9d8445505c9775feb5fde2d" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + patrol_mcp: + dependency: "direct dev" + description: + name: patrol_mcp + sha256: "90971474ef2c097c1d6ad9f6aee2fadc7dd7c587064435b86d1ce2e696a48da8" + url: "https://pub.dev" + source: hosted + version: "0.2.1" permission_handler: dependency: "direct main" description: @@ -1229,6 +1341,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" proj4dart: dependency: transitive description: @@ -1245,6 +1365,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "3aadeb8c68d15ef9d117b36474ddcc543b48ae1e64f7dd71407da26df66bf7c8" + url: "https://pub.dev" + source: hosted + version: "0.6.0" pubspec_parse: dependency: transitive description: @@ -1426,6 +1554,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.4" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" source_span: dependency: transitive description: @@ -1618,6 +1754,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + version: + dependency: transitive + description: + name: version + sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" + url: "https://pub.dev" + source: hosted + version: "3.0.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4594861..ac78abc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,8 @@ dev_dependencies: flutter_native_splash: ^2.4.8 image: ^4.9.2 fake_cloud_firestore: 3.1.0 + patrol: ^4.10.0 + patrol_mcp: ^0.2.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -139,3 +141,8 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +patrol: + app_name: MotorSignal + android: + package_name: app.motorsignal.motorcycle diff --git a/skills-lock.json b/skills-lock.json index 2afc7ce..d365c75 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -529,6 +529,12 @@ "skillPath": "skills/planning-and-task-breakdown/SKILL.md", "computedHash": "0dc14a1402cdabd376f2d81d2225fcb4a18ee63d58b945ff83affeb024285b8f" }, + "playwright-skill": { + "source": "testdino-hq/playwright-skill", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "08a2811eb05218533ce06dbf5c226bdb7932b0b85d1608fb9cab2b0f91532e1a" + }, "prototype": { "source": "mattpocock/skills", "sourceType": "github", diff --git a/test/billing_lifecycle_test.dart b/test/billing_lifecycle_test.dart index 34fb9fa..e95c937 100644 --- a/test/billing_lifecycle_test.dart +++ b/test/billing_lifecycle_test.dart @@ -266,6 +266,7 @@ void main() { test( 'verifyStorePurchase dequeues purchase when result is fully acknowledged', () async { + final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30)); final queue = InMemoryVerificationQueue(); final mockClient = MockClient((request) async { return http.Response( @@ -277,7 +278,7 @@ void main() { 'verificationStatus': 'verified', 'acknowledgementState': 'acknowledged', 'verifiedAt': '2026-09-03T18:00:00.000Z', - 'expiresAt': '2026-10-03T18:00:00.000Z', + 'expiresAt': futureExpiry.toIso8601String(), 'tokenHash': 'hash_ack', }), 200, @@ -320,6 +321,7 @@ void main() { test( 'verifyStorePurchase grants Pro but retains queue item with backoff on pendingRetry ack', () async { + final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30)); final queue = InMemoryVerificationQueue(); final mockClient = MockClient((request) async { return http.Response( @@ -331,7 +333,7 @@ void main() { 'verificationStatus': 'verified', 'acknowledgementState': 'pendingRetry', 'verifiedAt': '2026-09-03T18:00:00.000Z', - 'expiresAt': '2026-10-03T18:00:00.000Z', + 'expiresAt': futureExpiry.toIso8601String(), 'tokenHash': 'hash_pending_ack', }), 200, @@ -380,6 +382,62 @@ void main() { ); }, ); + + test( + 'verifyStorePurchase with past expiresAt sets status to expired and does not grant Pro', + () async { + final pastExpiry = DateTime.now().toUtc().subtract(const Duration(days: 1)); + final queue = InMemoryVerificationQueue(); + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'productId': ProductCatalog.proMonthly, + 'plan': 'proMonthly', + 'status': 'proMonthlyActive', + 'verificationStatus': 'verified', + 'acknowledgementState': 'acknowledged', + 'verifiedAt': '2026-09-03T18:00:00.000Z', + 'expiresAt': pastExpiry.toIso8601String(), + 'tokenHash': 'hash_expired', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final verifier = PurchaseVerificationService( + client: mockClient, + workerBaseUrl: 'https://test.worker', + ); + + final coordWithVerifier = EntitlementCoordinator( + billingService: billing, + storage: storage, + verificationService: verifier, + verificationQueue: queue, + ); + addTearDown(coordWithVerifier.dispose); + await coordWithVerifier.initialize(); + + const purchase = StorePurchase( + purchaseId: 'order_expired_1', + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.purchased, + transactionDate: '2026-09-01T12:00:00Z', + purchaseToken: 'token_expired_123', + ); + + await queue.enqueue(purchase); + await coordWithVerifier.verifyStorePurchase(purchase); + + expect(coordWithVerifier.state.isPro, isFalse); + expect( + coordWithVerifier.state.status, + MotorSignalEntitlementStatus.proMonthlyExpired, + ); + }, + ); }); group('Riverpod integration & Provider boundaries', () { diff --git a/test/hybrid_v1_phase1_test.dart b/test/hybrid_v1_phase1_test.dart new file mode 100644 index 0000000..482f44e --- /dev/null +++ b/test/hybrid_v1_phase1_test.dart @@ -0,0 +1,885 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/core/theme/app_colors.dart'; +import 'package:motorcycle_dash/core/theme/app_spacing.dart'; +import 'package:motorcycle_dash/core/theme/app_typography.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/core/widgets/hybrid_card.dart'; +import 'package:motorcycle_dash/core/widgets/hybrid_metric_tile.dart'; +import 'package:motorcycle_dash/core/widgets/hybrid_riding_action_bar.dart'; +import 'package:motorcycle_dash/core/widgets/hybrid_static_rec_badge.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; + +/// Test double for [RideStateMachine] allowing inspection of invoked transitions. +class _FakeRideStateMachine extends StateNotifier + implements RideStateMachine { + _FakeRideStateMachine([RideSession? initial]) + : super( + initial ?? + RideSession( + state: RideState.idle, + startTime: DateTime.now(), + ), + ); + + bool startRideCalled = false; + bool pauseRideCalled = false; + bool resumeRideCalled = false; + bool finishRideCalled = false; + + @override + Future startRide() async { + startRideCalled = true; + state = RideSession(state: RideState.riding, startTime: DateTime.now()); + } + + @override + void pauseRide() { + pauseRideCalled = true; + state = RideSession(state: RideState.paused, startTime: DateTime.now()); + } + + @override + void resumeRide() { + resumeRideCalled = true; + state = RideSession(state: RideState.riding, startTime: DateTime.now()); + } + + @override + Future finishRide() async { + finishRideCalled = true; + state = RideSession(state: RideState.finished, startTime: DateTime.now()); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // =========================================================================== + // SECTION 1: Canonical Tokens in AppColors and MotorSignalThemeColors + // =========================================================================== + group('1. Canonical Tokens in AppColors and MotorSignalThemeColors', () { + test('AppColors defines canonical hybrid canvases', () { + expect(AppColors.hybridNightCanvas, const Color(0xFF0B1326)); + expect(AppColors.hybridDayCanvas, const Color(0xFFF8FAFC)); + }); + + test('MotorSignalThemeColors surfaceCanvas matches canonical canvases', () { + expect( + MotorSignalThemeColors.dark.surfaceCanvas, + const Color(0xFF0B1326), + ); + expect( + MotorSignalThemeColors.light.surfaceCanvas, + const Color(0xFFF8FAFC), + ); + }); + + test('AppColors defines canonical hybrid core state tokens', () { + // Night tokens + expect(AppColors.hybridNightBrandCyan, const Color(0xFF06B6D4)); + expect(AppColors.hybridNightRecGreen, const Color(0xFF10B981)); + expect(AppColors.hybridNightCautionAmber, const Color(0xFFF59E0B)); + expect(AppColors.hybridNightDangerRed, const Color(0xFFEF4444)); + + // Day tokens + expect(AppColors.hybridDayBrandCyan, const Color(0xFF0284C7)); + expect(AppColors.hybridDayRecGreen, const Color(0xFF10B981)); + expect(AppColors.hybridDayCautionAmber, const Color(0xFFD97706)); + expect(AppColors.hybridDayDangerRed, const Color(0xFFDC2626)); + }); + + test('MotorSignalThemeColors presets map brandCyan, recStaticGreen, cautionAmber, and dangerRed', () { + // Dark mode preset + final dark = MotorSignalThemeColors.dark; + expect(dark.brandCyan, AppColors.hybridNightBrandCyan); + expect(dark.brandCyan, const Color(0xFF06B6D4)); + expect(dark.recStaticGreen, AppColors.hybridNightRecGreen); + expect(dark.recStaticGreen, const Color(0xFF10B981)); + expect(dark.cautionAmber, AppColors.hybridNightCautionAmber); + expect(dark.cautionAmber, const Color(0xFFF59E0B)); + expect(dark.dangerRed, AppColors.hybridNightDangerRed); + expect(dark.dangerRed, const Color(0xFFEF4444)); + + // Light mode preset + final light = MotorSignalThemeColors.light; + expect(light.brandCyan, AppColors.hybridDayBrandCyan); + expect(light.brandCyan, const Color(0xFF0284C7)); + expect(light.recStaticGreen, AppColors.hybridDayRecGreen); + expect(light.recStaticGreen, const Color(0xFF10B981)); + expect(light.cautionAmber, AppColors.hybridDayCautionAmber); + expect(light.cautionAmber, const Color(0xFFD97706)); + expect(light.dangerRed, AppColors.hybridDayDangerRed); + expect(light.dangerRed, const Color(0xFFDC2626)); + }); + + test('MotorSignalThemeColors.copyWith supports all 15 hybrid tokens', () { + const dark = MotorSignalThemeColors.dark; + + final modified = dark.copyWith( + surfaceCanvas: const Color(0xFF100001), + surfaceCardPrimary: const Color(0xFF100002), + surfaceCardSecondary: const Color(0xFF100003), + surfaceCardLowest: const Color(0xFF100004), + surfaceBorder: const Color(0xFF100005), + surfaceBorderSubtle: const Color(0xFF100006), + textPrimary: const Color(0xFF100007), + textMuted: const Color(0xFF100008), + brandCyan: const Color(0xFF100009), + brandCyanBright: const Color(0xFF10000A), + brandCyanContainer: const Color(0xFF10000B), + recStaticGreen: const Color(0xFF10000C), + recStaticGreenBg: const Color(0xFF10000D), + cautionAmber: const Color(0xFF10000E), + dangerRed: const Color(0xFF10000F), + ); + + expect(modified.surfaceCanvas, const Color(0xFF100001)); + expect(modified.surfaceCardPrimary, const Color(0xFF100002)); + expect(modified.surfaceCardSecondary, const Color(0xFF100003)); + expect(modified.surfaceCardLowest, const Color(0xFF100004)); + expect(modified.surfaceBorder, const Color(0xFF100005)); + expect(modified.surfaceBorderSubtle, const Color(0xFF100006)); + expect(modified.textPrimary, const Color(0xFF100007)); + expect(modified.textMuted, const Color(0xFF100008)); + expect(modified.brandCyan, const Color(0xFF100009)); + expect(modified.brandCyanBright, const Color(0xFF10000A)); + expect(modified.brandCyanContainer, const Color(0xFF10000B)); + expect(modified.recStaticGreen, const Color(0xFF10000C)); + expect(modified.recStaticGreenBg, const Color(0xFF10000D)); + expect(modified.cautionAmber, const Color(0xFF10000E)); + expect(modified.dangerRed, const Color(0xFF10000F)); + + // Existing non-hybrid tokens remain preserved + expect(modified.cyberCyan, dark.cyberCyan); + expect(modified.velocityBlue, dark.velocityBlue); + expect(modified.electricCyan, dark.electricCyan); + + // Unmodified copy retains all original values + final unchanged = dark.copyWith(); + expect(unchanged.surfaceCanvas, dark.surfaceCanvas); + expect(unchanged.brandCyan, dark.brandCyan); + expect(unchanged.recStaticGreen, dark.recStaticGreen); + expect(unchanged.cautionAmber, dark.cautionAmber); + expect(unchanged.dangerRed, dark.dangerRed); + }); + + test('MotorSignalThemeColors.lerp interpolates all 15 hybrid tokens', () { + const dark = MotorSignalThemeColors.dark; + const light = MotorSignalThemeColors.light; + + final lerped = dark.lerp(light, 0.5); + expect(lerped, isA()); + + expect(lerped.surfaceCanvas, Color.lerp(dark.surfaceCanvas, light.surfaceCanvas, 0.5)); + expect(lerped.surfaceCardPrimary, Color.lerp(dark.surfaceCardPrimary, light.surfaceCardPrimary, 0.5)); + expect(lerped.surfaceCardSecondary, Color.lerp(dark.surfaceCardSecondary, light.surfaceCardSecondary, 0.5)); + expect(lerped.surfaceCardLowest, Color.lerp(dark.surfaceCardLowest, light.surfaceCardLowest, 0.5)); + expect(lerped.surfaceBorder, Color.lerp(dark.surfaceBorder, light.surfaceBorder, 0.5)); + expect(lerped.surfaceBorderSubtle, Color.lerp(dark.surfaceBorderSubtle, light.surfaceBorderSubtle, 0.5)); + expect(lerped.textPrimary, Color.lerp(dark.textPrimary, light.textPrimary, 0.5)); + expect(lerped.textMuted, Color.lerp(dark.textMuted, light.textMuted, 0.5)); + expect(lerped.brandCyan, Color.lerp(dark.brandCyan, light.brandCyan, 0.5)); + expect(lerped.brandCyanBright, Color.lerp(dark.brandCyanBright, light.brandCyanBright, 0.5)); + expect(lerped.brandCyanContainer, Color.lerp(dark.brandCyanContainer, light.brandCyanContainer, 0.5)); + expect(lerped.recStaticGreen, Color.lerp(dark.recStaticGreen, light.recStaticGreen, 0.5)); + expect(lerped.recStaticGreenBg, Color.lerp(dark.recStaticGreenBg, light.recStaticGreenBg, 0.5)); + expect(lerped.cautionAmber, Color.lerp(dark.cautionAmber, light.cautionAmber, 0.5)); + expect(lerped.dangerRed, Color.lerp(dark.dangerRed, light.dangerRed, 0.5)); + + // Null or non-matching other returns this + expect(dark.lerp(null, 0.5), same(dark)); + }); + }); + + // =========================================================================== + // SECTION 2: Reusable Components in lib/core/widgets/ + // =========================================================================== + group('2. Reusable Components', () { + group('HybridCard', () { + testWidgets('renders with 1px border, primary surface color, and padding', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridCard( + child: Text('Card Content'), + ), + ), + ), + ); + + final containerFinder = find.descendant( + of: find.byType(HybridCard), + matching: find.byType(Container), + ).first; + final containerWidget = tester.widget(containerFinder); + final decoration = containerWidget.decoration as ShapeDecoration; + final shape = decoration.shape as RoundedRectangleBorder; + + // 1px border verification + expect(shape.side.width, 1.0); + expect(shape.side.color, AppColors.hybridNightBorder); + + // Surface color: primary by default + expect(decoration.color, AppColors.hybridNightSurfaceCardPrimary); + + // Padding: default AppSpacing.md (12.0) + final paddingFinder = find.ancestor( + of: find.text('Card Content'), + matching: find.byType(Padding), + ); + final paddingWidget = tester.widget(paddingFinder.first); + expect(paddingWidget.padding, const EdgeInsets.all(AppSpacing.md)); + + // Child content present + expect(find.text('Card Content'), findsOneWidget); + }); + + testWidgets('renders secondary surface color and custom border color when specified', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + home: const Scaffold( + body: HybridCard( + secondary: true, + borderColor: Color(0xFF123456), + padding: EdgeInsets.all(20.0), + child: Text('Secondary Card'), + ), + ), + ), + ); + + final containerFinder = find.descendant( + of: find.byType(HybridCard), + matching: find.byType(Container), + ).first; + final containerWidget = tester.widget(containerFinder); + final decoration = containerWidget.decoration as ShapeDecoration; + final shape = decoration.shape as RoundedRectangleBorder; + + expect(shape.side.width, 1.0); + expect(shape.side.color, const Color(0xFF123456)); + expect(decoration.color, AppColors.hybridDaySurfaceCardSecondary); + + final paddingFinder = find.ancestor( + of: find.text('Secondary Card'), + matching: find.byType(Padding), + ); + final paddingWidget = tester.widget(paddingFinder.first); + expect(paddingWidget.padding, const EdgeInsets.all(20.0)); + }); + + testWidgets('handles onTap callback and InkWell presence', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: HybridCard( + onTap: () { + tapped = true; + }, + child: const Text('Tappable Card'), + ), + ), + ), + ); + + expect(find.byType(InkWell), findsOneWidget); + + await tester.tap(find.text('Tappable Card')); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + }); + + testWidgets('omits InkWell when onTap is null', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridCard( + child: Text('Non-tappable Card'), + ), + ), + ), + ); + + expect(find.byType(InkWell), findsNothing); + }); + }); + + group('HybridStaticRecBadge', () { + testWidgets('renders isRecording true badge (REC • LIVE, green)', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridStaticRecBadge(isRecording: true), + ), + ), + ); + + expect(find.text('REC • LIVE'), findsOneWidget); + + final textWidget = tester.widget(find.text('REC • LIVE')); + expect(textWidget.style?.color, AppColors.hybridNightRecGreen); + + final outerContainer = tester.widget(find.byType(Container).first); + final boxDec = outerContainer.decoration as BoxDecoration; + expect(boxDec.color, AppColors.hybridNightRecGreenBg); + }); + + testWidgets('renders isPaused true badge (PAUSED, amber)', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridStaticRecBadge( + isRecording: false, + isPaused: true, + ), + ), + ), + ); + + expect(find.text('PAUSED'), findsOneWidget); + + final textWidget = tester.widget(find.text('PAUSED')); + expect(textWidget.style?.color, AppColors.hybridNightCautionAmber); + }); + + testWidgets('renders standby badge (STANDBY) when neither recording nor paused', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridStaticRecBadge( + isRecording: false, + isPaused: false, + ), + ), + ), + ); + + expect(find.text('STANDBY'), findsOneWidget); + + final textWidget = tester.widget(find.text('STANDBY')); + expect(textWidget.style?.color, AppColors.hybridNightTextMuted); + }); + + testWidgets('verifies zero animation controllers or ticker providers are used', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridStaticRecBadge(isRecording: true), + ), + ), + ); + + // HybridStaticRecBadge is strictly a StatelessWidget + final badgeWidget = tester.widget(find.byType(HybridStaticRecBadge)); + expect(badgeWidget, isA()); + + // Zero StatefulWidgets or TickerProviders anywhere in the badge tree + expect( + find.descendant( + of: find.byType(HybridStaticRecBadge), + matching: find.byWidgetPredicate((w) => w is StatefulWidget), + ), + findsNothing, + ); + + // Zero AnimatedWidget descendants + expect( + find.descendant( + of: find.byType(HybridStaticRecBadge), + matching: find.byType(AnimatedWidget), + ), + findsNothing, + ); + + // Zero active transient animation callbacks + expect(tester.binding.transientCallbackCount, 0); + }); + }); + + group('HybridMetricTile', () { + testWidgets('renders tabular value, unit, and label with uppercase tracking', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridMetricTile( + label: 'Speed', + value: '72', + unit: 'mph', + ), + ), + ), + ); + + // Label: uppercase tracking + expect(find.text('SPEED'), findsOneWidget); + final labelText = tester.widget(find.text('SPEED')); + expect(labelText.style?.fontFamily, AppTypography.fontHeadline); + expect(labelText.style?.fontSize, 11); + expect(labelText.style?.fontWeight, FontWeight.w700); + expect(labelText.style?.letterSpacing, 1.0); + expect(labelText.style?.color, AppColors.hybridNightTextMuted); + + // Value: tabular JetBrains Mono font + expect(find.text('72'), findsOneWidget); + final valueText = tester.widget(find.text('72')); + expect(valueText.style?.fontFamily, AppTypography.fontTelemetry); + expect(valueText.style?.fontSize, 26); + expect(valueText.style?.fontWeight, FontWeight.w700); + expect(valueText.style?.color, AppColors.hybridNightTextPrimary); + + // Unit: uppercase tracking + expect(find.text('MPH'), findsOneWidget); + final unitText = tester.widget(find.text('MPH')); + expect(unitText.style?.fontFamily, AppTypography.fontTelemetry); + expect(unitText.style?.fontSize, 11); + expect(unitText.style?.letterSpacing, 0.5); + }); + + testWidgets('renders compact mode with scaled font sizes and icon', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridMetricTile( + label: 'Lean', + value: '42°', + unit: 'deg', + icon: Icons.navigation_rounded, + compact: true, + ), + ), + ), + ); + + expect(find.text('LEAN'), findsOneWidget); + final labelText = tester.widget(find.text('LEAN')); + expect(labelText.style?.fontSize, 10); + + expect(find.text('42°'), findsOneWidget); + final valueText = tester.widget(find.text('42°')); + expect(valueText.style?.fontSize, 22); + + expect(find.text('DEG'), findsOneWidget); + final unitText = tester.widget(find.text('DEG')); + expect(unitText.style?.fontSize, 10); + + final iconWidget = tester.widget(find.byType(Icon)); + expect(iconWidget.size, 12); + }); + }); + + group('HybridRidingActionBar', () { + testWidgets('verifies 56dp height and 48dp min touch bounds in standard mode', (tester) async { + final fakeMachine = _FakeRideStateMachine( + RideSession(state: RideState.ready, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => fakeMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridRidingActionBar(compact: false), + ), + ), + ), + ); + + final buttonFinder = find.byKey(const ValueKey('hybrid_start_ride_button')); + expect(buttonFinder, findsOneWidget); + + final size = tester.getSize(buttonFinder); + expect(size.height, AppSpacing.primaryRidingCtaHeight); // 56dp + expect(size.height, greaterThanOrEqualTo(AppSpacing.minTouchTarget)); // >= 48dp + }); + + testWidgets('verifies 48dp compact height and min touch bounds', (tester) async { + final fakeMachine = _FakeRideStateMachine( + RideSession(state: RideState.ready, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => fakeMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridRidingActionBar(compact: true), + ), + ), + ), + ); + + final buttonFinder = find.byKey(const ValueKey('hybrid_start_ride_button')); + expect(buttonFinder, findsOneWidget); + + final size = tester.getSize(buttonFinder); + expect(size.height, AppSpacing.minTouchTarget); // 48dp + expect(size.height, greaterThanOrEqualTo(AppSpacing.minTouchTarget)); // >= 48dp + }); + + testWidgets('renders start button when ready / standby and invokes startRide', (tester) async { + final fakeMachine = _FakeRideStateMachine( + RideSession(state: RideState.ready, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => fakeMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridRidingActionBar(), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsOneWidget); + expect(find.text('Start Session'), findsOneWidget); + expect(find.byKey(const ValueKey('hybrid_pause_resume_ride_button')), findsNothing); + expect(find.byKey(const ValueKey('hybrid_finish_ride_button')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('hybrid_start_ride_button'))); + await tester.pumpAndSettle(); + + expect(fakeMachine.startRideCalled, isTrue); + }); + + testWidgets('renders Pause Ride and Finish Ride buttons when riding', (tester) async { + final fakeMachine = _FakeRideStateMachine( + RideSession(state: RideState.riding, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => fakeMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridRidingActionBar(), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsNothing); + + final pauseFinder = find.byKey(const ValueKey('hybrid_pause_resume_ride_button')); + final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button')); + + expect(pauseFinder, findsOneWidget); + expect(finishFinder, findsOneWidget); + expect(find.text('Pause Ride'), findsOneWidget); + expect(find.text('Finish Ride'), findsOneWidget); + + // Tap Pause + await tester.tap(pauseFinder); + await tester.pumpAndSettle(); + expect(fakeMachine.pauseRideCalled, isTrue); + + // Tap Finish + await tester.tap(finishFinder); + await tester.pumpAndSettle(); + expect(fakeMachine.finishRideCalled, isTrue); + }); + + testWidgets('renders Resume button when paused and invokes resumeRide', (tester) async { + final fakeMachine = _FakeRideStateMachine( + RideSession(state: RideState.paused, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => fakeMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridRidingActionBar(), + ), + ), + ), + ); + + final resumeFinder = find.byKey(const ValueKey('hybrid_pause_resume_ride_button')); + expect(resumeFinder, findsOneWidget); + expect(find.text('Resume'), findsOneWidget); + expect(find.text('Pause Ride'), findsNothing); + + await tester.tap(resumeFinder); + await tester.pumpAndSettle(); + expect(fakeMachine.resumeRideCalled, isTrue); + }); + }); + }); + + // =========================================================================== + // SECTION 3: Large Text Accessibility (200% Text Scale) + // =========================================================================== + group('3. Large Text Accessibility (200% text scale)', () { + const largeTextMedia = MediaQueryData( + size: Size(390, 844), + textScaler: TextScaler.linear(2.0), + ); + + testWidgets('HybridCard renders under 200% text scale without RenderFlex overflow', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: HybridCard( + child: Text('Hybrid Card under large text scaling mode'), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Hybrid Card under large text scaling mode'), findsOneWidget); + }); + + testWidgets('HybridMetricTile renders under 200% text scale without RenderFlex overflow', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + HybridMetricTile( + label: 'Speed', + value: '120', + unit: 'mph', + icon: Icons.speed, + ), + SizedBox(height: 16), + HybridMetricTile( + label: 'Max Lean Angle Left', + value: '48.5°', + unit: 'deg', + compact: true, + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('SPEED'), findsOneWidget); + expect(find.text('120'), findsOneWidget); + expect(find.text('MAX LEAN ANGLE LEFT'), findsOneWidget); + }); + + testWidgets('HybridStaticRecBadge renders under 200% text scale without RenderFlex overflow', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + HybridStaticRecBadge(isRecording: true), + SizedBox(height: 8), + HybridStaticRecBadge(isRecording: false, isPaused: true), + SizedBox(height: 8), + HybridStaticRecBadge(isRecording: false, isPaused: false), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('REC • LIVE'), findsOneWidget); + expect(find.text('PAUSED'), findsOneWidget); + expect(find.text('STANDBY'), findsOneWidget); + }); + + testWidgets('HybridRidingActionBar riding mode renders under 200% text scale without RenderFlex overflow', (tester) async { + final ridingMachine = _FakeRideStateMachine( + RideSession(state: RideState.riding, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => ridingMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: Padding( + padding: EdgeInsets.all(16.0), + child: HybridRidingActionBar(), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Pause Ride'), findsOneWidget); + expect(find.text('Finish Ride'), findsOneWidget); + }); + + testWidgets('HybridRidingActionBar paused mode renders under 200% text scale without RenderFlex overflow', (tester) async { + final pausedMachine = _FakeRideStateMachine( + RideSession(state: RideState.paused, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => pausedMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: Padding( + padding: EdgeInsets.all(16.0), + child: HybridRidingActionBar(compact: true), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Resume'), findsOneWidget); + expect(find.text('Finish Ride'), findsOneWidget); + }); + + testWidgets('HybridRidingActionBar standby/ready mode renders under 200% text scale without RenderFlex overflow', (tester) async { + final readyMachine = _FakeRideStateMachine( + RideSession(state: RideState.ready, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => readyMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: Padding( + padding: EdgeInsets.all(16.0), + child: HybridRidingActionBar(), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Start Session'), findsOneWidget); + }); + + testWidgets('Composite hybrid cockpit dashboard renders under 200% text scale without RenderFlex overflow', (tester) async { + final machine = _FakeRideStateMachine( + RideSession(state: RideState.riding, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => machine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const MediaQuery( + data: largeTextMedia, + child: Scaffold( + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Column( + children: [ + HybridStaticRecBadge(isRecording: true), + SizedBox(height: 16), + HybridCard( + child: Column( + children: [ + HybridMetricTile( + label: 'Speed', + value: '65', + unit: 'mph', + ), + SizedBox(height: 12), + HybridMetricTile( + label: 'Lean Angle', + value: '38°', + unit: 'deg', + compact: true, + ), + ], + ), + ), + SizedBox(height: 16), + HybridRidingActionBar(), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(HybridStaticRecBadge), findsOneWidget); + expect(find.byType(HybridCard), findsOneWidget); + expect(find.byType(HybridMetricTile), findsNWidgets(2)); + expect(find.byType(HybridRidingActionBar), findsOneWidget); + }); + }); +} diff --git a/test/isolated_sync_integration_fixture_test.dart b/test/isolated_sync_integration_fixture_test.dart new file mode 100644 index 0000000..760f600 --- /dev/null +++ b/test/isolated_sync_integration_fixture_test.dart @@ -0,0 +1,456 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/sync/r2_upload_service.dart'; +import 'package:motorcycle_dash/services/sync/sync_bundle_worker.dart'; +import 'package:motorcycle_dash/services/sync/sync_manager.dart'; +import 'package:motorcycle_dash/services/sync/sync_models.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; + +/// Architectural Boundaries & Verification Level for Offline Sync Fixture: +/// +/// 1. REAL COMPONENTS EXERCISED: +/// - Drift SQLite Schema 7 [AppDatabase] with in-memory SQLite engine: +/// Executes real relational schema operations, queries, and immutable identity +/// contracts (`ensureCloudRideId()`, tables `rides`, `telemetry_samples`, +/// `field_test_events`, `field_test_summaries`). +/// - [SyncBundleWorker]: Generates authentic ZIP archives with DEFLATE/gzip compression, +/// serializing 20 Hz telemetry CSV, forensic events CSV, and session JSON. +/// - [SyncManager]: Full queue state machine, retry backoff calculation, app lifecycle +/// transitions, and multi-provider orchestration. +/// - [SyncOutbox]: Durable queue manifest serialization and deserialization via +/// [SharedPreferences] storage. +/// - [R2UploadService]: Real HTTP PUT request formatting, custom headers, SHA-256 +/// checksum generation, and response parsing. +/// +/// 2. REPLACED / MOCKED COMPONENTS: +/// - [_IsolatedContractSyncProvider]: Replaces production `FirebaseSyncProvider` to isolate +/// test execution from live Google Play Services / Firebase C++ native bindings, while +/// strictly adhering to the identical `SyncProvider` contract. +/// - [MockClient]: Replaces the live Cloudflare R2 Worker HTTP endpoint, intercepting +/// requests and verifying byte-level payload checksums and headers. +/// - Simulated `networkAvailable` flag: Software-level mock on the test provider. +/// Note: Distinguished from true OS-level network interface routing isolation +/// (e.g., netfilter/iptables rules or taking down eth0/wlan0 interfaces). +/// +/// 3. UPLOAD SEQUENCING & CONSISTENCY CONTRACT: +/// - Sequential Execution: In the authoritative MotorCloud pipeline, the R2 binary bundle +/// upload executes first. Cloud Firestore write only executes after R2 returns HTTP 200/success. +/// This prevents orphan documents in Firestore if the R2 upload fails. +/// - NOT A DISTRIBUTED 2-PHASE COMMIT (2PC): Sequential execution ensures Firestore is +/// only updated upon R2 success, but is NOT a distributed two-phase commit across R2 +/// and Firestore. If Firestore write fails after R2 succeeds, R2 holds the bundle while +/// Firestore remains unpopulated until the next retry cycle reconciles the state. +class _FakeAuthService implements AuthService { + AuthUser? _user; + + @override + AuthUser? get currentUser => _user; + + void setUser(AuthUser? user) { + _user = user; + } + + @override + Future getIdToken([bool forceRefresh = false]) async { + return _user != null ? 'fake_jwt_token_${_user!.uid}' : null; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Test implementation of [SyncProvider] honoring the MotorCloud upload contract. +/// +/// Replaces production `FirebaseSyncProvider` to run in headless CI/test environments +/// without initializing Firebase native plugins. Replaces live R2 worker network endpoints +/// with [MockClient]. +class _IsolatedContractSyncProvider implements SyncProvider { + final AppDatabase _db; + final AuthService _authService; + final R2UploadService _r2UploadService; + final SyncBundleWorker _bundleWorker; + final List> recordedUploads = []; + bool networkAvailable = true; + + _IsolatedContractSyncProvider({ + required this._db, + required this._authService, + required this._r2UploadService, + required this._bundleWorker, + }); + + @override + String get providerId => 'firebase'; + + @override + Future isAvailable() async => true; + + @override + Future requiresAuthentication() async { + final user = _authService.currentUser; + return user == null || user.isAnonymous; + } + + @override + Future uploadRide(SyncItem item) async { + if (!networkAvailable) { + return SyncResult.temporaryFailure('Network isolated (all interfaces down). Cannot reach R2 gateway.'); + } + + final user = _authService.currentUser; + if (user == null || user.isAnonymous) { + return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); + } + + final remoteRideId = await _db.ensureCloudRideId(item.localRideId); + final idToken = await _authService.getIdToken(); + if (idToken == null || idToken.isEmpty) { + return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.'); + } + + final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); + if (zipBytes == null || zipBytes.isEmpty) { + return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); + } + + final sha256Hash = sha256.convert(zipBytes).toString(); + final r2Result = await _r2UploadService.uploadTelemetryBundle( + idToken: idToken, + remoteRideId: remoteRideId, + zipBytes: zipBytes, + sha256Hash: sha256Hash, + appVersion: '1.0.2', + ); + + if (!r2Result.success) { + if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 auth required'); + if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); + return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); + } + + // UPLOAD SEQUENCING CONTRACT: + // Sequential execution: In the authoritative MotorCloud pipeline, R2 bundle upload + // precedes Firestore document creation. This ordering ensures Firestore is only updated + // upon verified R2 success, avoiding orphan cloud records. + // + // Distributed Transaction Boundary: Sequential execution enforces error-gated ordering, + // but is NOT a distributed two-phase commit (2PC) across Cloudflare R2 and Firestore. + recordedUploads.add({ + 'localRideId': item.localRideId, + 'remoteId': remoteRideId, + 'objectKey': r2Result.objectKey, + 'sha256': sha256Hash, + 'bytesWritten': r2Result.bytesWritten, + 'timestamp': DateTime.now(), + }); + + return SyncResult.success(remoteId: remoteRideId); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late _FakeAuthService authService; + late SharedPreferences prefs; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + authService = _FakeAuthService(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + tearDown(() async { + await db.close(); + }); + + test('Isolated Offline Sync Contract: Network Isolation, R2 Upload, Reconciliation & Idempotency', () async { + // 1. Stage an offline recorded ride with 2,788 telemetry samples and 8 events + final startTime = DateTime.utc(2026, 10, 3, 14, 0, 0); + final endTime = startTime.add(const Duration(minutes: 40)); + + final localRideId = await db.into(db.rides).insert( + RidesCompanion.insert( + startTime: startTime, + endTime: drift.Value(endTime), + distanceMiles: const drift.Value(42.5), + topSpeedMph: const drift.Value(85.0), + movingTime: const drift.Value(2400), + stoppedTime: const drift.Value(0), + averageSpeed: const drift.Value(63.75), + maxLeanLeft: const drift.Value(44.2), + maxLeanRight: const drift.Value(46.8), + maxAccelG: const drift.Value(0.78), + maxBrakingG: const drift.Value(-0.92), + elevationGain: const drift.Value(1450), + elevationLoss: const drift.Value(1450), + motorcycleMake: const drift.Value('Ducati'), + motorcycleModel: const drift.Value('Streetfighter V4'), + motorcycleYear: const drift.Value(2024), + ), + ); + + // Insert 2,788 telemetry samples at 20 Hz + final samples = List.generate(2788, (i) { + final t = startTime.add(Duration(milliseconds: i * 50)); + return TelemetrySamplesCompanion.insert( + rideId: localRideId, + timestamp: t, + latitude: drift.Value(37.3861 + (i * 0.0001)), + longitude: drift.Value(-122.2641 - (i * 0.0001)), + speed: drift.Value(45.0 + (i % 20)), + leanAngle: drift.Value(15.0 + (i % 30)), + accelX: const drift.Value(0.05), + accelY: const drift.Value(0.12), + accelZ: const drift.Value(0.98), + ); + }); + await db.batch((b) { + b.insertAll(db.telemetrySamples, samples); + }); + + // Insert 8 forensic events + final events = List.generate(8, (i) { + return FieldTestEventsCompanion.insert( + rideId: drift.Value(localRideId), + timestamp: startTime.add(Duration(minutes: (i + 1) * 4)), + eventType: i.isEven ? 'Hard Braking' : 'Extreme Lean Corner', + severity: 'MEDIUM', + message: 'Forensic event #$i detected at 20Hz', + ); + }); + await db.batch((b) { + b.insertAll(db.fieldTestEvents, events); + }); + + // Insert 1 field test summary + await db.into(db.fieldTestSummaries).insert( + FieldTestSummariesCompanion.insert( + rideId: localRideId, + startTime: startTime, + endTime: drift.Value(endTime), + durationSeconds: const drift.Value(2400), + rideQualityScore: const drift.Value(96), + rideQualityRating: const drift.Value('EXCELLENT'), + ), + ); + + // 2. Claim ride for verified test rider + const testUid = 'pilot_qa_verified_123'; + authService.setUser(const AuthUser( + uid: testUid, + email: 'pilot_qa@motorsignal.test', + isAnonymous: false, + )); + await (db.update(db.rides)..where((r) => r.id.equals(localRideId))).write( + const RidesCompanion(cloudOwnerUid: drift.Value(testUid)), + ); + + final claimedRide = await (db.select(db.rides)..where((r) => r.id.equals(localRideId))).getSingle(); + expect(claimedRide.cloudOwnerUid, testUid); + + // 3. Set up R2 Worker mock implementing the authoritative upload contract + final uploadedR2Bundles = {}; + final r2Calls = >[]; + + final r2MockClient = MockClient((request) async { + r2Calls.add({ + 'method': request.method, + 'path': request.url.path, + 'headers': request.headers, + 'bodyLength': request.bodyBytes.length, + }); + + if (request.method == 'PUT' && request.url.path == '/r2/upload') { + final remoteRideId = request.headers['X-Remote-Ride-Id']; + final clientSha256 = request.headers['X-Bundle-Sha256']; + final authHeader = request.headers['Authorization']; + + if (authHeader == null || !authHeader.startsWith('Bearer ')) { + return http.Response(jsonEncode({'error': 'Unauthorized'}), 401); + } + + // Verify SHA-256 against body + final computedSha = sha256.convert(request.bodyBytes).toString(); + if (clientSha256 != null && clientSha256 != computedSha) { + return http.Response(jsonEncode({'error': 'SHA mismatch'}), 422); + } + + final objectKey = 'users/$testUid/rides/$remoteRideId/telemetry.zip'; + uploadedR2Bundles[objectKey] = Uint8List.fromList(request.bodyBytes); + + return http.Response( + jsonEncode({ + 'success': true, + 'objectKey': objectKey, + 'size': request.bodyBytes.length, + 'etag': 'test-etag-verified', + 'sha256': computedSha, + 'uploadedAt': DateTime.now().toUtc().toIso8601String(), + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('Not found', 404); + }); + + final r2Service = R2UploadService( + client: r2MockClient, + workerBaseUrl: 'https://r2-fixture.local', + ); + + final bundleWorker = SyncBundleWorker(db); + final contractProvider = _IsolatedContractSyncProvider( + db: db, + authService: authService, + r2UploadService: r2Service, + bundleWorker: bundleWorker, + ); + + final outbox = SyncOutbox(prefs); + final syncManager = SyncManager(outbox, initialProviders: [contractProvider]); + + // 4. Enqueue ride for sync (without autoProcess so we control step-by-step) + await syncManager.enqueueRide(localRideId, autoProcess: false); + expect(syncManager.pendingItems.isNotEmpty, isTrue); + expect(syncManager.currentState.pendingCount, 1); + + // 5. PHASE A: Network Isolated State (Simulating off-grid ride / offline interface) + contractProvider.networkAvailable = false; + await syncManager.processQueue(); + + // Verification: Outbox queue did NOT drain, ride remains pending with backoff + expect(syncManager.currentState.pendingCount, 1); + expect(uploadedR2Bundles, isEmpty); + expect(contractProvider.recordedUploads, isEmpty); + + // 6. PHASE B: Network Connectivity Restored + contractProvider.networkAvailable = true; + // Advance simulated time past the 5-second initial retry backoff + syncManager.setNowProvider(() => DateTime.now().add(const Duration(seconds: 10))); + await syncManager.processQueue(); + + // Verification: Queue drains completely to 0 pending and 1 synced + expect(syncManager.currentState.pendingCount, 0); + expect(syncManager.currentState.syncedCount, 1); + expect(syncManager.pendingItems, isEmpty); + + // Verification: R2 contains the authoritative telemetry bundle + expect(r2Calls, hasLength(1)); + final expectedRemoteId = await db.ensureCloudRideId(localRideId); + final expectedObjectKey = 'users/$testUid/rides/$expectedRemoteId/telemetry.zip'; + expect(uploadedR2Bundles.containsKey(expectedObjectKey), isTrue); + + // Verification: Uploaded bundle size and checksum reconcile + final uploadedBytes = uploadedR2Bundles[expectedObjectKey]!; + expect(uploadedBytes.length, greaterThan(100)); // Real zip archive with dense telemetry + final uploadedSha = sha256.convert(uploadedBytes).toString(); + expect(r2Calls.first['headers']['X-Bundle-Sha256'], uploadedSha); + + // Unzip and verify telemetry.csv and forensic events inside the uploaded bundle + final archive = ZipDecoder().decodeBytes(uploadedBytes); + final fileNames = archive.files.map((f) => f.name).toSet(); + expect(fileNames, contains('README.txt')); + expect(fileNames, contains('ride_summary.json')); + expect(fileNames, contains('field_test_session.json')); + expect(fileNames, contains('field_test_events.csv')); + expect(fileNames, contains('telemetry.csv')); + + // Verification: Provider recorded single successful sync + expect(contractProvider.recordedUploads, hasLength(1)); + expect(contractProvider.recordedUploads.first['remoteId'], expectedRemoteId); + + // 7. PHASE C: Genuine Service Reconstruction & Restart Idempotency (Zero duplicate uploads) + // First, dispose the original in-memory SyncManager to simulate application termination / cold shutdown. + syncManager.dispose(); + + // Reconstruct brand-new SyncOutbox and SyncManager instances from the persisted SharedPreferences + // state and Drift SQLite AppDatabase. + // This genuinely tests that persistent storage (not in-memory cache) faithfully retains the + // authoritative queue state across an application restart. + final reconstructedOutbox = SyncOutbox(prefs); + expect(reconstructedOutbox.isLoaded, isTrue); + expect(reconstructedOutbox.count, 1, + reason: 'Reconstructed outbox must restore exactly 1 item from persistent SharedPreferences storage.'); + + final persistedItem = reconstructedOutbox.getItem(localRideId, 'firebase'); + expect(persistedItem, isNotNull, + reason: 'Persisted item for ride #$localRideId must exist in reconstructed outbox.'); + expect(persistedItem!.status, SyncStatus.synced, + reason: 'Persisted item status must be preserved as SyncStatus.synced.'); + expect(persistedItem.remoteId, expectedRemoteId, + reason: 'Persisted remoteId must match authoritative cloudRideId.'); + expect(persistedItem.uploadedAt, isNotNull, + reason: 'Persisted uploadedAt timestamp must be preserved.'); + expect(reconstructedOutbox.getPendingItems(), isEmpty, + reason: 'Reconstructed outbox must have 0 pending items.'); + + final reconstructedContractProvider = _IsolatedContractSyncProvider( + db: db, + authService: authService, + r2UploadService: r2Service, + bundleWorker: bundleWorker, + ); + + final reconstructedSyncManager = SyncManager( + reconstructedOutbox, + initialProviders: [reconstructedContractProvider], + db: db, + authService: authService, + ); + + // Verify queue state on the reconstructed service immediately after initialization + expect(reconstructedSyncManager.currentState.pendingCount, 0); + expect(reconstructedSyncManager.currentState.syncedCount, 1); + expect(reconstructedSyncManager.currentState.failedCount, 0); + expect(reconstructedSyncManager.pendingItems, isEmpty); + + // Trigger processQueue() on the reconstructed service. + // The service must recognize that the item is already synced and bypass upload. + await reconstructedSyncManager.processQueue(); + expect(reconstructedContractProvider.recordedUploads, isEmpty, + reason: 'Reconstructed service must NOT perform any uploads for already-synced items.'); + expect(r2Calls, hasLength(1), + reason: 'Total R2 HTTP PUT calls must remain strictly 1 across entire fixture run.'); + + // Attempt re-enqueuing the same ride into the reconstructed manager. + // SyncOutbox.enqueue() must safely detect the existing 'synced' record and return it without re-queueing. + await reconstructedSyncManager.enqueueRide(localRideId, autoProcess: false); + expect(reconstructedOutbox.getItem(localRideId, 'firebase')!.status, SyncStatus.synced); + expect(reconstructedSyncManager.currentState.pendingCount, 0); + expect(reconstructedSyncManager.currentState.syncedCount, 1); + + // Trigger processQueue() again to verify post-enqueue idempotency + await reconstructedSyncManager.processQueue(); + expect(reconstructedContractProvider.recordedUploads, isEmpty, + reason: 'No uploads may occur after re-enqueuing an already synced ride on reconstructed service.'); + expect(r2Calls, hasLength(1), + reason: 'Total R2 HTTP PUT calls must remain strictly 1 (zero duplicate uploads).'); + + // The remote ID in SQLite AppDatabase must remain completely stable and immutable across re-queries + final secondRemoteId = await db.ensureCloudRideId(localRideId); + expect(secondRemoteId, expectedRemoteId, + reason: 'cloudRideId contract requires immutable identity across restarts and re-queries.'); + + expect(reconstructedSyncManager.currentState.pendingCount, 0); + expect(reconstructedSyncManager.currentState.syncedCount, 1); + + reconstructedSyncManager.dispose(); + }); +} diff --git a/test/location_permission_refresh_test.dart b/test/location_permission_refresh_test.dart new file mode 100644 index 0000000..8b0bf1a --- /dev/null +++ b/test/location_permission_refresh_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/dashboard_alert_region.dart'; +import 'package:motorcycle_dash/services/location_service.dart'; + +class _MockLocationService extends LocationService { + bool _mockGranted = false; + + void grant() { + _mockGranted = true; + } + + void revoke() { + _mockGranted = false; + } + + @override + Future hasPermissions() async => _mockGranted; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'DashboardAlertRegion automatically clears location banner when app resumes with granted permission', + (tester) async { + final mockService = _MockLocationService(); + addTearDown(() => mockService.dispose()); + // Initially permissions are denied (system dialog is showing) + mockService.revoke(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + locationServiceProvider.overrideWithValue(mockService), + ], + child: const MaterialApp( + home: Scaffold( + body: DashboardAlertRegion(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Banner should be visible initially + expect( + find.text('Location permission needed for GPS speedometer.'), + findsOneWidget, + ); + + // User grants permission in system dialog + mockService.grant(); + + // System dialog closes -> Flutter engine receives AppLifecycleState.resumed + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + // The banner must automatically dismiss WITHOUT tapping Enable or restarting the app! + expect( + find.text('Location permission needed for GPS speedometer.'), + findsNothing, + ); + + mockService.dispose(); + }, + ); +} diff --git a/test/pro_csv_export_verification_test.dart b/test/pro_csv_export_verification_test.dart new file mode 100644 index 0000000..990033e --- /dev/null +++ b/test/pro_csv_export_verification_test.dart @@ -0,0 +1,333 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart'; +import 'package:motorcycle_dash/features/history/ride_detail_screen.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; +import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart'; +import 'package:motorcycle_dash/services/export_service.dart'; +import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart'; +import 'package:motorcycle_dash/services/github/github_sync_service.dart'; + +class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService { + @override + GitHubUploadState get state => GitHubUploadState.idle; + @override + String get statusMessage => ''; + @override + Set get queuedRideIds => const {}; + @override + bool isRideUploaded(int rideId) => false; + @override + String? getRideCommitSha(int rideId) => null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late ExportService exportService; + late FieldTestExportService fieldTestExportService; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + exportService = ExportService(db); + fieldTestExportService = FieldTestExportService(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('Pro CSV Export Verification with Isolated Test Entitlement', () { + final testStartTime = DateTime.utc(2026, 6, 15, 14, 0, 0); + final testEndTime = DateTime.utc(2026, 6, 15, 14, 45, 0); + + final testRide = Ride( + id: 42, + startTime: testStartTime, + endTime: testEndTime, + distanceMiles: 32.4, + topSpeedMph: 72.5, + movingTime: 2500, + stoppedTime: 200, + distance: 32.4, + topSpeed: 72.5, + averageSpeed: 45.2, + elevationGain: 450.0, + elevationLoss: 420.0, + maxLeanLeft: 38.5, + maxLeanRight: 41.2, + maxAccelG: 0.82, + maxBrakingG: 0.91, + ); + + // Seed known telemetry samples for ride #42 + final sample1Time = DateTime.utc(2026, 6, 15, 14, 0, 1); + final sample2Time = DateTime.utc(2026, 6, 15, 14, 0, 2); + final sample3Time = DateTime.utc(2026, 6, 15, 14, 0, 3); + + setUp(() async { + await db.into(db.rides).insert( + RidesCompanion.insert( + id: const Value(42), + startTime: testStartTime, + endTime: Value(testEndTime), + distanceMiles: const Value(32.4), + topSpeedMph: const Value(72.5), + movingTime: const Value(2500), + stoppedTime: const Value(200), + averageSpeed: const Value(45.2), + elevationGain: const Value(450.0), + elevationLoss: const Value(420.0), + maxLeanLeft: const Value(38.5), + maxLeanRight: const Value(41.2), + maxAccelG: const Value(0.82), + maxBrakingG: const Value(0.91), + ), + ); + + await db.into(db.telemetrySamples).insert( + TelemetrySamplesCompanion.insert( + rideId: 42, + timestamp: sample1Time, + latitude: const Value(34.567890), + longitude: const Value(-84.123456), + altitude: const Value(450.2), + speed: const Value(55.4), + heading: const Value(180.5), + gpsAccuracy: const Value(1.8), + leanAngle: const Value(-24.5), + pitchAngle: const Value(3.2), + accelX: const Value(0.12), + accelY: const Value(0.34), + accelZ: const Value(0.98), + longitudinalG: const Value(0.25), + lateralG: const Value(-0.42), + ), + ); + + await db.into(db.telemetrySamples).insert( + TelemetrySamplesCompanion.insert( + rideId: 42, + timestamp: sample2Time, + latitude: const Value(34.567950), + longitude: const Value(-84.123510), + altitude: const Value(451.0), + speed: const Value(58.1), + heading: const Value(182.0), + gpsAccuracy: const Value(1.6), + leanAngle: const Value(-28.2), + pitchAngle: const Value(2.8), + accelX: const Value(0.15), + accelY: const Value(0.40), + accelZ: const Value(0.95), + longitudinalG: const Value(0.31), + lateralG: const Value(-0.48), + ), + ); + + await db.into(db.telemetrySamples).insert( + TelemetrySamplesCompanion.insert( + rideId: 42, + timestamp: sample3Time, + latitude: const Value(34.568010), + longitude: const Value(-84.123570), + altitude: const Value(451.8), + speed: const Value(61.0), + heading: const Value(183.5), + gpsAccuracy: const Value(1.5), + leanAngle: const Value(-15.0), + pitchAngle: const Value(1.9), + accelX: const Value(0.10), + accelY: const Value(0.22), + accelZ: const Value(0.99), + longitudinalG: const Value(0.18), + lateralG: const Value(-0.25), + ), + ); + + // Seed a known field test event + await db.into(db.fieldTestEvents).insert( + FieldTestEventsCompanion.insert( + rideId: const Value(42), + timestamp: sample2Time, + eventType: 'LEAN_PEAK', + severity: 'INFO', + message: 'Maximum lean angle reached in turn 3', + speed: const Value(58.1), + latitude: const Value(34.567950), + longitude: const Value(-84.123510), + gpsAccuracy: const Value(1.6), + gpsAgeMs: const Value(50), + leanAngle: const Value(-28.2), + longitudinalG: const Value(0.31), + lateralG: const Value(-0.48), + overallConfidence: const Value(0.985), + metadata: const Value('{"turn_id": 3}'), + ), + ); + }); + + test('1. Free tier entitlement denies CSV export capability', () { + const freeState = EntitlementState.free(); + final freeAccess = FeatureAccessService(freeState); + + expect(freeState.isPro, isFalse); + expect(freeState.canExportCsv, isFalse); + expect(freeAccess.canAccess(MotorSignalFeature.csvExport), isFalse); + }); + + test('2. Legitimate isolated test entitlement grants CSV export capability', () { + final now = DateTime.now(); + final proState = EntitlementState.proMonthlyActive( + productId: 'motorsignal_pro_monthly', + purchaseTokenHash: 'test_token_hash_qa_isolated', + expiresAt: now.add(const Duration(days: 30)), + lastVerifiedAt: now, + ); + final proAccess = FeatureAccessService(proState); + + expect(proState.isPro, isTrue); + expect(proState.canExportCsv, isTrue); + expect(proAccess.canAccess(MotorSignalFeature.csvExport), isTrue); + expect(proState.verificationStatus, EntitlementVerificationStatus.verified); + expect(proState.source, PurchaseSource.googlePlay); + }); + + test('3. Exported ride CSV contents match database samples row-by-row and column-by-column', () async { + final csvContent = await exportService.exportRideCsv(42); + final lines = csvContent.trim().split('\n').map((l) => l.trim()).toList(); + + // Check header row + expect( + lines[0], + 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG', + ); + + // Check total row count: 1 header + 3 data rows + expect(lines.length, 4); + + // Verify row 1 + final row1 = lines[1].split(','); + expect(DateTime.parse(row1[0]).toUtc(), sample1Time); + expect(double.parse(row1[1]), closeTo(34.567890, 0.000001)); + expect(double.parse(row1[2]), closeTo(-84.123456, 0.000001)); + expect(double.parse(row1[3]), closeTo(450.2, 0.01)); + expect(double.parse(row1[4]), closeTo(55.4, 0.01)); + expect(double.parse(row1[5]), closeTo(180.5, 0.01)); + expect(double.parse(row1[6]), closeTo(1.8, 0.01)); + expect(double.parse(row1[7]), closeTo(-24.5, 0.01)); + expect(double.parse(row1[8]), closeTo(3.2, 0.01)); + expect(double.parse(row1[9]), closeTo(0.12, 0.01)); + expect(double.parse(row1[10]), closeTo(0.34, 0.01)); + expect(double.parse(row1[11]), closeTo(0.98, 0.01)); + expect(double.parse(row1[12]), closeTo(0.25, 0.01)); + expect(double.parse(row1[13]), closeTo(-0.42, 0.01)); + + // Verify row 2 + final row2 = lines[2].split(','); + expect(DateTime.parse(row2[0]).toUtc(), sample2Time); + expect(double.parse(row2[1]), closeTo(34.567950, 0.000001)); + expect(double.parse(row2[2]), closeTo(-84.123510, 0.000001)); + expect(double.parse(row2[3]), closeTo(451.0, 0.01)); + expect(double.parse(row2[4]), closeTo(58.1, 0.01)); + expect(double.parse(row2[7]), closeTo(-28.2, 0.01)); + expect(double.parse(row2[12]), closeTo(0.31, 0.01)); + expect(double.parse(row2[13]), closeTo(-0.48, 0.01)); + + // Verify row 3 + final row3 = lines[3].split(','); + expect(DateTime.parse(row3[0]).toUtc(), sample3Time); + expect(double.parse(row3[1]), closeTo(34.568010, 0.000001)); + expect(double.parse(row3[2]), closeTo(-84.123570, 0.000001)); + expect(double.parse(row3[3]), closeTo(451.8, 0.01)); + expect(double.parse(row3[4]), closeTo(61.0, 0.01)); + expect(double.parse(row3[7]), closeTo(-15.0, 0.01)); + expect(double.parse(row3[12]), closeTo(0.18, 0.01)); + expect(double.parse(row3[13]), closeTo(-0.25, 0.01)); + }); + + test('4. Forensic FieldTestEvents CSV export matches Step 15 schema', () async { + final eventsCsv = await fieldTestExportService.generateFieldTestEventsCsv(42); + final lines = eventsCsv.trim().split('\n').map((l) => l.trim()).toList(); + + expect( + lines[0], + 'timestamp,ride_id,event_type,severity,message,speed,latitude,longitude,gps_accuracy,gps_age_ms,lean_angle,longitudinal_g,lateral_g,confidence,metadata', + ); + expect(lines.length, 2); + + final eventRow = lines[1]; + expect(eventRow, contains('LEAN_PEAK')); + expect(eventRow, contains('INFO')); + expect(eventRow, contains('"Maximum lean angle reached in turn 3"')); + expect(eventRow, contains('58.10')); + expect(eventRow, contains('34.567950')); + expect(eventRow, contains('-84.123510')); + expect(eventRow, contains('-28.2')); + expect(eventRow, contains('0.310')); + expect(eventRow, contains('-0.480')); + expect(eventRow, contains('0.985')); + expect(eventRow, contains('"{""turn_id"": 3}"')); + }); + + testWidgets('5. RideDetailScreen UI shows ProBadge under Free tier and hides under Pro tier', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final freeState = const EntitlementState.free(); + final proState = EntitlementState.proMonthlyActive( + productId: 'motorsignal_pro_monthly', + purchaseTokenHash: 'test_token_hash_qa_isolated', + expiresAt: DateTime.now().add(const Duration(days: 30)), + lastVerifiedAt: DateTime.now(), + ); + + // Phase A: Pump with Free Tier + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()), + featureAccessProvider.overrideWithValue(FeatureAccessService(freeState)), + ], + child: MaterialApp( + home: RideDetailScreen(ride: testRide), + ), + ), + ); + await tester.pumpAndSettle(); + + // Free user must see EXPORT CSV with ProBadge + expect(find.text('EXPORT CSV'), findsOneWidget); + expect(find.byType(ProBadge), findsWidgets); + + // Phase B: Pump with Pro Tier + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()), + featureAccessProvider.overrideWithValue(FeatureAccessService(proState)), + ], + child: MaterialApp( + home: RideDetailScreen(ride: testRide), + ), + ), + ); + await tester.pumpAndSettle(); + + // Pro user must see EXPORT CSV without ProBadge + expect(find.text('EXPORT CSV'), findsOneWidget); + expect(find.byType(ProBadge), findsNothing); + }); + }); +} diff --git a/test/purchase_verification_service_test.dart b/test/purchase_verification_service_test.dart index 27cb45a..b298119 100644 --- a/test/purchase_verification_service_test.dart +++ b/test/purchase_verification_service_test.dart @@ -44,6 +44,7 @@ void main() { test( 'successful monthly subscription response parses to active Pro', () async { + final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30)); final mockClient = MockClient((request) async { expect(request.url.path, '/billing/verify-play-purchase'); final body = jsonDecode(request.body) as Map; @@ -58,7 +59,7 @@ void main() { 'status': 'proMonthlyActive', 'verificationStatus': 'verified', 'verifiedAt': '2026-09-03T18:00:00.000Z', - 'expiresAt': '2026-10-03T18:00:00.000Z', + 'expiresAt': futureExpiry.toIso8601String(), 'tokenHash': 'hash_123', }), 200, @@ -91,13 +92,14 @@ void main() { ); expect(state.isPro, isTrue); expect(state.purchaseTokenHash, 'hash_123'); - expect(state.expiresAt, DateTime.parse('2026-10-03T18:00:00.000Z')); + expect(state.expiresAt, futureExpiry); }, ); test( 'subscription response with pendingRetry acknowledgementState preserves state with isPendingAckRetry', () async { + final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30)); final mockClient = MockClient((request) async { return http.Response( jsonEncode({ @@ -108,7 +110,7 @@ void main() { 'verificationStatus': 'verified', 'acknowledgementState': 'pendingRetry', 'verifiedAt': '2026-09-03T18:00:00.000Z', - 'expiresAt': '2026-10-03T18:00:00.000Z', + 'expiresAt': futureExpiry.toIso8601String(), 'tokenHash': 'hash_123', }), 200, @@ -141,6 +143,7 @@ void main() { test( 'paused subscription response parses to proMonthlyPaused and denies Pro', () async { + final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30)); final mockClient = MockClient((request) async { return http.Response( jsonEncode({ @@ -151,7 +154,7 @@ void main() { 'verificationStatus': 'verified', 'acknowledgementState': 'notApplicable', 'verifiedAt': '2026-09-03T18:00:00.000Z', - 'expiresAt': '2026-10-03T18:00:00.000Z', + 'expiresAt': futureExpiry.toIso8601String(), 'tokenHash': 'hash_paused', }), 200, @@ -182,6 +185,55 @@ void main() { }, ); + test( + 'expired subscription response normalizes to proMonthlyExpired and denies Pro', + () async { + final pastExpiry = DateTime.now().toUtc().subtract(const Duration(days: 1)); + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'productId': ProductCatalog.proMonthly, + 'plan': 'proMonthly', + 'status': 'proMonthlyActive', + 'verificationStatus': 'verified', + 'acknowledgementState': 'notApplicable', + 'verifiedAt': '2026-09-03T18:00:00.000Z', + 'expiresAt': pastExpiry.toIso8601String(), + 'tokenHash': 'hash_expired', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = PurchaseVerificationService( + client: mockClient, + workerBaseUrl: 'https://test.worker', + ); + + const purchase = StorePurchase( + purchaseId: 'p1', + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.purchased, + transactionDate: '2026-09-01T12:00:00Z', + purchaseToken: 'token_expired', + ); + + final result = await service.verifyPurchase(purchase: purchase); + expect(result.success, isTrue); + expect( + result.entitlementState!.status, + MotorSignalEntitlementStatus.proMonthlyExpired, + ); + expect( + result.entitlementState!.verificationStatus, + EntitlementVerificationStatus.expired, + ); + expect(result.entitlementState!.isPro, isFalse); + }, + ); + test( 'successful lifetime purchase parses to lifetime Pro without expiration', () async { diff --git a/test/render_hub_screenshots_test.dart b/test/render_hub_screenshots_test.dart new file mode 100644 index 0000000..5e41ae1 --- /dev/null +++ b/test/render_hub_screenshots_test.dart @@ -0,0 +1,446 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/features/group_rides/group_rides_hub_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; +import 'package:motorcycle_dash/services/social/social_providers.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; + +class _FakeAuth implements AuthService { + @override + Future ensureBackendIdentity() async {} + + final _states = StreamController.broadcast(); + AuthUser? user; + + _FakeAuth({this.user}); + + @override + Stream get authStateChanges => _states.stream; + + @override + AuthState get currentState => user == null + ? const AuthState.unauthenticated(isFirebaseConfigured: true) + : (user!.isAnonymous + ? AuthState.anonymous(user!, isFirebaseConfigured: true) + : AuthState.authenticated(user!, isFirebaseConfigured: true)); + + @override + AuthUser? get currentUser => user; + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => true; + + @override + Future deleteCurrentUser() async {} + + @override + void dispose() => _states.close(); + + @override + Future getIdToken([bool forceRefresh = false]) async => 'token'; + + @override + Future reauthenticate({String? password}) async {} + + @override + Future reloadCurrentUser() async {} + + @override + Future sendEmailVerification() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async {} + + @override + Future signInWithEmailAndPassword(String email, String password) async {} + + @override + Future createUserWithEmailAndPassword(String email, String password, + {String? displayName}) async {} + + @override + Future signInWithGoogle() async {} + + @override + Future signOut() async {} +} + +Future _captureScreen( + WidgetTester tester, + GlobalKey repaintKey, + String filePath, +) async { + await tester.binding.runAsync(() async { + final boundary = + repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) { + throw StateError('RepaintBoundary not found for capture'); + } + final image = await boundary.toImage(pixelRatio: 2.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw StateError('Failed to encode image to PNG'); + } + final pngBytes = byteData.buffer.asUint8List(); + final file = File(filePath); + await file.parent.create(recursive: true); + await file.writeAsBytes(pngBytes, flush: true); + }); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + const ownerUser = AuthUser( + uid: 'qa-owner-uid', + email: 'qa-owner@motorsignal.example', + displayName: 'QA Owner', + isAnonymous: false, + ); + + const memberUser = AuthUser( + uid: 'qa-member-uid', + email: 'qa-member@motorsignal.example', + displayName: 'QA Member', + isAnonymous: false, + ); + + final ownerProfile = RiderProfile( + uid: 'qa-owner-uid', + handle: 'qa-owner', + displayName: 'QA Owner (Ride Lead)', + bio: 'Pacesetter and group organizer.', + region: 'North Georgia Mountains', + avatarPreset: 'preset_neon_cyan', + privacySettings: const ProfilePrivacySettings( + isSearchableByHandle: true, + isProfilePublic: true, + allowFriendRequests: true, + ), + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final memberProfile = RiderProfile( + uid: 'qa-member-uid', + handle: 'qa-member', + displayName: 'QA Member (Participant)', + bio: 'Weekend enthusiast.', + region: 'North Georgia Mountains', + avatarPreset: 'preset_hyper_purple', + privacySettings: const ProfilePrivacySettings( + isSearchableByHandle: true, + isProfilePublic: true, + allowFriendRequests: true, + ), + createdAt: DateTime(2026, 2, 1), + updatedAt: DateTime(2026, 2, 1), + ); + + final now = DateTime(2026, 10, 6, 10, 0); + + final ride1 = GroupRide( + id: 'qa-e2e-join-approval-ride', + organizerUid: 'qa-owner-uid', + organizerHandle: 'qa-owner', + organizerDisplayName: 'QA Owner', + title: 'Blood Mountain Sweep', + description: 'Scenic group ride through Neels Gap and Vogel State Park.', + scheduledStartAt: now.add(const Duration(hours: 2)), + departureAt: now.add(const Duration(hours: 2, minutes: 30)), + status: GroupRideStatus.scheduled, + joinPolicy: GroupRideJoinPolicy.requestToJoin, + visibility: GroupRideVisibility.public, + participantLimit: 12, + participantCount: 3, + accessUids: const ['qa-owner-uid', 'qa-member-uid', 'qa-admin-uid'], + meetingPoint: const GroupRideLocation( + latitude: 34.5268, + longitude: -83.9849, + displayName: 'Dahlonega Square', + address: 'Public Square, Dahlonega, GA 30533', + locationType: GroupRideLocationType.meetup, + ), + destination: const GroupRideLocation( + latitude: 34.7644, + longitude: -83.9248, + displayName: 'Neels Gap / Vogel State Park', + address: 'Blairsville, GA', + locationType: GroupRideLocationType.destination, + ), + stops: const [ + GroupRideLocation( + latitude: 34.6710, + longitude: -83.9180, + displayName: 'Mountain Crossing Viewpoint', + locationType: GroupRideLocationType.scenic, + ), + ], + createdAt: now.subtract(const Duration(days: 1)), + updatedAt: now, + ); + + final ride2 = GroupRide( + id: 'qa-member-georgia-twisties', + organizerUid: 'qa-member-uid', + organizerHandle: 'qa-member', + organizerDisplayName: 'QA Member', + title: 'North Georgia Sunday Twisties', + description: 'Scenic sweepers through Suches and Blood Mountain.', + scheduledStartAt: now.add(const Duration(days: 1)), + departureAt: now.add(const Duration(days: 1, minutes: 30)), + status: GroupRideStatus.scheduled, + joinPolicy: GroupRideJoinPolicy.open, + visibility: GroupRideVisibility.public, + participantLimit: 8, + participantCount: 2, + accessUids: const ['qa-member-uid', 'qa-owner-uid'], + meetingPoint: const GroupRideLocation( + latitude: 34.6895, + longitude: -84.0227, + displayName: 'Two Wheels of Suches', + address: '1915 GA-60, Suches, GA 30572', + locationType: GroupRideLocationType.meetup, + ), + destination: const GroupRideLocation( + latitude: 34.7644, + longitude: -83.9248, + displayName: 'Vogel State Park', + address: 'Blairsville, GA', + locationType: GroupRideLocationType.destination, + ), + stops: const [], + createdAt: now.subtract(const Duration(hours: 12)), + updatedAt: now, + ); + + const phoneViewport = Size(448, 997); // Pixel 8 Pro logical size + + group('GroupRidesHubScreen Populated Hub Evidence & Captures', () { + testWidgets('Populated Hub - Organizer Account (Night Mode)', (tester) async { + tester.view.physicalSize = const Size(1344, 2992); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + + final fakeAuth = _FakeAuth(user: ownerUser); + final repaintKey = GlobalKey(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(fakeAuth), + currentUserProvider.overrideWithValue(ownerUser), + myRiderProfileProvider.overrideWith( + (ref) => Stream.value(ownerProfile), + ), + upcomingGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1, ride2]), + ), + myGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1]), + ), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: RepaintBoundary( + key: repaintKey, + child: const GroupRidesHubScreen(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('GROUP RIDES'), findsOneWidget); + expect(find.text('@qa-owner'), findsOneWidget); + expect(find.text('Blood Mountain Sweep'), findsOneWidget); + expect(find.text('North Georgia Sunday Twisties'), findsOneWidget); + expect(tester.takeException(), isNull); + + await _captureScreen( + tester, + repaintKey, + 'docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_25_hub_organizer_night.png', + ); + }); + + testWidgets('Populated Hub - Organizer Account (Day Mode)', (tester) async { + tester.view.physicalSize = const Size(1344, 2992); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + + final fakeAuth = _FakeAuth(user: ownerUser); + final repaintKey = GlobalKey(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(fakeAuth), + currentUserProvider.overrideWithValue(ownerUser), + myRiderProfileProvider.overrideWith( + (ref) => Stream.value(ownerProfile), + ), + upcomingGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1, ride2]), + ), + myGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1]), + ), + ], + child: MaterialApp( + theme: AppTheme.lightTheme, + home: RepaintBoundary( + key: repaintKey, + child: const GroupRidesHubScreen(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('GROUP RIDES'), findsOneWidget); + expect(find.text('Blood Mountain Sweep'), findsOneWidget); + expect(tester.takeException(), isNull); + + await _captureScreen( + tester, + repaintKey, + 'docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_26_hub_organizer_day.png', + ); + }); + + testWidgets('Populated Hub - Organizer Account (Large Text 200% Scale)', (tester) async { + tester.view.physicalSize = const Size(1344, 2992); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + + final fakeAuth = _FakeAuth(user: ownerUser); + final repaintKey = GlobalKey(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(fakeAuth), + currentUserProvider.overrideWithValue(ownerUser), + myRiderProfileProvider.overrideWith( + (ref) => Stream.value(ownerProfile), + ), + upcomingGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1, ride2]), + ), + myGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1]), + ), + ], + child: MediaQuery( + data: const MediaQueryData( + size: phoneViewport, + textScaler: TextScaler.linear(2.0), + ), + child: MaterialApp( + theme: AppTheme.darkTheme, + home: RepaintBoundary( + key: repaintKey, + child: const GroupRidesHubScreen(), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('GROUP RIDES'), findsOneWidget); + expect(find.text('Blood Mountain Sweep'), findsOneWidget); + final exception = tester.takeException(); + if (exception is FlutterError) { + debugPrint(exception.toStringDeep()); + } + expect(exception, isNull); + + await _captureScreen( + tester, + repaintKey, + 'docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_27_hub_organizer_large_text.png', + ); + }); + + testWidgets('Populated Hub - Participant Account (Night Mode)', (tester) async { + tester.view.physicalSize = const Size(1344, 2992); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + + final fakeAuth = _FakeAuth(user: memberUser); + final repaintKey = GlobalKey(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(fakeAuth), + currentUserProvider.overrideWithValue(memberUser), + myRiderProfileProvider.overrideWith( + (ref) => Stream.value(memberProfile), + ), + upcomingGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride1, ride2]), + ), + myGroupRidesProvider.overrideWith( + (ref) => Stream.value([ride2]), + ), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: RepaintBoundary( + key: repaintKey, + child: const GroupRidesHubScreen(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('GROUP RIDES'), findsOneWidget); + expect(find.text('@qa-member'), findsOneWidget); + expect(find.text('Blood Mountain Sweep'), findsOneWidget); + expect(tester.takeException(), isNull); + + await _captureScreen( + tester, + repaintKey, + 'docs/design/motorsignal-hybrid-v1/screenshots/checkpoint_28_hub_participant.png', + ); + }); + }); +} diff --git a/test/replay_screen_responsive_test.dart b/test/replay_screen_responsive_test.dart new file mode 100644 index 0000000..6b93077 --- /dev/null +++ b/test/replay_screen_responsive_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/features/replay/replay_screen.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' as drift; + +class _TestHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) => _MockHttpClient(); +} + +class _MockHttpHeaders extends Fake implements HttpHeaders { + @override + void add(String name, Object value, {bool preserveHeaderCase = false}) {} + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) {} +} + +class _MockHttpClient extends Fake implements HttpClient { + @override + Future getUrl(Uri url) async => _MockHttpRequest(); + + @override + Future openUrl(String method, Uri url) async => _MockHttpRequest(); + + @override + void close({bool force = false}) {} +} + +class _MockHttpRequest extends Fake implements HttpClientRequest { + @override + final HttpHeaders headers = _MockHttpHeaders(); + + @override + bool followRedirects = true; + + @override + int maxRedirects = 5; + + @override + int contentLength = -1; + + @override + Future close() async => _MockHttpResponse(); +} + +class _MockHttpResponse extends Fake implements HttpClientResponse { + @override + int get statusCode => 200; + @override + int get contentLength => _transparent1x1Png.length; + @override + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream>.value(_transparent1x1Png).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +final _transparent1x1Png = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, + 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + + setUp(() async { + HttpOverrides.global = _TestHttpOverrides(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + // Insert a test ride and telemetry samples + await db.into(db.rides).insert( + RidesCompanion.insert( + id: const drift.Value(1), + startTime: DateTime.now(), + distanceMiles: const drift.Value(10.0), + topSpeedMph: const drift.Value(60.0), + movingTime: const drift.Value(600), + stoppedTime: const drift.Value(60), + ), + ); + + for (int i = 0; i < 20; i++) { + await db.into(db.telemetrySamples).insert( + TelemetrySamplesCompanion.insert( + rideId: 1, + timestamp: DateTime.now().add(Duration(seconds: i)), + latitude: drift.Value(34.0 + (i * 0.001)), + longitude: drift.Value(-84.0 + (i * 0.001)), + speed: drift.Value(20.0 + (i * 2.0)), + leanAngle: drift.Value(i < 10 ? 10.0 : 25.0), + ), + ); + } + }); + + tearDown(() async { + HttpOverrides.global = null; + await db.close(); + }); + + testWidgets('ReplayScreen corner apex header renders without overflow on 320px screen', (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + ], + child: const MaterialApp( + home: Scaffold( + body: ReplayScreen(rideId: 1), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget); + }); + + testWidgets('ReplayScreen corner apex header renders without overflow on 412px Pixel screen', (tester) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + ], + child: const MaterialApp( + home: Scaffold( + body: ReplayScreen(rideId: 1), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget); + }); +} diff --git a/test/services/sensor_fusion_lean_angle_regression_test.dart b/test/services/sensor_fusion_lean_angle_regression_test.dart new file mode 100644 index 0000000..34dd1e1 --- /dev/null +++ b/test/services/sensor_fusion_lean_angle_regression_test.dart @@ -0,0 +1,821 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/lean_visualizer.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/calibration_service.dart'; +import 'package:motorcycle_dash/services/sensor_fusion_service.dart'; +import 'package:motorcycle_dash/services/telemetry_service.dart'; +import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:motorcycle_dash/services/compass_service.dart'; +import 'package:motorcycle_dash/services/field_test/field_test_logger.dart'; +import 'package:motorcycle_dash/services/location_service.dart'; +import 'package:sensors_plus/sensors_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const g = 9.80665; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + }); + + group('SensorFusionService: Google Pixel 8a Mounted Lean Angle Pipeline', () { + test('1. Upright motorcycle calibration produces approximately 0° lean', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Pixel 8a mounted upright in portrait facing rider on Yamaha R6: + // In upright orientation: +Y points Up (~g), +X is Right (~0), +Z points Rearward (~0) + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + + // Rider taps calibrate upright + service.calibrateUpright(); + + expect(service.isCalibrated, isTrue); + + double? emittedLean; + final sub = service.leanAngleStream.listen((val) => emittedLean = val); + + // Process another upright sample + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + expect(emittedLean, isNotNull); + expect(emittedLean!.abs(), lessThan(0.5)); // ~0° lean + + await sub.cancel(); + service.dispose(); + }); + + test('2. 15° Left lean produces negative angle and correct sign', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Baseline upright calibration + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Bike leans 15° Left: + // Phone tilts left, so reaction force opposes gravity and tilts to phone's right: + // ax = +g * sin(15°), ay = g * cos(15°), az = 0.0 + final angleRad = 15.0 * (math.pi / 180.0); + final ax = g * math.sin(angleRad); + final ay = g * math.cos(angleRad); + + for (int i = 0; i < 40; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(ax, ay, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + expect(latestLean, lessThan(0.0)); // Left lean must be negative + expect(latestLean, closeTo(-15.0, 1.0)); + + await sub.cancel(); + service.dispose(); + }); + + test('3. 15° Right lean produces positive angle opposite to left', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Bike leans 15° Right: + // Phone tilts right, reaction force tilts to phone's left: + // ax = -g * sin(15°), ay = g * cos(15°), az = 0.0 + final angleRad = 15.0 * (math.pi / 180.0); + final ax = -g * math.sin(angleRad); + final ay = g * math.cos(angleRad); + + for (int i = 0; i < 40; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(ax, ay, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + expect(latestLean, greaterThan(0.0)); // Right lean must be positive + expect(latestLean, closeTo(15.0, 1.0)); + + await sub.cancel(); + service.dispose(); + }); + + test('4. 30° Left and 30° Right lean scale accurately', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + final angle30Rad = 30.0 * (math.pi / 180.0); + + // 30° Left + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(g * math.sin(angle30Rad), g * math.cos(angle30Rad), 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, closeTo(-30.0, 1.5)); + + // 30° Right + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(-g * math.sin(angle30Rad), g * math.cos(angle30Rad), 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, closeTo(30.0, 1.5)); + + await sub.cancel(); + service.dispose(); + }); + + test('5. Returning upright returns toward 0° without persistent drift or pinning', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Lean 35° right during cornering + final angle35Rad = 35.0 * (math.pi / 180.0); + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(-g * math.sin(angle35Rad), g * math.cos(angle35Rad), 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, closeTo(35.0, 2.0)); + + // Now exit corner and travel straight upright: + // Phone is upright again (ax = 0, ay = g, az = 0) + for (int i = 0; i < 60; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + // Must decay back toward 0° and NOT remain pinned at 35° or 65°! + expect(latestLean, closeTo(0.0, 1.0)); + + await sub.cancel(); + service.dispose(); + }); + + test('6. Acceleration, braking, and vibration do not create false sustained lean', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + final random = math.Random(42); + + // Straight line travel with: + // - Hard acceleration forward (reaction force along +Z, longitudinal) + // - Hard braking (reaction force along -Z, longitudinal) + // - High-frequency Yamaha R6 engine vibration noise on all axes (+/- 1.5 m/s²) + for (int i = 0; i < 60; i++) { + final forwardAccel = (i < 30) ? 7.5 : -9.0; // accel then braking + final noiseX = (random.nextDouble() - 0.5) * 1.5; + final noiseY = (random.nextDouble() - 0.5) * 1.5; + final noiseZ = (random.nextDouble() - 0.5) * 1.5; + + // In portrait upright: forward/aft is Z axis, vertical is Y axis + service.handleGyroscopeEvent(GyroscopeEvent(0.01 * noiseX, 0.01 * noiseY, 0.01 * noiseZ, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent( + noiseX, // Lateral (should be ~0) + g + noiseY, // Vertical + forwardAccel + noiseZ, // Longitudinal forward/braking + DateTime.now(), + )); + } + await Future.delayed(const Duration(milliseconds: 15)); + + // Roll must remain tightly bounded around 0° (within +/- 1.5° noise floor) + expect(latestLean.abs(), lessThan(2.0)); + + await sub.cancel(); + service.dispose(); + }); + + test('7. Mounted Landscape configurations produce correct sign and lean', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Landscape Left: +X is Up (~g), -Y is Right, -Z is Forward + service.setOrientation(true, isLandscapeLeft: true); + service.handleAccelerometerEvent(AccelerometerEvent(g, 0.0, 0.0, DateTime.now())); + service.calibrateUpright(); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Upright + for (int i = 0; i < 5; i++) { + service.handleAccelerometerEvent(AccelerometerEvent(g, 0.0, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean.abs(), lessThan(0.5)); + + // Lean 20° Right in Landscape Left: + // Bike tilts right, reaction force tilts to bike's left (+Y in device) + final angleRad = 20.0 * (math.pi / 180.0); + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(g * math.cos(angleRad), g * math.sin(angleRad), 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, closeTo(20.0, 1.5)); + + // Lean 20° Left in Landscape Left: + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(g * math.cos(angleRad), -g * math.sin(angleRad), 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, closeTo(-20.0, 1.5)); + + await sub.cancel(); + service.dispose(); + }); + + test('8. Device remounting invalidates stale calibration', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Calibrate upright in portrait (+Y ~ g) + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + expect(service.isCalibrated, isTrue); + + // Simulate rider unmounting device and remounting at a 60° shifted tilt or different orientation + // For instance, device now resting primarily along +X or +Z (> 35° shift) + service.handleAccelerometerEvent(AccelerometerEvent(g * 0.8, 0.0, g * 0.6, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + // Service must detect remount and invalidate calibration + expect(service.isCalibrated, isFalse); + + service.dispose(); + }); + + test('9. Diagnostic logging captures IMU, offsets, and dashboard angles', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + SensorFusionDiagnostics? latestDiag; + final sub = service.diagnosticsStream.listen((d) => latestDiag = d); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + service.calibrateUpright(); + + await Future.delayed(const Duration(milliseconds: 15)); + + expect(latestDiag, isNotNull); + expect(latestDiag!.rawAccelY, closeTo(g, 0.1)); + expect(latestDiag!.isCalibrated, isTrue); + expect(latestDiag!.mountOrientation, 'portraitUpright'); + expect(latestDiag!.dashboardDisplayedAngle, closeTo(0.0, 0.5)); + expect(latestDiag!.dashboardDirection, 'CENTER'); + + await sub.cancel(); + service.dispose(); + }); + + test('10. 45° stem mount pitch tilt rejects pure yaw rate leakage (0.0° false roll)', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Pixel 8a mounted on Yamaha R6 clip-on stem mount: + // Phone is tilted back ~45° from vertical facing rider. + // Gravity reaction force on phone: + final angle45Rad = 45.0 * (math.pi / 180.0); + final ay = g * math.cos(angle45Rad); + final az = -g * math.sin(angle45Rad); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, ay, az, DateTime.now())); + await service.calibrateUpright(); + + expect(service.isCalibrated, isTrue); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Pure yaw rotation around world vertical axis: + // At 45° tilt, vehicle yaw axis projects onto phone Y and Z: + // omega_yaw = 0.8 rad/s (~46°/s yaw rate e.g. turning handlebars or tracking highway curve) + const omegaYaw = 0.8; + final gyYaw = omegaYaw * math.cos(angle45Rad); + final gzYaw = -omegaYaw * math.sin(angle45Rad); + + for (int i = 0; i < 20; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, gyYaw, gzYaw, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(0.0, ay, az, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 2)); + } + + // With Gram-Schmidt 3D basis, forward roll axis is strictly orthogonal to calibrated vertical. + // Roll rate from pure yaw is mathematically 0.0°/s; lean angle must NOT leak or pin! + expect(service.latestRollRateDegPerSec.abs(), lessThan(0.001)); + expect(latestLean.abs(), lessThan(0.5)); + + await sub.cancel(); + service.dispose(); + }); + + test('11. Flat mount (tank bag) produces symmetrical left and right lean', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Flat mount: phone resting flat in tank bag, screen up (+Z ~ g), top forward (+Y) + service.handleAccelerometerEvent(AccelerometerEvent(0.0, 0.0, g, DateTime.now())); + await service.calibrateUpright(); + + expect(service.isCalibrated, isTrue); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Lean 20° Left: reaction force tilts to motorcycle right (+X in phone) + final angle20Rad = 20.0 * (math.pi / 180.0); + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent( + g * math.sin(angle20Rad), + 0.0, + g * math.cos(angle20Rad), + DateTime.now(), + )); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, lessThan(0.0)); + expect(latestLean, closeTo(-20.0, 1.5)); + + // Lean 20° Right: reaction force tilts to motorcycle left (-X in phone) + for (int i = 0; i < 45; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent( + -g * math.sin(angle20Rad), + 0.0, + g * math.cos(angle20Rad), + DateTime.now(), + )); + } + await Future.delayed(const Duration(milliseconds: 15)); + expect(latestLean, greaterThan(0.0)); + expect(latestLean, closeTo(20.0, 1.5)); + + await sub.cancel(); + service.dispose(); + }); + + test('12. App restart preserves valid calibration when device remains mounted', () async { + final calibrationService = CalibrationService(); + final service1 = SensorFusionService(calibrationService, autoStartSensors: false); + + service1.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service1.calibrateUpright(); + expect(service1.isCalibrated, isTrue); + service1.dispose(); + + // Simulate app restart: new instance loads saved SharedPreferences calibration + final service2 = SensorFusionService(calibrationService, autoStartSensors: false); + await service2.initializationDone; + + expect(service2.isCalibrated, isTrue); + + double? emittedLean; + final sub = service2.leanAngleStream.listen((val) => emittedLean = val); + + // Device is still in the mount (+Y ~ g) + service2.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + expect(service2.isCalibrated, isTrue); + expect(emittedLean, isNotNull); + expect(emittedLean!.abs(), lessThan(0.5)); + + await sub.cancel(); + service2.dispose(); + }); + + test('13. App restart invalidates calibration when device placed flat on desk (>35° shift)', () async { + final calibrationService = CalibrationService(); + final service1 = SensorFusionService(calibrationService, autoStartSensors: false); + + service1.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service1.calibrateUpright(); + expect(service1.isCalibrated, isTrue); + service1.dispose(); + + final service2 = SensorFusionService(calibrationService, autoStartSensors: false); + await service2.initializationDone; + expect(service2.isCalibrated, isTrue); + + // Phone removed from bike mount and laid flat on desk (+Z ~ g, 90° away from upright +Y ~ g) + service2.handleAccelerometerEvent(AccelerometerEvent(0.0, 0.0, g, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + expect(service2.isCalibrated, isFalse); + + service2.dispose(); + }); + + test('14. Kickstand parking (25°-35° lean) preserves calibration without invalidating', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + // Calibrate upright in portrait + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service.calibrateUpright(); + expect(service.isCalibrated, isTrue); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Simulate Yamaha R6 parked on kickstand at 28° left lean: + // Phone tilts left: ax = g * sin(28°), ay = g * cos(28°), az = 0.0 + // Norm is exactly 1.0g (stationary) + final angle28Rad = 28.0 * (math.pi / 180.0); + final ax28 = g * math.sin(angle28Rad); + final ay28 = g * math.cos(angle28Rad); + + for (int i = 0; i < 50; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(ax28, ay28, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + // Calibration MUST REMAIN VALID! Must not revert to uncalibrated! + expect(service.isCalibrated, isTrue); + expect(latestLean, closeTo(-28.0, 1.5)); + + // Test extreme kickstand lean (35° on sloped road) + final angle35Rad = 35.0 * (math.pi / 180.0); + final ax35 = g * math.sin(angle35Rad); + final ay35 = g * math.cos(angle35Rad); + + for (int i = 0; i < 50; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(ax35, ay35, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + expect(service.isCalibrated, isTrue); + expect(latestLean, closeTo(-35.0, 1.5)); + + // Return bike upright + for (int i = 0; i < 50; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + expect(service.isCalibrated, isTrue); + expect(latestLean, closeTo(0.0, 1.0)); + + await sub.cancel(); + service.dispose(); + }); + + test('15. Deep cornering lean (45°-55°) preserves calibration during riding', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service.calibrateUpright(); + expect(service.isCalibrated, isTrue); + + double latestLean = 0.0; + final sub = service.leanAngleStream.listen((val) => latestLean = val); + + // Deep 50° right turn + final angle50Rad = 50.0 * (math.pi / 180.0); + final ax50 = -g * math.sin(angle50Rad); + final ay50 = g * math.cos(angle50Rad); + + for (int i = 0; i < 50; i++) { + service.handleGyroscopeEvent(GyroscopeEvent(0.0, 0.0, 0.0, DateTime.now())); + service.handleAccelerometerEvent(AccelerometerEvent(ax50, ay50, 0.0, DateTime.now())); + } + await Future.delayed(const Duration(milliseconds: 15)); + + expect(service.isCalibrated, isTrue); + expect(latestLean, closeTo(50.0, 2.0)); + + await sub.cancel(); + service.dispose(); + }); + + test('16. App restart while motorcycle parked on kickstand preserves calibration', () async { + final calibrationService = CalibrationService(); + final service1 = SensorFusionService(calibrationService, autoStartSensors: false); + + service1.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service1.calibrateUpright(); + expect(service1.isCalibrated, isTrue); + service1.dispose(); + + // Relaunch app while bike is on kickstand at 28° lean + final service2 = SensorFusionService(calibrationService, autoStartSensors: false); + await service2.initializationDone; + expect(service2.isCalibrated, isTrue); + + final angle28Rad = 28.0 * (math.pi / 180.0); + service2.handleAccelerometerEvent(AccelerometerEvent(g * math.sin(angle28Rad), g * math.cos(angle28Rad), 0.0, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + // Must remain calibrated! + expect(service2.isCalibrated, isTrue); + + service2.dispose(); + }); + + test('17. Single transient bump/jolt does not invalidate calibration due to debouncing', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service.calibrateUpright(); + expect(service.isCalibrated, isTrue); + + // Single transient anomalous sample (say 69° shift from pothole / vibration) + final angle69Rad = 69.0 * (math.pi / 180.0); + service.handleAccelerometerEvent(AccelerometerEvent(g * math.sin(angle69Rad), g * math.cos(angle69Rad), 0.0, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + // 1 sample should NOT clear calibration (debounced) + expect(service.isCalibrated, isTrue); + + // Followed immediately by upright riding + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await Future.delayed(const Duration(milliseconds: 15)); + + expect(service.isCalibrated, isTrue); + + service.dispose(); + }); + }); + + group('Dashboard UI: Lean Gauge Display & Availability Semantics', () { + testWidgets('Uncalibrated lean displays --° and UNCALIBRATED', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 892)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + telemetryStreamProvider.overrideWith( + (ref) => Stream.value( + TelemetrySample( + timestamp: DateTime.now(), + leanAngle: null, + speed: 35.0, + quality: const TelemetryQuality( + isMountCalibrated: false, + leanConfidence: 0.0, + ), + ), + ), + ), + rideStateMachineProvider.overrideWith( + (ref) => _TestRideStateMachine( + RideSession( + state: RideState.riding, + startTime: DateTime.now(), + maxLeanLeft: 20.0, + maxLeanRight: 22.0, + ), + ), + ), + ], + child: const MaterialApp( + home: Scaffold(body: LeanAngleIndicator()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('LEAN ANGLE'), findsOneWidget); + expect(find.text('--°'), findsOneWidget); + expect(find.text('UNCALIBRATED'), findsOneWidget); + }); + + testWidgets('LeanVisualizer displays --° and UNCALIBRATED when uncalibrated', (tester) async { + final session = RideSession( + state: RideState.riding, + startTime: DateTime.now(), + maxLeanLeft: 30.0, + maxLeanRight: 32.0, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LeanVisualizer( + lean: null, + session: session, + isCalibrated: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('LEAN ANGLE'), findsOneWidget); + expect(find.text('--°'), findsOneWidget); + expect(find.text('UNCALIBRATED'), findsOneWidget); + }); + + testWidgets('LeanAngleIndicator renders calibrated left lean correctly', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 892)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + telemetryStreamProvider.overrideWith( + (ref) => Stream.value( + TelemetrySample( + timestamp: DateTime.now(), + leanAngle: -28.4, + speed: 45.0, + quality: const TelemetryQuality(isMountCalibrated: true), + ), + ), + ), + rideStateMachineProvider.overrideWith( + (ref) => _TestRideStateMachine( + RideSession( + state: RideState.riding, + startTime: DateTime.now(), + maxLeanLeft: 35.0, + maxLeanRight: 30.0, + ), + ), + ), + ], + child: const MaterialApp( + home: Scaffold(body: LeanAngleIndicator()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('28°'), findsOneWidget); + expect(find.text('LEFT'), findsOneWidget); + }); + + testWidgets('LeanAngleIndicator renders calibrated right lean correctly', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 892)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + telemetryStreamProvider.overrideWith( + (ref) => Stream.value( + TelemetrySample( + timestamp: DateTime.now(), + leanAngle: 31.7, + speed: 52.0, + quality: const TelemetryQuality(isMountCalibrated: true), + ), + ), + ), + rideStateMachineProvider.overrideWith( + (ref) => _TestRideStateMachine( + RideSession( + state: RideState.riding, + startTime: DateTime.now(), + maxLeanLeft: 35.0, + maxLeanRight: 35.0, + ), + ), + ), + ], + child: const MaterialApp( + home: Scaffold(body: LeanAngleIndicator()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('32°'), findsOneWidget); + expect(find.text('RIGHT'), findsOneWidget); + }); + + test('TelemetryService emits null leanAngle when uncalibrated even while moving at speed (Req 8)', () async { + final locationService = _FakeLocationService(); + final calibrationService = CalibrationService(); + final sensorFusionService = SensorFusionService(calibrationService, autoStartSensors: false); + final compassService = _FakeCompassService(); + final fieldTestLogger = _FakeFieldTestLogger(); + final validator = TelemetryValidator(); + + final telemetryService = TelemetryService( + locationService, + sensorFusionService, + compassService, + fieldTestLogger, + validator, + ); + + TelemetrySample? latestSample; + final sub = telemetryService.telemetryStream.listen((sample) => latestSample = sample); + + // Simulate vehicle moving at 45 MPH with GPS turn + locationService.emitPosition(Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime.now(), + altitude: 10.0, + accuracy: 2.5, + heading: 90.0, + floor: null, + speed: 20.1168, // ~45 MPH + speedAccuracy: 0.5, + altitudeAccuracy: 1.0, + headingAccuracy: 1.0, + )); + + // Wait for telemetry ticks + await Future.delayed(const Duration(milliseconds: 120)); + + expect(latestSample, isNotNull); + expect(sensorFusionService.isCalibrated, isFalse); + // Lean angle MUST be null when uncalibrated (Requirement 8) + expect(latestSample!.leanAngle, isNull); + expect(latestSample!.quality?.isMountCalibrated, isFalse); + + await sub.cancel(); + telemetryService.dispose(); + sensorFusionService.dispose(); + locationService.dispose(); + compassService.dispose(); + }); + }); +} + +class _TestRideStateMachine extends StateNotifier implements RideStateMachine { + _TestRideStateMachine([RideSession? initial]) + : super(initial ?? RideSession(state: RideState.idle, startTime: DateTime.now())); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLocationService extends Fake implements LocationService { + final StreamController _controller = StreamController.broadcast(); + + @override + void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic; + + @override + Stream getFilteredPositionStream() => _controller.stream; + + void emitPosition(Position position) => _controller.add(position); + + @override + void dispose() => _controller.close(); +} + +class _FakeCompassService extends Fake implements CompassService { + final StreamController _controller = StreamController.broadcast(); + + @override + Stream get headingStream => _controller.stream; + + void emitHeading(double heading) => _controller.add(heading); + + @override + void dispose() => _controller.close(); +} + +class _FakeFieldTestLogger extends Fake implements FieldTestLogger { + @override + bool get isSessionActive => false; +} + diff --git a/tools/qa_auth_main.dart b/tools/qa_auth_main.dart index 28ed0f8..98368a4 100644 --- a/tools/qa_auth_main.dart +++ b/tools/qa_auth_main.dart @@ -30,16 +30,22 @@ Future main() async { if (!app.options.projectId.startsWith('demo-')) { throw StateError('QA requires a demo-* Firebase project'); } - // Force loopback and disable FlutterFire's emulator-host remapping so adb - // reverse forwards only to the isolated host-local emulator listeners. + // Connect to isolated Auth and Firestore emulators. + // On Android emulator, use the standard 10.0.2.2 host bridge rather than device loopback. + final emulatorHost = defaultTargetPlatform == TargetPlatform.android + ? const String.fromEnvironment('FIREBASE_EMULATOR_HOST', defaultValue: '10.0.2.2') + : const String.fromEnvironment('FIREBASE_EMULATOR_HOST', defaultValue: '127.0.0.1'); + const authPort = int.fromEnvironment('FIREBASE_AUTH_PORT', defaultValue: 9099); + const firestorePort = int.fromEnvironment('FIRESTORE_PORT', defaultValue: 8088); + await FirebaseAuth.instance.useAuthEmulator( - '127.0.0.1', - 9299, + emulatorHost, + authPort, automaticHostMapping: false, ); FirebaseFirestore.instance.useFirestoreEmulator( - '127.0.0.1', - 8288, + emulatorHost, + firestorePort, automaticHostMapping: false, ); final preferences = await SharedPreferences.getInstance(); diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 63b4c3d..c500117 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -5,10 +5,17 @@ import Link from 'next/link'; import { onAuthStateChanged, User } from 'firebase/auth'; import { firebaseAuth } from '../../lib/firebase-client'; import { AppCloudData } from '../../components/app-cloud-data'; -import { SessionManager } from '../../components/session-manager'; import { FirebaseLink } from '../../components/firebase-link'; import { SignOutButton } from '../../components/sign-out-button'; +type DeviceSession = { + id: string; + device_name: string | null; + created_at: string; + expires_at?: string; + current: boolean; +}; + export default function AccountPage() { const [user, setUser] = useState(null); const [authChecked, setAuthChecked] = useState(false); @@ -17,6 +24,16 @@ export default function AccountPage() { const [lastSynced, setLastSynced] = useState(null); const [metrics, setMetrics] = useState({ totalMiles: 12480, rideCount: 184, peakLean: 56.4 }); + // Active Device Sessions State + const [sessions, setSessions] = useState([]); + const [sessionMessage, setSessionMessage] = useState(''); + const [visibleSessionCount, setVisibleSessionCount] = useState(10); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + + // Billing Management State + const [isBillingLoading, setIsBillingLoading] = useState(false); + const [billingNotice, setBillingNotice] = useState<{ text: string; type: 'error' | 'info' | 'success' } | null>(null); + useEffect(() => { const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => { setUser(currentUser); @@ -25,6 +42,109 @@ export default function AccountPage() { return () => unsubscribe(); }, []); + async function loadSessions() { + setIsLoadingSessions(true); + try { + const response = await fetch('/api/account/sessions', { cache: 'no-store' }); + if (response.ok) { + const data: DeviceSession[] = await response.json(); + setSessions(Array.isArray(data) ? data : []); + } else { + setSessions([]); + } + } catch { + setSessions([]); + } finally { + setIsLoadingSessions(false); + } + } + + useEffect(() => { + void loadSessions(); + }, []); + + async function handleRevokeSession(id: string) { + try { + const response = await fetch(`/api/account/sessions/${id}`, { method: 'DELETE' }); + if (response.ok) { + setSessionMessage('Session revoked.'); + await loadSessions(); + } else { + setSessionMessage('Failed to revoke session.'); + } + } catch { + setSessionMessage('Network error revoking session.'); + } + } + + async function handleRevokeAllSessions() { + try { + const response = await fetch('/api/account/sessions', { method: 'POST' }); + if (response.ok) { + setSessionMessage('All sessions revoked.'); + await loadSessions(); + } else { + setSessionMessage('Failed to revoke all sessions.'); + } + } catch { + setSessionMessage('Network error revoking all sessions.'); + } + } + + async function handleManagePlan() { + if (!user) { + setBillingNotice({ + type: 'info', + text: 'Please sign in to manage your plan and billing invoices.', + }); + return; + } + + setIsBillingLoading(true); + setBillingNotice(null); + + try { + const response = await fetch('/api/billing/portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + + if (response.ok) { + const data = await response.json(); + if (data?.url) { + window.location.href = data.url; + return; + } + } + + if (response.status === 404) { + setBillingNotice({ + type: 'error', + text: 'Web billing portal is currently unavailable (endpoint /api/billing/portal is unconfigured). Subscriptions and invoices are managed through the mobile app.', + }); + } else if (response.status === 401) { + setBillingNotice({ + type: 'error', + text: 'Authentication required. Please sign in again to access the billing portal.', + }); + } else { + const errorData = await response.json().catch(() => null); + setBillingNotice({ + type: 'error', + text: errorData?.detail || errorData?.message || 'Unable to load billing portal at this time. Please manage subscriptions in the mobile app.', + }); + } + } catch { + setBillingNotice({ + type: 'error', + text: 'Web billing portal is unconfigured or unreachable. Subscriptions and invoices can be managed directly in the mobile app.', + }); + } finally { + setIsBillingLoading(false); + } + } + function handleQuickSync() { setIsSyncing(true); setSyncTrigger((prev) => prev + 1); @@ -205,11 +325,13 @@ export default function AccountPage() {
Subscription billing

-Not connected -Web billing unavailable +{user ? 'Pro Telemetry Tier' : 'Guest Tier'} + +{user ? 'Pro Account' : 'Web billing unavailable'} +

-No active plan data +{user ? 'Active telemetry tier' : 'No active plan data'} {/* Inclusions Pills */}
@@ -260,13 +382,56 @@ export default function AccountPage() { shield Founder Portal - +
+{billingNotice && ( +
+ + + + {billingNotice.text} + +
+)} {/* Right 5 cols: Connected Apps & Cluster Sync */}
@@ -288,10 +453,12 @@ export default function AccountPage() {
Google Account -bponders12@gmail.com +{user?.email || 'Not connected'}
-Connected + + {user ? 'Connected' : 'Not Connected'} + {/* MotorSignal Mobile App */}
@@ -322,7 +489,7 @@ export default function AccountPage() {
-Apple Watch Companion: Active (HRM Sync) +Apple Watch Companion: Not Connected (Unavailable on Web)
@@ -337,8 +504,83 @@ export default function AccountPage() {

Active Device Sessions

Authenticated tokens accessing MotorSignal account telemetry

+ + {sessions.length} {sessions.length === 1 ? 'Session' : 'Sessions'} + + + +
+
+
+

Sessions

+ {sessions.length > 0 && ( + + (Showing {Math.min(visibleSessionCount, sessions.length)} of {sessions.length}) + + )} +
+ {sessions.length > 0 && ( + + )} +
+ + {sessions.length > 0 ? ( +
+ {sessions.slice(0, visibleSessionCount).map((session) => ( +
+
+ + {session.device_name ?? 'Unnamed device'} {session.current && '(this device)'} + +

+ Created {new Date(session.created_at).toLocaleString()} +

+
+ {!session.current && ( + + )} +
+ ))} + + {visibleSessionCount < sessions.length && ( +
+ +
+ )} +
+ ) : ( +

+ {isLoadingSessions ? 'Loading sessions...' : 'No active sessions.'} +

+ )} + + {sessionMessage && ( +

+ {sessionMessage} +

+ )}
- {/* Security & SOS Preferences (5 cols) */}
diff --git a/website/app/api/account/rides/[rideId]/telemetry/route.ts b/website/app/api/account/rides/[rideId]/telemetry/route.ts new file mode 100644 index 0000000..5afdd29 --- /dev/null +++ b/website/app/api/account/rides/[rideId]/telemetry/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createHash } from 'node:crypto'; +import { unzipSync, strFromU8 } from 'fflate'; +import { getAdminAuth, getAdminFirestore } from '../../../../../../lib/firebase-admin'; +import { normalizeTelemetryPoints, parseGpxToTelemetryPoints, parseCsvToTelemetryPoints } from '../../../../../../lib/telemetry-track'; +import type { TelemetryPoint } from '../../../../../../lib/telemetry-export'; + +export const runtime = 'nodejs'; +const MAX_BUNDLE = 32 * 1024 * 1024; +const MAX_EXTRACTED = 64 * 1024 * 1024; +const reply = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: { 'Cache-Control': 'private, no-store' } }); +const failure = (status: number, error: string) => reply({success:false,error},status); + +export async function GET(request: NextRequest, { params }: { params: Promise<{ rideId: string }> }) { + const authorization = request.headers.get('authorization'); + if (!authorization?.match(/^Bearer\s+\S+$/i)) return failure(401,'Sign in required.'); + let uid: string; + try { + const decoded = await getAdminAuth().verifyIdToken(authorization.replace(/^Bearer\s+/i,''), true); + if (decoded.firebase?.sign_in_provider === 'anonymous') return failure(401,'Sign in required.'); + uid = decoded.uid; + } catch { return failure(401,'Sign in required.'); } + const {rideId} = await params; + if (!/^[A-Za-z0-9_-]{1,128}$/.test(rideId)) return failure(400,'Invalid ride identity.'); + + try { + const snapshot = await getAdminFirestore().doc(`users/${uid}/rides/${rideId}`).get(); + if (!snapshot.exists) return failure(404,'Ride not found.'); + const ride = snapshot.data()!; + if (ride.ownerId !== uid) return failure(403,'Ride access denied.'); + if (ride.id && ride.id !== rideId) return failure(409,'Ride identity does not match.'); + const bundlePath = ride.telemetryBundlePath || ride.telemetryBundle?.objectKey; + let points: TelemetryPoint[] = []; + let source = ''; + let bundleSha256: string | undefined; + if (bundlePath) { + if (bundlePath !== `users/${uid}/rides/${rideId}/telemetry.zip`) return failure(409,'Telemetry bundle identity does not match.'); + // Same authenticated gateway and integrity contract used by MotorCloud restore. + const url = new URL('https://motorsignal-r2-worker.bponders12.workers.dev/r2/download'); + url.searchParams.set('rideId',rideId); + const response = await fetch(url.toString(), {headers:{Authorization:authorization,Accept:'application/zip'},cache:'no-store',signal:AbortSignal.timeout(45000)}); + if (!response.ok) return failure([401,403,404,409,422].includes(response.status) ? response.status : 502,'Recorded telemetry is unavailable.'); + if (response.headers.get('x-motorsignal-ride-id') !== rideId) return failure(422,'Telemetry bundle identity does not match.'); + const expected = response.headers.get('x-motorsignal-sha256')?.trim().toLowerCase(); + if (!expected || !/^[a-f0-9]{64}$/.test(expected)) return failure(422,'Telemetry bundle integrity metadata is missing.'); + if (Number(response.headers.get('content-length')) > MAX_BUNDLE) return failure(422,'Telemetry bundle exceeds replay limits.'); + const reader = response.body?.getReader(); + if (!reader) return failure(422,'Telemetry bundle is empty.'); + const chunks: Uint8Array[] = []; + let size=0; + try { + for (;;) { + const {done,value}=await reader.read(); + if(done) break; + size+=value.length; + if(size>MAX_BUNDLE) { await reader.cancel(); return failure(422,'Telemetry bundle exceeds replay limits.'); } + chunks.push(value); + } + } finally { reader.releaseLock(); } + const bytes = Buffer.concat(chunks); + bundleSha256 = createHash('sha256').update(bytes).digest('hex'); + if (!bytes.length || bundleSha256 !== expected || (ride.telemetryBundle?.sha256 && ride.telemetryBundle.sha256 !== bundleSha256)) return failure(422,'Telemetry bundle integrity verification failed.'); + let expanded=0; + const files = unzipSync(bytes,{filter:file=>{ + if (!['telemetry.gpx','telemetry.csv'].includes(file.name)) return false; + expanded+=file.originalSize; + if(expanded>MAX_EXTRACTED) throw Error('Telemetry exceeds replay limits'); + return true; + }}); + // CSV retains GPS-loss rows that legacy GPX exports omit; prefer it when present. + if(files['telemetry.csv']) { points=parseCsvToTelemetryPoints(strFromU8(files['telemetry.csv'])); source='r2-csv'; } + if(!points.length && files['telemetry.gpx']) { points=parseGpxToTelemetryPoints(strFromU8(files['telemetry.gpx'])); source='r2-gpx'; } + } else { + const samples = Array.isArray(ride.points) ? ride.points : ride.telemetryPoints; + if(Array.isArray(samples)) { points=normalizeTelemetryPoints(samples); source='inline-points'; } + if(!points.length && typeof ride.csvContent === 'string') { points=parseCsvToTelemetryPoints(ride.csvContent); source='inline-csv'; } + if(!points.length && typeof ride.gpxContent === 'string') { points=parseGpxToTelemetryPoints(ride.gpxContent); source='inline-gpx'; } + } + if(!points.length) return failure(404,'No recorded GPS coordinates are available for this ride.'); + const first=points[0], last=points[points.length-1]; + return reply({success:true,rideId,source,bundleSha256,pointCount:points.length,segmentCount:new Set(points.map(p=>p.segmentId)).size, + startLat:first.latitude,startLon:first.longitude,endLat:last.latitude,endLon:last.longitude,points}); + } catch { + console.error('[RIDE_TELEMETRY] Recorded telemetry retrieval failed.'); + return failure(502,'Recorded telemetry could not be loaded.'); + } +} diff --git a/website/app/api/account/sessions/[sessionId]/route.ts b/website/app/api/account/sessions/[sessionId]/route.ts index 82ca18c..bc2d21d 100644 --- a/website/app/api/account/sessions/[sessionId]/route.ts +++ b/website/app/api/account/sessions/[sessionId]/route.ts @@ -7,5 +7,5 @@ export async function DELETE(request: NextRequest, context: { params: Promise<{ const { sessionId } = await context.params; const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; const response = await fetch(`${baseUrl}/api/v1/auth/sessions/${sessionId}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }); - return new NextResponse(await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); + return new NextResponse(response.status === 204 ? null : await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); } diff --git a/website/app/api/account/sessions/route.ts b/website/app/api/account/sessions/route.ts index 7566694..0e25155 100644 --- a/website/app/api/account/sessions/route.ts +++ b/website/app/api/account/sessions/route.ts @@ -6,7 +6,7 @@ async function forward(request: NextRequest, method: 'GET' | 'POST') { if (!token) return NextResponse.json({ detail: 'Authentication required.' }, { status: 401 }); const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; const response = await fetch(`${baseUrl}/api/v1/auth/${method === 'GET' ? 'sessions' : 'logout-all'}`, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined, cache: 'no-store' }); - return new NextResponse(await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); + return new NextResponse(response.status === 204 ? null : await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); } export async function GET(request: NextRequest) { return forward(request, 'GET'); } diff --git a/website/app/api/group-rides/route.ts b/website/app/api/group-rides/route.ts index d3b4402..f1b2db4 100644 --- a/website/app/api/group-rides/route.ts +++ b/website/app/api/group-rides/route.ts @@ -63,7 +63,7 @@ export async function GET(request: NextRequest) { const candidates = rideSnapshot.docs.filter((ride: QueryDocumentSnapshot) => { const data = ride.data(); const scheduledStartAt = isoDate(data.scheduledStartAt); - return !('clubId' in data) && scheduledStartAt !== null && + return data.clubId == null && scheduledStartAt !== null && new Date(scheduledStartAt).getTime() > Date.now() && typeof data.organizerUid === 'string' && data.organizerUid.length > 0; }); diff --git a/website/components/app-cloud-data.tsx b/website/components/app-cloud-data.tsx index d140c58..658b1f6 100644 --- a/website/components/app-cloud-data.tsx +++ b/website/components/app-cloud-data.tsx @@ -14,7 +14,12 @@ import Link from 'next/link'; import React, { useEffect, useState, useRef } from 'react'; import { firebaseAuth, firestore } from '../lib/firebase-client'; import { WebTelemetryReplayModal } from './web-telemetry-replay-modal'; -import { generateRideGpx, generateRideCsv, downloadFile } from '../lib/telemetry-export'; +import { + generateRideGpx, + generateRideCsv, + downloadFile, + TelemetryPoint, +} from '../lib/telemetry-export'; export type Motorcycle = { id: string; @@ -44,6 +49,14 @@ export type Ride = { elevationGain: number; qualityScore: number; motorcycle: string; + startLat?: number; + startLon?: number; + endLat?: number; + endLon?: number; + points?: TelemetryPoint[]; + gpxContent?: string; + csvContent?: string; + telemetryBundlePath?: string; }; function text(value: unknown): string { @@ -157,10 +170,44 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad // Expandable Ride Detail state const [expandedRideId, setExpandedRideId] = useState(null); + // Pagination for Recent Telemetry Sessions + const [visibleRideCount, setVisibleRideCount] = useState(10); + // Replay Modal & Export State const [replayRide, setReplayRide] = useState(null); const [exportNotice, setExportNotice] = useState(null); + useEffect(() => { + if (typeof window !== 'undefined') { + (window as any).openReplayModalForTest = (r?: Partial) => { + setReplayRide({ + id: r?.id || 'ride_3_20260831_141255', + startedAt: r?.startedAt || new Date('2026-08-28T23:50:03.000Z'), + endedAt: r?.endedAt || new Date('2026-08-29T00:09:05.000Z'), + distanceMiles: r?.distanceMiles || 14.2, + topSpeedMph: r?.topSpeedMph || 68.5, + averageSpeed: r?.averageSpeed || 44.2, + maxLeanLeft: r?.maxLeanLeft || 38.5, + maxLeanRight: r?.maxLeanRight || 41.2, + maxAccelG: r?.maxAccelG || 0.65, + maxBrakingG: r?.maxBrakingG || 0.78, + movingTime: r?.movingTime || 1142, + elevationGain: r?.elevationGain || 420, + qualityScore: r?.qualityScore || 96, + motorcycle: r?.motorcycle || '2024 Yamaha YZF-R7', + startLat: r?.startLat ?? (r?.points?.[0]?.latitude ?? 34.0400856), + startLon: r?.startLon ?? (r?.points?.[0]?.longitude ?? -85.2399786), + endLat: r?.endLat ?? (r?.points?.[r.points.length - 1]?.latitude ?? 34.2435959), + endLon: r?.endLon ?? (r?.points?.[r.points.length - 1]?.longitude ?? -85.1615908), + points: r?.points, + gpxContent: r?.gpxContent, + csvContent: r?.csvContent, + telemetryBundlePath: r?.telemetryBundlePath, + }); + }; + } + }, []); + function handleExportRideGpx(e: React.MouseEvent, ride: Ride) { e.stopPropagation(); const gpx = generateRideGpx(ride); @@ -181,6 +228,40 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad setTimeout(() => setExportNotice(null), 3000); } + // Handle Delete Ride Session + async function handleDeleteRide(e: React.MouseEvent, ride: Ride) { + e.stopPropagation(); + if (!user) return; + const confirmDelete = window.confirm( + `Delete telemetry session recorded on ${ride.startedAt ? ride.startedAt.toLocaleDateString() : 'session'}?` + ); + if (!confirmDelete) return; + + try { + await deleteDoc(doc(firestore, 'users', user.uid, 'rides', ride.id)); + setRides((prev) => { + const updated = prev.filter((r) => r.id !== ride.id); + if (onMetricsLoaded) { + const totalMiles = updated.reduce((acc, r) => acc + r.distanceMiles, 0); + let peakLean = 0; + for (const r of updated) { + if (r.maxLeanLeft > peakLean) peakLean = r.maxLeanLeft; + if (r.maxLeanRight > peakLean) peakLean = r.maxLeanRight; + } + onMetricsLoaded({ + totalMiles: Math.round(totalMiles), + rideCount: updated.length, + peakLean: Number(peakLean.toFixed(1)), + }); + } + return updated; + }); + } catch (err) { + console.error('Error deleting ride:', err); + alert('Unable to delete telemetry session. Please check permissions.'); + } + } + // Auto-resolved Stock Photography Cache (from /api/motorcycle-image) const [stockImages, setStockImages] = useState< Record @@ -277,6 +358,19 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad if (leanL > peakLean) peakLean = leanL; if (leanR > peakLean) peakLean = leanR; + const startLat = number(data.startLat) || number(data.startLatitude) || undefined; + const startLon = number(data.startLon) || number(data.startLongitude) || undefined; + const endLat = number(data.endLat) || number(data.endLatitude) || undefined; + const endLon = number(data.endLon) || number(data.endLongitude) || undefined; + const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined; + + let ridePoints: TelemetryPoint[] | undefined; + if (Array.isArray(data.points) && data.points.length > 0) { + ridePoints = data.points; + } else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) { + ridePoints = data.telemetryPoints; + } + return { id: snapshot.id, startedAt: startedAt && !Number.isNaN(startedAt.getTime()) ? startedAt : null, @@ -292,6 +386,14 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad elevationGain: number(data.elevationGain), qualityScore: number(data.qualityScore) || 100, motorcycle: [number(data.motorcycleYear) || '', text(data.motorcycleMake), text(data.motorcycleModel)].filter(Boolean).join(' '), + startLat, + startLon, + endLat, + endLon, + points: ridePoints, + gpxContent: text(data.gpxContent) || undefined, + csvContent: text(data.csvContent) || undefined, + telemetryBundlePath: bundlePath, }; }).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0)); @@ -772,7 +874,7 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad {rides.length > 0 ? (
- {rides.map((ride) => { + {rides.slice(0, visibleRideCount).map((ride) => { const isExpanded = expandedRideId === ride.id; const dateStr = ride.startedAt ? ride.startedAt.toLocaleDateString(undefined, { @@ -888,6 +990,22 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad 20 Hz CSV + +
); })} + + {/* Show more button when more sessions exist */} + {rides.length > visibleRideCount && ( +
+ + + Showing {Math.min(visibleRideCount, rides.length)} of {rides.length} sessions + +
+ )}
) : (
diff --git a/website/components/group-rides/group-rides-management.tsx b/website/components/group-rides/group-rides-management.tsx index 438ba19..9d7a2f8 100644 --- a/website/components/group-rides/group-rides-management.tsx +++ b/website/components/group-rides/group-rides-management.tsx @@ -2,6 +2,8 @@ import React, { useState, useMemo, useEffect } from 'react'; import Link from 'next/link'; +import { getApps, initializeApp } from 'firebase/app'; +import { getAuth, connectAuthEmulator } from 'firebase/auth'; import { collection, query, @@ -17,11 +19,40 @@ import { increment, serverTimestamp, getDoc, + getFirestore, + connectFirestoreEmulator, } from 'firebase/firestore'; import { firestore } from '../../lib/firebase-client'; + +function getActiveFirestore() { + if ( + typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') + ) { + let qaApp = getApps().find((a) => a.name === 'motorsignal-local-qa'); + if (!qaApp) { + try { + qaApp = initializeApp( + { apiKey: 'demo-key', projectId: 'demo-motorsignal-qa', authDomain: 'localhost' }, + 'motorsignal-local-qa' + ); + const db = getFirestore(qaApp); + connectFirestoreEmulator(db, '127.0.0.1', 8088); + const auth = getAuth(qaApp); + connectAuthEmulator(auth, 'http://127.0.0.1:9099'); + return db; + } catch (_) {} + } + if (qaApp) { + try { + return getFirestore(qaApp); + } catch (_) {} + } + } + return firestore; +} import { INITIAL_GROUP_RIDES } from './fixtures'; import { GroupRideSummary } from './types'; -import { CreateRideModal } from './create-ride-modal'; import { EditRideModal } from './edit-ride-modal'; import { DeleteConfirmModal } from './delete-confirm-modal'; import { JoinRideModal } from './join-ride-modal'; @@ -129,23 +160,582 @@ function mapFirestoreDocToSummary( status, rideCode: data.id || docId, clubName: data.clubName || undefined, - }; + accessUids: Array.isArray(data.accessUids) ? data.accessUids : (data.organizerUid ? [data.organizerUid] : []), + organizerUid: data.organizerUid, + meetingPointCoords: data.meetingPoint?.latitude && data.meetingPoint?.longitude + ? { lat: Number(data.meetingPoint.latitude), lon: Number(data.meetingPoint.longitude) } + : undefined, + } as any; } type ManagementTab = 'my-rides' | 'discover' | 'radar'; type MyRidesFilter = 'all' | 'organized' | 'joined'; +/** + * Redesigned Create Group Ride Modal + * Features: + * - Plain, clear labels and helpful hints + * - Sensible defaults: Current date/time + 1 hour, standard pace, paved surface, 12 riders, moderated join + * - Explicit Review Step before final submission + * - Preserves personal vs club ride distinctions, visibility settings, join policies, and authorization rules + */ +interface CreateRideModalProps { + isOpen: boolean; + onClose: () => void; + onCreateRide: (ride: GroupRideSummary) => void; +} + +function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) { + // Sensible default date/time calculation: current time + 1 hour + const defaultDateValues = useMemo(() => { + const oneHourAhead = new Date(Date.now() + 60 * 60 * 1000); + const dateStr = oneHourAhead.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + const timeStr = oneHourAhead.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + return { dateStr, timeStr }; + }, [isOpen]); + + const [activeStep, setActiveStep] = useState<'details' | 'review'>('details'); + const [title, setTitle] = useState(''); + const [region, setRegion] = useState(''); + const [scheduledDate, setScheduledDate] = useState(defaultDateValues.dateStr); + const [scheduledTime, setScheduledTime] = useState(defaultDateValues.timeStr); + const [surfaceType, setSurfaceType] = useState('Paved Asphalt'); + const [paceClassification, setPaceClassification] = useState('Brisk Pace'); + const [discipline, setDiscipline] = useState>('Sport / Canyon'); + const [rideType, setRideType] = useState<'personal' | 'club'>('personal'); + const [clubName, setClubName] = useState(''); + const [joinPolicy, setJoinPolicy] = useState('requestToJoin'); + const [visibility, setVisibility] = useState<'public' | 'unlisted' | 'private'>('public'); + const [formationProfile, setFormationProfile] = useState('Staggered Dual Column [B]'); + const [distance, setDistance] = useState('45.0 Miles'); + const [meetingPoint, setMeetingPoint] = useState(''); + const [routeSnippet, setRouteSnippet] = useState('Skyline Blvd > Stage Rd > PCH Pacific Coast Hwy'); + const [participantLimit, setParticipantLimit] = useState(12); + const [description, setDescription] = useState(''); + const [errorMsg, setErrorMsg] = useState(null); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setActiveStep('details'); + setTitle(''); + setRegion(''); + setScheduledDate(defaultDateValues.dateStr); + setScheduledTime(defaultDateValues.timeStr); + setSurfaceType('Paved Asphalt'); + setPaceClassification('Brisk Pace'); + setDiscipline('Sport / Canyon'); + setRideType('personal'); + setClubName(''); + setJoinPolicy('requestToJoin'); + setVisibility('public'); + setFormationProfile('Staggered Dual Column [B]'); + setDistance('45.0 Miles'); + setMeetingPoint(''); + setRouteSnippet('Skyline Blvd > Stage Rd > PCH Pacific Coast Hwy'); + setParticipantLimit(12); + setDescription(''); + setErrorMsg(null); + } + }, [isOpen, defaultDateValues]); + + // Handle ESC key press + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const validate = (): boolean => { + if (!title.trim()) { + setErrorMsg('Please enter a ride title.'); + setActiveStep('details'); + return false; + } + if (!region.trim()) { + setErrorMsg('Please enter a location or region.'); + setActiveStep('details'); + return false; + } + if (participantLimit < 2) { + setErrorMsg('Participant limit must be at least 2 riders.'); + setActiveStep('details'); + return false; + } + setErrorMsg(null); + return true; + }; + + const handleProceedToReview = (e: React.MouseEvent) => { + e.preventDefault(); + if (validate()) { + setActiveStep('review'); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + const newRide: GroupRideSummary & { + visibility?: 'public' | 'unlisted' | 'private'; + surfaceType?: string; + clubId?: string; + } = { + id: `ride-${Date.now()}`, + title: title.trim(), + description: description.trim() || 'Custom squadron pack run with MotorSignal live telemetry.', + region: region.trim(), + scheduledDate, + scheduledTime, + paceClassification, + discipline, + formationProfile, + joinPolicy, + leadHandle: 'You (Organizer)', + leadAvatarText: 'YOU', + participantCount: 1, + participantLimit: Number(participantLimit) || 12, + distance: distance.trim() || '45.0 Miles', + meetingPoint: meetingPoint.trim() || 'Designated Rally Staging Point', + routeSnippet: routeSnippet.trim() || 'Custom GPX route uploaded by organizer.', + userStatus: 'joined', + status: 'scheduled', + visibility, + surfaceType, + ...(rideType === 'club' && clubName.trim() ? { clubName: clubName.trim(), clubId: `club-${clubName.toLowerCase().replace(/\s+/g, '-')}` } : {}), + }; + + onCreateRide(newRide); + onClose(); + }; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+ + add_circle + +

+ Create Group Ride Run +

+
+ +
+ + {/* Step Tabs Indicator */} +
+ + +
+ + {errorMsg && ( +
+ error + {errorMsg} +
+ )} + +
+ {activeStep === 'details' && ( + <> + {/* Ride Title */} +
+ + setTitle(e.target.value)} + placeholder="e.g. Sierra Foothills Canyon Run" + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> +
+ + {/* Region & Distance Grid */} +
+
+ + setRegion(e.target.value)} + placeholder="e.g. Sacramento, CA" + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> +
+
+ + setDistance(e.target.value)} + placeholder="e.g. 45.0 Miles" + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> +
+
+ + {/* Date & Time Grid (Defaults to current time + 1 hour) */} +
+
+ + setScheduledDate(e.target.value)} + placeholder="e.g. Saturday" + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> + + Defaults to upcoming run date + +
+
+ + setScheduledTime(e.target.value)} + placeholder="e.g. 09:30 AM PST" + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> + + Defaulted to 1 hour from now + +
+
+ + {/* Surface & Pace Classification */} +
+
+ + +
+
+ + +
+
+ + {/* Ride Classification (Personal vs Club) & Privacy/Join Policy */} +
+
+ + + {rideType === 'club' && ( + setClubName(e.target.value)} + className="mt-2 w-full bg-surface-container-lowest text-xs text-on-surface px-3 py-1.5 rounded-lg border border-outline-variant/30" + /> + )} +
+
+ + +
+
+ + {/* Rider Limit & Discipline */} +
+
+ + setParticipantLimit(Number(e.target.value))} + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> +
+
+ + +
+
+ + {/* Meeting Point */} +
+ + setMeetingPoint(e.target.value)} + placeholder="e.g. Chevron Staging Lot at Highway 49" + className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none" + /> +
+ + {/* Description */} +
+ +