Compare commits

..

13 Commits

Author SHA1 Message Date
Claude Haiku 4.5 a573b2d275 fix: recognize flutter-ci-setup composite action in change detection
Add .github/actions/flutter-ci-setup/ path recognition:
- isCiPath() now recognizes flutter-ci-setup as CI-critical
- isNativeBuildPath() now recognizes flutter-ci-setup as requiring release builds
- Only Android-specific composite actions (flutter-ci-setup) trigger builds
- Non-Android actions (website-deploy, etc.) are not recognized as CI-critical

Add comprehensive test coverage:
- Test composite action path classification (7 new tests added)
- Verify workflow YAML for output mapping and release condition
- Ensure non-Android actions are correctly skipped
- Test workflow condition evaluation for all scenarios

Test results: 65/65 passing (all new tests verify the composite action fix)

Flutter-ci-setup is a composite action used by Android workflow jobs
(analysis, test, and debug APK), so changes to it must trigger Android
verification and release builds.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-30 17:07:27 -04:00
Claude Haiku 4.5 32059f9b8a fix(ci): complete PR #92 critical fixes
- Add missing job-level output mapping for native_build_changed to expose detector output to release job
- Fix path logic inconsistency: make isNativeBuildPath() selective to only CI-critical files, not all .github/ changes
- Add workflow-wiring end-to-end tests verifying GitHub Actions output mapping and release job conditions (6 new tests)

Test results: 58/58 passing (6 new workflow-wiring tests verify output wiring and release condition behavior)
2026-10-30 16:55:25 -04:00
Claude Haiku 4.5 48f1057ff0 fix(ci): remove duplicate native_build_changed properties and add comprehensive release-job gating tests
ChatGPT review findings:
- Removed duplicate native_build_changed object property in classifyFiles() return (lines 69-71)
- Removed duplicate native_build_changed property in manual dispatch return (lines 150-152)

Added explicit behavioral tests for release-job gating:
- Dart-only changes on master → release skipped (native_build_changed=false)
- Android changes → release runs (native_build_changed=true)
- CI-only changes (.github/) → release runs (native_build_changed=true)
- Config changes (pubspec.yaml, build.yaml) → release runs (native_build_changed=true)
- Mixed changes → release runs (native_build_changed=true)
- Manual dispatch → release always runs (native_build_changed=true)
- Invalid Git SHA → safe fail to trigger release (native_build_changed=true)
- Unrelated changes (docs/, website/) → release skipped (native_build_changed=false)

All 52 tests pass. Release job condition verified to depend on:
- analyze passing
- test shards passing
- debug APK passing
- native_build_changed==true
- master ref

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-30 16:39:59 -04:00
Claude Haiku 4.5 b1abb6b086 ci: implement option 3 gate (skip release builds for Dart-only changes)
- Add isNativeBuildPath() to detect Android code, CI changes, and build config
- Update classifyFiles() to return native_build_changed output
- Update all detectAndroidChanges() return statements to include native_build_changed
- Add native_build_changed to GitHub Actions output
- Update release job condition to use native_build_changed output
- Skip release APK/AAB builds when only Dart/Flutter code changes
- Preserve and test all existing behavior (app_changed, android_changed)
- Add comprehensive test coverage: 42 tests, 100% pass rate

Tested scenarios:
- Dart-only changes (lib/, test/, assets/) → native_build_changed=false, skip release
- Native Android changes (android/, pubspec.yaml) → native_build_changed=true
- Dependency/config changes (pubspec.lock, build.yaml, .github/) → native_build_changed=true
- CI-only changes (.github/workflows/) → native_build_changed=true
- Mixed changes verified
- Invalid Git SHAs fail safe to true
- Manual workflow dispatch runs full build
- Historical repository commits tested
- Repository fixture tests verified

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-30 16:28:45 -04:00
Claude Haiku 4.5 70553e04a4 ci: implement option 3 gate (skip release builds for Dart-only changes)
- Add isNativeBuildPath() detector to distinguish Dart-only from native changes
- Add native_build_changed output to detect job
- Conditional release job: skip for Dart-only PRs, run for native/dependency changes
- Preserves all other jobs (analyze, test, debug APK) running always

This prevents redundant release artifact builds for Dart-only changes,
reducing storage quota consumption and CI time.
2026-10-30 16:18:16 -04:00
Billie Ponder 8fc267d5fd
fix(clubs,routes): owner no longer shown "Request to Join"; Route Planner starts at rider's area (#89)
* fix(clubs): respect existing membership in join presentation (#72)

* fix(routes): start planner from cached rider location (#76)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-26 07:04:37 -04:00
Billie Ponder 63df8f9a50
Fix/forms keyboard batch2 (#87)
* fix(group_rides): make Join Group Ride dialog keyboard-safe in landscape (#69)

* fix(group_rides): responsive landscape form and keyboard scrolling in CreateGroupRideScreen (#70)

* fix(group_rides): constrain and scroll Attach Saved Route sheet in landscape (#75)

* fix(routes): make RoutePlannerScreen keyboard-safe and scrollable in landscape (#76)

* fix(clubs): make CreateClubScreen keyboard-safe and scrollable in landscape (#77)

* fix(forms): address PR #87 review findings on saved-route sheet and stop list auto-scrolling

---------

Co-authored-by: Developer <dev@example.com>
2026-10-26 01:51:12 -04:00
Billie Ponder 2524cd066e
fix(ui): landscape layout fixes for clubs, group rides and feed share (batch 3) (#88)
* fix(group_rides): prevent card bottom overflow in landscape grid (#68)

* fix(feed): ensure feed content and empty state scroll clear of share button (#71)

* fix(feed): make ShareRecentSessionModal keyboard-safe and scrollable in landscape

* fix(clubs): responsive grid and constrained card sizing in landscape (#73)

* fix(group_rides): prevent horizontal control clipping in Live Formation landscape (#74)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-18 05:00:38 -04:00
Billie Ponder 74943a8420
Fix/garage layout batch4 (#86)
* fix(garage): improve landscape layout (#62)

* fix(garage): make service dialog responsive (#63)

* fix(garage): truncate service type dropdown (#64)

* fix(garage): avoid compact landscape card overflow (#62)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-18 02:10:52 -04:00
Billie Ponder 3f64083bdd
Fix/landscape dashboard replay batch1 (#85)
* fix(dashboard): resolve landscape clipping and nav rail alignment (#54)

* fix(alerts): constrain road incident report modal in landscape (#55)

* fix(performance): adapt launch setup layout in landscape (#56)

* fix(performance): resolve 68px countdown overflow in landscape (#57)

* fix(history): resolve 12px card overflow in landscape (#59)

* fix(map): remove duplicate replay action in landscape (#60)

* fix(replay): optimize ride replay layout in landscape (#61)

* fix(dashboard): prevent Start Session button text clipping in landscape (#54)

* fix(dashboard): position lean angle title above rider graphic to prevent overlap (#54)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-17 19:06:30 -04:00
Billie Ponder 1fdca019fb
Speed up Android CI with parallel checks (#83)
Co-authored-by: Developer <dev@example.com>
2026-10-17 15:47:56 -04:00
Billie Ponder 2d1bfbeef6
Fix/android followup 81 49 (#82)
* Fix coarse location labels and squad feed copy

* fix(social): align feed/share/inspector colors with app theme (#49)

* Avoid fabricated social feed locations

---------

Co-authored-by: Developer <dev@example.com>
2026-10-17 15:13:09 -04:00
Billie Ponder b005bb00ae
fix(android): resolve BUG-001 through BUG-005 (#78)
* fix(dashboard): ensure Touring map follows rider and supports recenter (#48)

* fix(social): make shared session telemetry read-only and authoritative (#50)

* fix(social): use recorded telemetry for feed charts and verify GPS badge (#51)

* fix(social): standardize community feed typography and theme colors (#49)

* fix(social): prevent horizontal overflow in GPX export snackbar (#52)

- Wrap GPX export snackbar message Text in Expanded with maxLines: 2 and TextOverflow.ellipsis

- Add ValueKey('gpx_export_button_<id>') for deterministic testing

- Wrap narrow feed header/metric/empty-state Row labels in Flexible to prevent overflow under 320px/360px at 1.5x text scale

- Add BUG-005 widget tests across 320x640, 360x800, and 800x480 viewports at 1.5x text scale

* fix(social): harden share modal, inspector theming, and PR #78 accuracy (#49, #50, #51, #52)

- Require a selected recorded Ride in ShareSessionModal before enabling publish

- Theme ShareSessionModal and FeedTelemetryInspectorModal for Light and Dark modes

- Use LayoutBuilder width for FeedTelemetryInspectorModal scrubber drag/tap

- Route PopupMenuButton export_gpx action through the overflow-safe GPX snackbar

- Enforce telemetrySamples >= 2 when provided to verifyGpsCriteria

* fix(android): remediate map follow smoothness, orientation controls, starting location, and landscape overflow (#79, #80, #81, #52)

* fix(android): stop Pro upsell sheet overflowing in landscape (#52 / BUG-005)

The "Forensic CSV & PDF Export" Pro upsell sheet showed "BOTTOM OVERFLOWED BY 90
PIXELS" in landscape on a Pixel 8a. showProUpgradeSheet built a non-scrolling Column
inside a default modal bottom sheet, which Flutter caps at 9/16 of the screen height
(about 230 dp in landscape) - shorter than the header, description and two actions.

Use isScrollControlled and wrap the content in a SingleChildScrollView so the sheet can
take the full height and never overflows. This also covers every other caller (garage,
add motorcycle, ride detail).

Tests: pro_upgrade_sheet_landscape_test covers 914x411 (Pixel 8a), 800x360 and 780x360
at 1.0x and 1.3x text scale plus NOT NOW dismissal. Verified to fail on the old code
(RenderFlex overflowed 64-144 px) and pass on the fix. Full suite 1700 passed,
flutter analyze clean. Not validated on the physical device yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(social): require telemetry evidence, geodesic GPS threshold, and 3 MPH lean gate (#50, #51)

---------

Co-authored-by: Developer <dev@example.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:35:33 -04:00
67 changed files with 6942 additions and 2237 deletions

View File

@ -0,0 +1,36 @@
name: Flutter CI setup
description: Set up Flutter dependencies and restore build_runner state for CI jobs.
inputs:
setup-java:
description: Whether this job needs the pinned Java toolchain.
required: false
default: 'false'
build-runner-cache-key:
description: Cache key for build_runner state.
required: true
runs:
using: composite
steps:
- name: Set up Java
if: inputs.setup-java == 'true'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
cache: gradle
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Get dependencies
shell: bash
run: flutter pub get
- name: Restore build_runner state
uses: actions/cache@v4
with:
path: .dart_tool/build
key: ${{ inputs.build-runner-cache-key }}

View File

@ -39,12 +39,32 @@ function isAppPath(filePath) {
); );
} }
function isNativeBuildPath(filePath) {
const p = normalizePath(filePath);
if (!p) return false;
// Native build requires: Android code, CI-critical files, or build config.
// Only CI files that affect build behavior trigger release builds; doc/comment changes do not.
// Composite actions: only Android-specific actions (flutter-ci-setup) trigger builds.
return (
p.startsWith('android/') ||
p === '.github/workflows/android-build.yml' ||
p.startsWith('.github/scripts/') ||
p.startsWith('.github/actions/flutter-ci-setup/') ||
p === 'pubspec.yaml' ||
p === 'pubspec.lock' ||
p === 'analysis_options.yaml' ||
p === 'build.yaml'
);
}
function isCiPath(filePath) { function isCiPath(filePath) {
const p = normalizePath(filePath); const p = normalizePath(filePath);
if (!p) return false; if (!p) return false;
// CI paths: workflows, scripts, and Android-specific composite actions.
return ( return (
p === '.github/workflows/android-build.yml' || p === '.github/workflows/android-build.yml' ||
p.startsWith('.github/scripts/') p.startsWith('.github/scripts/') ||
p.startsWith('.github/actions/flutter-ci-setup/')
); );
} }
@ -53,6 +73,7 @@ function classifyFiles(fileList) {
return { return {
android_changed: false, android_changed: false,
app_changed: false, app_changed: false,
native_build_changed: false,
appFiles: [], appFiles: [],
ciFiles: [], ciFiles: [],
unrelatedFiles: [] unrelatedFiles: []
@ -62,6 +83,7 @@ function classifyFiles(fileList) {
const appFiles = []; const appFiles = [];
const ciFiles = []; const ciFiles = [];
const unrelatedFiles = []; const unrelatedFiles = [];
const nativeBuildFiles = [];
for (const rawFile of fileList) { for (const rawFile of fileList) {
const file = normalizePath(rawFile); const file = normalizePath(rawFile);
@ -74,14 +96,21 @@ function classifyFiles(fileList) {
} else { } else {
unrelatedFiles.push(file); unrelatedFiles.push(file);
} }
// Track files that trigger native builds (Android code, CI, config)
if (isNativeBuildPath(file)) {
nativeBuildFiles.push(file);
}
} }
const app_changed = appFiles.length > 0; const app_changed = appFiles.length > 0;
const android_changed = appFiles.length > 0 || ciFiles.length > 0; const android_changed = appFiles.length > 0 || ciFiles.length > 0;
const native_build_changed = nativeBuildFiles.length > 0;
return { return {
android_changed, android_changed,
app_changed, app_changed,
native_build_changed,
appFiles, appFiles,
ciFiles, ciFiles,
unrelatedFiles unrelatedFiles
@ -124,6 +153,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
return { return {
android_changed: true, android_changed: true,
app_changed: true, app_changed: true,
native_build_changed: true,
reason: 'Manual workflow_dispatch; full pipeline authorized', reason: 'Manual workflow_dispatch; full pipeline authorized',
appFiles: [], appFiles: [],
ciFiles: [], ciFiles: [],
@ -135,6 +165,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) { if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) {
return { return {
android_changed: true, android_changed: true,
native_build_changed: true,
app_changed: true, app_changed: true,
reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`, reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`,
appFiles: [], appFiles: [],
@ -147,6 +178,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) { if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) {
return { return {
android_changed: true, android_changed: true,
native_build_changed: true,
app_changed: true, app_changed: true,
reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`, reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`,
appFiles: [], appFiles: [],
@ -159,6 +191,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
if (baseSha.trim() === headSha.trim()) { if (baseSha.trim() === headSha.trim()) {
return { return {
android_changed: false, android_changed: false,
native_build_changed: false,
app_changed: false, app_changed: false,
reason: 'Base SHA and Head SHA are identical; no changes detected', reason: 'Base SHA and Head SHA are identical; no changes detected',
appFiles: [], appFiles: [],
@ -174,6 +207,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
} catch (err) { } catch (err) {
return { return {
android_changed: true, android_changed: true,
native_build_changed: true,
app_changed: true, app_changed: true,
reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`, reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`,
appFiles: [], appFiles: [],
@ -211,6 +245,7 @@ function runCli() {
console.log(`[detect-android-changes] android_changed=${result.android_changed}`); console.log(`[detect-android-changes] android_changed=${result.android_changed}`);
console.log(`[detect-android-changes] app_changed=${result.app_changed}`); console.log(`[detect-android-changes] app_changed=${result.app_changed}`);
console.log(`[detect-android-changes] native_build_changed=${result.native_build_changed}`);
console.log(`[detect-android-changes] Reason: ${result.reason}`); console.log(`[detect-android-changes] Reason: ${result.reason}`);
if (result.appFiles && result.appFiles.length > 0) { if (result.appFiles && result.appFiles.length > 0) {
@ -238,7 +273,7 @@ function runCli() {
if (githubOutput) { if (githubOutput) {
fs.appendFileSync( fs.appendFileSync(
githubOutput, githubOutput,
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\n` `android_changed=${result.android_changed}\napp_changed=${result.app_changed}\nnative_build_changed=${result.native_build_changed}\n`
); );
console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`); console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`);
} }
@ -256,6 +291,7 @@ module.exports = {
isZeroSha, isZeroSha,
isAppPath, isAppPath,
isCiPath, isCiPath,
isNativeBuildPath,
classifyFiles, classifyFiles,
getChangedFiles, getChangedFiles,
detectAndroidChanges detectAndroidChanges

View File

@ -12,6 +12,7 @@ const {
isZeroSha, isZeroSha,
isAppPath, isAppPath,
isCiPath, isCiPath,
isNativeBuildPath,
classifyFiles, classifyFiles,
getChangedFiles, getChangedFiles,
detectAndroidChanges detectAndroidChanges
@ -71,14 +72,37 @@ describe('File Path Classification', () => {
assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true); assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true);
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true); assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true);
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true); assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true);
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/action.yml'), true);
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/index.js'), true);
// Negative matches for Android CI paths // Negative matches for Android CI paths
assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false); assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false);
assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false); assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false);
assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false); assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false);
assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false); assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false);
assert.strictEqual(isCiPath('.github/actions/website-deploy/action.yml'), false);
assert.strictEqual(isCiPath('.github/actions/other-action/index.js'), false);
assert.strictEqual(isCiPath('.github/pull_request_template.md'), false); assert.strictEqual(isCiPath('.github/pull_request_template.md'), false);
}); });
it('identifies native build paths correctly', () => {
assert.strictEqual(isNativeBuildPath('android/app/build.gradle'), true);
assert.strictEqual(isNativeBuildPath('.github/workflows/android-build.yml'), true);
assert.strictEqual(isNativeBuildPath('.github/scripts/detect-android-changes.cjs'), true);
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/action.yml'), true);
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/index.js'), true);
assert.strictEqual(isNativeBuildPath('pubspec.yaml'), true);
assert.strictEqual(isNativeBuildPath('pubspec.lock'), true);
assert.strictEqual(isNativeBuildPath('analysis_options.yaml'), true);
assert.strictEqual(isNativeBuildPath('build.yaml'), true);
// Negative matches for native build paths
assert.strictEqual(isNativeBuildPath('.github/workflows/website-ci.yml'), false);
assert.strictEqual(isNativeBuildPath('.github/actions/website-deploy/action.yml'), false);
assert.strictEqual(isNativeBuildPath('.github/actions/other-action/action.yml'), false);
assert.strictEqual(isNativeBuildPath('lib/main.dart'), false);
assert.strictEqual(isNativeBuildPath('website/app/page.tsx'), false);
});
}); });
describe('classifyFiles Scenarios', () => { describe('classifyFiles Scenarios', () => {
@ -145,6 +169,7 @@ describe('classifyFiles Scenarios', () => {
const result = classifyFiles(files); const result = classifyFiles(files);
assert.strictEqual(result.android_changed, true); assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true); assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, false);
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']); assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
}); });
@ -168,6 +193,7 @@ describe('classifyFiles Scenarios', () => {
const result = classifyFiles(files); const result = classifyFiles(files);
assert.strictEqual(result.android_changed, true); assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true); assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, true);
}); });
it('classifies pubspec change as true', () => { it('classifies pubspec change as true', () => {
@ -184,7 +210,9 @@ describe('classifyFiles Scenarios', () => {
it('classifies analysis_options and build.yaml changes as true', () => { it('classifies analysis_options and build.yaml changes as true', () => {
assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true); assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true);
assert.strictEqual(classifyFiles(['analysis_options.yaml']).native_build_changed, true);
assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true); assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true);
assert.strictEqual(classifyFiles(['build.yaml']).native_build_changed, true);
}); });
it('classifies assets change as true', () => { it('classifies assets change as true', () => {
@ -239,6 +267,26 @@ describe('classifyFiles Scenarios', () => {
assert.strictEqual(result.android_changed, false); assert.strictEqual(result.android_changed, false);
assert.strictEqual(result.app_changed, false); assert.strictEqual(result.app_changed, false);
}); });
it('classifies composite action (flutter-ci-setup) change as android_changed=true, native_build_changed=true', () => {
const files = ['.github/actions/flutter-ci-setup/action.yml'];
const result = classifyFiles(files);
assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, false);
assert.strictEqual(result.native_build_changed, true);
assert.strictEqual(result.ciFiles.length, 1);
assert.deepStrictEqual(result.ciFiles, ['.github/actions/flutter-ci-setup/action.yml']);
});
it('classifies non-Android composite action (e.g., website-deploy) as false', () => {
const files = ['.github/actions/website-deploy/action.yml'];
const result = classifyFiles(files);
assert.strictEqual(result.android_changed, false);
assert.strictEqual(result.app_changed, false);
assert.strictEqual(result.native_build_changed, false);
assert.strictEqual(result.ciFiles.length, 0);
assert.strictEqual(result.unrelatedFiles.length, 1);
});
}); });
describe('detectAndroidChanges Workflow Event Logic', () => { describe('detectAndroidChanges Workflow Event Logic', () => {
@ -253,6 +301,7 @@ describe('detectAndroidChanges Workflow Event Logic', () => {
}); });
assert.strictEqual(result.android_changed, true); assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true); assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, true);
assert.match(result.reason, /Manual workflow_dispatch/); assert.match(result.reason, /Manual workflow_dispatch/);
}); });
@ -566,6 +615,271 @@ describe('Workflow Scenario Matrix Verification', () => {
assert.strictEqual(result.android_changed, true); assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true); assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, true);
});
});
describe('Release Job Gating (native_build_changed output)', () => {
const baseSha = '0000000000000000000000000000000000000001';
const headSha = '0000000000000000000000000000000000000002';
it('Dart-only changes skip release job (native_build_changed=false)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart', 'assets/icons/app.png'] });
assert.strictEqual(result.native_build_changed, false, 'Dart-only changes should skip release');
assert.strictEqual(result.app_changed, true, 'app_changed should still be true');
});
it('Android code changes trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
assert.strictEqual(result.native_build_changed, true, 'Android changes should trigger release');
});
it('CI-only changes (.github/) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/workflows/other.yml', '.github/scripts/helper.sh'] });
assert.strictEqual(result.native_build_changed, true, 'CI changes should trigger release');
});
it('Config file changes (pubspec.yaml) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
assert.strictEqual(result.native_build_changed, true, 'pubspec.yaml changes should trigger release');
});
it('Build config changes (build.yaml) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['build.yaml'] });
assert.strictEqual(result.native_build_changed, true, 'build.yaml changes should trigger release');
});
it('Mixed Dart and Android changes trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart', 'android/app/build.gradle'] });
assert.strictEqual(result.native_build_changed, true, 'Mixed changes should trigger release');
});
it('Manual dispatch always triggers release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'workflow_dispatch',
baseSha: '',
headSha: ''
});
assert.strictEqual(result.native_build_changed, true, 'Manual dispatch should always trigger release');
});
it('Invalid base SHA fails safe to trigger release (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha: 'invalid',
headSha
}, { getChangedFilesFn: () => ['lib/main.dart'] });
assert.strictEqual(result.native_build_changed, true, 'Invalid SHA should fail safe to trigger release');
});
it('Unrelated changes (docs/, website/) skip release job (native_build_changed=false)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/index.html'] });
assert.strictEqual(result.native_build_changed, false, 'Unrelated changes should skip release');
});
it('dependency changes (pubspec.lock) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['pubspec.lock'] });
assert.strictEqual(result.native_build_changed, true, 'pubspec.lock changes should trigger release');
});
});
describe('GitHub Actions Workflow Output Wiring and Release Job Conditions', () => {
const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a';
const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424';
it('detector outputs native_build_changed for GitHub Actions output file', () => {
// Simulate the detector writing to GITHUB_OUTPUT
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart'] });
// The detector MUST set native_build_changed=false for Dart-only changes
assert.strictEqual(result.native_build_changed, false, 'Dart-only should set native_build_changed=false');
assert.strictEqual(result.android_changed, true, 'But android_changed=true still drives heavy build');
});
it('workflow release condition evaluates correctly: Dart-only changes skip release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] });
// Release job condition evaluates to false
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, false, 'Dart-only should output false');
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for Dart-only');
});
it('workflow release condition evaluates correctly: Android changes run release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
// Release job condition evaluates to true
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'Android changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for Android changes');
});
it('workflow release condition evaluates correctly: CI-critical changes run release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] });
// Release job condition evaluates to true
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'CI-critical changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for CI-critical changes');
});
it('workflow release condition evaluates correctly: Config changes run release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
// Release job condition evaluates to true
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'Config changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for config changes');
});
it('workflow release condition evaluates correctly: Unrelated changes skip release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/app.tsx'] });
// Release job condition evaluates to false
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, false, 'Unrelated changes should output false');
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for unrelated changes');
});
it('workflow release condition evaluates correctly: Composite action changes run release', () => {
// flutter-ci-setup is a composite action used by analysis, test, and debug APK jobs
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/actions/flutter-ci-setup/action.yml'] });
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'Composite action changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for composite action changes');
});
it('workflow release condition evaluates correctly: Non-Android composite action (website-deploy) skips release', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/actions/website-deploy/action.yml'] });
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, false, 'Non-Android composite actions should output false');
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for non-Android actions');
});
});
describe('Workflow YAML Verification', () => {
it('verifies android-build.yml defines jobs.detect.outputs.native_build_changed', () => {
const workflowPath = '.github/workflows/android-build.yml';
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
// Check that the workflow file exists and contains the output mapping
assert.ok(workflowContent.includes('native_build_changed'),
'Workflow must expose native_build_changed output from detector step');
assert.ok(workflowContent.includes('steps.detector.outputs.native_build_changed'),
'Workflow must reference detector output with ${{ steps.detector.outputs.native_build_changed }}');
});
it('verifies release job condition includes native_build_changed check', () => {
const workflowPath = '.github/workflows/android-build.yml';
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
// Check that release job condition references the output
assert.ok(workflowContent.includes("needs.detect.outputs.native_build_changed == 'true'"),
'Release job condition must check native_build_changed output');
assert.ok(workflowContent.includes('refs/heads/master'),
'Release job condition must ensure it only runs on master branch');
}); });
}); });

View File

@ -30,6 +30,7 @@ jobs:
outputs: outputs:
android_changed: ${{ steps.detector.outputs.android_changed }} android_changed: ${{ steps.detector.outputs.android_changed }}
app_changed: ${{ steps.detector.outputs.app_changed }} app_changed: ${{ steps.detector.outputs.app_changed }}
native_build_changed: ${{ steps.detector.outputs.native_build_changed }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@ -49,8 +50,54 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: node .github/scripts/detect-android-changes.cjs run: node .github/scripts/detect-android-changes.cjs
analyze-and-build: analyze:
name: Analyze, test and debug build name: Analyze
runs-on: ubuntu-latest
needs: detect
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Set up Flutter CI
uses: ./.github/actions/flutter-ci-setup
with:
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
- name: Generate Drift sources
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze --no-fatal-infos
test:
name: Tests (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest
needs: detect
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
permissions:
contents: read
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Set up Flutter CI
uses: ./.github/actions/flutter-ci-setup
with:
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
- name: Generate Drift sources
run: dart run build_runner build --delete-conflicting-outputs
- name: Run test shard
run: flutter test --total-shards 4 --shard-index ${{ matrix.shard }}
debug-apk:
name: Debug APK build
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: detect needs: detect
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true' if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
@ -61,18 +108,11 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up Java - name: Set up Flutter CI
uses: actions/setup-java@v5 uses: ./.github/actions/flutter-ci-setup
with: with:
distribution: temurin setup-java: 'true'
java-version: ${{ env.JAVA_VERSION }} build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
cache: gradle
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Free runner disk space - name: Free runner disk space
shell: bash shell: bash
run: | run: |
@ -130,8 +170,6 @@ jobs:
flutter --version flutter --version
flutter doctor -v flutter doctor -v
java -version java -version
- name: Get dependencies
run: flutter pub get
- name: Generate Drift sources - name: Generate Drift sources
run: dart run build_runner build --delete-conflicting-outputs run: dart run build_runner build --delete-conflicting-outputs
- name: Commit regenerated Drift sources - name: Commit regenerated Drift sources
@ -148,10 +186,6 @@ jobs:
git add lib/database/database.g.dart git add lib/database/database.g.dart
git commit -m 'Regenerate Drift database source' git commit -m 'Regenerate Drift database source'
git push origin HEAD:${GITHUB_REF_NAME} git push origin HEAD:${GITHUB_REF_NAME}
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Run tests
run: flutter test
- name: Build debug APK - name: Build debug APK
shell: bash shell: bash
run: flutter build apk --debug run: flutter build apk --debug
@ -184,11 +218,31 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 3 retention-days: 3
analyze-and-build:
name: Analyze, test and debug build
runs-on: ubuntu-latest
needs: [detect, analyze, test, debug-apk]
if: always() && (github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true')
permissions:
contents: read
steps:
- name: Require all Android CI jobs to pass
env:
ANALYZE_RESULT: ${{ needs.analyze.result }}
TEST_RESULT: ${{ needs.test.result }}
DEBUG_APK_RESULT: ${{ needs.debug-apk.result }}
shell: bash
run: |
set -euo pipefail
test "$ANALYZE_RESULT" = success
test "$TEST_RESULT" = success
test "$DEBUG_APK_RESULT" = success
release: release:
name: Build signed release APK and AAB name: Build signed release APK and AAB
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [detect, analyze-and-build] needs: [detect, analyze-and-build]
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.app_changed == 'true')) if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.native_build_changed == 'true'))
permissions: permissions:
contents: read contents: read
steps: steps:

View File

@ -223,6 +223,8 @@ class HybridRidingActionBar extends ConsumerWidget {
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: Text(
finishLabel ?? 'Finish Ride', finishLabel ?? 'Finish Ride',
maxLines: 1,
softWrap: false,
style: const TextStyle( style: const TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 15, fontSize: 15,
@ -238,7 +240,6 @@ class HybridRidingActionBar extends ConsumerWidget {
); );
} }
// Ready / Standby state: Single prominent start button // Ready / Standby state: Single prominent start button
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
@ -253,18 +254,24 @@ class HybridRidingActionBar extends ConsumerWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
elevation: 0, elevation: 0,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
), ),
onPressed: () { onPressed: () {
notifier.startRide(); notifier.startRide();
}, },
icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan), icon: Icon(Icons.play_arrow_rounded, size: compact ? 22 : 26, color: colors.brandCyan),
label: const Text( label: const FittedBox(
'Start Session', fit: BoxFit.scaleDown,
style: TextStyle( child: Text(
fontFamily: AppTypography.fontHeadline, 'Start Session',
fontSize: 17, maxLines: 1,
fontWeight: FontWeight.w700, softWrap: false,
letterSpacing: 0.5, style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
), ),
), ),
), ),

View File

@ -138,45 +138,48 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
minHeight: constraints.maxHeight, minHeight: constraints.maxHeight,
), ),
child: IntrinsicHeight( child: IntrinsicHeight(
child: NavigationRail( child: () {
minWidth: 58, final isShortRail = constraints.maxHeight < 400;
groupAlignment: 0.0, return NavigationRail(
selectedIndex: selectedIndex, minWidth: isShortRail ? 52 : 58,
onDestinationSelected: _onDestinationSelected, groupAlignment: isShortRail ? -1.0 : 0.0,
backgroundColor: colors.surfaceCanvas, selectedIndex: selectedIndex,
indicatorColor: colors.brandCyan.withValues(alpha: 0.14), onDestinationSelected: _onDestinationSelected,
selectedIconTheme: IconThemeData( backgroundColor: colors.surfaceCanvas,
color: colors.brandCyan, indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
size: 21, selectedIconTheme: IconThemeData(
), color: colors.brandCyan,
unselectedIconTheme: IconThemeData( size: isShortRail ? 19 : 21,
color: colors.textMuted, ),
size: 19, unselectedIconTheme: IconThemeData(
), color: colors.textMuted,
selectedLabelTextStyle: TextStyle( size: isShortRail ? 17 : 19,
fontFamily: AppTypography.fontHeadline, ),
fontSize: 10.0, selectedLabelTextStyle: TextStyle(
fontWeight: FontWeight.w700, fontFamily: AppTypography.fontHeadline,
color: colors.brandCyan, fontSize: isShortRail ? 9.0 : 10.0,
), fontWeight: FontWeight.w700,
unselectedLabelTextStyle: TextStyle( color: colors.brandCyan,
fontFamily: AppTypography.fontHeadline, ),
fontSize: 9.5, unselectedLabelTextStyle: TextStyle(
fontWeight: FontWeight.w500, fontFamily: AppTypography.fontHeadline,
color: colors.textMuted, fontSize: isShortRail ? 8.5 : 9.5,
), fontWeight: FontWeight.w500,
labelType: NavigationRailLabelType.all, color: colors.textMuted,
destinations: kAppNavDestinations.map((dest) { ),
return NavigationRailDestination( labelType: NavigationRailLabelType.all,
padding: const EdgeInsets.symmetric(vertical: 4), destinations: kAppNavDestinations.map((dest) {
icon: Icon(dest.icon, key: dest.railKey), return NavigationRailDestination(
selectedIcon: dest.selectedIcon != null padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
? Icon(dest.selectedIcon, key: dest.railKey) icon: Icon(dest.icon, key: dest.railKey),
: null, selectedIcon: dest.selectedIcon != null
label: Text(dest.label), ? Icon(dest.selectedIcon, key: dest.railKey)
); : null,
}).toList(), label: Text(dest.label),
), );
}).toList(),
);
}(),
), ),
), ),
), ),

View File

@ -81,11 +81,14 @@ class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter; final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
final isFresh = vehicleCoords.isGpsFresh; final isFresh = vehicleCoords.isGpsFresh;
final isFollowMode = ref.watch(mapFollowStateProvider); final isFollowMode = ref.watch(mapFollowStateProvider);
final orientation = ref.watch(mapOrientationProvider);
final isNorthUp = orientation == MapOrientation.northUp;
final effectiveBearing = isNorthUp ? 0.0 : vehicleCoords.heading;
final vehicleMarker = AppMapMarker( final vehicleMarker = AppMapMarker(
id: 'dash_vehicle', id: 'dash_vehicle',
position: activeCenter, position: activeCenter,
heading: vehicleCoords.heading, heading: isNorthUp ? vehicleCoords.heading : 0.0,
iconWidget: Icon( iconWidget: Icon(
Icons.navigation, Icons.navigation,
color: isFresh color: isFresh
@ -156,11 +159,66 @@ class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
key: const ValueKey('dash_map_x'), key: const ValueKey('dash_map_x'),
center: activeCenter, center: activeCenter,
zoom: widget.zoom, zoom: widget.zoom,
bearing: 0.0, bearing: effectiveBearing,
markers: mapMarkers, markers: mapMarkers,
polylines: polylines, polylines: polylines,
), ),
), ),
Positioned(
top: 10,
right: 10,
child: Material(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(20),
child: InkWell(
key: const ValueKey('dashboard_map_orientation_button'),
borderRadius: BorderRadius.circular(20),
onTap: () {
ref.read(mapOrientationProvider.notifier).toggle();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isNorthUp
? Colors.white24
: AppColors.cyberCyan,
width: 1.2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isNorthUp ? Icons.explore : Icons.navigation,
size: 14,
color: isNorthUp
? Colors.white70
: AppColors.cyberCyan,
),
const SizedBox(width: 5),
Text(
isNorthUp ? 'NORTH UP' : 'HEADING UP',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: isNorthUp
? Colors.white70
: AppColors.cyberCyan,
),
),
],
),
),
),
),
),
if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null)) if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
Positioned( Positioned(
top: 10, top: 10,

View File

@ -233,6 +233,7 @@ class LeanAngleIndicator extends ConsumerWidget {
// Header // Header
Text( Text(
'LEAN ANGLE', 'LEAN ANGLE',
key: const ValueKey('lean_angle_title'),
style: TextStyle( style: TextStyle(
color: onSurfaceVariant, color: onSurfaceVariant,
fontSize: compact ? 9 : 11, fontSize: compact ? 9 : 11,
@ -240,22 +241,25 @@ class LeanAngleIndicator extends ConsumerWidget {
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
SizedBox(height: compact ? 1 : 3), SizedBox(height: compact ? 3 : 6),
// Motorcycle Leaning Silhouette with Stable Horizon // Motorcycle Leaning Silhouette with Stable Horizon
RepaintBoundary( RepaintBoundary(
child: SizedBox( child: SizedBox(
key: const ValueKey('lean_angle_rider_graphic'),
width: gaugeWidth, width: gaugeWidth,
height: gaugeHeight, height: gaugeHeight,
child: CustomPaint( child: ClipRect(
size: Size(gaugeWidth, gaugeHeight), child: CustomPaint(
painter: _MotorcycleLeanPainter( size: Size(gaugeWidth, gaugeHeight),
leanAngle: displayLean, painter: _MotorcycleLeanPainter(
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0), leanAngle: displayLean,
accentColor: colors.brandCyan, motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
horizonColor: isDark ? Colors.white24 : Colors.black26, accentColor: colors.brandCyan,
tickColor: colors.brandCyan, horizonColor: isDark ? Colors.white24 : Colors.black26,
isDark: isDark, tickColor: colors.brandCyan,
isDark: isDark,
),
), ),
), ),
), ),
@ -426,6 +430,10 @@ class _MotorcycleLeanPainter extends CustomPainter {
// 2. Draw Leaning Motorcycle Silhouette // 2. Draw Leaning Motorcycle Silhouette
canvas.save(); canvas.save();
canvas.translate(contactPoint.dx, contactPoint.dy); canvas.translate(contactPoint.dx, contactPoint.dy);
// Scale silhouette to fit comfortably below the top edge of the gauge
final availableTopSpace = horizonY - 4.0;
final double scale = (availableTopSpace / 47.5).clamp(0.5, 0.95);
canvas.scale(scale);
final clampedLean = leanAngle.clamp(-60.0, 60.0); final clampedLean = leanAngle.clamp(-60.0, 60.0);
canvas.rotate(clampedLean * math.pi / 180.0); canvas.rotate(clampedLean * math.pi / 180.0);

View File

@ -326,17 +326,29 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const FittedBox(
mainAxisAlignment: MainAxisAlignment.spaceBetween, fit: BoxFit.scaleDown,
children: [ child: Row(
DashboardLayoutSwitcher(), mainAxisSize: MainAxisSize.min,
GPSStatusIndicator(showLabel: false), children: [
HeadingIndicator(compact: true), DashboardLayoutSwitcher(),
RideStatusChip(), SizedBox(width: 4),
], QuickSwarmReportButton(compact: true),
SizedBox(width: 4),
QuickSquadFeedButton(compact: true),
SizedBox(width: 4),
GPSStatusIndicator(showLabel: false),
SizedBox(width: 4),
HeadingIndicator(compact: true),
SizedBox(width: 4),
RideStatusChip(),
],
),
), ),
const Center( const Expanded(
child: SpeedReadout(size: SpeedReadoutSize.compact), child: Center(
child: SpeedReadout(size: SpeedReadoutSize.compact),
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@ -386,7 +398,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)), child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
), ),
SizedBox( SizedBox(
width: 140, width: 164,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -463,6 +475,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
children: [ children: [
DashboardLayoutSwitcher(), DashboardLayoutSwitcher(),
SizedBox(width: 4), SizedBox(width: 4),
QuickSwarmReportButton(compact: true),
SizedBox(width: 4),
QuickSquadFeedButton(compact: true),
SizedBox(width: 4),
GPSStatusIndicator(), GPSStatusIndicator(),
SizedBox(width: 4), SizedBox(width: 4),
HeadingIndicator(compact: true), HeadingIndicator(compact: true),
@ -472,7 +488,9 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
), ),
), ),
const NavigationManeuverCard(compact: true), const NavigationManeuverCard(compact: true),
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)), const Expanded(
child: Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
),
const RideActionButton(compact: true), const RideActionButton(compact: true),
const SizedBox(height: 4), const SizedBox(height: 4),
const PerformanceLaunchShortcut(compact: true), const PerformanceLaunchShortcut(compact: true),

View File

@ -168,13 +168,17 @@ Future<void> showProUpgradeSheet(
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
// A default modal sheet is capped at 9/16 of the screen height, which in landscape
// (~230 dp on a phone) is shorter than this content. Allow the full height and make
// the content scrollable so it can never overflow (Issue #52 / BUG-005).
isScrollControlled: true,
backgroundColor: const Color(0xFF0F0F0F), backgroundColor: const Color(0xFF0F0F0F),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (ctx) { builder: (ctx) {
return SafeArea( return SafeArea(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.all(20.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -112,14 +112,17 @@ class GarageScreen extends ConsumerWidget {
if (isMultiColumn) { if (isMultiColumn) {
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 550, maxCrossAxisExtent: responsive.isShortViewport ? 600 : 550,
mainAxisExtent: 420, mainAxisExtent: responsive.isShortViewport ? 300 : 420,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
), ),
itemCount: bikes.length, itemCount: bikes.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == bikes.length) {
return _buildAddMotorcycleCard(context, ref, bikes);
}
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true); return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true);
}, },
); );
@ -127,8 +130,11 @@ class GarageScreen extends ConsumerWidget {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
itemCount: bikes.length, itemCount: bikes.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == bikes.length) {
return _buildAddMotorcycleCard(context, ref, bikes);
}
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false); return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false);
}, },
); );
@ -136,19 +142,6 @@ class GarageScreen extends ConsumerWidget {
); );
}, },
), ),
floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty
? FloatingActionButton.extended(
key: const ValueKey('garage_add_motorcycle_fab'),
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
icon: const Icon(Icons.two_wheeler),
label: const Text(
'Add Motorcycle',
style: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.2),
),
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
)
: null,
); );
} }
@ -173,6 +166,27 @@ class GarageScreen extends ConsumerWidget {
context.push('/garage/add'); context.push('/garage/add');
} }
Widget _buildAddMotorcycleCard(
BuildContext context,
WidgetRef ref,
List<GarageMotorcycle> bikes,
) {
final colors = context.motorColors;
return Card(
child: Center(
child: OutlinedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Motorcycle'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)),
),
onPressed: () => _onAddMotorcycle(context, ref, bikes),
),
),
);
}
Widget _buildEmptyState( Widget _buildEmptyState(
BuildContext context, BuildContext context,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
@ -306,7 +320,7 @@ class GarageScreen extends ConsumerWidget {
year: bike.year, year: bike.year,
make: bike.makeName, make: bike.makeName,
model: bike.modelName, model: bike.modelName,
height: 150, height: isGrid ? 100 : 150,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@ -1207,17 +1207,29 @@ class MotorcycleDetailScreen extends ConsumerWidget {
builder: (context) { builder: (context) {
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
final size = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
return AlertDialog( return AlertDialog(
scrollable: true,
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: size.height - viewInsets.bottom - 48,
),
title: const Text('Log New Service'), title: const Text('Log New Service'),
content: SingleChildScrollView( content: Column(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: selectedType, initialValue: selectedType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Service Type'), decoration: const InputDecoration(labelText: 'Service Type'),
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), items: serviceTypes
.map((t) => DropdownMenuItem(
value: t,
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
))
.toList(),
onChanged: (val) { onChanged: (val) {
if (val != null) setState(() => selectedType = val); if (val != null) setState(() => selectedType = val);
}, },
@ -1268,7 +1280,6 @@ class MotorcycleDetailScreen extends ConsumerWidget {
), ),
], ],
), ),
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton( ElevatedButton(
@ -1340,8 +1351,14 @@ class MotorcycleDetailScreen extends ConsumerWidget {
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: selectedType, initialValue: selectedType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Service Type'), decoration: const InputDecoration(labelText: 'Service Type'),
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), items: serviceTypes
.map((t) => DropdownMenuItem(
value: t,
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
))
.toList(),
onChanged: (val) { onChanged: (val) {
if (val != null) setState(() => selectedType = val); if (val != null) setState(() => selectedType = val);
}, },

View File

@ -378,8 +378,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final onSurface = theme.colorScheme.onSurface; final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
FocusScope.of(context).unfocus();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true,
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
@ -388,91 +391,112 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return Consumer( return Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final routesAsync = ref.watch(userRoutesProvider(user.uid)); final routesAsync = ref.watch(userRoutesProvider(user.uid));
final viewportSize = MediaQuery.sizeOf(sheetCtx);
final viewInsets = MediaQuery.viewInsetsOf(sheetCtx);
final isCompact = viewportSize.height < 480 || viewInsets.bottom > 0;
final availableHeight = (viewportSize.height - viewInsets.bottom)
.clamp(120.0, viewportSize.height);
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: Column( child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: BoxConstraints(
crossAxisAlignment: CrossAxisAlignment.start, maxHeight: availableHeight * 0.9,
children: [ maxWidth: 560,
Text( ),
'USE EXISTING ROUTE', child: SingleChildScrollView(
style: TextStyle( padding: EdgeInsets.fromLTRB(
color: onSurface, 16,
fontWeight: FontWeight.bold, isCompact ? 12 : 16,
fontSize: 16, 16,
), isCompact ? 12 : 16,
), ),
const SizedBox(height: 8), child: Column(
Text( mainAxisSize: MainAxisSize.min,
'Pre-fills title, meetup location, intermediate waypoints, and destination.', crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(color: onSurfaceVariant, fontSize: 12), children: [
), Text(
const SizedBox(height: 12), 'USE EXISTING ROUTE',
routesAsync.when( style: TextStyle(
loading: () => Center( color: onSurface,
child: Padding( fontWeight: FontWeight.bold,
padding: const EdgeInsets.all(24), fontSize: 16,
child:
CircularProgressIndicator(color: colors.cyberCyan),
), ),
), ),
error: (err, _) => Text( const SizedBox(height: 6),
'Failed to load routes: $err', Text(
style: TextStyle(color: colors.raceRed), 'Pre-fills title, meetup location, intermediate waypoints, and destination.',
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
), ),
data: (routes) { const SizedBox(height: 10),
if (routes.isEmpty) { routesAsync.when(
return Padding( loading: () => Center(
padding: const EdgeInsets.symmetric(vertical: 24), child: Padding(
child: Center( padding: EdgeInsets.all(isCompact ? 12 : 24),
child: Text( child: CircularProgressIndicator(
'No saved routes found.', color: colors.cyberCyan),
style: TextStyle(color: onSurfaceVariant), ),
),
error: (err, _) => Text(
'Failed to load routes: $err',
style: TextStyle(color: colors.raceRed),
),
data: (routes) {
if (routes.isEmpty) {
return Padding(
padding: EdgeInsets.symmetric(
vertical: isCompact ? 12 : 24),
child: Center(
child: Text(
'No saved routes found.',
style: TextStyle(color: onSurfaceVariant),
),
), ),
);
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: availableHeight * 0.5,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: routes.length,
itemBuilder: (ctx, idx) {
final route = routes[idx];
return ListTile(
dense: isCompact,
leading: Icon(Icons.alt_route,
color: colors.cyberCyan),
title: Text(
route.title,
style: TextStyle(
color: onSurface,
fontWeight: FontWeight.bold),
),
subtitle: Text(
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
style: TextStyle(
color: onSurfaceVariant, fontSize: 12),
),
onTap: () {
Navigator.pop(sheetCtx);
_applySavedRoute(route);
},
);
},
), ),
); );
} },
return ConstrainedBox( ),
constraints: BoxConstraints( ],
maxHeight: MediaQuery.of(context).size.height * 0.4, ),
),
child: ListView.builder(
shrinkWrap: true,
itemCount: routes.length,
itemBuilder: (ctx, idx) {
final route = routes[idx];
return ListTile(
leading: Icon(Icons.alt_route,
color: colors.cyberCyan),
title: Text(
route.title,
style: TextStyle(
color: onSurface,
fontWeight: FontWeight.bold),
),
subtitle: Text(
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
style: TextStyle(
color: onSurfaceVariant, fontSize: 12),
),
onTap: () {
Navigator.pop(sheetCtx);
_applySavedRoute(route);
},
);
},
),
);
},
),
],
), ),
), ),
); ),
}, );
); },
}, );
},
); );
} }
@ -509,9 +533,34 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final theme = Theme.of(ctx); final theme = Theme.of(ctx);
final onSurface = theme.colorScheme.onSurface; final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
final screenSize = MediaQuery.sizeOf(ctx);
final isCompactDialog = screenSize.height < 480;
return AlertDialog( return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
insetPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompactDialog ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 12 : 20,
20,
0,
),
contentPadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 10 : 16,
20,
isCompactDialog ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactDialog ? 8 : 16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),
@ -520,7 +569,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop', existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
), ),
content: SingleChildScrollView( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -541,6 +591,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: nameCtrl, controller: nameCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface), style: TextStyle(color: onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Custom Stop Label (Optional)', labelText: 'Custom Stop Label (Optional)',
@ -589,6 +640,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: notesCtrl, controller: notesCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface), style: TextStyle(color: onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Notes (optional)', labelText: 'Notes (optional)',
@ -698,11 +750,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
} }
} }
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
appBar: AppBar( appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
elevation: 0, elevation: 0,
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
title: Text( title: Text(
widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE', widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
style: const TextStyle( style: const TextStyle(
@ -738,18 +793,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
], ],
), ),
body: Form( body: SafeArea(
key: _formKey, top: false,
child: ResponsiveLayout( child: Form(
builder: (context, snapshot) { key: _formKey,
final isWide = snapshot.sizeClass == WindowSizeClass.expanded || child: ResponsiveLayout(
(snapshot.isLandscape && snapshot.width >= 720); builder: (context, snapshot) {
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 600);
if (isWide) { if (isWide) {
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError); return _buildWideForm(
} context,
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError); colors,
}, snapshot,
canSubmit,
clubAsync,
memberAsync,
clubValidationError,
);
}
return _buildCompactForm(
context,
colors,
snapshot,
canSubmit,
clubAsync,
memberAsync,
clubValidationError,
);
},
),
), ),
), ),
); );
@ -764,52 +838,65 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
AsyncValue<ClubMember?>? memberAsync, AsyncValue<ClubMember?>? memberAsync,
String? clubValidationError, String? clubValidationError,
) { ) {
return SingleChildScrollView( final isCompact = snapshot.isLandscape || snapshot.isShortViewport;
padding: const EdgeInsets.all(24), final sectionGap = isCompact ? 12.0 : 24.0;
child: Column( final outerPadding = isCompact
crossAxisAlignment: CrossAxisAlignment.stretch, ? const EdgeInsets.symmetric(horizontal: 16, vertical: 10)
children: [ : const EdgeInsets.all(24);
if (widget.clubId != null) final colGap = isCompact ? 16.0 : 24.0;
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors), return Scrollbar(
Row( thumbVisibility: isCompact,
crossAxisAlignment: CrossAxisAlignment.start, child: SingleChildScrollView(
children: [ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
// Left Column: Details, Schedule, Privacy padding: outerPadding,
Expanded( child: Column(
flex: 5, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, if (widget.clubId != null)
children: [ _buildClubAffiliationHeader(
_buildRideDetailsSection(colors), colors, clubAsync, memberAsync, clubValidationError),
const SizedBox(height: 24), if (_errorMessage != null) _buildErrorBanner(colors),
_buildScheduleSection(colors), Row(
const SizedBox(height: 24), crossAxisAlignment: CrossAxisAlignment.start,
_buildPrivacySection(colors), children: [
], // Left Column: Details, Schedule, Privacy
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRideDetailsSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildScheduleSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildPrivacySection(colors, isCompact: isCompact),
],
),
), ),
), SizedBox(width: colGap),
const SizedBox(width: 24), // Right Column: Meeting Point, Destination & Stops, Preview, Submit
// Right Column: Meeting Point, Destination & Stops, Preview, Submit Expanded(
Expanded( flex: 5,
flex: 5, child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ _buildMeetingPointSection(colors, isCompact: isCompact),
_buildMeetingPointSection(colors), SizedBox(height: sectionGap),
const SizedBox(height: 24), _buildDestinationAndStopsSection(colors,
_buildDestinationAndStopsSection(colors), isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildMapPreviewSection(colors, snapshot), _buildMapPreviewSection(colors, snapshot),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildSubmitButton(colors, canSubmit), _buildSubmitButton(colors, canSubmit),
], ],
),
), ),
), ],
], ),
), SizedBox(height: isCompact ? 16 : 32),
const SizedBox(height: 32), ],
], ),
), ),
); );
} }
@ -823,27 +910,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
AsyncValue<ClubMember?>? memberAsync, AsyncValue<ClubMember?>? memberAsync,
String? clubValidationError, String? clubValidationError,
) { ) {
return ListView( final isCompact = snapshot.isShortViewport;
padding: const EdgeInsets.all(20), final sectionGap = isCompact ? 14.0 : 24.0;
children: [ return Scrollbar(
if (widget.clubId != null) thumbVisibility: isCompact,
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError), child: SingleChildScrollView(
if (_errorMessage != null) _buildErrorBanner(colors), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
_buildRideDetailsSection(colors), padding: EdgeInsets.all(isCompact ? 14 : 20),
const SizedBox(height: 24), child: Column(
_buildScheduleSection(colors), crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 24), children: [
_buildMeetingPointSection(colors), if (widget.clubId != null)
const SizedBox(height: 24), _buildClubAffiliationHeader(
_buildDestinationAndStopsSection(colors), colors, clubAsync, memberAsync, clubValidationError),
const SizedBox(height: 24), if (_errorMessage != null) _buildErrorBanner(colors),
_buildMapPreviewSection(colors, snapshot), _buildRideDetailsSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildPrivacySection(colors), _buildScheduleSection(colors, isCompact: isCompact),
const SizedBox(height: 32), SizedBox(height: sectionGap),
_buildSubmitButton(colors, canSubmit), _buildMeetingPointSection(colors, isCompact: isCompact),
const SizedBox(height: 32), SizedBox(height: sectionGap),
], _buildDestinationAndStopsSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildMapPreviewSection(colors, snapshot),
SizedBox(height: sectionGap),
_buildPrivacySection(colors, isCompact: isCompact),
SizedBox(height: isCompact ? 16 : 32),
_buildSubmitButton(colors, canSubmit),
SizedBox(height: isCompact ? 16 : 32),
],
),
),
); );
} }
@ -858,8 +955,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) { if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -893,8 +990,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
if (validationError != null) { if (validationError != null) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12), color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -937,8 +1034,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final member = memberAsync.value; final member = memberAsync.value;
return Container( return Container(
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -1001,7 +1098,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Text( Text(
'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.', 'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.',
style: TextStyle( style: TextStyle(
@ -1017,7 +1114,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
Widget _buildErrorBanner(MotorSignalThemeColors colors) { Widget _buildErrorBanner(MotorSignalThemeColors colors) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.15), color: colors.raceRed.withValues(alpha: 0.15),
@ -1039,19 +1136,25 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
); );
} }
Widget _buildRideDetailsSection(MotorSignalThemeColors colors) { Widget _buildRideDetailsSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final fieldGap = isCompact ? 8.0 : 12.0;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader('1. RIDE DETAILS', colors), _buildSectionHeader('1. RIDE DETAILS', colors),
const SizedBox(height: 10), SizedBox(height: isCompact ? 6 : 10),
TextFormField( TextFormField(
controller: _titleController, controller: _titleController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration( decoration: _inputDecoration(
'Ride Title', 'Ride Title',
'e.g. Blood Mountain Loop', 'e.g. Blood Mountain Loop',
colors, colors,
isCompact: isCompact,
), ),
validator: (v) { validator: (v) {
if (v == null || v.trim().length < 3) { if (v == null || v.trim().length < 3) {
@ -1060,50 +1163,58 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return null; return null;
}, },
), ),
const SizedBox(height: 12), SizedBox(height: fieldGap),
TextFormField( TextFormField(
controller: _descriptionController, controller: _descriptionController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 3, maxLines: isCompact ? 2 : 3,
decoration: _inputDecoration( decoration: _inputDecoration(
'Description & Route Notes', 'Description & Route Notes',
'Pace, skill level, gear recommendations...', 'Pace, skill level, gear recommendations...',
colors, colors,
isCompact: isCompact,
), ),
), ),
], ],
); );
} }
Widget _buildScheduleSection(MotorSignalThemeColors colors) { Widget _buildScheduleSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader('2. DATE & SCHEDULE', colors), _buildSectionHeader('2. DATE & SCHEDULE', colors),
const SizedBox(height: 10), SizedBox(height: isCompact ? 6 : 10),
Card( Card(
margin: EdgeInsets.zero,
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(isCompact ? 10 : 16),
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: Icon(Icons.calendar_month, color: colors.cyberCyan), leading: Icon(Icons.calendar_month, color: colors.cyberCyan),
title: Text('Date', title: Text('Date',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13)), fontSize: isCompact ? 12 : 13)),
subtitle: Text( subtitle: Text(
'${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}', '${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: isCompact ? 14 : 16,
), ),
), ),
trailing: TextButton( trailing: TextButton(
@ -1125,11 +1236,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
), ),
), ),
Divider(color: colors.outlineVariant), Divider(
color: colors.outlineVariant,
height: isCompact ? 8 : 16,
),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: ListTile( child: ListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text('Meetup Arrival', title: Text('Meetup Arrival',
style: TextStyle( style: TextStyle(
@ -1157,6 +1273,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
Expanded( Expanded(
child: ListTile( child: ListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text('Kickstands Up (KSU)', title: Text('Kickstands Up (KSU)',
style: TextStyle( style: TextStyle(
@ -1192,12 +1310,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
); );
} }
Widget _buildMeetingPointSection(MotorSignalThemeColors colors) { Widget _buildMeetingPointSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final fieldGap = isCompact ? 8.0 : 12.0;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader('3. MEETING LOCATION', colors), _buildSectionHeader('3. MEETING LOCATION', colors),
const SizedBox(height: 10), SizedBox(height: isCompact ? 6 : 10),
AddressAutocompleteField( AddressAutocompleteField(
labelText: 'Meeting Place Address / Location *', labelText: 'Meeting Place Address / Location *',
hintText: 'Search meetup address or landmark...', hintText: 'Search meetup address or landmark...',
@ -1213,14 +1335,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_triggerRouteCalculation(); _triggerRouteCalculation();
}, },
), ),
const SizedBox(height: 12), SizedBox(height: fieldGap),
TextFormField( TextFormField(
controller: _meetupNameController, controller: _meetupNameController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration( decoration: _inputDecoration(
'Meeting Place Name', 'Meeting Place Name',
'e.g. Dahlonega Square / Shell Station', 'e.g. Dahlonega Square / Shell Station',
colors, colors,
isCompact: isCompact,
), ),
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) { if (v == null || v.trim().isEmpty) {
@ -1229,30 +1353,38 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return null; return null;
}, },
), ),
const SizedBox(height: 12), SizedBox(height: fieldGap),
TextFormField( TextFormField(
controller: _meetupNotesController, controller: _meetupNotesController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration( decoration: _inputDecoration(
'Meeting Notes (optional)', 'Meeting Notes (optional)',
'e.g. Park on the north side near pumps', 'e.g. Park on the north side near pumps',
colors, colors,
isCompact: isCompact,
), ),
), ),
], ],
); );
} }
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) { Widget _buildDestinationAndStopsSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final canAdd = _canAddStop; final canAdd = _canAddStop;
final totalStops = _totalPlannedStops; final totalStops = _totalPlannedStops;
final fieldGap = isCompact ? 8.0 : 12.0;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader('4. DESTINATION & STOPS', colors), _buildSectionHeader('4. DESTINATION & STOPS', colors),
const SizedBox(height: 10), SizedBox(height: isCompact ? 4 : 10),
SwitchListTile( SwitchListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan, activeThumbColor: colors.cyberCyan,
title: Text('Specific Destination', title: Text('Specific Destination',
@ -1266,7 +1398,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
}, },
), ),
if (_hasDestination) ...[ if (_hasDestination) ...[
const SizedBox(height: 8), SizedBox(height: isCompact ? 4 : 8),
AddressAutocompleteField( AddressAutocompleteField(
labelText: 'Destination Address / Location *', labelText: 'Destination Address / Location *',
hintText: 'Search destination address or scenic peak...', hintText: 'Search destination address or scenic peak...',
@ -1282,22 +1414,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_triggerRouteCalculation(); _triggerRouteCalculation();
}, },
), ),
const SizedBox(height: 12), SizedBox(height: fieldGap),
TextFormField( TextFormField(
controller: _destNameController, controller: _destNameController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration( decoration: _inputDecoration(
'Destination Name', 'Destination Name',
'e.g. Brasstown Bald Summit', 'e.g. Brasstown Bald Summit',
colors, colors,
isCompact: isCompact,
), ),
), ),
], ],
const SizedBox(height: 16), SizedBox(height: isCompact ? 10 : 16),
// USE EXISTING ROUTE Card // USE EXISTING ROUTE Card
Container( Container(
margin: const EdgeInsets.only(bottom: 12), margin: EdgeInsets.only(bottom: isCompact ? 8 : 12),
padding: const EdgeInsets.all(12), padding: EdgeInsets.all(isCompact ? 10 : 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@ -1349,21 +1483,27 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
}, },
) )
else else
ElevatedButton.icon( Flexible(
onPressed: () => _showSelectRouteSheet(colors), child: ElevatedButton.icon(
icon: const Icon(Icons.alt_route, size: 14), onPressed: () => _showSelectRouteSheet(colors),
label: const Text('USE EXISTING ROUTE'), icon: const Icon(Icons.alt_route, size: 14),
style: ElevatedButton.styleFrom( label: const Text(
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15), 'USE EXISTING ROUTE',
foregroundColor: colors.cyberCyan, overflow: TextOverflow.ellipsis,
side: BorderSide(color: colors.cyberCyan), maxLines: 1,
padding: ),
const EdgeInsets.symmetric(horizontal: 10, vertical: 6), style: ElevatedButton.styleFrom(
minimumSize: Size.zero, backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, foregroundColor: colors.cyberCyan,
textStyle: const TextStyle( side: BorderSide(color: colors.cyberCyan),
fontSize: 11, padding: const EdgeInsets.symmetric(
fontWeight: FontWeight.bold, horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
), ),
), ),
), ),
@ -1437,6 +1577,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
for (int i = 0; i < _stops.length; i++) for (int i = 0; i < _stops.length; i++)
ListTile( ListTile(
key: ValueKey('stop_$i'), key: ValueKey('stop_$i'),
dense: isCompact,
tileColor: colors.surfaceContainerHigh, tileColor: colors.surfaceContainerHigh,
onTap: () => _addStopDialog(colors, i), onTap: () => _addStopDialog(colors, i),
leading: CircleAvatar( leading: CircleAvatar(
@ -1575,7 +1716,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_destinationPlace?.latLng ?? _destinationPlace?.latLng ??
const AppLatLng(34.5378, -83.9849); const AppLatLng(34.5378, -83.9849);
final mapHeight = snapshot.isLandscape ? 170.0 : 210.0; final mapHeight = snapshot.isShortViewport
? 130.0
: (snapshot.isLandscape ? 160.0 : 210.0);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -1602,7 +1745,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
], ],
), ),
const SizedBox(height: 10), SizedBox(height: snapshot.isShortViewport ? 6 : 10),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
@ -1718,54 +1861,80 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
); );
} }
Widget _buildPrivacySection(MotorSignalThemeColors colors) { Widget _buildPrivacySection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface; final onSurface = theme.colorScheme.onSurface;
final fieldGap = isCompact ? 8.0 : 12.0;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader('5. PRIVACY & PARTICIPATION', colors), _buildSectionHeader('5. PRIVACY & PARTICIPATION', colors),
const SizedBox(height: 10), SizedBox(height: isCompact ? 6 : 10),
DropdownButtonFormField<GroupRideVisibility>( DropdownButtonFormField<GroupRideVisibility>(
initialValue: _visibility, initialValue: _visibility,
isExpanded: true,
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: TextStyle(color: onSurface), style: TextStyle(color: onSurface),
decoration: _inputDecoration('Visibility', '', colors), decoration: _inputDecoration(
'Visibility',
'',
colors,
isCompact: isCompact,
),
items: GroupRideVisibility.values.map((v) { items: GroupRideVisibility.values.map((v) {
return DropdownMenuItem( return DropdownMenuItem(
value: v, value: v,
child: Text(v.label, style: TextStyle(color: onSurface)), child: Text(
v.label,
style: TextStyle(color: onSurface),
overflow: TextOverflow.ellipsis,
),
); );
}).toList(), }).toList(),
onChanged: (val) { onChanged: (val) {
if (val != null) setState(() => _visibility = val); if (val != null) setState(() => _visibility = val);
}, },
), ),
const SizedBox(height: 12), SizedBox(height: fieldGap),
DropdownButtonFormField<GroupRideJoinPolicy>( DropdownButtonFormField<GroupRideJoinPolicy>(
initialValue: _joinPolicy, initialValue: _joinPolicy,
isExpanded: true,
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: TextStyle(color: onSurface), style: TextStyle(color: onSurface),
decoration: _inputDecoration('Join Policy', '', colors), decoration: _inputDecoration(
'Join Policy',
'',
colors,
isCompact: isCompact,
),
items: GroupRideJoinPolicy.values.map((p) { items: GroupRideJoinPolicy.values.map((p) {
return DropdownMenuItem( return DropdownMenuItem(
value: p, value: p,
child: Text(p.label, style: TextStyle(color: onSurface)), child: Text(
p.label,
style: TextStyle(color: onSurface),
overflow: TextOverflow.ellipsis,
),
); );
}).toList(), }).toList(),
onChanged: (val) { onChanged: (val) {
if (val != null) setState(() => _joinPolicy = val); if (val != null) setState(() => _joinPolicy = val);
}, },
), ),
const SizedBox(height: 12), SizedBox(height: fieldGap),
TextFormField( TextFormField(
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface), style: TextStyle(color: onSurface),
decoration: _inputDecoration( decoration: _inputDecoration(
'Participant Limit (optional)', 'Participant Limit (optional)',
'Leave blank for no limit (min 2)', 'Leave blank for no limit (min 2)',
colors, colors,
isCompact: isCompact,
), ),
onChanged: (val) { onChanged: (val) {
final parsed = int.tryParse(val.trim()); final parsed = int.tryParse(val.trim());
@ -1826,11 +1995,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
InputDecoration _inputDecoration( InputDecoration _inputDecoration(
String label, String label,
String hint, String hint,
MotorSignalThemeColors colors, MotorSignalThemeColors colors, {
) { bool isCompact = false,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return InputDecoration( return InputDecoration(
isDense: isCompact,
contentPadding: isCompact
? const EdgeInsets.symmetric(horizontal: 12, vertical: 10)
: null,
labelText: label, labelText: label,
hintText: hint.isNotEmpty ? hint : null, hintText: hint.isNotEmpty ? hint : null,
labelStyle: TextStyle(color: onSurfaceVariant, fontSize: 13), labelStyle: TextStyle(color: onSurfaceVariant, fontSize: 13),

View File

@ -1439,6 +1439,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true,
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
@ -1447,156 +1448,180 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
return Consumer( return Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final routesAsync = ref.watch(userRoutesProvider(user.uid)); final routesAsync = ref.watch(userRoutesProvider(user.uid));
final screenSize = MediaQuery.sizeOf(sheetCtx);
final viewInsets = MediaQuery.viewInsetsOf(sheetCtx);
final isCompact = screenSize.height < 480 || viewInsets.bottom > 0;
final availableHeight = (screenSize.height - viewInsets.bottom)
.clamp(120.0, screenSize.height);
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: Column( child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: BoxConstraints(
crossAxisAlignment: CrossAxisAlignment.start, maxHeight: availableHeight * 0.9,
children: [ maxWidth: 560,
Text( ),
'ATTACH SAVED ROUTE', child: SingleChildScrollView(
style: TextStyle( padding: EdgeInsets.symmetric(
color: Theme.of(context).colorScheme.onSurface, horizontal: 16,
fontWeight: FontWeight.bold, vertical: isCompact ? 10 : 16,
fontSize: 16,
),
), ),
const SizedBox(height: 12), child: Column(
routesAsync.when( mainAxisSize: MainAxisSize.min,
loading: () => Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.all(24), Text(
child: 'ATTACH SAVED ROUTE',
CircularProgressIndicator(color: colors.cyberCyan), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 16,
),
), ),
), SizedBox(height: isCompact ? 8 : 12),
error: (err, _) => Text( routesAsync.when(
'Failed to load routes: $err', loading: () => Center(
style: TextStyle(color: colors.raceRed), child: Padding(
), padding: EdgeInsets.all(isCompact ? 12 : 24),
data: (routes) { child: CircularProgressIndicator(
if (routes.isEmpty) { color: colors.cyberCyan,
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Column(
children: [
Text(
'You have no saved routes yet.',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
Navigator.pop(sheetCtx);
context.push(
'/routes/planner?groupRideId=${ride.id}',
);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: colors.onBrand,
),
child: const Text('CREATE A ROUTE'),
),
],
), ),
), ),
);
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
), ),
child: ListView.builder( error: (err, _) => Text(
shrinkWrap: true, 'Failed to load routes: $err',
itemCount: routes.length, style: TextStyle(color: colors.raceRed),
itemBuilder: (ctx, idx) { ),
final route = routes[idx]; data: (routes) {
return ListTile( if (routes.isEmpty) {
leading: Icon( return Padding(
Icons.alt_route, padding: EdgeInsets.symmetric(
color: colors.cyberCyan, vertical: isCompact ? 8 : 24,
), ),
title: Text( child: Center(
route.title, child: Column(
style: TextStyle( mainAxisSize: MainAxisSize.min,
color: Theme.of(context) children: [
.colorScheme Text(
.onSurface, 'You have no saved routes yet.',
fontWeight: FontWeight.bold, style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
SizedBox(height: isCompact ? 8 : 12),
ElevatedButton(
onPressed: () {
Navigator.pop(sheetCtx);
context.push(
'/routes/planner?groupRideId=${ride.id}',
);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: colors.onBrand,
),
child: const Text('CREATE A ROUTE'),
),
],
), ),
), ),
subtitle: Text(
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontSize: 12,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.4),
),
onTap: () async {
Navigator.pop(sheetCtx);
try {
final user = ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) {
throw StateError(
'Sign-in required to attach route.');
}
await ref
.read(routeServiceProvider)
.attachRouteToGroupRide(
groupRideId: ride.id,
route: route,
callerUid: user.uid,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
'Attached "${route.title}" to group ride.',
),
backgroundColor:
colors.launchGreen,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
'Failed to attach route: $e',
),
),
);
}
}
},
); );
}, }
), return ConstrainedBox(
); constraints: BoxConstraints(
}, maxHeight: availableHeight * 0.55,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: routes.length,
itemBuilder: (ctx, idx) {
final route = routes[idx];
return ListTile(
dense: isCompact,
leading: Icon(
Icons.alt_route,
color: colors.cyberCyan,
),
title: Text(
route.title,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontSize: 12,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.4),
),
onTap: () async {
Navigator.pop(sheetCtx);
try {
final user =
ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) {
throw StateError(
'Sign-in required to attach route.',
);
}
await ref
.read(routeServiceProvider)
.attachRouteToGroupRide(
groupRideId: ride.id,
route: route,
callerUid: user.uid,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
'Attached "${route.title}" to group ride.',
),
backgroundColor:
colors.launchGreen,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
'Failed to attach route: $e',
),
),
);
}
}
},
);
},
),
);
},
),
],
), ),
], ),
), ),
), ),
); );

View File

@ -115,9 +115,17 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
if (!isAuthed) { if (!isAuthed) {
return _buildAccountRequiredNotice(context, colors); return _buildAccountRequiredNotice(context, colors);
} }
final showBanner = snapshot.height >= 160;
return Column( return Column(
children: [ children: [
_buildMemberProfileBanner(context, ref, user, colors), if (showBanner)
_buildMemberProfileBanner(
context,
ref,
user,
colors,
isCompact: snapshot.isShortViewport,
),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@ -151,8 +159,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
AuthUser user, AuthUser user,
MotorSignalThemeColors colors, MotorSignalThemeColors colors, {
) { bool isCompact = false,
}) {
final profileAsync = ref.watch(myRiderProfileProvider); final profileAsync = ref.watch(myRiderProfileProvider);
final isPro = ref.watch(isProSubscriberProvider); final isPro = ref.watch(isProSubscriberProvider);
final profile = profileAsync.valueOrNull; final profile = profileAsync.valueOrNull;
@ -177,7 +186,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
bottom: BorderSide(color: colors.surfaceBorder, width: 1.0), bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
), ),
), ),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), padding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: isCompact ? 8.0 : 12.0,
),
child: Column( child: Column(
children: [ children: [
Row( Row(
@ -415,12 +427,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
mainAxisExtent: 260, mainAxisExtent: 300,
), ),
itemCount: rides.length, itemCount: rides.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final ride = rides[index]; final ride = rides[index];
return _buildRideCard(context, ride, currentUid, colors); return _buildRideCard(
context,
ride,
currentUid,
colors,
inGrid: true,
);
}, },
); );
} }
@ -479,12 +497,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
mainAxisExtent: 260, mainAxisExtent: 300,
), ),
itemCount: myRides.length, itemCount: myRides.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final ride = myRides[index]; final ride = myRides[index];
return _buildRideCard(context, ride, currentUid, colors); return _buildRideCard(
context,
ride,
currentUid,
colors,
inGrid: true,
);
}, },
); );
} }
@ -563,9 +587,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return Center(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
@ -638,61 +663,113 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
final controller = TextEditingController(); final controller = TextEditingController();
showDialog( showDialog(
context: context, context: context,
builder: (dialogCtx) => AlertDialog( builder: (dialogCtx) {
backgroundColor: colors.surfaceContainerHigh, final size = MediaQuery.sizeOf(dialogCtx);
title: Row( final viewInsets = MediaQuery.viewInsetsOf(dialogCtx);
children: [ final availableHeight = size.height - viewInsets.bottom;
Icon(Icons.group_add, color: colors.cyberCyan, size: 24), final isCompactVertical = availableHeight < 280;
const SizedBox(width: 10),
const Text( return AlertDialog(
'Join Group Ride', scrollable: true,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), backgroundColor: colors.surfaceContainerHigh,
insetPadding: EdgeInsets.symmetric(
horizontal: 24,
vertical: isCompactVertical ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactVertical ? 12 : 20,
20,
isCompactVertical ? 6 : 12,
),
contentPadding: EdgeInsets.fromLTRB(
20,
0,
20,
isCompactVertical ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactVertical ? 8 : 14,
),
title: Row(
children: [
Icon(
Icons.group_add,
color: colors.cyberCyan,
size: isCompactVertical ? 20 : 24,
),
const SizedBox(width: 10),
Flexible(
child: Text(
'Join Group Ride',
style: TextStyle(
fontSize: isCompactVertical ? 16 : 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter the Ride ID or shared link:',
style: TextStyle(fontSize: 13),
),
SizedBox(height: isCompactVertical ? 8 : 12),
TextField(
key: const ValueKey('join_ride_id_field'),
controller: controller,
autofocus: true,
scrollPadding: const EdgeInsets.fromLTRB(20, 20, 20, 80),
decoration: InputDecoration(
hintText: 'e.g. ride_1790320722076_4btn4W',
isDense: isCompactVertical,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: isCompactVertical ? 8 : 10,
),
),
),
],
), ),
], ),
), actions: [
content: Column( TextButton(
mainAxisSize: MainAxisSize.min, onPressed: () => Navigator.of(dialogCtx).pop(),
crossAxisAlignment: CrossAxisAlignment.start, child: const Text('CANCEL'),
children: [
const Text(
'Enter the Ride ID or shared link:',
style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 12), ElevatedButton(
TextField( key: const ValueKey('join_ride_dialog_submit_button'),
key: const ValueKey('join_ride_id_field'), style: ElevatedButton.styleFrom(
controller: controller, backgroundColor: colors.cyberCyan,
autofocus: true, foregroundColor: colors.onBrand,
decoration: InputDecoration( ),
hintText: 'e.g. ride_1790320722076_4btn4W', onPressed: () {
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), final raw = controller.text.trim();
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), if (raw.isEmpty) return;
final rideId = raw.contains('/') ? raw.split('/').last : raw;
Navigator.of(dialogCtx).pop();
context.push('/group-rides/$rideId');
},
child: const Text(
'VIEW RIDE',
style: TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
], ],
), );
actions: [ },
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('CANCEL'),
),
ElevatedButton(
key: const ValueKey('join_ride_dialog_submit_button'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: colors.onBrand,
),
onPressed: () {
final raw = controller.text.trim();
if (raw.isEmpty) return;
final rideId = raw.contains('/') ? raw.split('/').last : raw;
Navigator.of(dialogCtx).pop();
context.push('/group-rides/$rideId');
},
child: const Text('VIEW RIDE', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
); );
} }
@ -700,13 +777,14 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
BuildContext context, BuildContext context,
GroupRide ride, GroupRide ride,
String currentUid, String currentUid,
MotorSignalThemeColors colors, MotorSignalThemeColors colors, {
) { bool inGrid = false,
}) {
final isOrganizer = ride.organizerUid == currentUid; final isOrganizer = ride.organizerUid == currentUid;
final isParticipant = ride.accessUids.contains(currentUid); final isParticipant = ride.accessUids.contains(currentUid);
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: inGrid ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12),
color: colors.surfaceCardPrimary, color: colors.surfaceCardPrimary,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -924,9 +1002,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
), ),
), ),
onPressed: () => context.push('/group-rides/${ride.id}'), onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text( child: const FittedBox(
'MANAGE RIDE', fit: BoxFit.scaleDown,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), child: Text(
'MANAGE RIDE',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
), ),
), ),
), ),
@ -945,9 +1026,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
), ),
), ),
onPressed: () => context.push('/group-rides/${ride.id}'), onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text( child: const FittedBox(
'OPEN HUB', fit: BoxFit.scaleDown,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), child: Text(
'OPEN HUB',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
), ),
), ),
), ),
@ -971,9 +1055,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
), ),
), ),
onPressed: () => context.push('/group-rides/${ride.id}'), onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text( child: const FittedBox(
'RIDE DETAILS', fit: BoxFit.scaleDown,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), child: Text(
'RIDE DETAILS',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
), ),
), ),
), ),
@ -994,9 +1081,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
), ),
), ),
onPressed: () => context.push('/group-rides/${ride.id}'), onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text( child: const FittedBox(
'LEAVE PACK', fit: BoxFit.scaleDown,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), child: Text(
'LEAVE PACK',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
), ),
), ),
), ),
@ -1019,9 +1109,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
), ),
), ),
onPressed: () => context.push('/group-rides/${ride.id}'), onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text( child: const FittedBox(
'RIDE DETAILS', fit: BoxFit.scaleDown,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), child: Text(
'RIDE DETAILS',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
), ),
), ),
), ),
@ -1040,9 +1133,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
), ),
), ),
onPressed: () => context.push('/group-rides/${ride.id}'), onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text( child: const FittedBox(
'JOIN RIDE', fit: BoxFit.scaleDown,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), child: Text(
'JOIN RIDE',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
), ),
), ),
), ),

View File

@ -295,10 +295,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
body: ResponsiveLayout( body: ResponsiveLayout(
builder: (context, snapshot) { builder: (context, snapshot) {
final isShort = snapshot.height < 450; final isShort = snapshot.height < 450;
final isLandscapeWide = final isLandscape =
snapshot.isLandscape && snapshot.width >= 720; snapshot.isLandscape && snapshot.width >= 560;
final mapStack = Stack( Widget buildMapStack({required bool inLandscape}) => Stack(
children: [ children: [
// 1. Unified Map // 1. Unified Map
Positioned.fill( Positioned.fill(
@ -336,7 +336,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point // 5. Map HUD Bottom: Map Mode Selector & Regroup Point
Positioned( Positioned(
bottom: 8, bottom: inLandscape ? (isShort ? 94 : 108) : 8,
left: 8, left: 8,
right: 8, right: 8,
child: FittedBox( child: FittedBox(
@ -360,7 +360,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
Positioned( Positioned(
top: 54, top: 54,
left: 8, left: 8,
right: isLandscapeWide ? snapshot.width * 0.35 : 8, right: inLandscape ? snapshot.width * 0.35 : 8,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -386,15 +386,17 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
], ],
); );
if (isLandscapeWide) { if (isLandscape) {
final leftFlex = snapshot.width >= 720 ? 65 : 60;
final rightFlex = 100 - leftFlex;
return Row( return Row(
children: [ children: [
// Left: Tactical Map Canvas // Left: Tactical Map Canvas
Expanded( Expanded(
flex: 65, flex: leftFlex,
child: Stack( child: Stack(
children: [ children: [
mapStack, buildMapStack(inLandscape: true),
Positioned( Positioned(
bottom: isShort ? 6 : 10, bottom: isShort ? 6 : 10,
left: 8, left: 8,
@ -435,7 +437,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
), ),
// Right: Pack Telemetry Stream & Formation Safety Metrics // Right: Pack Telemetry Stream & Formation Safety Metrics
Expanded( Expanded(
flex: 35, flex: rightFlex,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainer, color: colors.surfaceContainer,
@ -477,7 +479,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
// Top Section: Map Canvas with HUDs // Top Section: Map Canvas with HUDs
Expanded( Expanded(
flex: 52, flex: 52,
child: mapStack, child: buildMapStack(inLandscape: false),
), ),
// Cockpit Navigation Tab Bar // Cockpit Navigation Tab Bar
@ -1019,7 +1021,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
]; ];
return Container( return Container(
height: isShort ? 40 : 46, height: isShort ? 38 : 46,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh.withValues(alpha: 0.95), color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@ -1028,71 +1030,84 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)), BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
], ],
), ),
child: SingleChildScrollView( child: LayoutBuilder(
scrollDirection: Axis.horizontal, builder: (context, constraints) {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), final statusItems = Row(
child: Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ for (int index = 0; index < statuses.length; index++) ...[
for (int index = 0; index < statuses.length; index++) ...[ if (index > 0) const SizedBox(width: 4),
if (index > 0) const SizedBox(width: 6), () {
() { final (status, label, icon) = statuses[index];
final (status, label, icon) = statuses[index]; final isSelected = currentStatus == status;
final isSelected = currentStatus == status;
Color activeColor = colors.cyberCyan; Color activeColor = colors.cyberCyan;
if (status == LivePresenceStatus.needFuel) { if (status == LivePresenceStatus.needFuel) {
activeColor = Colors.orangeAccent; activeColor = Colors.orangeAccent;
} }
if (status == LivePresenceStatus.regrouping) { if (status == LivePresenceStatus.regrouping) {
activeColor = colors.warningAmber; activeColor = colors.warningAmber;
} }
if (status == LivePresenceStatus.arrived) { if (status == LivePresenceStatus.arrived) {
activeColor = colors.launchGreen; activeColor = colors.launchGreen;
} }
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
onTap: () => onStatusSelected(status), onTap: () => onStatusSelected(status),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: isShort ? 6 : 8,
color: isSelected vertical: isShort ? 3 : 4,
? activeColor.withValues(alpha: 0.2) ),
: Colors.transparent, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6), color: isSelected
border: Border.all( ? activeColor.withValues(alpha: 0.2)
color: isSelected ? activeColor : Colors.transparent, : Colors.transparent,
width: 1.2, borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSelected ? activeColor : Colors.transparent,
width: 1.2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: isShort ? 14 : 16,
color: isSelected ? activeColor : Colors.white60,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: isSelected ? activeColor : Colors.white70,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
fontSize: isShort ? 10 : 11,
),
),
],
), ),
), ),
child: Row( );
mainAxisSize: MainAxisSize.min, }(),
children: [ ],
Icon(
icon,
size: 16,
color: isSelected ? activeColor : Colors.white60,
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
color: isSelected ? activeColor : Colors.white70,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
fontSize: 11,
),
),
],
),
),
);
}(),
], ],
], );
),
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: statusItems,
),
);
},
), ),
); );
} }
@ -1109,165 +1124,190 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
required MotorSignalThemeColors colors, required MotorSignalThemeColors colors,
bool isShort = false, bool isShort = false,
}) { }) {
final screenWidth = MediaQuery.of(context).size.width; return LayoutBuilder(
final isCompact = screenWidth < 360; builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final isCompact = availableWidth < 420;
return Container( return Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 8, horizontal: isCompact ? 6 : 8,
vertical: isShort ? 4 : 6, vertical: isShort ? 4 : 6,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh.withValues(alpha: 0.95), color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant), border: Border.all(color: colors.outlineVariant),
boxShadow: const [ boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)), BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
], ],
), ),
child: Row( child: SingleChildScrollView(
children: [ scrollDirection: Axis.horizontal,
// Ghost mode / Broadcast switch child: ConstrainedBox(
Expanded( constraints: BoxConstraints(
child: Row( minWidth: availableWidth > 16 ? availableWidth - 16 : 0,
children: [ ),
Icon( child: Row(
broadcastState.isBroadcasting mainAxisSize: MainAxisSize.min,
? Icons.radar mainAxisAlignment: MainAxisAlignment.spaceBetween,
: Icons.location_off, children: [
color: broadcastState.isBroadcasting // Broadcast state info & switch
? colors.cyberCyan Row(
: Colors.white38,
size: isCompact ? 18 : 20,
),
SizedBox(width: isCompact ? 4 : 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Icon(
broadcastState.isBroadcasting broadcastState.isBroadcasting
? (broadcastState.isPaused ? Icons.radar
? 'PAUSED' : Icons.location_off,
: (broadcastState.isBackground color: broadcastState.isBroadcasting
? 'BACKGROUND' ? colors.cyberCyan
: 'SHARING LIVE')) : Colors.white38,
: 'GHOST MODE', size: isCompact ? 16 : 20,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: broadcastState.isBroadcasting
? (broadcastState.isPaused
? colors.warningAmber
: colors.cyberCyan)
: Colors.white60,
fontWeight: FontWeight.bold,
fontSize: isCompact ? 10 : 11,
letterSpacing: 0.5,
),
), ),
Text( SizedBox(width: isCompact ? 4 : 8),
broadcastState.isBroadcasting Column(
? (broadcastState.isPaused crossAxisAlignment: CrossAxisAlignment.start,
? 'Reconnecting...' mainAxisSize: MainAxisSize.min,
: (broadcastState.isBackground children: [
? 'Foreground active' Text(
: 'Broadcasting')) broadcastState.isBroadcasting
: 'Location paused', ? (broadcastState.isPaused
maxLines: 1, ? 'PAUSED'
overflow: TextOverflow.ellipsis, : (broadcastState.isBackground
style: TextStyle( ? 'BACKGROUND'
color: Colors.white38, : 'SHARING LIVE'))
fontSize: isCompact ? 9 : 10, : 'GHOST MODE',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: broadcastState.isBroadcasting
? (broadcastState.isPaused
? colors.warningAmber
: colors.cyberCyan)
: Colors.white60,
fontWeight: FontWeight.bold,
fontSize: isCompact ? 10 : 11,
letterSpacing: 0.5,
),
),
if (!isShort)
Text(
broadcastState.isBroadcasting
? (broadcastState.isPaused
? 'Reconnecting...'
: (broadcastState.isBackground
? 'Foreground active'
: 'Broadcasting'))
: 'Location paused',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white38,
fontSize: isCompact ? 8.5 : 10,
),
),
],
),
SizedBox(width: isCompact ? 4 : 6),
Transform.scale(
scale: isCompact ? 0.8 : 1.0,
child: Switch(
key: const ValueKey('live_ride_broadcast_switch'),
value: broadcastState.isOptedIn,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeThumbColor: colors.cyberCyan,
onChanged: (val) {
if (val && !broadcastState.isMasterAllowed) {
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
}
broadcastController.setOptIn(val);
},
), ),
), ),
], ],
), ),
),
Switch(
key: const ValueKey('live_ride_broadcast_switch'),
value: broadcastState.isOptedIn,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeThumbColor: colors.cyberCyan,
onChanged: (val) {
if (val && !broadcastState.isMasterAllowed) {
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
}
broadcastController.setOptIn(val);
},
),
],
),
),
SizedBox(width: isCompact ? 4 : 8), SizedBox(width: isCompact ? 6 : 10),
// Recenter Map Button (min 48x48 tap target) // Action buttons
IconButton( Row(
key: const ValueKey('live_ride_recenter_button'), mainAxisSize: MainAxisSize.min,
style: IconButton.styleFrom( children: [
backgroundColor: colors.surfaceContainer, // Recenter Map Button
padding: EdgeInsets.all(isCompact ? 6 : 8), IconButton(
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48), key: const ValueKey('live_ride_recenter_button'),
), style: IconButton.styleFrom(
icon: Icon( backgroundColor: colors.surfaceContainer,
Icons.my_location, padding: EdgeInsets.all(isCompact ? 4 : 8),
color: Colors.white70, minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
size: isCompact ? 18 : 20, ),
), icon: Icon(
tooltip: 'Center on Me', Icons.my_location,
onPressed: () { color: Colors.white70,
if (broadcastState.lastPosition != null) { size: isCompact ? 16 : 20,
setState(() { ),
_customMapCenter = AppLatLng( tooltip: 'Center on Me',
broadcastState.lastPosition!.latitude, onPressed: () {
broadcastState.lastPosition!.longitude, if (broadcastState.lastPosition != null) {
); setState(() {
_mapZoom = 15.0; _customMapCenter = AppLatLng(
}); broadcastState.lastPosition!.latitude,
} else { broadcastState.lastPosition!.longitude,
setState(() { );
_customMapCenter = AppLatLng( _mapZoom = 15.0;
ride.meetingPoint.latitude, });
ride.meetingPoint.longitude, } else {
); setState(() {
}); _customMapCenter = AppLatLng(
} ride.meetingPoint.latitude,
}, ride.meetingPoint.longitude,
), );
});
}
},
),
SizedBox(width: isCompact ? 4 : 6), SizedBox(width: isCompact ? 4 : 6),
// Roster Opener Button // Roster Opener Button
ElevatedButton.icon( ElevatedButton.icon(
key: const ValueKey('live_ride_roster_button'), key: const ValueKey('live_ride_roster_button'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15), backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
foregroundColor: colors.cyberCyan, foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan), side: BorderSide(color: colors.cyberCyan),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 10, horizontal: isCompact ? 6 : 10,
vertical: isCompact ? 6 : 8, vertical: isCompact ? 4 : 8,
),
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
),
icon: const Icon(Icons.format_list_bulleted, size: 14),
label: Text(
'${participants.length}',
style: TextStyle(
fontSize: isCompact ? 10 : 11,
fontWeight: FontWeight.bold,
),
),
onPressed: () => _showRosterSheet(
context: context,
participants: participants,
presences: presences,
leadPresence: leadPresence,
currentUid: currentUid,
colors: colors,
),
),
],
),
],
), ),
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
),
icon: const Icon(Icons.format_list_bulleted, size: 16),
label: Text(
'${participants.length}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
onPressed: () => _showRosterSheet(
context: context,
participants: participants,
presences: presences,
leadPresence: leadPresence,
currentUid: currentUid,
colors: colors,
), ),
), ),
], );
), },
); );
} }
@ -2171,16 +2211,16 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Flexible( Expanded(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.stream, size: 13, color: colors.cyberCyan), Icon(Icons.stream, size: 13, color: colors.cyberCyan),
const SizedBox(width: 5), const SizedBox(width: 5),
Flexible( Expanded(
child: Text( child: Text(
'PACK TELEMETRY STREAM', 'PACK TELEMETRY STREAM',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle( style: TextStyle(
color: colors.cyberCyan, color: colors.cyberCyan,
fontSize: 10, fontSize: 10,

View File

@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 600, maxCrossAxisExtent: 600,
mainAxisExtent: 175, mainAxisExtent: 196,
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisSpacing: 12, mainAxisSpacing: 12,
), ),
itemCount: rides.length, itemCount: rides.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildRideCard(context, rides[index], colors); return _buildRideCard(context, rides[index], colors, isGrid: true);
}, },
); );
} }
@ -154,7 +154,12 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
); );
} }
Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) { Widget _buildRideCard(
BuildContext context,
Ride ride,
MotorSignalThemeColors colors, {
bool isGrid = false,
}) {
final bikeName = (ride.motorcycleYear != null && final bikeName = (ride.motorcycleYear != null &&
ride.motorcycleMake != null && ride.motorcycleMake != null &&
ride.motorcycleModel != null) ride.motorcycleModel != null)
@ -169,7 +174,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
return Card( return Card(
elevation: 2, elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 6), margin: isGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),

View File

@ -48,7 +48,6 @@ class _MapScreenState extends ConsumerState<MapScreen> {
bool _showCornering = true; bool _showCornering = true;
double _currentZoom = 17.0; double _currentZoom = 17.0;
bool _isNorthUp = true;
String _destinationQuery = ''; String _destinationQuery = '';
late final TextEditingController _searchController; late final TextEditingController _searchController;
Timer? _searchDebounce; Timer? _searchDebounce;
@ -907,6 +906,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final isFollowMode = ref.watch(mapFollowStateProvider); final isFollowMode = ref.watch(mapFollowStateProvider);
final layerSettings = ref.watch(mapLayerSettingsProvider); final layerSettings = ref.watch(mapLayerSettingsProvider);
final isNorthUp = ref.watch(mapOrientationProvider) == MapOrientation.northUp;
final colors = context.motorColors; final colors = context.motorColors;
final theme = Theme.of(context); final theme = Theme.of(context);
@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
), ),
), ),
// Bottom Center: FIRE OFF REPLAY floating button // Bottom Center: FIRE OFF REPLAY floating button (portrait only; in landscape play_replay_appbar_button is used)
if (_routePoints.isNotEmpty) if (_routePoints.isNotEmpty && !responsive.isLandscape)
Positioned( Positioned(
bottom: 24, bottom: 24,
left: 20, left: 20,
@ -1084,7 +1084,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final motorcycleMarker = AppMapMarker( final motorcycleMarker = AppMapMarker(
id: 'motorcycle_live', id: 'motorcycle_live',
position: currentPos, position: currentPos,
heading: _isNorthUp ? currentHeading : 0.0, heading: isNorthUp ? currentHeading : 0.0,
iconWidget: Icon( iconWidget: Icon(
Icons.navigation, Icons.navigation,
color: colors.cyberCyan, color: colors.cyberCyan,
@ -1189,7 +1189,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
key: const ValueKey('live_map_view'), key: const ValueKey('live_map_view'),
center: currentPos, center: currentPos,
zoom: _currentZoom, zoom: _currentZoom,
bearing: _isNorthUp ? 0.0 : currentHeading, bearing: isNorthUp ? 0.0 : currentHeading,
markers: mapMarkers, markers: mapMarkers,
polylines: livePolylines, polylines: livePolylines,
), ),
@ -1280,10 +1280,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
_HudIconButton( _HudIconButton(
key: const ValueKey('compass_orient_button'), key: const ValueKey('compass_orient_button'),
icon: _isNorthUp ? Icons.explore : Icons.navigation, icon: isNorthUp ? Icons.explore : Icons.navigation,
tooltip: _isNorthUp ? 'North Up' : 'Heading Up', tooltip: isNorthUp ? 'North Up' : 'Heading Up',
color: _isNorthUp ? Colors.white70 : colors.cyberCyan, color: isNorthUp ? Colors.white70 : colors.cyberCyan,
onTap: () => setState(() => _isNorthUp = !_isNorthUp), onTap: () => ref.read(mapOrientationProvider.notifier).toggle(),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_HudIconButton( _HudIconButton(

View File

@ -182,21 +182,23 @@ class _SetupView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport; final isLandscape = snapshot.isLandscape;
if (isShortLandscape) { if (isLandscape) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildModeSelector(context), _buildModeSelector(context, isLandscape: true),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildTargetSelector(context), _buildTargetSelector(context, isLandscape: true),
], ],
), ),
), ),
@ -204,12 +206,13 @@ class _SetupView extends StatelessWidget {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildExplanationCard(context), _buildExplanationCard(context, isLandscape: true),
const SizedBox(height: 12), const SizedBox(height: 10),
_buildArmButton(context), _buildArmButton(context, isLandscape: true),
], ],
), ),
), ),
@ -236,7 +239,7 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildModeSelector(BuildContext context) { Widget _buildModeSelector(BuildContext context, {bool isLandscape = false}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -244,13 +247,13 @@ class _SetupView extends StatelessWidget {
'START MODE', 'START MODE',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 1.0, letterSpacing: 1.0,
color: isDark ? Colors.white60 : Colors.black54, color: isDark ? Colors.white60 : Colors.black54,
), ),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 6 : 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -263,6 +266,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onModeChanged(PerformanceLaunchMode.auto), onTap: () => onModeChanged(PerformanceLaunchMode.auto),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -276,6 +280,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onModeChanged(PerformanceLaunchMode.countdown), onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
], ],
@ -284,7 +289,7 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildTargetSelector(BuildContext context) { Widget _buildTargetSelector(BuildContext context, {bool isLandscape = false}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -292,13 +297,13 @@ class _SetupView extends StatelessWidget {
'BENCHMARK TARGET', 'BENCHMARK TARGET',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 1.0, letterSpacing: 1.0,
color: isDark ? Colors.white60 : Colors.black54, color: isDark ? Colors.white60 : Colors.black54,
), ),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 6 : 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -311,6 +316,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty), onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -324,6 +330,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred), onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
], ],
@ -332,13 +339,13 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildExplanationCard(BuildContext context) { Widget _buildExplanationCard(BuildContext context, {bool isLandscape = false}) {
final text = selectedMode == PerformanceLaunchMode.auto final text = selectedMode == PerformanceLaunchMode.auto
? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).' ? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).'
: 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.'; : 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.';
return Container( return Container(
padding: const EdgeInsets.all(14), padding: EdgeInsets.all(isLandscape ? 10 : 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainer, color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -349,17 +356,17 @@ class _SetupView extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.info_outline, Icons.info_outline,
size: 20, size: isLandscape ? 18 : 20,
color: colors.velocityBlue, color: colors.velocityBlue,
), ),
const SizedBox(width: 10), SizedBox(width: isLandscape ? 8 : 10),
Expanded( Expanded(
child: Text( child: Text(
text, text,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: isLandscape ? 11.5 : 13,
height: 1.35, height: 1.3,
color: isDark ? Colors.white70 : Colors.black87, color: isDark ? Colors.white70 : Colors.black87,
), ),
), ),
@ -369,15 +376,16 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildArmButton(BuildContext context) { Widget _buildArmButton(BuildContext context, {bool isLandscape = false}) {
return SizedBox( return SizedBox(
height: 52, height: isLandscape ? 44 : 52,
width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
key: const ValueKey('btn_arm_launch'), key: const ValueKey('btn_arm_launch'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.launchGreen, backgroundColor: colors.launchGreen,
foregroundColor: Colors.black, foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 2, elevation: 2,
), ),
onPressed: onArm, onPressed: onArm,
@ -385,7 +393,7 @@ class _SetupView extends StatelessWidget {
'Arm Launch', 'Arm Launch',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
@ -403,6 +411,7 @@ class _SelectionButton extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final MotorSignalThemeColors colors; final MotorSignalThemeColors colors;
final bool isDark; final bool isDark;
final bool isLandscape;
const _SelectionButton({ const _SelectionButton({
super.key, super.key,
@ -413,6 +422,7 @@ class _SelectionButton extends StatelessWidget {
required this.onTap, required this.onTap,
required this.colors, required this.colors,
required this.isDark, required this.isDark,
this.isLandscape = false,
}); });
@override @override
@ -428,7 +438,10 @@ class _SelectionButton extends StatelessWidget {
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 8 : 12,
vertical: isLandscape ? 8 : 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -439,27 +452,31 @@ class _SelectionButton extends StatelessWidget {
children: [ children: [
Icon( Icon(
icon, icon,
size: 22, size: isLandscape ? 18 : 22,
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54), color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 4 : 8),
Text( Text(
title, title,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 15, fontSize: isLandscape ? 13 : 15,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle, subtitle,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 11, fontSize: isLandscape ? 10 : 11,
color: isDark ? Colors.white60 : Colors.black54, color: isDark ? Colors.white60 : Colors.black54,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@ -508,44 +525,48 @@ class _WaitingView extends ConsumerWidget {
statusColor = colors.velocityBlue; statusColor = colors.velocityBlue;
} }
return Padding( final isLandscape = snapshot.isLandscape;
padding: const EdgeInsets.all(24), return Center(
child: Column( child: SingleChildScrollView(
mainAxisAlignment: MainAxisAlignment.center, physics: const ClampingScrollPhysics(),
crossAxisAlignment: CrossAxisAlignment.center, padding: EdgeInsets.all(isLandscape ? 16 : 24),
children: [ child: Column(
Icon(statusIcon, size: 54, color: statusColor), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 20), crossAxisAlignment: CrossAxisAlignment.center,
Text( children: [
statusText, Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor),
key: const ValueKey('waiting_status_text'), SizedBox(height: isLandscape ? 10 : 20),
textAlign: TextAlign.center, Text(
style: TextStyle( statusText,
fontFamily: AppTypography.fontHeadline, key: const ValueKey('waiting_status_text'),
fontSize: 18, textAlign: TextAlign.center,
fontWeight: FontWeight.w800, style: TextStyle(
letterSpacing: 1.0, fontFamily: AppTypography.fontHeadline,
color: statusColor, fontSize: isLandscape ? 15 : 18,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: statusColor,
),
), ),
), SizedBox(height: isLandscape ? 6 : 12),
const SizedBox(height: 12), Text(
Text( 'Current speed: ${speed.toStringAsFixed(1)} MPH',
'Current speed: ${speed.toStringAsFixed(1)} MPH', style: TextStyle(
style: TextStyle( fontFamily: AppTypography.fontTelemetry,
fontFamily: AppTypography.fontTelemetry, fontSize: isLandscape ? 12 : 14,
fontSize: 14, color: isDark ? Colors.white70 : Colors.black54,
color: isDark ? Colors.white70 : Colors.black54, ),
), ),
), SizedBox(height: isLandscape ? 16 : 32),
const SizedBox(height: 32), OutlinedButton(
OutlinedButton( key: const ValueKey('btn_cancel_waiting'),
key: const ValueKey('btn_cancel_waiting'), onPressed: () {
onPressed: () { ref.read(performanceLaunchProvider.notifier).reset();
ref.read(performanceLaunchProvider.notifier).reset(); },
}, child: const Text('Cancel'),
child: const Text('Cancel'), ),
), ],
], ),
), ),
); );
} }
@ -570,108 +591,118 @@ class _ReadyView extends ConsumerWidget {
final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode)); final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode));
final target = ref.watch(performanceLaunchProvider.select((s) => s.target)); final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
final isCountdown = mode == PerformanceLaunchMode.countdown; final isCountdown = mode == PerformanceLaunchMode.countdown;
final isLandscape = snapshot.isLandscape;
return Padding( return Center(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: SingleChildScrollView(
child: Column( physics: const ClampingScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.symmetric(
crossAxisAlignment: CrossAxisAlignment.stretch, horizontal: isLandscape ? 32 : 20,
children: [ vertical: isLandscape ? 8 : 16,
Center( ),
child: Container( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), mainAxisAlignment: MainAxisAlignment.center,
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.stretch,
color: colors.launchGreen.withValues(alpha: 0.15), children: [
borderRadius: BorderRadius.circular(16), Center(
border: Border.all(color: colors.launchGreen, width: 2.0), child: Container(
padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 16 : 24,
vertical: isLandscape ? 4 : 8,
),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.launchGreen, width: 2.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: colors.launchGreen, size: isLandscape ? 20 : 28),
const SizedBox(width: 8),
Text(
'READY',
key: const ValueKey('ready_status_badge'),
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: isLandscape ? 20 : 28,
fontWeight: FontWeight.w900,
letterSpacing: 2.0,
color: colors.launchGreen,
),
),
],
),
), ),
child: Row( ),
mainAxisSize: MainAxisSize.min, SizedBox(height: isLandscape ? 8 : 20),
children: [ Text(
Icon(Icons.check_circle, color: colors.launchGreen, size: 28), target.label,
const SizedBox(width: 10), textAlign: TextAlign.center,
Text( style: TextStyle(
'READY', fontFamily: AppTypography.fontHeadline,
key: const ValueKey('ready_status_badge'), fontSize: isLandscape ? 18 : 22,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
),
),
SizedBox(height: isLandscape ? 4 : 10),
Text(
isCountdown
? 'Tap below to initiate countdown'
: 'Accelerate when ready',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: isLandscape ? 13 : 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black54,
),
),
SizedBox(height: isLandscape ? 12 : 40),
if (isCountdown) ...[
SizedBox(
height: isLandscape ? 44 : 56,
child: ElevatedButton(
key: const ValueKey('btn_start_countdown'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.velocityBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
elevation: 4,
),
onPressed: () {
ref.read(performanceLaunchProvider.notifier).startCountdown();
},
child: const Text(
'Start Countdown',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 28, fontSize: 16,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w700,
letterSpacing: 2.0, letterSpacing: 0.5,
color: colors.launchGreen,
), ),
), ),
],
),
),
),
const SizedBox(height: 20),
Text(
target.label,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 10),
Text(
isCountdown
? 'Tap below to initiate countdown'
: 'Accelerate when ready',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black54,
),
),
const SizedBox(height: 40),
if (isCountdown) ...[
SizedBox(
height: 56,
child: ElevatedButton(
key: const ValueKey('btn_start_countdown'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.velocityBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
elevation: 4,
), ),
),
SizedBox(height: isLandscape ? 8 : 16),
],
Center(
child: TextButton(
onPressed: () { onPressed: () {
ref.read(performanceLaunchProvider.notifier).startCountdown(); ref.read(performanceLaunchProvider.notifier).reset();
}, },
child: const Text( child: Text(
'Start Countdown', 'Cancel & Change Setup',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, color: isDark ? Colors.white60 : Colors.black54,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
), ),
), ),
), ),
), ),
const SizedBox(height: 16),
], ],
Center( ),
child: TextButton(
onPressed: () {
ref.read(performanceLaunchProvider.notifier).reset();
},
child: Text(
'Cancel & Change Setup',
style: TextStyle(
color: isDark ? Colors.white60 : Colors.black54,
),
),
),
),
],
), ),
); );
} }

View File

@ -7,6 +7,7 @@ import '../../database/database.dart';
import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_provider_interface.dart';
import '../../services/map/unified_map_view.dart'; import '../../services/map/unified_map_view.dart';
import '../../core/theme/theme_extensions.dart'; import '../../core/theme/theme_extensions.dart';
import '../../core/responsive/responsive_layout.dart';
/// Corner performance metrics and apex grading for forensic telemetry replay. /// Corner performance metrics and apex grading for forensic telemetry replay.
class CornerAnalysis { class CornerAnalysis {
@ -474,117 +475,120 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
], ],
), ),
body: Column( body: ResponsiveLayout(
children: [ builder: (context, snapshot) {
// Map View (with multi-color lean heatmap and telemetry HUD overlay) final isLandscape = snapshot.isLandscape;
Expanded(
flex: 5, final mapWidget = Stack(
child: Stack( children: [
children: [ UnifiedMapView(
UnifiedMapView( center: currentPos,
center: currentPos, zoom: _zoom,
zoom: _zoom, markers: allMarkers,
markers: allMarkers, polylines: polylines,
polylines: polylines, isFollowModeOverride: _isFollowingRider,
isFollowModeOverride: _isFollowingRider, onUserInteraction: () {
onUserInteraction: () { if (_isFollowingRider) {
if (_isFollowingRider) { setState(() => _isFollowingRider = false);
setState(() => _isFollowingRider = false); }
} },
}, ),
), // Map Navigation Controls (Zoom In, Zoom Out, Recenter)
// Map Navigation Controls (Zoom In, Zoom Out, Recenter) Positioned(
Positioned( right: isLandscape ? 10 : 14,
right: 14, bottom: isLandscape ? 10 : 14,
bottom: 14, child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (!_isFollowingRider) ...[
if (!_isFollowingRider) ...[
_ReplayMapButton(
key: const ValueKey('replay_recenter_button'),
icon: Icons.my_location,
tooltip: 'Recenter on Rider',
color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
onTap: () => setState(() => _isFollowingRider = true),
),
const SizedBox(height: 8),
],
_ReplayMapButton( _ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'), key: const ValueKey('replay_recenter_button'),
icon: Icons.add, icon: Icons.my_location,
tooltip: 'Zoom In', tooltip: 'Recenter on Rider',
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)), color: colors.cyberCyan,
), backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
const SizedBox(height: 8), isCompact: isLandscape,
_ReplayMapButton( onTap: () => setState(() => _isFollowingRider = true),
key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
), ),
SizedBox(height: isLandscape ? 6 : 8),
], ],
_ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'),
icon: Icons.add,
tooltip: 'Zoom In',
isCompact: isLandscape,
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
),
SizedBox(height: isLandscape ? 6 : 8),
_ReplayMapButton(
key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
isCompact: isLandscape,
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
),
],
),
),
// Telemetry Overlay
Positioned(
top: isLandscape ? 8 : 14,
left: isLandscape ? 8 : 14,
right: isLandscape ? 8 : 14,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph', isLandscape: isLandscape),
SizedBox(width: isLandscape ? 6 : 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°', isLandscape: isLandscape),
SizedBox(width: isLandscape ? 6 : 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G', isLandscape: isLandscape),
],
),
), ),
), ),
// Telemetry Overlay ),
Positioned( // Heatmap Legend Overlay
top: 14, Positioned(
left: 14, bottom: isLandscape ? 8 : 10,
right: 14, left: isLandscape ? 8 : 14,
child: Center( right: isLandscape ? 60 : 14,
child: FittedBox( child: Align(
fit: BoxFit.scaleDown, 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( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'), _buildLegendItem(Colors.amberAccent, '≤20°'),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'), _buildLegendItem(Colors.cyanAccent, '20°-35°'),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'), _buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
], ],
), ),
), ),
), ),
), ),
// Heatmap Legend Overlay ),
Positioned( ],
bottom: 10, );
left: 14,
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'),
],
),
),
),
),
),
],
),
),
// Playback Controls Bar final playbackControlsBar = Container(
Container( padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), horizontal: isLandscape ? 12 : 16,
vertical: isLandscape ? 4 : 8,
),
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -596,10 +600,10 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
child: Text( child: Text(
currentSample.timestamp.toLocal().toString().split('.')[0], currentSample.timestamp.toLocal().toString().split('.')[0],
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: Colors.cyanAccent, color: Colors.cyanAccent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
), ),
), ),
@ -609,30 +613,42 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
'Sample ${_currentIndex + 1} / ${_telemetry.length}', 'Sample ${_currentIndex + 1} / ${_telemetry.length}',
style: TextStyle( style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6), color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 11, fontSize: isLandscape ? 10 : 11,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
), ),
), ),
], ],
), ),
Slider( SliderTheme(
value: currentSliderValue, data: isLandscape
min: 0, ? SliderTheme.of(context).copyWith(
max: maxSliderValue, trackHeight: 2,
onChanged: _telemetry.length > 1 thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
? (val) { overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
setState(() { )
_currentIndex = val.toInt(); : SliderTheme.of(context),
}); child: Slider(
} value: currentSliderValue,
: null, min: 0,
activeColor: Colors.cyanAccent, max: maxSliderValue,
onChanged: _telemetry.length > 1
? (val) {
setState(() {
_currentIndex = val.toInt();
});
}
: null,
activeColor: Colors.cyanAccent,
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.restart_alt), visualDensity: isLandscape ? VisualDensity.compact : null,
padding: isLandscape ? const EdgeInsets.all(4) : null,
constraints: isLandscape ? const BoxConstraints() : null,
icon: Icon(Icons.restart_alt, size: isLandscape ? 20 : 24),
tooltip: 'Restart', tooltip: 'Restart',
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -640,35 +656,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
}); });
}, },
), ),
const SizedBox(width: 16), SizedBox(width: isLandscape ? 8 : 16),
FloatingActionButton.small( FloatingActionButton.small(
onPressed: _togglePlayback, onPressed: _togglePlayback,
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: isLandscape ? 18 : 20),
), ),
const SizedBox(width: 16), SizedBox(width: isLandscape ? 8 : 16),
TextButton( TextButton(
style: isLandscape
? TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
)
: null,
onPressed: _changeSpeed, onPressed: _changeSpeed,
child: Text( child: Text(
'${_playbackSpeed}x', '${_playbackSpeed}x',
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono'), style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: isLandscape ? 12 : 14,
),
), ),
), ),
], ],
), ),
], ],
), ),
), );
// Corner Entry / Apex / Exit Breakdown Table Widget buildCornerTable() {
Expanded( return Container(
flex: _corners.isEmpty ? 3 : 4,
child: Container(
color: theme.colorScheme.surfaceContainerLow, color: theme.colorScheme.surfaceContainerLow,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 10 : 14,
vertical: isLandscape ? 6 : 8,
),
color: theme.colorScheme.surfaceContainer, color: theme.colorScheme.surfaceContainer,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -678,7 +705,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
'CORNER APEX BREAKDOWN & GRADING', 'CORNER APEX BREAKDOWN & GRADING',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: isLandscape ? 10 : 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: colors.cyberCyan, color: colors.cyberCyan,
@ -690,7 +717,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Text( Text(
_corners.isEmpty ? '' : 'Tap row to seek apex', _corners.isEmpty ? '' : 'Tap row to seek apex',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: isLandscape ? 9 : 10,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface.withValues(alpha: 0.5), color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
), ),
@ -712,7 +739,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
hasLeanData hasLeanData
? Icons.straighten_rounded ? Icons.straighten_rounded
: Icons.sensors_off_rounded, : Icons.sensors_off_rounded,
size: 26, size: isLandscape ? 22 : 26,
color: hasLeanData color: hasLeanData
? colors.cyberCyan.withValues(alpha: 0.7) ? colors.cyberCyan.withValues(alpha: 0.7)
: colors.warningAmber.withValues(alpha: 0.8), : colors.warningAmber.withValues(alpha: 0.8),
@ -724,7 +751,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
: 'Sensor Fusion Uncalibrated / No Lean Telemetry', : 'Sensor Fusion Uncalibrated / No Lean Telemetry',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface.withValues(alpha: 0.85), color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
@ -732,14 +759,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280), constraints: BoxConstraints(maxWidth: isLandscape ? 320 : 280),
child: Text( child: Text(
hasLeanData hasLeanData
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.' ? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
: 'Lean angle was not recorded or IMU was uncalibrated during this session.', : 'Lean angle was not recorded or IMU was uncalibrated during this session.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: isLandscape ? 10 : 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.55), color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
fontFamily: 'Inter', fontFamily: 'Inter',
), ),
@ -771,43 +798,49 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
}); });
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 10 : 14,
vertical: isLandscape ? 6 : 9,
),
color: isCurrent color: isCurrent
? colors.cyberCyan.withValues(alpha: 0.12) ? colors.cyberCyan.withValues(alpha: 0.12)
: Colors.transparent, : Colors.transparent,
child: Row( child: FittedBox(
children: [ fit: BoxFit.scaleDown,
// Corner ID & Direction alignment: Alignment.centerLeft,
SizedBox( child: Row(
width: 75, children: [
child: Row( // Corner ID & Direction
children: [ SizedBox(
Text( width: isLandscape ? 65 : 75,
'T${corner.id}', child: Row(
style: const TextStyle( children: [
fontWeight: FontWeight.bold, Text(
fontFamily: 'JetBrains Mono', 'T${corner.id}',
fontSize: 13, style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: isLandscape ? 12 : 13,
),
), ),
), const SizedBox(width: 4),
const SizedBox(width: 4), Text(
Text( corner.direction == 'Left' ? '↰ L' : '↱ R',
corner.direction == 'Left' ? '↰ L' : '↱ R', style: TextStyle(
style: TextStyle( fontSize: isLandscape ? 10 : 11,
fontSize: 11, fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: corner.direction == 'Left'
color: corner.direction == 'Left' ? Colors.blueAccent
? Colors.blueAccent : Colors.greenAccent,
: Colors.greenAccent, fontFamily: 'JetBrains Mono',
fontFamily: 'JetBrains Mono', ),
), ),
), ],
], ),
), ),
), const SizedBox(width: 8),
// Entry Speed // Entry Speed
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -820,14 +853,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
Text( Text(
'${corner.entrySpeed.toStringAsFixed(0)} mph', '${corner.entrySpeed.toStringAsFixed(0)} mph',
style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'), style: TextStyle(
fontSize: isLandscape ? 11 : 12,
fontFamily: 'JetBrains Mono',
),
), ),
], ],
), ),
), SizedBox(width: isLandscape ? 10 : 14),
// Apex Speed & Lean // Apex Speed & Lean
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -841,7 +876,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Text( Text(
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°', '${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colors.cyberCyan, color: colors.cyberCyan,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
@ -849,10 +884,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
], ],
), ),
), SizedBox(width: isLandscape ? 10 : 14),
// Exit Speed & Delta // Exit Speed & Delta
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -866,33 +900,34 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Text( Text(
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})', '${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: isLandscape ? 11 : 12,
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent, color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
), ),
), ),
], ],
), ),
), SizedBox(width: isLandscape ? 10 : 14),
// Grade Badge // Grade Badge
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: corner.getGradeColor().withValues(alpha: 0.15), color: corner.getGradeColor().withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)), border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
), ),
child: Text( child: Text(
corner.grade, corner.grade,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: corner.getGradeColor(), color: corner.getGradeColor(),
),
), ),
), ),
), ],
], ),
), ),
), ),
); );
@ -901,9 +936,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
], ],
), ),
), );
), }
],
if (isLandscape) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 5,
child: mapWidget,
),
Expanded(
flex: 4,
child: Column(
children: [
playbackControlsBar,
Expanded(
child: buildCornerTable(),
),
],
),
),
],
);
}
return Column(
children: [
Expanded(
flex: 5,
child: mapWidget,
),
playbackControlsBar,
Expanded(
flex: _corners.isEmpty ? 3 : 4,
child: buildCornerTable(),
),
],
);
},
), ),
); );
} }
@ -926,18 +998,28 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
); );
} }
Widget _buildOverlayStat(String label, String value) { Widget _buildOverlayStat(String label, String value, {bool isLandscape = false}) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 10 : 16,
vertical: isLandscape ? 4 : 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black87, color: Colors.black87,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(isLandscape ? 8 : 12),
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)), border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
), ),
child: Column( child: Column(
children: [ children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)), Text(label, style: TextStyle(fontSize: isLandscape ? 8.5 : 10, color: Colors.white70)),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)), Text(
value,
style: TextStyle(
fontSize: isLandscape ? 15 : 20,
fontWeight: FontWeight.bold,
color: Colors.cyanAccent,
),
),
], ],
), ),
); );
@ -950,6 +1032,7 @@ class _ReplayMapButton extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final Color? color; final Color? color;
final Color? backgroundColor; final Color? backgroundColor;
final bool isCompact;
const _ReplayMapButton({ const _ReplayMapButton({
super.key, super.key,
@ -958,10 +1041,13 @@ class _ReplayMapButton extends StatelessWidget {
required this.onTap, required this.onTap,
this.color, this.color,
this.backgroundColor, this.backgroundColor,
this.isCompact = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = isCompact ? 36.0 : 48.0;
final iconSize = isCompact ? 18.0 : 24.0;
return Material( return Material(
color: backgroundColor ?? Colors.black87, color: backgroundColor ?? Colors.black87,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -974,12 +1060,12 @@ class _ReplayMapButton extends StatelessWidget {
child: Tooltip( child: Tooltip(
message: tooltip, message: tooltip,
child: SizedBox( child: SizedBox(
width: 48, width: size,
height: 48, height: size,
child: Icon( child: Icon(
icon, icon,
color: color ?? Colors.white, color: color ?? Colors.white,
size: 24, size: iconSize,
), ),
), ),
), ),

View File

@ -7,6 +7,7 @@ import '../../core/responsive/viewport_size.dart';
import '../../core/theme/theme_extensions.dart'; import '../../core/theme/theme_extensions.dart';
import '../../services/auth/auth_providers.dart'; import '../../services/auth/auth_providers.dart';
import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_provider_interface.dart';
import '../../services/map/map_providers.dart';
import '../../services/map/unified_map_view.dart'; import '../../services/map/unified_map_view.dart';
import '../../services/routes/navigation_handoff.dart'; import '../../services/routes/navigation_handoff.dart';
import '../../services/routes/route_models.dart'; import '../../services/routes/route_models.dart';
@ -43,6 +44,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(mapFollowStateProvider.notifier).recenter();
_loadInitialData(); _loadInitialData();
}); });
} }
@ -148,8 +151,33 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
context: context, context: context,
builder: (dialogCtx) => StatefulBuilder( builder: (dialogCtx) => StatefulBuilder(
builder: (context, setDialogState) { builder: (context, setDialogState) {
final screenSize = MediaQuery.sizeOf(context);
final isCompactDialog = screenSize.height < 480;
return AlertDialog( return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
insetPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompactDialog ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 12 : 20,
20,
0,
),
contentPadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 10 : 16,
20,
isCompactDialog ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactDialog ? 8 : 16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),
@ -161,7 +189,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
content: SingleChildScrollView( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -196,6 +225,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: nameCtrl, controller: nameCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Stop Name *', labelText: 'Stop Name *',
@ -566,12 +596,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const maxAllowed = RouteService.maxTechnicalStops; const maxAllowed = RouteService.maxTechnicalStops;
final isAtLimit = state.stops.length >= maxAllowed; final isAtLimit = state.stops.length >= maxAllowed;
// Center map on road geometry, first stop, or default final lastKnownLocation = ref.watch(routePlannerLocationProvider).valueOrNull;
// Route context takes priority over cached rider location and the US overview.
final mapCenter = state.geometry.isNotEmpty final mapCenter = state.geometry.isNotEmpty
? state.geometry.first ? state.geometry.first
: (state.stops.isNotEmpty : (state.stops.isNotEmpty
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude) ? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
: const AppLatLng(37.7749, -122.4194)); : lastKnownLocation ?? const AppLatLng(39.5, -98.35));
final mapZoom = state.geometry.isNotEmpty || state.stops.isNotEmpty || lastKnownLocation != null
? 12.0 : 3.0;
// Build markers // Build markers
final markers = state.stops.map((stop) { final markers = state.stops.map((stop) {
@ -629,11 +663,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
} }
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
appBar: AppBar( appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
elevation: 0, elevation: 0,
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
leading: IconButton( leading: IconButton(
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
onPressed: () => context.pop(), onPressed: () => context.pop(),
@ -682,16 +719,18 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
final isNarrow = snapshot.width < 360; final isNarrow = snapshot.width < 360;
final isExpandedLayout = final isExpandedLayout =
snapshot.sizeClass == WindowSizeClass.expanded || snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720); (snapshot.isLandscape && snapshot.width >= 600);
if (isExpandedLayout) { if (isExpandedLayout) {
return _buildExpandedLayout( return _buildExpandedLayout(
context, context,
colors, colors,
snapshot,
state, state,
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
mapCenter, mapCenter,
mapZoom,
markers, markers,
polylines, polylines,
isNarrow, isNarrow,
@ -706,6 +745,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
mapCenter, mapCenter,
mapZoom,
markers, markers,
polylines, polylines,
isNarrow, isNarrow,
@ -718,14 +758,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Widget _buildExpandedLayout( Widget _buildExpandedLayout(
BuildContext context, BuildContext context,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot,
RoutePlannerState state, RoutePlannerState state,
int maxAllowed, int maxAllowed,
bool isAtLimit, bool isAtLimit,
AppLatLng mapCenter, AppLatLng mapCenter,
double mapZoom,
List<AppMapMarker> markers, List<AppMapMarker> markers,
List<AppPolyline> polylines, List<AppPolyline> polylines,
bool isNarrow, bool isNarrow,
) { ) {
final isCompactHeight = snapshot.height < 500;
final isVeryCompactHeight = snapshot.height < 360;
final showDisclaimer = snapshot.height >= 260;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -743,22 +789,29 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
UnifiedMapView( UnifiedMapView(
key: const ValueKey('route_planner_map_expanded'), key: const ValueKey('route_planner_map_expanded'),
center: mapCenter, center: mapCenter,
zoom: 12.0, zoom: mapZoom,
markers: markers, markers: markers,
polylines: polylines, polylines: polylines,
), ),
Positioned( Positioned(
top: 12, top: isCompactHeight ? 6 : 12,
left: 12, left: isCompactHeight ? 8 : 12,
right: 12, right: isCompactHeight ? 8 : 12,
child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false), child: _buildHudOverlay(
), colors,
Positioned( state,
bottom: 8, maxAllowed,
left: 12, isAtLimit,
right: 12, isCompactHeight,
child: _buildDisclaimerText(), ),
), ),
if (showDisclaimer)
Positioned(
bottom: 6,
left: 8,
right: 8,
child: _buildDisclaimerText(),
),
], ],
), ),
), ),
@ -767,23 +820,56 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Right Column: Form inputs + Stop list // Right Column: Form inputs + Stop list
Expanded( Expanded(
flex: 6, flex: 6,
child: Column( child: isCompactHeight
children: [ ? _buildStopsList(
if (_errorMessage != null) _buildErrorBanner(colors), colors,
Padding( state,
padding: const EdgeInsets.all(16), true,
child: _buildRouteInfoInputs(colors, false), isVeryCompactHeight,
), header: Column(
Divider(color: colors.outlineVariant, height: 1), mainAxisSize: MainAxisSize.min,
Padding( crossAxisAlignment: CrossAxisAlignment.stretch,
padding: const EdgeInsets.all(12), children: [
child: _buildStopControls(colors, state, isNarrow, false), if (_errorMessage != null) _buildErrorBanner(colors),
), Padding(
Expanded( padding: const EdgeInsets.fromLTRB(12, 6, 12, 4),
child: _buildStopsList(colors, state, false, false), child: _buildRouteInfoInputs(colors, true),
), ),
], Divider(color: colors.outlineVariant, height: 1),
), Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child:
_buildStopControls(colors, state, isNarrow, true),
),
if (state.stops.isEmpty)
_buildEmptyStopsContent(
colors,
true,
isVeryCompactHeight,
),
],
),
)
: Column(
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
Padding(
padding: const EdgeInsets.all(16),
child: _buildRouteInfoInputs(colors, false),
),
Divider(color: colors.outlineVariant, height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: _buildStopControls(colors, state, isNarrow, false),
),
Expanded(
child: _buildStopsList(colors, state, false, false),
),
],
),
), ),
], ],
); );
@ -797,6 +883,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
int maxAllowed, int maxAllowed,
bool isAtLimit, bool isAtLimit,
AppLatLng mapCenter, AppLatLng mapCenter,
double mapZoom,
List<AppMapMarker> markers, List<AppMapMarker> markers,
List<AppPolyline> polylines, List<AppPolyline> polylines,
bool isNarrow, bool isNarrow,
@ -829,6 +916,65 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
showDisclaimer = false; showDisclaimer = false;
} }
if (isCompactHeight) {
return _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
if (mapHeight > 0)
SizedBox(
height: mapHeight,
width: double.infinity,
child: Stack(
children: [
UnifiedMapView(
key: const ValueKey('route_planner_map_compact'),
center: mapCenter,
zoom: 12.0,
markers: markers,
polylines: polylines,
),
Positioned(
top: 4,
left: 8,
right: 8,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
true,
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: _buildRouteInfoInputs(colors, true),
),
Divider(color: colors.outlineVariant, height: 1),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: _buildStopControls(colors, state, isNarrow, true),
),
if (state.stops.isEmpty)
_buildEmptyStopsContent(
colors,
true,
isVeryCompactHeight,
),
],
),
);
}
return Column( return Column(
children: [ children: [
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
@ -843,12 +989,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
UnifiedMapView( UnifiedMapView(
key: const ValueKey('route_planner_map_compact'), key: const ValueKey('route_planner_map_compact'),
center: mapCenter, center: mapCenter,
zoom: 12.0, zoom: mapZoom,
markers: markers, markers: markers,
polylines: polylines, polylines: polylines,
), ),
Positioned( Positioned(
top: isCompactHeight ? 4 : 8, top: 8,
left: 8, left: 8,
right: 8, right: 8,
child: _buildHudOverlay( child: _buildHudOverlay(
@ -856,7 +1002,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
state, state,
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
isCompactHeight, false,
), ),
), ),
if (showDisclaimer) if (showDisclaimer)
@ -872,24 +1018,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Route Info Inputs // Route Info Inputs
Padding( Padding(
padding: EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
16, child: _buildRouteInfoInputs(colors, false),
isCompactHeight ? 4 : 12,
16,
isCompactHeight ? 2 : 4,
),
child: _buildRouteInfoInputs(colors, isCompactHeight),
), ),
Divider(color: colors.outlineVariant, height: 1), Divider(color: colors.outlineVariant, height: 1),
// Stop List Controls // Stop List Controls
Padding( Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
horizontal: 16, child: _buildStopControls(colors, state, isNarrow, false),
vertical: isCompactHeight ? 4 : 8,
),
child: _buildStopControls(colors, state, isNarrow, isCompactHeight),
), ),
// Reorderable Stop List // Reorderable Stop List
@ -897,7 +1035,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
child: _buildStopsList( child: _buildStopsList(
colors, colors,
state, state,
isCompactHeight, false,
isVeryCompactHeight, isVeryCompactHeight,
), ),
), ),
@ -1072,6 +1210,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
children: [ children: [
TextField( TextField(
controller: _titleController, controller: _titleController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle( style: TextStyle(
color: onSurface, color: onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -1163,15 +1302,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
colors: colors, colors: colors,
isCompact: isCompactHeight, isCompact: isCompactHeight,
), ),
const SizedBox(height: 8), SizedBox(height: isCompactHeight ? 6 : 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 6,
alignment: WrapAlignment.spaceBetween, alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Row( Wrap(
mainAxisSize: MainAxisSize.min, spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
key: const ValueKey('route_planner_add_stop_button'), key: const ValueKey('route_planner_add_stop_button'),
@ -1181,17 +1322,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan, backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 8, vertical: isCompactHeight ? 6 : 8,
), ),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
key: const ValueKey('route_planner_current_gps_button'), key: const ValueKey('route_planner_current_gps_button'),
onPressed: _addCurrentLocationStop, onPressed: _addCurrentLocationStop,
@ -1206,10 +1350,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 8, vertical: isCompactHeight ? 6 : 8,
), ),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
), ),
@ -1248,56 +1396,74 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
} }
Widget _buildStopsList( Widget _buildEmptyStopsContent(
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight, bool isCompactHeight,
bool isVeryCompactHeight, bool isVeryCompactHeight,
) { ) {
final theme = Theme.of(context);
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return Padding(
padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isVeryCompactHeight) ...[
Icon(
Icons.alt_route,
size: isCompactHeight ? 28 : 48,
color: onSurfaceVariant.withValues(alpha: 0.2),
),
SizedBox(height: isCompactHeight ? 4 : 12),
],
Text(
'No stops added yet.',
style: TextStyle(
color: onSurfaceVariant,
fontSize: isCompactHeight ? 13 : 14,
),
),
const SizedBox(height: 4),
Text(
'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: isCompactHeight ? 11 : 12,
),
),
],
),
);
}
Widget _buildStopsList(
MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight,
bool isVeryCompactHeight, {
Widget? header,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface; final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (state.stops.isEmpty) { if (header == null && state.stops.isEmpty) {
return Center( return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
child: Padding( child: _buildEmptyStopsContent(
padding: const EdgeInsets.symmetric(vertical: 4), colors,
child: Column( isCompactHeight,
mainAxisSize: MainAxisSize.min, isVeryCompactHeight,
children: [
if (!isVeryCompactHeight) ...[
Icon(
Icons.alt_route,
size: isCompactHeight ? 32 : 48,
color: onSurfaceVariant.withValues(alpha: 0.2),
),
SizedBox(height: isCompactHeight ? 6 : 12),
],
Text(
'No stops added yet.',
style: TextStyle(
color: onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 12,
),
),
],
),
), ),
), ),
); );
} }
return ReorderableListView.builder( return ReorderableListView.builder(
header: header,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 4, vertical: 4,

View File

@ -302,6 +302,7 @@ class _AddressAutocompleteFieldState
focusNode: _focusNode, focusNode: _focusNode,
enabled: widget.enabled && !_isResolving, enabled: widget.enabled && !_isResolving,
autofocus: widget.autofocus, autofocus: widget.autofocus,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface, fontSize: 14), style: TextStyle(color: onSurface, fontSize: 14),
onChanged: _onTextChanged, onChanged: _onTextChanged,
validator: (val) { validator: (val) {

View File

@ -353,8 +353,10 @@ class _ClubDetailScreenState extends ConsumerState<ClubDetailScreen> {
), ),
_buildPill( _buildPill(
icon: club.admissionPolicy == ClubAdmissionPolicy.open icon: club.admissionPolicy == ClubAdmissionPolicy.open
? Icons.lock_open_outlined ? Icons.group_add_outlined
: Icons.how_to_reg_outlined, : (club.admissionPolicy == ClubAdmissionPolicy.request
? Icons.how_to_reg_outlined
: Icons.mail_outline),
label: club.admissionPolicy.label, label: club.admissionPolicy.label,
colors: colors, colors: colors,
theme: theme, theme: theme,

View File

@ -119,9 +119,9 @@ class ClubMembershipPresentation {
); );
} }
// Authenticated user with an active membership // Root ownership remains authoritative when the member record is missing or stale.
if (member != null && member.status == ClubMemberStatus.active) { if (club.ownerUid == currentUserId || member?.isActive == true) {
final isOwner = member.role == ClubRole.owner || club.ownerUid == currentUserId; final isOwner = member?.role == ClubRole.owner || club.ownerUid == currentUserId;
if (isOwner) { if (isOwner) {
return const ClubMembershipPresentation( return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeOwner, state: ClubMembershipPresentationState.activeOwner,
@ -135,7 +135,7 @@ class ClubMembershipPresentation {
); );
} }
if (member.role == ClubRole.admin) { if (member?.role == ClubRole.admin) {
return const ClubMembershipPresentation( return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeAdmin, state: ClubMembershipPresentationState.activeAdmin,
statusBadge: 'ADMIN', statusBadge: 'ADMIN',

View File

@ -356,11 +356,45 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
), ),
), ),
), ),
for (final member in memberships) LayoutBuilder(
ClubMemberCard( builder: (context, constraints) {
key: ValueKey('my_club_${member.clubId}'), final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
member: member, final useGrid = isLandscape || constraints.maxWidth >= 600;
),
if (useGrid) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 215,
),
itemCount: memberships.length,
itemBuilder: (context, index) {
return ClubMemberCard(
key: ValueKey('my_club_${memberships[index].clubId}'),
member: memberships[index],
inGrid: true,
);
},
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: memberships.length,
itemBuilder: (context, index) {
return ClubMemberCard(
key: ValueKey('my_club_${memberships[index].clubId}'),
member: memberships[index],
);
},
);
},
),
], ],
], ],
), ),
@ -571,9 +605,19 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
), ),
], ],
), ),
ClubCard( LayoutBuilder(
club: _searchedClub!, builder: (context, constraints) {
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'), final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLandscape ? 400 : double.infinity,
),
child: ClubCard(
club: _searchedClub!,
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
),
);
},
), ),
], ],
); );
@ -631,16 +675,46 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
); );
} }
return ListView.builder( return LayoutBuilder(
shrinkWrap: true, builder: (context, constraints) {
physics: const NeverScrollableScrollPhysics(), final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
itemCount: clubs.length, final useGrid = isLandscape || constraints.maxWidth >= 600;
itemBuilder: (context, index) {
final club = clubs[index]; if (useGrid) {
return ClubCard( return GridView.builder(
key: ValueKey('discover_club_${club.id}'), shrinkWrap: true,
club: club, physics: const NeverScrollableScrollPhysics(),
onTap: () => context.push('/social/clubs/${club.id}'), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 215,
),
itemCount: clubs.length,
itemBuilder: (context, index) {
final club = clubs[index];
return ClubCard(
key: ValueKey('discover_club_${club.id}'),
club: club,
inGrid: true,
onTap: () => context.push('/social/clubs/${club.id}'),
);
},
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: clubs.length,
itemBuilder: (context, index) {
final club = clubs[index];
return ClubCard(
key: ValueKey('discover_club_${club.id}'),
club: club,
onTap: () => context.push('/social/clubs/${club.id}'),
);
},
); );
}, },
); );

View File

@ -178,12 +178,17 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
); );
} }
final mediaSize = MediaQuery.sizeOf(context);
final isCompactScreen = mediaSize.height < 500;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( toolbarHeight: isCompactScreen ? 44 : null,
title: Text(
'Create Club', 'Create Club',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: isCompactScreen ? 16 : null,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@ -191,332 +196,419 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
body: SafeArea( body: SafeArea(
child: ResponsiveLayout( child: ResponsiveLayout(
builder: (context, responsive) { builder: (context, responsive) {
final isCompact =
responsive.isLandscape || responsive.isShortViewport;
final isTwoColumn =
responsive.isLandscape && responsive.width >= 600;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
final nameField = TextFormField(
controller: _nameController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Club Name *',
hintText: 'e.g. Ridge Runners MC',
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.cyberCyan,
width: 1.5,
),
),
),
validator: (val) {
final clean = val?.trim() ?? '';
if (clean.length < 3) {
return 'Name must be at least 3 characters';
}
if (clean.length > 50) {
return 'Name cannot exceed 50 characters';
}
return null;
},
);
final slugField = SlugInputField(
controller: _slugController,
enabled: !_isSubmitting,
onValidationChanged: (isValid) {
setState(() {
_isSlugValidAndAvailable = isValid;
});
},
);
final regionSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFieldLabel(
'Home base',
'Where this club primarily rides or organizes from.',
colors,
),
SizedBox(height: isCompact ? 6 : 8),
TextFormField(
controller: _regionController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Home region (optional)',
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
prefixIcon: const Icon(Icons.location_on_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
],
);
final focusSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFieldLabel(
'Rider community',
'Name the motorcycle types or riding style this club is for.',
colors,
),
SizedBox(height: isCompact ? 6 : 8),
TextFormField(
controller: _focusController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Motorcycle focus (optional)',
hintText: 'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
prefixIcon: const Icon(Icons.two_wheeler_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
],
);
final visibilitySection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Club Visibility',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
SizedBox(height: isCompact ? 6 : 8),
_buildVisibilitySelector(colors, theme),
],
);
final admissionSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Admission Policy',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
SizedBox(height: isCompact ? 6 : 8),
_buildAdmissionSelector(colors, theme),
],
);
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700), constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 700),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: ListView( child: Scrollbar(
padding: const EdgeInsets.all(18.0), thumbVisibility: isCompact,
children: [ child: ListView(
// Header card explaining Club identity keyboardDismissBehavior:
Container( ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(14.0), padding: EdgeInsets.fromLTRB(
decoration: BoxDecoration( isCompact ? 16.0 : 18.0,
color: colors.surfaceContainer, isCompact ? 10.0 : 18.0,
borderRadius: BorderRadius.circular(12), isCompact ? 16.0 : 18.0,
border: Border.all( (isCompact ? 16.0 : 32.0) +
color: colors.outlineVariant, (bottomInset > 0 ? 16.0 : 0.0),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
color: colors.cyberCyan,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Found a Motorcycle Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
height: 1.3,
),
),
],
),
),
],
),
), ),
const SizedBox(height: 20), children: [
Column(
// Submission error alert if present crossAxisAlignment: CrossAxisAlignment.stretch,
if (_submissionError != null) ...[ children: [
// Header card explaining Club identity
Container( Container(
padding: const EdgeInsets.all(12), padding: EdgeInsets.all(isCompact ? 10.0 : 14.0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12), color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: colors.raceRed.withValues(alpha: 0.4), color: colors.outlineVariant,
width: 0.8,
), ),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Icons.error_outline, Icons.shield_outlined,
color: colors.raceRed, color: colors.cyberCyan,
size: 20, size: isCompact ? 20 : 24,
), ),
const SizedBox(width: 8), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Column(
_submissionError!, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
fontFamily: AppTypography.fontBody, const Text(
fontSize: 12.5, 'Found a Motorcycle Club',
color: colors.raceRed, style: TextStyle(
), fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color:
theme.colorScheme.onSurfaceVariant,
height: 1.3,
),
),
],
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 16), SizedBox(height: isCompact ? 12 : 20),
],
// Club Name // Submission error alert if present
TextFormField( if (_submissionError != null) ...[
controller: _nameController, Container(
enabled: !_isSubmitting, padding: const EdgeInsets.all(12),
textCapitalization: TextCapitalization.words, decoration: BoxDecoration(
decoration: InputDecoration( color: colors.raceRed.withValues(alpha: 0.12),
labelText: 'Club Name *', borderRadius: BorderRadius.circular(10),
hintText: 'e.g. Ridge Runners MC', border: Border.all(
filled: true, color: colors.raceRed.withValues(alpha: 0.4),
fillColor: colors.surfaceContainer, ),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
), ),
), child: Row(
enabledBorder: OutlineInputBorder( children: [
borderRadius: BorderRadius.circular(12), Icon(
borderSide: BorderSide( Icons.error_outline,
color: colors.outlineVariant, color: colors.raceRed,
width: 0.8, size: 20,
), ),
), const SizedBox(width: 8),
focusedBorder: OutlineInputBorder( Expanded(
borderRadius: BorderRadius.circular(12), child: Text(
borderSide: BorderSide( _submissionError!,
color: colors.cyberCyan, style: TextStyle(
width: 1.5, fontFamily: AppTypography.fontBody,
), fontSize: 12.5,
), color: colors.raceRed,
),
validator: (val) {
final clean = val?.trim() ?? '';
if (clean.length < 3) {
return 'Name must be at least 3 characters';
}
if (clean.length > 50) {
return 'Name cannot exceed 50 characters';
}
return null;
},
),
const SizedBox(height: 18),
// Slug / Handle with live debounce & availability checking
SlugInputField(
controller: _slugController,
enabled: !_isSubmitting,
onValidationChanged: (isValid) {
setState(() {
_isSlugValidAndAvailable = isValid;
});
},
),
const SizedBox(height: 18),
// Description
TextFormField(
controller: _descController,
enabled: !_isSubmitting,
maxLines: 3,
maxLength: 500,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'Description (Optional)',
hintText:
'Share your club mission, riding style, or club guidelines...',
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.cyberCyan,
width: 1.5,
),
),
),
),
const SizedBox(height: 14),
// Give optional discovery fields enough room for a clear
// label and example on narrow phone layouts.
_buildFieldLabel(
'Home base',
'Where this club primarily rides or organizes from.',
colors,
),
const SizedBox(height: 8),
TextFormField(
controller: _regionController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: 'Home region (optional)',
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
prefixIcon: const Icon(Icons.location_on_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
const SizedBox(height: 18),
_buildFieldLabel(
'Rider community',
'Name the motorcycle types or riding style this club is for.',
colors,
),
const SizedBox(height: 8),
TextFormField(
controller: _focusController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: 'Motorcycle focus (optional)',
hintText:
'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
prefixIcon: const Icon(Icons.two_wheeler_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
const SizedBox(height: 24),
// Visibility Selection
Text(
'Club Visibility',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
const SizedBox(height: 8),
_buildVisibilitySelector(colors, theme),
const SizedBox(height: 20),
// Admission Policy Selection
Text(
'Admission Policy',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
const SizedBox(height: 8),
_buildAdmissionSelector(colors, theme),
const SizedBox(height: 32),
// Submit Button
SizedBox(
height: 50,
child: FilledButton(
key: const ValueKey('create_club_submit_button'),
onPressed:
(_isSubmitting || !_isSlugValidAndAvailable)
? null
: _submitForm,
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black,
), ),
), ),
)
: const Text(
'Create Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
),
), ),
],
),
),
SizedBox(height: isCompact ? 12 : 16),
],
// Club Name & Slug / Handle
if (isTwoColumn)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: nameField),
const SizedBox(width: 16),
Expanded(child: slugField),
],
)
else ...[
nameField,
const SizedBox(height: 18),
slugField,
],
SizedBox(height: isCompact ? 12 : 18),
// Description
TextFormField(
controller: _descController,
enabled: !_isSubmitting,
maxLines: isCompact ? 2 : 3,
maxLength: 500,
textCapitalization: TextCapitalization.sentences,
scrollPadding:
const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Description (Optional)',
hintText:
'Share your club mission, riding style, or club guidelines...',
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.cyberCyan,
width: 1.5,
),
),
),
), ),
), SizedBox(height: isCompact ? 10 : 14),
const SizedBox(height: 32),
], // Home base & Rider community
if (isTwoColumn)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: regionSection),
const SizedBox(width: 16),
Expanded(child: focusSection),
],
)
else ...[
regionSection,
const SizedBox(height: 18),
focusSection,
],
SizedBox(height: isCompact ? 14 : 24),
// Visibility & Admission Policy Selection
if (isTwoColumn)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: visibilitySection),
const SizedBox(width: 16),
Expanded(child: admissionSection),
],
)
else ...[
visibilitySection,
const SizedBox(height: 20),
admissionSection,
],
SizedBox(height: isCompact ? 16 : 32),
// Submit Button
SizedBox(
height: isCompact ? 46 : 50,
child: FilledButton(
key: const ValueKey('create_club_submit_button'),
onPressed:
(_isSubmitting || !_isSlugValidAndAvailable)
? null
: _submitForm,
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black,
),
),
)
: const Text(
'Create Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
),
),
),
),
SizedBox(height: isCompact ? 12 : 32),
],
),
],
),
), ),
), ),
), ),
@ -533,12 +625,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
) { ) {
return DropdownButtonFormField<ClubVisibility>( return DropdownButtonFormField<ClubVisibility>(
initialValue: _visibility, initialValue: _visibility,
isExpanded: true,
decoration: _choiceDecoration('Who can find this club', colors), decoration: _choiceDecoration('Who can find this club', colors),
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white), style: TextStyle(color: theme.colorScheme.onSurface),
items: ClubVisibility.values items: ClubVisibility.values
.map( .map(
(value) => DropdownMenuItem(value: value, child: Text(value.label)), (value) => DropdownMenuItem(
value: value,
child: Text(
value.label,
overflow: TextOverflow.ellipsis,
),
),
) )
.toList(), .toList(),
onChanged: _isSubmitting onChanged: _isSubmitting
@ -555,12 +654,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
) { ) {
return DropdownButtonFormField<ClubAdmissionPolicy>( return DropdownButtonFormField<ClubAdmissionPolicy>(
initialValue: _admissionPolicy, initialValue: _admissionPolicy,
isExpanded: true,
decoration: _choiceDecoration('How riders join', colors), decoration: _choiceDecoration('How riders join', colors),
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white), style: TextStyle(color: theme.colorScheme.onSurface),
items: ClubAdmissionPolicy.values items: ClubAdmissionPolicy.values
.map( .map(
(value) => DropdownMenuItem(value: value, child: Text(value.label)), (value) => DropdownMenuItem(
value: value,
child: Text(
value.label,
overflow: TextOverflow.ellipsis,
),
),
) )
.toList(), .toList(),
onChanged: _isSubmitting onChanged: _isSubmitting

View File

@ -10,12 +10,14 @@ class ClubCard extends StatelessWidget {
final Club club; final Club club;
final ClubRole? role; final ClubRole? role;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool inGrid;
const ClubCard({ const ClubCard({
super.key, super.key,
required this.club, required this.club,
this.role, this.role,
this.onTap, this.onTap,
this.inGrid = false,
}); });
@override @override
@ -24,7 +26,7 @@ class ClubCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 6.0), margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainer, color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@ -127,7 +129,8 @@ class ClubCard extends StatelessWidget {
), ),
// Admission policy badge // Admission policy badge
_buildAdmissionBadge(context, club.admissionPolicy, colors), if (role == null)
_buildAdmissionBadge(context, club.admissionPolicy, colors),
// Region badge if available // Region badge if available
if (club.homeRegion != null && club.homeRegion!.trim().isNotEmpty) if (club.homeRegion != null && club.homeRegion!.trim().isNotEmpty)
@ -215,7 +218,7 @@ class ClubCard extends StatelessWidget {
switch (policy) { switch (policy) {
case ClubAdmissionPolicy.open: case ClubAdmissionPolicy.open:
color = colors.launchGreen; color = colors.launchGreen;
icon = Icons.lock_open_outlined; icon = Icons.group_add_outlined;
break; break;
case ClubAdmissionPolicy.request: case ClubAdmissionPolicy.request:
color = colors.cyberCyan; color = colors.cyberCyan;

View File

@ -10,10 +10,12 @@ import 'club_card.dart';
/// Club document in real time. /// Club document in real time.
class ClubMemberCard extends ConsumerWidget { class ClubMemberCard extends ConsumerWidget {
final ClubMember member; final ClubMember member;
final bool inGrid;
const ClubMemberCard({ const ClubMemberCard({
super.key, super.key,
required this.member, required this.member,
this.inGrid = false,
}); });
@override @override
@ -28,6 +30,7 @@ class ClubMemberCard extends ConsumerWidget {
return ClubCard( return ClubCard(
club: club, club: club,
role: member.role, role: member.role,
inGrid: inGrid,
onTap: () => context.push('/social/clubs/${club.id}'), onTap: () => context.push('/social/clubs/${club.id}'),
); );
}, },
@ -40,7 +43,7 @@ class ClubMemberCard extends ConsumerWidget {
final colors = context.motorColors; final colors = context.motorColors;
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 6.0), margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
height: 96, height: 96,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainer, color: colors.surfaceContainer,

View File

@ -151,6 +151,7 @@ class _SlugInputFieldState extends ConsumerState<SlugInputField> {
enableSuggestions: false, enableSuggestions: false,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.none, textCapitalization: TextCapitalization.none,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')), FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
_LowerCaseTextFormatter(), _LowerCaseTextFormatter(),

File diff suppressed because it is too large Load Diff

View File

@ -103,10 +103,12 @@ class _FeedCommentsBottomSheetState
final isLight = Theme.of(context).brightness == Brightness.light; final isLight = Theme.of(context).brightness == Brightness.light;
final secondaryTextColor = final secondaryTextColor =
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
final primaryTextColor =
isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface;
final accentColor = final accentColor =
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright; isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan; final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
final sendFg = isLight ? AppColors.onBrand : const Color(0xFF00363D); final sendFg = AppColors.onBrand;
final bottomInset = MediaQuery.of(context).viewInsets.bottom; final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final feedItems = ref.watch(socialFeedNotifierProvider); final feedItems = ref.watch(socialFeedNotifierProvider);
@ -124,9 +126,9 @@ class _FeedCommentsBottomSheetState
), ),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceCardPrimary, color: colors.surfaceContainer,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(color: colors.surfaceBorder), border: Border.all(color: colors.outlineVariant),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: colors.glowShadow, color: colors.glowShadow,
@ -147,7 +149,7 @@ class _FeedCommentsBottomSheetState
width: 44, width: 44,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceBorder, color: colors.outlineVariant,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@ -169,7 +171,7 @@ class _FeedCommentsBottomSheetState
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: colors.textPrimary, color: primaryTextColor,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -177,7 +179,7 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceCardSecondary, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: accentColor.withValues(alpha: 0.35), color: accentColor.withValues(alpha: 0.35),
@ -205,7 +207,7 @@ class _FeedCommentsBottomSheetState
], ],
), ),
), ),
Divider(color: colors.surfaceBorder, height: 1), Divider(color: colors.outlineVariant, height: 1),
// Comment List or Empty // Comment List or Empty
Flexible( Flexible(
@ -228,7 +230,7 @@ class _FeedCommentsBottomSheetState
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colors.textPrimary, color: primaryTextColor,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -256,10 +258,10 @@ class _FeedCommentsBottomSheetState
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceCardSecondary, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: colors.surfaceBorder, color: colors.outlineVariant,
), ),
), ),
child: Column( child: Column(
@ -269,7 +271,7 @@ class _FeedCommentsBottomSheetState
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 14, radius: 14,
backgroundColor: colors.surfaceCardPrimary, backgroundColor: colors.surfaceContainer,
child: Icon( child: Icon(
Icons.sports_motorsports_rounded, Icons.sports_motorsports_rounded,
size: 16, size: 16,
@ -289,7 +291,7 @@ class _FeedCommentsBottomSheetState
AppTypography.fontHeadline, AppTypography.fontHeadline,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colors.textPrimary, color: primaryTextColor,
), ),
), ),
), ),
@ -327,7 +329,7 @@ class _FeedCommentsBottomSheetState
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
height: 1.35, height: 1.35,
color: colors.textPrimary, color: primaryTextColor,
), ),
), ),
), ),
@ -338,7 +340,7 @@ class _FeedCommentsBottomSheetState
), ),
), ),
Divider(color: colors.surfaceBorder, height: 1), Divider(color: colors.outlineVariant, height: 1),
// Comment Input Field (Glove Friendly) // Comment Input Field (Glove Friendly)
Padding( Padding(
@ -349,10 +351,10 @@ class _FeedCommentsBottomSheetState
child: Container( child: Container(
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceCardSecondary, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: colors.surfaceBorder, color: colors.outlineVariant,
), ),
), ),
child: TextField( child: TextField(
@ -360,7 +362,7 @@ class _FeedCommentsBottomSheetState
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
color: colors.textPrimary, color: primaryTextColor,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Add comment or pace tip...', hintText: 'Add comment or pace tip...',

View File

@ -58,19 +58,26 @@ class _FeedTelemetryInspectorModalState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final isLight = Theme.of(context).brightness == Brightness.light;
final secondaryTextColor =
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
final primaryTextColor =
isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface;
final accentColor =
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
final item = widget.feedItem; final item = widget.feedItem;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final maxHeight = mediaQuery.size.height * 0.90; final maxHeight = mediaQuery.size.height * 0.90;
return Container( return Container(
constraints: BoxConstraints(maxHeight: maxHeight), constraints: BoxConstraints(maxHeight: maxHeight),
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Color(0xFF0F1318), color: colors.surfaceContainer,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border( border: Border(
top: BorderSide(color: Color(0xFF222933), width: 1.5), top: BorderSide(color: colors.outlineVariant, width: 1.5),
left: BorderSide(color: Color(0xFF222933), width: 1.5), left: BorderSide(color: colors.outlineVariant, width: 1.5),
right: BorderSide(color: Color(0xFF222933), width: 1.5), right: BorderSide(color: colors.outlineVariant, width: 1.5),
), ),
), ),
child: SafeArea( child: SafeArea(
@ -86,7 +93,7 @@ class _FeedTelemetryInspectorModalState
width: 40, width: 40,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF333E4D), color: colors.outlineVariant,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@ -100,15 +107,15 @@ class _FeedTelemetryInspectorModalState
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.electricCyan.withValues(alpha: 0.15), color: accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.35), color: accentColor.withValues(alpha: 0.35),
), ),
), ),
child: const Icon( child: Icon(
Icons.insights_rounded, Icons.insights_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
), ),
@ -123,11 +130,11 @@ class _FeedTelemetryInspectorModalState
child: Text( child: Text(
item.authorDisplayName, item.authorDisplayName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: primaryTextColor,
), ),
), ),
), ),
@ -139,19 +146,27 @@ class _FeedTelemetryInspectorModalState
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.hazardAmber.withValues(alpha: 0.2), color: (isLight
? AppColors.semanticWarningLight
: AppColors.hazardAmber)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: AppColors.hazardAmber.withValues(alpha: 0.4), color: (isLight
? AppColors.semanticWarningLight
: AppColors.hazardAmber)
.withValues(alpha: 0.4),
), ),
), ),
child: Text( child: Text(
item.authorBadge!, item.authorBadge!,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 9, fontSize: 9,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: AppColors.hazardAmber, color: isLight
? AppColors.semanticWarningLight
: AppColors.hazardAmber,
), ),
), ),
), ),
@ -161,24 +176,24 @@ class _FeedTelemetryInspectorModalState
Text( Text(
'${item.rideTitle} • ${item.bikeModel}', '${item.rideTitle} • ${item.bikeModel}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 12, fontSize: 12,
color: Color(0xFF8B98A5), color: secondaryTextColor,
), ),
), ),
], ],
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close_rounded, color: Colors.white70), icon: Icon(Icons.close_rounded, color: secondaryTextColor),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
], ],
), ),
), ),
const Divider(color: Color(0xFF1D242E), height: 1), Divider(color: colors.outlineVariant, height: 1),
// Scrollable Content: Metrics + Graph + Replay Trigger // Scrollable Content: Metrics + Graph + Replay Trigger
Flexible( Flexible(
@ -188,23 +203,23 @@ class _FeedTelemetryInspectorModalState
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 2x3 Telemetry Insets Grid // 2x3 Telemetry Insets Grid
_buildMetricsGrid(item, colors), _buildMetricsGrid(item, colors, isLight, secondaryTextColor, accentColor, primaryTextColor),
const SizedBox(height: 18), const SizedBox(height: 18),
// Trace Selector Tabs // Trace Selector Tabs
_buildTraceSelector(colors), _buildTraceSelector(colors, secondaryTextColor, accentColor, primaryTextColor),
const SizedBox(height: 12), const SizedBox(height: 12),
// Interactive Telemetry Graph Canvas // Interactive Telemetry Graph Canvas
_buildInteractiveGraphCard(item, colors), _buildInteractiveGraphCard(item, colors, isLight, secondaryTextColor, accentColor, primaryTextColor),
const SizedBox(height: 16), const SizedBox(height: 16),
// Scrubber Details Pill // Scrubber Details Pill
_buildScrubberReadout(item, colors), _buildScrubberReadout(item, colors, secondaryTextColor, accentColor, primaryTextColor),
const SizedBox(height: 20), const SizedBox(height: 20),
// Launch Full Replay Button // Launch Full Replay Button
_buildReplayButton(context), _buildReplayButton(context, colors, accentColor, primaryTextColor),
], ],
), ),
), ),
@ -215,13 +230,25 @@ class _FeedTelemetryInspectorModalState
); );
} }
Widget _buildMetricsGrid(SocialFeedItem item, MotorSignalThemeColors colors) { Widget _buildMetricsGrid(
SocialFeedItem item,
MotorSignalThemeColors colors,
bool isLight,
Color secondaryTextColor,
Color accentColor,
Color primaryTextColor,
) {
final successColor =
isLight ? AppColors.semanticSuccessLight : AppColors.semanticSuccess;
final warnColor =
isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber;
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141A22), color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.outlineVariant),
), ),
child: Column( child: Column(
children: [ children: [
@ -232,34 +259,37 @@ class _FeedTelemetryInspectorModalState
label: 'PEAK LEAN', label: 'PEAK LEAN',
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°', value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
subtext: '${item.maxLeanSide} APEX', subtext: '${item.maxLeanSide} APEX',
accentColor: AppColors.electricCyan, accentColor: accentColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.rotate_right_rounded, icon: Icons.rotate_right_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.outlineVariant),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'DISTANCE', label: 'DISTANCE',
value: item.distanceMiles.toStringAsFixed(1), value: item.distanceMiles.toStringAsFixed(1),
subtext: 'MILES', subtext: 'MILES',
accentColor: Colors.white, accentColor: primaryTextColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.route_rounded, icon: Icons.route_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.outlineVariant),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'DURATION', label: 'DURATION',
value: _formatDuration(item.durationSeconds), value: _formatDuration(item.durationSeconds),
subtext: 'MOVING', subtext: 'MOVING',
accentColor: Colors.white, accentColor: primaryTextColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
), ),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider(color: Color(0xFF222B38), height: 1), Divider(color: colors.outlineVariant, height: 1),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
@ -270,29 +300,30 @@ class _FeedTelemetryInspectorModalState
subtext: item.paceFactor.contains(' ') subtext: item.paceFactor.contains(' ')
? item.paceFactor.split(' ').sublist(1).join(' ') ? item.paceFactor.split(' ').sublist(1).join(' ')
: 'PACE', : 'PACE',
accentColor: const Color(0xFF00E676), accentColor: successColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.speed_rounded, icon: Icons.speed_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.outlineVariant),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'ELEVATION', label: 'ELEVATION',
value: '+${item.elevationFt}', value: '+${item.elevationFt}',
subtext: 'FEET GAIN', subtext: 'FEET GAIN',
accentColor: Colors.white, accentColor: primaryTextColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.terrain_rounded, icon: Icons.terrain_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.outlineVariant),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'HAZARDS', label: 'HAZARDS',
value: '${item.hazardCount}', value: '${item.hazardCount}',
subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED', subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED',
accentColor: item.hazardCount == 0 accentColor: item.hazardCount == 0 ? successColor : warnColor,
? const Color(0xFF00E676) secondaryTextColor: secondaryTextColor,
: AppColors.hazardAmber,
icon: Icons.warning_amber_rounded, icon: Icons.warning_amber_rounded,
), ),
), ),
@ -308,6 +339,7 @@ class _FeedTelemetryInspectorModalState
required String value, required String value,
required String subtext, required String subtext,
required Color accentColor, required Color accentColor,
required Color secondaryTextColor,
required IconData icon, required IconData icon,
}) { }) {
return Padding( return Padding(
@ -317,18 +349,18 @@ class _FeedTelemetryInspectorModalState
children: [ children: [
Row( Row(
children: [ children: [
Icon(icon, size: 12, color: const Color(0xFF6B7A8D)), Icon(icon, size: 12, color: secondaryTextColor),
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: Text(
label, label,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
color: Color(0xFF7B8B9E), color: secondaryTextColor,
), ),
), ),
), ),
@ -350,12 +382,12 @@ class _FeedTelemetryInspectorModalState
), ),
Text( Text(
subtext, subtext,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.4, letterSpacing: 0.4,
color: Color(0xFF5D6C7E), color: secondaryTextColor,
), ),
), ),
], ],
@ -363,13 +395,18 @@ class _FeedTelemetryInspectorModalState
); );
} }
Widget _buildTraceSelector(MotorSignalThemeColors colors) { Widget _buildTraceSelector(
MotorSignalThemeColors colors,
Color secondaryTextColor,
Color accentColor,
Color primaryTextColor,
) {
return Container( return Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141920), color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.outlineVariant),
), ),
child: Row( child: Row(
children: [ children: [
@ -377,16 +414,28 @@ class _FeedTelemetryInspectorModalState
tab: _InspectorTraceTab.leanApex, tab: _InspectorTraceTab.leanApex,
label: 'LEAN & APEX', label: 'LEAN & APEX',
icon: Icons.rotate_left_rounded, icon: Icons.rotate_left_rounded,
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
primaryTextColor: primaryTextColor,
), ),
_buildTraceTabItem( _buildTraceTabItem(
tab: _InspectorTraceTab.speedGForce, tab: _InspectorTraceTab.speedGForce,
label: 'SPEED & G-FORCE', label: 'SPEED & G-FORCE',
icon: Icons.show_chart_rounded, icon: Icons.show_chart_rounded,
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
primaryTextColor: primaryTextColor,
), ),
_buildTraceTabItem( _buildTraceTabItem(
tab: _InspectorTraceTab.elevation, tab: _InspectorTraceTab.elevation,
label: 'ELEVATION', label: 'ELEVATION',
icon: Icons.terrain_rounded, icon: Icons.terrain_rounded,
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
primaryTextColor: primaryTextColor,
), ),
], ],
), ),
@ -397,6 +446,10 @@ class _FeedTelemetryInspectorModalState
required _InspectorTraceTab tab, required _InspectorTraceTab tab,
required String label, required String label,
required IconData icon, required IconData icon,
required MotorSignalThemeColors colors,
required Color secondaryTextColor,
required Color accentColor,
required Color primaryTextColor,
}) { }) {
final isSelected = _selectedTab == tab; final isSelected = _selectedTab == tab;
return Expanded( return Expanded(
@ -407,10 +460,10 @@ class _FeedTelemetryInspectorModalState
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? const Color(0xFF202A36) : Colors.transparent, color: isSelected ? colors.surfaceContainer : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: isSelected border: isSelected
? Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)) ? Border.all(color: accentColor.withValues(alpha: 0.4))
: null, : null,
), ),
child: Row( child: Row(
@ -419,7 +472,7 @@ class _FeedTelemetryInspectorModalState
Icon( Icon(
icon, icon,
size: 13, size: 13,
color: isSelected ? AppColors.electricCyan : const Color(0xFF6B7A8D), color: isSelected ? accentColor : secondaryTextColor,
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
Flexible( Flexible(
@ -431,7 +484,7 @@ class _FeedTelemetryInspectorModalState
fontSize: 10, fontSize: 10,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
letterSpacing: 0.3, letterSpacing: 0.3,
color: isSelected ? Colors.white : const Color(0xFF7A8B9E), color: isSelected ? primaryTextColor : secondaryTextColor,
), ),
), ),
), ),
@ -445,51 +498,65 @@ class _FeedTelemetryInspectorModalState
Widget _buildInteractiveGraphCard( Widget _buildInteractiveGraphCard(
SocialFeedItem item, SocialFeedItem item,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
bool isLight,
Color secondaryTextColor,
Color accentColor,
Color primaryTextColor,
) { ) {
final hasRecorded = item.hasRecordedTelemetry; final hasRecorded = item.hasRecordedTelemetry;
return Container( return Container(
height: 220, height: 220,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0C1015), color: isLight
? AppColors.lightSurfaceContainerHighest
: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.outlineVariant),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
GestureDetector( LayoutBuilder(
onHorizontalDragUpdate: hasRecorded builder: (context, graphConstraints) {
? (details) { final graphWidth =
final box = context.findRenderObject() as RenderBox?; graphConstraints.maxWidth > 0 ? graphConstraints.maxWidth : 340.0;
if (box != null) { return GestureDetector(
final localX = details.localPosition.dx; onHorizontalDragUpdate: hasRecorded
setState(() { ? (details) {
_scrubFraction = (localX / 340).clamp(0.0, 1.0); setState(() {
}); _scrubFraction =
} (details.localPosition.dx / graphWidth)
} .clamp(0.0, 1.0);
: null, });
onTapDown: hasRecorded }
? (details) { : null,
final box = context.findRenderObject() as RenderBox?; onTapDown: hasRecorded
if (box != null) { ? (details) {
final localX = details.localPosition.dx; setState(() {
setState(() { _scrubFraction =
_scrubFraction = (localX / 340).clamp(0.0, 1.0); (details.localPosition.dx / graphWidth)
}); .clamp(0.0, 1.0);
} });
} }
: null, : null,
child: CustomPaint( child: CustomPaint(
size: const Size(double.infinity, 220), size: const Size(double.infinity, 220),
painter: _TelemetryWaveformPainter( painter: _TelemetryWaveformPainter(
tab: _selectedTab, tab: _selectedTab,
item: item, item: item,
scrubFraction: _scrubFraction, scrubFraction: _scrubFraction,
), accentColor: accentColor,
), gridColor: colors.outlineVariant,
baselineColor: colors.outline,
badgeBgColor: isLight
? AppColors.lightSurfaceContainerHighest
: AppColors.darkSurfaceContainerHighest,
),
),
);
},
), ),
if (!hasRecorded) if (!hasRecorded)
Center( Center(
@ -501,19 +568,22 @@ class _FeedTelemetryInspectorModalState
vertical: 14, vertical: 14,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141A22).withValues(alpha: 0.92), color: (isLight
? AppColors.lightSurface
: AppColors.darkSurfaceContainer)
.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A3647)), border: Border.all(color: colors.outlineVariant),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: const [ children: [
Icon( Icon(
Icons.sensors_off_rounded, Icons.sensors_off_rounded,
size: 24, size: 24,
color: Color(0xFF8B98A5), color: secondaryTextColor,
), ),
SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'NO RECORDED TELEMETRY SAMPLES', 'NO RECORDED TELEMETRY SAMPLES',
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -522,17 +592,17 @@ class _FeedTelemetryInspectorModalState
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: 0.5, letterSpacing: 0.5,
color: Colors.white, color: primaryTextColor,
), ),
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Detailed multi-trace waveforms require recorded ride telemetry points.', 'Detailed multi-trace waveforms require recorded ride telemetry points.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 11, fontSize: 11,
color: Color(0xFF8B98A5), color: secondaryTextColor,
), ),
), ),
], ],
@ -548,6 +618,9 @@ class _FeedTelemetryInspectorModalState
Widget _buildScrubberReadout( Widget _buildScrubberReadout(
SocialFeedItem item, SocialFeedItem item,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
Color secondaryTextColor,
Color accentColor,
Color primaryTextColor,
) { ) {
final hasRecorded = item.hasRecordedTelemetry; final hasRecorded = item.hasRecordedTelemetry;
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1); final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
@ -559,9 +632,9 @@ class _FeedTelemetryInspectorModalState
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141920), color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.outlineVariant),
), ),
child: Column( child: Column(
children: [ children: [
@ -571,10 +644,10 @@ class _FeedTelemetryInspectorModalState
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.touch_app_rounded, Icons.touch_app_rounded,
size: 14, size: 14,
color: AppColors.electricCyan, color: accentColor,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( Flexible(
@ -583,12 +656,12 @@ class _FeedTelemetryInspectorModalState
? 'POINT: $scrubDist MI ($scrubPct%)' ? 'POINT: $scrubDist MI ($scrubPct%)'
: 'TELEMETRY TRACE UNAVAILABLE', : 'TELEMETRY TRACE UNAVAILABLE',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 9.5, fontSize: 9.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
), ),
@ -598,11 +671,11 @@ class _FeedTelemetryInspectorModalState
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY', hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY',
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF5D6C7E), color: secondaryTextColor,
), ),
), ),
], ],
@ -611,11 +684,11 @@ class _FeedTelemetryInspectorModalState
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH'), _buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH', colors, secondaryTextColor, primaryTextColor),
Container(width: 1, height: 24, color: const Color(0xFF222B38)), Container(width: 1, height: 24, color: colors.outlineVariant),
_buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°'), _buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°', colors, secondaryTextColor, primaryTextColor),
Container(width: 1, height: 24, color: const Color(0xFF222B38)), Container(width: 1, height: 24, color: colors.outlineVariant),
_buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G'), _buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G', colors, secondaryTextColor, primaryTextColor),
], ],
), ),
], ],
@ -638,69 +711,87 @@ class _FeedTelemetryInspectorModalState
final a = samples[lowerIdx]; final a = samples[lowerIdx];
final b = samples[upperIdx]; final b = samples[upperIdx];
final aLean = FeedTelemetrySample.applyMovingLeanGate(
speedMph: a.speedMph,
leanAngleDeg: a.leanAngleDeg,
).abs();
final bLean = FeedTelemetrySample.applyMovingLeanGate(
speedMph: b.speedMph,
leanAngleDeg: b.leanAngleDeg,
).abs();
final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round(); final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round();
final lean = (a.leanAngleDeg.abs() + final lean = (aLean + (bLean - aLean) * t).round();
(b.leanAngleDeg.abs() - a.leanAngleDeg.abs()) * t)
.round();
final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t) final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t)
.toStringAsFixed(2); .toStringAsFixed(2);
return (speed, lean, latG); return (speed, lean, latG);
} }
Widget _buildScrubStat(String label, String value) { Widget _buildScrubStat(
String label,
String value,
MotorSignalThemeColors colors,
Color secondaryTextColor,
Color primaryTextColor,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF6B7A8D), color: secondaryTextColor,
), ),
), ),
Text( Text(
value, value,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white, color: primaryTextColor,
), ),
), ),
], ],
); );
} }
Widget _buildReplayButton(BuildContext context) { Widget _buildReplayButton(
BuildContext context,
MotorSignalThemeColors colors,
Color accentColor,
Color primaryTextColor,
) {
return SizedBox( return SizedBox(
height: 48, height: 48,
child: OutlinedButton.icon( child: OutlinedButton.icon(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
backgroundColor: const Color(0xFF161E28), backgroundColor: colors.surfaceContainerHigh,
side: BorderSide( side: BorderSide(
color: AppColors.electricCyan.withValues(alpha: 0.5), color: accentColor.withValues(alpha: 0.5),
width: 1.5, width: 1.5,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
icon: const Icon( icon: Icon(
Icons.play_circle_fill_rounded, Icons.play_circle_fill_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
label: const Text( label: Text(
'OPEN FULL RIDE REPLAY', 'OPEN FULL RIDE REPLAY',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: 0.8, letterSpacing: 0.8,
color: Colors.white, color: primaryTextColor,
), ),
), ),
onPressed: () { onPressed: () {
@ -718,11 +809,19 @@ class _TelemetryWaveformPainter extends CustomPainter {
final _InspectorTraceTab tab; final _InspectorTraceTab tab;
final SocialFeedItem item; final SocialFeedItem item;
final double scrubFraction; final double scrubFraction;
final Color accentColor;
final Color gridColor;
final Color baselineColor;
final Color badgeBgColor;
_TelemetryWaveformPainter({ _TelemetryWaveformPainter({
required this.tab, required this.tab,
required this.item, required this.item,
required this.scrubFraction, required this.scrubFraction,
required this.accentColor,
required this.gridColor,
required this.baselineColor,
required this.badgeBgColor,
}); });
@override @override
@ -732,7 +831,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
// Draw subtle grid lines // Draw subtle grid lines
final gridPaint = Paint() final gridPaint = Paint()
..color = const Color(0xFF19222E) ..color = gridColor
..strokeWidth = 1.0; ..strokeWidth = 1.0;
for (var i = 1; i <= 4; i++) { for (var i = 1; i <= 4; i++) {
@ -763,7 +862,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
// Draw Scrub Cursor Line & Indicator // Draw Scrub Cursor Line & Indicator
final scrubX = (w * scrubFraction).clamp(0.0, w); final scrubX = (w * scrubFraction).clamp(0.0, w);
final scrubPaint = Paint() final scrubPaint = Paint()
..color = AppColors.electricCyan ..color = accentColor
..strokeWidth = 1.8; ..strokeWidth = 1.8;
canvas.drawLine(Offset(scrubX, 0), Offset(scrubX, h), scrubPaint); canvas.drawLine(Offset(scrubX, 0), Offset(scrubX, h), scrubPaint);
@ -782,7 +881,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
// Baseline (0° upright) // Baseline (0° upright)
final baselinePaint = Paint() final baselinePaint = Paint()
..color = const Color(0xFF2A3647) ..color = baselineColor
..strokeWidth = 1.2 ..strokeWidth = 1.2
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint); canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
@ -796,7 +895,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
final s = samples[i]; final s = samples[i];
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
final x = t * w; final x = t * w;
final leanAngle = s.leanAngleDeg.clamp(-65.0, 65.0); final leanAngle = FeedTelemetrySample.applyMovingLeanGate(
speedMph: s.speedMph,
leanAngleDeg: s.leanAngleDeg,
);
final y = midY - (leanAngle / 65.0) * (h * 0.38); final y = midY - (leanAngle / 65.0) * (h * 0.38);
final pt = Offset(x, y); final pt = Offset(x, y);
if (i == 0) { if (i == 0) {
@ -804,8 +906,8 @@ class _TelemetryWaveformPainter extends CustomPainter {
} else { } else {
path.lineTo(x, y); path.lineTo(x, y);
} }
if (s.leanAngleDeg.abs() >= peakAbsLean) { if (leanAngle.abs() >= peakAbsLean) {
peakAbsLean = s.leanAngleDeg.abs(); peakAbsLean = leanAngle.abs();
peakIndex = i; peakIndex = i;
peakPoint = pt; peakPoint = pt;
} }
@ -818,25 +920,25 @@ class _TelemetryWaveformPainter extends CustomPainter {
..close(); ..close();
final fillPaint = Paint() final fillPaint = Paint()
..shader = const LinearGradient( ..shader = LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Color(0x3300F2FE), accentColor.withValues(alpha: 0.20),
Color(0x0500F2FE), accentColor.withValues(alpha: 0.02),
], ],
).createShader(Rect.fromLTWH(0, 0, w, h)); ).createShader(Rect.fromLTWH(0, 0, w, h));
canvas.drawPath(fillPath, fillPaint); canvas.drawPath(fillPath, fillPaint);
// Draw Trace Line // Draw Trace Line
final linePaint = Paint() final linePaint = Paint()
..color = AppColors.electricCyan ..color = accentColor
..strokeWidth = 2.5 ..strokeWidth = 2.5
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawPath(path, linePaint); canvas.drawPath(path, linePaint);
if (peakIndex >= 0 && peakAbsLean > 0) { if (peakIndex >= 0 && peakAbsLean > 0) {
const markerColor = Color(0xFF00E676); final markerColor = AppColors.semanticSuccess;
final dotPaint = Paint()..color = markerColor; final dotPaint = Paint()..color = markerColor;
canvas.drawCircle(peakPoint, 5.0, dotPaint); canvas.drawCircle(peakPoint, 5.0, dotPaint);
@ -1011,7 +1113,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
Rect.fromLTWH(offset.dx - 4, offset.dy - 2, painter.width + 8, painter.height + 4), Rect.fromLTWH(offset.dx - 4, offset.dy - 2, painter.width + 8, painter.height + 4),
const Radius.circular(4), const Radius.circular(4),
); );
final bgPaint = Paint()..color = const Color(0xFF0F151D); final bgPaint = Paint()..color = badgeBgColor;
final borderPaint = Paint() final borderPaint = Paint()
..color = color.withValues(alpha: 0.5) ..color = color.withValues(alpha: 0.5)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
@ -1026,6 +1128,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
bool shouldRepaint(covariant _TelemetryWaveformPainter oldDelegate) { bool shouldRepaint(covariant _TelemetryWaveformPainter oldDelegate) {
return oldDelegate.tab != tab || return oldDelegate.tab != tab ||
oldDelegate.scrubFraction != scrubFraction || oldDelegate.scrubFraction != scrubFraction ||
oldDelegate.item != item; oldDelegate.item != item ||
oldDelegate.accentColor != accentColor ||
oldDelegate.gridColor != gridColor ||
oldDelegate.baselineColor != baselineColor ||
oldDelegate.badgeBgColor != badgeBgColor;
} }
} }

View File

@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_typography.dart'; import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/theme_extensions.dart';
/// Glove-friendly quick launcher button for the Squad & Canyon Community Feed. /// Glove-friendly quick launcher button for the Squad Community Feed.
/// ///
/// Designed to sit directly in the Dashboard status bar for instantaneous /// Designed to sit directly in the Dashboard status bar for instantaneous
/// 1-tap access to live rider activity, route snapshots, and kudos. /// 1-tap access to live rider activity, route snapshots, and kudos.
@ -14,15 +15,20 @@ class QuickSquadFeedButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors;
final isLight = Theme.of(context).brightness == Brightness.light;
final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
final textColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface;
if (compact) { if (compact) {
return IconButton( return IconButton(
key: const ValueKey('dash_quick_feed_button'), key: const ValueKey('dash_quick_feed_button'),
icon: const Icon( icon: Icon(
Icons.dynamic_feed_rounded, Icons.dynamic_feed_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 19, size: 19,
), ),
tooltip: 'Squad & Canyon Feed', tooltip: 'Squad Feed',
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => context.push('/social/feed'), onPressed: () => context.push('/social/feed'),
@ -36,10 +42,10 @@ class QuickSquadFeedButton extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF181C21), color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.3), color: colors.outlineVariant,
width: 0.8, width: 0.8,
), ),
), ),
@ -49,26 +55,26 @@ class QuickSquadFeedButton extends StatelessWidget {
Container( Container(
width: 7, width: 7,
height: 7, height: 7,
decoration: const BoxDecoration( decoration: BoxDecoration(
color: AppColors.electricCyan, color: accentColor,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.electricCyan, color: accentColor.withValues(alpha: 0.4),
blurRadius: 4, blurRadius: 4,
), ),
], ],
), ),
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
const Text( Text(
'FEED', 'FEED',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: 0.6, letterSpacing: 0.6,
color: Color(0xFFC3F5FF), color: textColor,
), ),
), ),
], ],

View File

@ -8,12 +8,13 @@ import '../../../../core/theme/theme_extensions.dart';
import '../../../../database/database.dart'; import '../../../../database/database.dart';
import '../../../../services/auth/auth_providers.dart'; import '../../../../services/auth/auth_providers.dart';
import '../../../../services/map/map_provider_interface.dart'; import '../../../../services/map/map_provider_interface.dart';
import '../../../../services/routes/route_providers.dart';
import '../../../../services/social/social_feed_models.dart'; import '../../../../services/social/social_feed_models.dart';
import '../../../../services/social/social_feed_providers.dart'; import '../../../../services/social/social_feed_providers.dart';
import '../../../garage/garage_providers.dart'; import '../../../garage/garage_providers.dart';
/// Modal dialog / bottom sheet allowing riders to share their recent ride /// Modal dialog / bottom sheet allowing riders to share their recent ride
/// or canyon session to the Community Social Feed. /// or recorded session to the Community Social Feed.
class ShareSessionModal extends ConsumerStatefulWidget { class ShareSessionModal extends ConsumerStatefulWidget {
final Ride? initialRide; final Ride? initialRide;
final List<TelemetrySample>? initialTelemetrySamples; final List<TelemetrySample>? initialTelemetrySamples;
@ -46,11 +47,11 @@ class ShareSessionModal extends ConsumerStatefulWidget {
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> { class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController(text: 'Morning Canyon Carve'); final _titleController = TextEditingController(text: 'Morning Ride');
final _locationController = final _locationController =
TextEditingController(text: 'Skyline Blvd & Highway 9'); TextEditingController(text: 'Location unavailable');
final _bikeController = TextEditingController(); final _bikeController = TextEditingController();
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport; RidingDiscipline _selectedDiscipline = RidingDiscipline.touring;
bool _isSubmitting = false; bool _isSubmitting = false;
List<Ride> _recentRides = []; List<Ride> _recentRides = [];
@ -141,6 +142,11 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
if (widget.initialRide != null) { if (widget.initialRide != null) {
if (widget.initialTelemetrySamples == null) { if (widget.initialTelemetrySamples == null) {
await _loadSamplesForRide(widget.initialRide!); await _loadSamplesForRide(widget.initialRide!);
} else {
await _resolveStartingLocation(
widget.initialTelemetrySamples!,
widget.initialRide!.id,
);
} }
} else { } else {
await _loadRecentRides(); await _loadRecentRides();
@ -211,17 +217,27 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final sampleStep = final sampleStep =
(samples.length / 60).ceil().clamp(1, samples.length); (samples.length / 60).ceil().clamp(1, samples.length);
final totalIdx = math.max(1, samples.length - 1); final totalIdx = math.max(1, samples.length - 1);
downsampledTelemetry = [ FeedTelemetrySample buildSample(int index) {
for (int i = 0; i < samples.length; i += sampleStep) final s = samples[index];
FeedTelemetrySample( return FeedTelemetrySample(
progress: (i / totalIdx).clamp(0.0, 1.0), progress: (index / totalIdx).clamp(0.0, 1.0),
speedMph: samples[i].speed ?? 0.0, speedMph: s.speed ?? 0.0,
leanAngleDeg: samples[i].leanAngle ?? 0.0, leanAngleDeg: FeedTelemetrySample.applyMovingLeanGate(
lateralG: samples[i].lateralG ?? 0.0, speedMph: s.speed,
altitudeFt: (samples[i].altitude ?? 0.0) * 3.28084, leanAngleDeg: s.leanAngle,
latitude: samples[i].latitude,
longitude: samples[i].longitude,
), ),
lateralG: s.lateralG ?? 0.0,
altitudeFt: (s.altitude ?? 0.0) * 3.28084,
latitude: s.latitude,
longitude: s.longitude,
timestamp: s.timestamp,
);
}
downsampledTelemetry = [
for (int i = 0; i < samples.length; i += sampleStep) buildSample(i),
if ((samples.length - 1) % sampleStep != 0)
buildSample(samples.length - 1),
]; ];
} }
@ -229,6 +245,46 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
_selectedTelemetrySamples = downsampledTelemetry; _selectedTelemetrySamples = downsampledTelemetry;
} }
Future<void> _resolveStartingLocation(
List<TelemetrySample> samples,
int rideId,
) async {
final firstGps = samples.cast<TelemetrySample?>().firstWhere(
(s) =>
s != null &&
s.latitude != null &&
s.longitude != null &&
s.latitude!.isFinite &&
s.longitude!.isFinite &&
(s.latitude != 0.0 || s.longitude != 0.0),
orElse: () => null,
);
if (firstGps != null) {
try {
final routeProvider = ref.read(awsLocationRouteProviderInstance);
final place = await routeProvider.reverseGeocodeCoarse(
AppLatLng(firstGps.latitude!, firstGps.longitude!),
);
if (mounted && _selectedRide?.id == rideId) {
if (place != null && place.trim().isNotEmpty) {
_locationController.text = place.trim();
} else {
_locationController.text = 'Location unavailable';
}
}
} catch (_) {
if (mounted && _selectedRide?.id == rideId) {
_locationController.text = 'Location unavailable';
}
}
} else {
if (mounted && _selectedRide?.id == rideId) {
_locationController.text = 'Location unavailable';
}
}
}
Future<void> _loadSamplesForRide(Ride ride) async { Future<void> _loadSamplesForRide(Ride ride) async {
try { try {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
@ -240,6 +296,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
setState(() { setState(() {
_extractSamples(samples); _extractSamples(samples);
}); });
await _resolveStartingLocation(samples, ride.id);
} }
} catch (_) {} } catch (_) {}
} }
@ -262,7 +319,11 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
} }
Future<void> _handlePublish() async { Future<void> _handlePublish() async {
if (!_formKey.currentState!.validate() || _isSubmitting) return; if (!_formKey.currentState!.validate() ||
_isSubmitting ||
_selectedRide == null) {
return;
}
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
@ -272,7 +333,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final authorDisplayName = user?.displayName ?? 'Apex Rider'; final authorDisplayName = user?.displayName ?? 'Apex Rider';
final authorHandle = user?.email?.split('@').first ?? 'apex_rider'; final authorHandle = user?.email?.split('@').first ?? 'apex_rider';
final ride = _selectedRide; final ride = _selectedRide!;
await ref.read(socialFeedServiceProvider).shareRecentRide( await ref.read(socialFeedServiceProvider).shareRecentRide(
authorUid: authorUid, authorUid: authorUid,
@ -280,12 +341,10 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
authorHandle: authorHandle, authorHandle: authorHandle,
authorBadge: 'PRO', authorBadge: 'PRO',
clubName: 'Bay Area Twisties Club', clubName: 'Bay Area Twisties Club',
locationName: _locationController.text.trim().isNotEmpty locationName: _locationController.text.trim(),
? _locationController.text.trim()
: 'Skyline Blvd & Highway 9',
rideTitle: _titleController.text.trim().isNotEmpty rideTitle: _titleController.text.trim().isNotEmpty
? _titleController.text.trim() ? _titleController.text.trim()
: 'Morning Canyon Carve', : 'Morning Ride',
bikeModel: _bikeController.text.trim().isNotEmpty bikeModel: _bikeController.text.trim().isNotEmpty
? _bikeController.text.trim() ? _bikeController.text.trim()
: '2024 Ducati Streetfighter V4', : '2024 Ducati Streetfighter V4',
@ -301,29 +360,33 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
routePoints: _selectedRoutePoints, routePoints: _selectedRoutePoints,
telemetrySamples: _selectedTelemetrySamples, telemetrySamples: _selectedTelemetrySamples,
authoritativeRide: ride, authoritativeRide: ride,
rideId: ride?.id, rideId: ride.id,
); );
// Reset filter to All Activity so published session is immediately displayed // Reset filter to All Activity so published session is immediately displayed
ref.read(feedFilterStateProvider.notifier).state = FeedFilter.allActivity; ref.read(feedFilterStateProvider.notifier).state = FeedFilter.allActivity;
if (mounted) { if (mounted) {
final colors = context.motorColors;
final isLight = Theme.of(context).brightness == Brightness.light;
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
backgroundColor: const Color(0xFF1D2025), backgroundColor: colors.surfaceContainerHigh,
content: Row( content: Row(
children: const [ children: [
Icon(Icons.check_circle_rounded, const Icon(Icons.check_circle_rounded,
color: AppColors.electricCyan, size: 20), color: AppColors.semanticSuccess, size: 20),
SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
'Ride session published to Community Feed!', 'Ride session published to Community Feed!',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 13, fontSize: 13,
color: Colors.white, color: isLight
? AppColors.lightOnSurface
: AppColors.darkOnSurface,
), ),
), ),
), ),
@ -342,22 +405,32 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final bottomInset = MediaQuery.of(context).viewInsets.bottom; final isLight = Theme.of(context).brightness == Brightness.light;
final secondaryTextColor =
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
final primaryTextColor =
isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface;
final accentColor =
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
final buttonFg = AppColors.onBrand;
final size = MediaQuery.sizeOf(context);
final isLandscape = size.width > size.height && size.height <= 500;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.9, maxHeight: size.height * (isLandscape ? 0.98 : 0.9),
), ),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF101419), color: colors.surfaceContainer,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), border: Border.all(color: colors.outlineVariant),
boxShadow: const [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black87, color: colors.glowShadow,
blurRadius: 30, blurRadius: 30,
offset: Offset(0, -10), offset: const Offset(0, -10),
), ),
], ],
), ),
@ -371,14 +444,19 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
children: [ children: [
// Modal Header & Handle // Modal Header & Handle
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), padding: EdgeInsets.fromLTRB(
20,
isLandscape ? 8 : 12,
20,
isLandscape ? 8 : 12,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Drag handle // Drag handle
Center( Center(
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 12), margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
width: 44, width: 44,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -397,12 +475,12 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.electricCyan.withValues(alpha: 0.15), color: accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon( child: Icon(
Icons.add_circle_outline_rounded, Icons.add_circle_outline_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
), ),
@ -411,23 +489,23 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'Share Recent Session', 'Share Recent Session',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: primaryTextColor,
), ),
), ),
const Text( Text(
'Publish telemetry & route to community feed', 'Publish telemetry & route to community feed',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 12, fontSize: 12,
color: AppColors.darkOnSurfaceVariant, color: secondaryTextColor,
), ),
), ),
], ],
@ -437,8 +515,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close_rounded, icon: Icon(Icons.close_rounded,
color: Color(0xFF849396)), color: secondaryTextColor),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
], ],
@ -456,25 +534,25 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
// Recent Database Rides Selector // Recent Database Rides Selector
if (_recentRides.isNotEmpty) ...[ if (_recentRides.isNotEmpty) ...[
_buildFieldLabel('SELECT RECORDED SESSION', colors), _buildFieldLabel('SELECT RECORDED SESSION', colors, secondaryTextColor),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF161B22), color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)), border: Border.all(color: colors.outlineVariant),
), ),
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButton<Ride>( child: DropdownButton<Ride>(
key: const ValueKey('share_session_ride_picker'), key: const ValueKey('share_session_ride_picker'),
value: _selectedRide, value: _selectedRide,
isExpanded: true, isExpanded: true,
dropdownColor: const Color(0xFF161B22), dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: primaryTextColor,
), ),
items: _recentRides.map((ride) { items: _recentRides.map((ride) {
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
@ -483,7 +561,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
value: ride, value: ride,
child: Row( child: Row(
children: [ children: [
const Icon(Icons.two_wheeler_rounded, size: 16, color: AppColors.electricCyan), Icon(Icons.two_wheeler_rounded, size: 16, color: accentColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@ -505,56 +583,64 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
], ],
// Ride Title Field // Ride Title Field
_buildFieldLabel('SESSION / ROUTE TITLE', colors), _buildFieldLabel('SESSION / ROUTE TITLE', colors, secondaryTextColor),
TextFormField( TextFormField(
controller: _titleController, controller: _titleController,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 14, fontSize: 14,
color: Colors.white, color: primaryTextColor,
), ),
decoration: _buildInputDecoration( decoration: _buildInputDecoration(
hint: 'e.g. Sunday Skyline & Pescadero Carve'), hint: 'e.g. Sunday Skyline & Pescadero Carve',
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
),
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Enter a ride title' : null, v == null || v.trim().isEmpty ? 'Enter a ride title' : null,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// Motorcycle Model Field // Motorcycle Model Field
_buildFieldLabel('MOTORCYCLE MODEL', colors), _buildFieldLabel('MOTORCYCLE MODEL', colors, secondaryTextColor),
TextFormField( TextFormField(
controller: _bikeController, controller: _bikeController,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 14, fontSize: 14,
color: Colors.white, color: primaryTextColor,
), ),
decoration: _buildInputDecoration( decoration: _buildInputDecoration(
hint: 'e.g. 2024 Ducati Streetfighter V4'), hint: 'e.g. 2024 Ducati Streetfighter V4',
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
),
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Enter motorcycle model' : null, v == null || v.trim().isEmpty ? 'Enter motorcycle model' : null,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// Riding Discipline / Style Field // Riding Discipline / Style Field
_buildFieldLabel('RIDING DISCIPLINE / STYLE', colors), _buildFieldLabel('RIDING DISCIPLINE / STYLE', colors, secondaryTextColor),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF161B22), color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF263238)), border: Border.all(color: colors.outlineVariant),
), ),
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButton<RidingDiscipline>( child: DropdownButton<RidingDiscipline>(
value: _selectedDiscipline, value: _selectedDiscipline,
isExpanded: true, isExpanded: true,
dropdownColor: const Color(0xFF161B22), dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: primaryTextColor,
), ),
items: RidingDiscipline.values items: RidingDiscipline.values
.where((d) => d != RidingDiscipline.all) .where((d) => d != RidingDiscipline.all)
@ -573,29 +659,33 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// Location Field // Starting Location Field (Privacy-safe)
_buildFieldLabel('LOCATION / CANYON', colors), _buildFieldLabel('STARTING LOCATION', colors, secondaryTextColor),
TextFormField( TextFormField(
controller: _locationController, controller: _locationController,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 14, fontSize: 14,
color: Colors.white, color: primaryTextColor,
), ),
decoration: _buildInputDecoration( decoration: _buildInputDecoration(
hint: "e.g. Alice's Restaurant / Skyline"), hint: 'e.g. Saratoga, CA or Trailhead',
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
),
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Enter location' : null, v == null || v.trim().isEmpty ? 'Enter starting location' : null,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace) // Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace)
Row( Row(
children: [ children: [
const Icon( Icon(
Icons.lock_outline_rounded, Icons.lock_outline_rounded,
size: 13, size: 13,
color: AppColors.electricCyan, color: accentColor,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
@ -604,12 +694,12 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
? 'RECORDED TELEMETRY (READ-ONLY)' ? 'RECORDED TELEMETRY (READ-ONLY)'
: 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)', : 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 10.5, fontSize: 10.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.7, letterSpacing: 0.7,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
), ),
@ -617,6 +707,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
key: const ValueKey('share_session_readonly_telemetry_grid'),
children: [ children: [
Expanded( Expanded(
child: _buildReadOnlyTelemetryTile( child: _buildReadOnlyTelemetryTile(
@ -624,6 +715,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
label: 'DIST (MI)', label: 'DIST (MI)',
value: _authoritativeDistanceMiles.toStringAsFixed(1), value: _authoritativeDistanceMiles.toStringAsFixed(1),
colors: colors, colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -633,6 +726,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
label: 'TIME (MINS)', label: 'TIME (MINS)',
value: _authoritativeDurationMinutes.toString(), value: _authoritativeDurationMinutes.toString(),
colors: colors, colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -642,6 +737,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
label: 'LEAN (DEG)', label: 'LEAN (DEG)',
value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°', value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°',
colors: colors, colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
highlight: true, highlight: true,
), ),
), ),
@ -656,6 +753,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
label: 'LEAN APEX SIDE', label: 'LEAN APEX SIDE',
value: _authoritativeLeanSide, value: _authoritativeLeanSide,
colors: colors, colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -665,62 +764,28 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
label: 'PACE FACTOR', label: 'PACE FACTOR',
value: _authoritativePaceFactor, value: _authoritativePaceFactor,
colors: colors, colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
), ),
], ],
), ),
if (isLandscape) ...[
const SizedBox(height: 18),
_buildPublishButton(accentColor, buttonFg),
const SizedBox(height: 8),
],
], ],
), ),
), ),
), ),
// Publish CTA Button (pinned at modal bottom) // Publish CTA Button (pinned at modal bottom in portrait mode)
Padding( if (!isLandscape)
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), Padding(
child: SizedBox( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
height: 52, child: _buildPublishButton(accentColor, buttonFg),
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan,
foregroundColor: const Color(0xFF00363D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
onPressed: _isSubmitting ? null : _handlePublish,
child: _isSubmitting
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Color(0xFF00363D),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.share_rounded, size: 20),
SizedBox(width: 8),
Flexible(
child: Text(
'PUBLISH TO COMMUNITY FEED',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
],
),
),
), ),
),
], ],
), ),
), ),
@ -729,25 +794,78 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
); );
} }
Widget _buildPublishButton(Color accentColor, Color buttonFg) {
return SizedBox(
height: 52,
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: accentColor,
foregroundColor: buttonFg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
onPressed:
(_isSubmitting || _selectedRide == null) ? null : _handlePublish,
child: _isSubmitting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: buttonFg,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.share_rounded, size: 20),
SizedBox(width: 8),
Flexible(
child: Text(
'PUBLISH TO COMMUNITY FEED',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
],
),
),
);
}
Widget _buildReadOnlyTelemetryTile({ Widget _buildReadOnlyTelemetryTile({
required Key key, required Key key,
required String label, required String label,
required String value, required String value,
required MotorSignalThemeColors colors, required MotorSignalThemeColors colors,
required Color secondaryTextColor,
required Color accentColor,
bool highlight = false, bool highlight = false,
}) { }) {
final primaryTextColor = colors.surfaceContainer == AppColors.darkSurfaceContainer
? AppColors.darkOnSurface
: AppColors.lightOnSurface;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildFieldLabel(label, colors), _buildFieldLabel(label, colors, secondaryTextColor),
Container( Container(
key: key, key: key,
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF14181E), color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF263238)), border: Border.all(color: colors.outlineVariant),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -760,14 +878,14 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: highlight ? AppColors.electricCyan : Colors.white, color: highlight ? accentColor : primaryTextColor,
), ),
), ),
), ),
const Icon( Icon(
Icons.lock_outline_rounded, Icons.lock_outline_rounded,
size: 13, size: 13,
color: Color(0xFF64748B), color: secondaryTextColor,
), ),
], ],
), ),
@ -776,44 +894,53 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
); );
} }
Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) { Widget _buildFieldLabel(
String label,
MotorSignalThemeColors colors,
Color secondaryTextColor,
) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: Text( child: Text(
label, label,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.8, letterSpacing: 0.8,
color: Color(0xFF94A3B8), color: secondaryTextColor,
), ),
), ),
); );
} }
InputDecoration _buildInputDecoration({required String hint}) { InputDecoration _buildInputDecoration({
required String hint,
required MotorSignalThemeColors colors,
required Color secondaryTextColor,
required Color accentColor,
}) {
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
hintStyle: const TextStyle( hintStyle: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
color: Color(0xFF64748B), color: secondaryTextColor,
), ),
filled: true, filled: true,
fillColor: const Color(0xFF161B22), fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFF263238)), borderSide: BorderSide(color: colors.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFF263238)), borderSide: BorderSide(color: colors.outlineVariant),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.electricCyan, width: 1.5), borderSide: BorderSide(color: accentColor, width: 1.5),
), ),
); );
} }

View File

@ -251,7 +251,7 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
key: const ValueKey('social_card_feed'), key: const ValueKey('social_card_feed'),
icon: Icons.dynamic_feed_outlined, icon: Icons.dynamic_feed_outlined,
title: 'Community Feed', title: 'Community Feed',
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos', subtitle: 'Squad telemetry, route snapshots, paces & kudos',
colors: colors, colors: colors,
onTap: () => setState(() => _currentTab = SocialHubTab.feed), onTap: () => setState(() => _currentTab = SocialHubTab.feed),
), ),
@ -276,7 +276,7 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
key: const ValueKey('social_card_feed'), key: const ValueKey('social_card_feed'),
icon: Icons.dynamic_feed_outlined, icon: Icons.dynamic_feed_outlined,
title: 'Community Feed', title: 'Community Feed',
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos', subtitle: 'Squad telemetry, route snapshots, paces & kudos',
colors: colors, colors: colors,
onTap: () => setState(() => _currentTab = SocialHubTab.feed), onTap: () => setState(() => _currentTab = SocialHubTab.feed),
), ),

View File

@ -152,7 +152,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxHeight = MediaQuery.of(context).size.height * 0.85; final size = MediaQuery.sizeOf(context);
final insets = MediaQuery.viewInsetsOf(context);
final isLandscape = size.width > size.height;
final maxHeight = (size.height - insets.bottom) * (isLandscape ? 0.95 : 0.85);
final crossAxisCount = size.width >= 720
? 4
: (size.width >= 480 ? 3 : 2);
return Container( return Container(
constraints: BoxConstraints(maxHeight: maxHeight), constraints: BoxConstraints(maxHeight: maxHeight),
@ -165,7 +171,12 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
child: SafeArea( child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), padding: EdgeInsets.fromLTRB(
16,
isLandscape ? 8 : 12,
16,
isLandscape ? 8 : 16,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -180,17 +191,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
), ),
), ),
const SizedBox(height: 12), SizedBox(height: isLandscape ? 6 : 12),
Row( Row(
children: [ children: [
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24), Icon(Icons.campaign, color: AppColors.cyberCyan, size: isLandscape ? 20 : 24),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
'REPORT ROAD INCIDENT', 'REPORT ROAD INCIDENT',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: isLandscape ? 14 : 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 0.8, letterSpacing: 0.8,
color: Colors.white, color: Colors.white,
@ -199,23 +210,27 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.close, color: Colors.white60), icon: const Icon(Icons.close, color: Colors.white60),
padding: isLandscape ? EdgeInsets.zero : const EdgeInsets.all(8.0),
constraints: isLandscape ? const BoxConstraints() : null,
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
], ],
), ),
const SizedBox(height: 4), if (!isLandscape) ...[
Text( const SizedBox(height: 4),
'1-tap broadcast to all nearby riders & pack members.', Text(
style: TextStyle( '1-tap broadcast to all nearby riders & pack members.',
fontFamily: AppTypography.fontBody, style: TextStyle(
fontSize: 12, fontFamily: AppTypography.fontBody,
color: Colors.white70, fontSize: 12,
color: Colors.white70,
),
), ),
), ],
if (_isMovingFast) ...[ if (_isMovingFast) ...[
const SizedBox(height: 10), SizedBox(height: isLandscape ? 6 : 10),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15), color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -223,7 +238,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
child: const Row( child: const Row(
children: [ children: [
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18), Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@ -239,22 +254,23 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
), ),
], ],
const SizedBox(height: 14), SizedBox(height: isLandscape ? 8 : 14),
Flexible( Flexible(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: GridView.builder( child: GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemCount: _kReportCategories.length, itemCount: _kReportCategories.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: crossAxisCount,
childAspectRatio: 2.2, mainAxisExtent: isLandscape ? 56.0 : 70.0,
crossAxisSpacing: 10, crossAxisSpacing: 8,
mainAxisSpacing: 10, mainAxisSpacing: 8,
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _kReportCategories[index]; final item = _kReportCategories[index];
return _buildGridCard(item); return _buildGridCard(item, isLandscape: isLandscape);
}, },
), ),
), ),
@ -266,14 +282,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
); );
} }
Widget _buildGridCard(_ReportItem item) { Widget _buildGridCard(_ReportItem item, {bool isLandscape = false}) {
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: item.color.withValues(alpha: 0.12), backgroundColor: item.color.withValues(alpha: 0.12),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 6 : 10,
vertical: isLandscape ? 4 : 8,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5), side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
), ),
elevation: 0, elevation: 0,
@ -282,14 +301,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(7), padding: EdgeInsets.all(isLandscape ? 5 : 7),
decoration: BoxDecoration( decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.22), color: item.color.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(item.icon, color: item.color, size: 22), child: Icon(item.icon, color: item.color, size: isLandscape ? 18 : 22),
), ),
const SizedBox(width: 8), SizedBox(width: isLandscape ? 6 : 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -299,20 +318,22 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
item.title, item.title,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 13, fontSize: isLandscape ? 11.5 : 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), if (!isLandscape) ...[
Text( const SizedBox(height: 2),
item.subtitle, Text(
style: const TextStyle(fontSize: 10, color: Colors.white70), item.subtitle,
maxLines: 1, style: const TextStyle(fontSize: 10, color: Colors.white70),
overflow: TextOverflow.ellipsis, maxLines: 1,
), overflow: TextOverflow.ellipsis,
),
],
], ],
), ),
), ),

View File

@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' as ll; import 'package:latlong2/latlong.dart' as ll;
import '../routes/aws_location_route_provider.dart'; import '../routes/aws_location_route_provider.dart';
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
import 'map_providers.dart';
/// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite). /// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite).
class AwsMapProvider implements AppMapProvider { class AwsMapProvider implements AppMapProvider {
@ -99,13 +99,12 @@ class _AwsMapView extends StatefulWidget {
State<_AwsMapView> createState() => _AwsMapViewState(); State<_AwsMapView> createState() => _AwsMapViewState();
} }
class _AwsMapViewState extends State<_AwsMapView> { class _AwsMapViewState extends State<_AwsMapView>
with TickerProviderStateMixin {
late final MapController _mapController; late final MapController _mapController;
AnimationController? _animController;
ll.LatLng? _lastFollowPos; ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime; DateTime? _lastFollowTime;
Timer? _pendingFollowTimer;
static const int _followThrottleMs = 250;
@override @override
void initState() { void initState() {
@ -114,20 +113,53 @@ class _AwsMapViewState extends State<_AwsMapView> {
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude); _lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
} }
void _applyFollowMove(ll.LatLng target, double zoom) { void _animateCameraTo({
_lastFollowPos = target; required ll.LatLng target,
_lastFollowTime = DateTime.now(); required double targetZoom,
try { required double targetBearing,
_mapController.move(target, zoom); required Duration duration,
} catch (_) { Curve curve = Curves.easeOutCubic,
WidgetsBinding.instance.addPostFrameCallback((_) { }) {
if (mounted) { if (!mounted) return;
try {
_mapController.move(target, zoom); _animController?.stop();
} catch (_) {} _animController?.dispose();
} _animController = null;
});
} final startCenter = _mapController.camera.center;
final startZoom = _mapController.camera.zoom;
final startBearing = _mapController.camera.rotation;
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
final endBearing = startBearing + bearingDiff;
_animController = AnimationController(
vsync: this,
duration: duration,
);
final curved = CurvedAnimation(parent: _animController!, curve: curve);
final startLat = startCenter.latitude;
final startLng = startCenter.longitude;
final endLat = target.latitude;
final endLng = target.longitude;
_animController!.addListener(() {
if (!mounted) return;
final t = curved.value;
final lat = startLat + (endLat - startLat) * t;
final lng = startLng + (endLng - startLng) * t;
final z = startZoom + (targetZoom - startZoom) * t;
final b = startBearing + (endBearing - startBearing) * t;
try {
_mapController.move(ll.LatLng(lat, lng), z);
_mapController.rotate(normalizeAngle(b));
} catch (_) {}
});
_animController!.forward();
} }
@override @override
@ -145,62 +177,56 @@ class _AwsMapViewState extends State<_AwsMapView> {
? 1.0 ? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() + : (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs(); (centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001; final hasMoved = posDelta > 0.000005;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
final shouldMoveNow = justBecameFollowMode || final intervalMs = _lastFollowTime == null
_lastFollowPos == null || ? 250
zoomChanged || : now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
if (shouldMoveNow) { final isInitialOrLargeJump =
_pendingFollowTimer?.cancel(); justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
_pendingFollowTimer = null;
_applyFollowMove(centerLl, widget.zoom); if (isInitialOrLargeJump) {
} else if (hasMoved) { _animController?.stop();
_pendingFollowTimer?.cancel(); _lastFollowPos = centerLl;
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs); _lastFollowTime = now;
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () { try {
_pendingFollowTimer = null; _mapController.move(centerLl, widget.zoom);
if (mounted && widget.isFollowMode) { _mapController.rotate(normalizeAngle(widget.bearing));
final latestCenter = ll.LatLng( } catch (_) {}
widget.center.latitude, } else if (hasMoved || bearingChanged || zoomChanged) {
widget.center.longitude, _lastFollowPos = centerLl;
); _lastFollowTime = now;
_applyFollowMove(latestCenter, widget.zoom); _animateCameraTo(
} target: centerLl,
}); targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
);
} }
} else { } else {
_pendingFollowTimer?.cancel(); if (bearingChanged) {
_pendingFollowTimer = null; _animateCameraTo(
if (zoomChanged) { target: _mapController.camera.center,
WidgetsBinding.instance.addPostFrameCallback((_) { targetZoom: widget.zoom,
if (mounted) { targetBearing: widget.bearing,
try { duration: const Duration(milliseconds: 200),
_mapController.move(_mapController.camera.center, widget.zoom); curve: Curves.easeOutCubic,
} catch (_) {} );
} } else if (zoomChanged) {
}); try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
} }
} }
if (bearingChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.rotate(widget.bearing);
} catch (_) {}
}
});
}
} }
@override @override
void dispose() { void dispose() {
_pendingFollowTimer?.cancel(); _animController?.stop();
_animController?.dispose();
_animController = null;
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps; import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps;
import '../../core/theme/map_style.dart'; import '../../core/theme/map_style.dart';
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
import 'map_providers.dart';
class GoogleMapProvider implements AppMapProvider { class GoogleMapProvider implements AppMapProvider {
@override @override
@ -87,7 +88,7 @@ class _GoogleMapViewState extends State<_GoogleMapView> {
gmaps.CameraPosition( gmaps.CameraPosition(
target: target, target: target,
zoom: zoom, zoom: zoom,
bearing: bearing, bearing: normalizeAngle(bearing),
), ),
), ),
); );

View File

@ -94,3 +94,69 @@ final mapFollowStateProvider =
StateNotifierProvider<MapFollowNotifier, bool>((ref) { StateNotifierProvider<MapFollowNotifier, bool>((ref) {
return MapFollowNotifier(); return MapFollowNotifier();
}); });
/// Map orientation mode: North Up vs Heading Up.
enum MapOrientation {
northUp,
headingUp;
String get displayName => this == northUp ? 'North Up' : 'Heading Up';
}
/// Computes the shortest angular difference in degrees from [from] to [to].
/// Result is in range (-180.0, 180.0].
/// Handles circular transitions across the 0°/360° discontinuity:
/// e.g. from 359° to 1° yields +2°, not -358°.
double shortestAngleDiff(double from, double to) {
return ((to - from + 540.0) % 360.0) - 180.0;
}
/// Normalizes an angle in degrees into [0.0, 360.0).
double normalizeAngle(double angle) {
return (angle % 360.0 + 360.0) % 360.0;
}
class MapOrientationNotifier extends StateNotifier<MapOrientation> {
static const String _prefKey = 'selected_map_orientation';
MapOrientationNotifier() : super(MapOrientation.northUp) {
_loadFromPrefs();
}
Future<void> _loadFromPrefs() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedVal = prefs.getString(_prefKey);
if (savedVal == 'headingUp') {
state = MapOrientation.headingUp;
} else if (savedVal == 'northUp') {
state = MapOrientation.northUp;
}
} catch (_) {}
}
Future<void> toggle() async {
final next = state == MapOrientation.northUp
? MapOrientation.headingUp
: MapOrientation.northUp;
state = next;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, next.name);
} catch (_) {}
}
Future<void> setOrientation(MapOrientation orientation) async {
state = orientation;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, orientation.name);
} catch (_) {}
}
}
final mapOrientationProvider =
StateNotifierProvider<MapOrientationNotifier, MapOrientation>((ref) {
return MapOrientationNotifier();
});

View File

@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' as ll; import 'package:latlong2/latlong.dart' as ll;
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
import 'map_providers.dart';
class OpenStreetMapProvider implements AppMapProvider { class OpenStreetMapProvider implements AppMapProvider {
@override @override
@ -66,13 +66,12 @@ class _OsmMapView extends StatefulWidget {
State<_OsmMapView> createState() => _OsmMapViewState(); State<_OsmMapView> createState() => _OsmMapViewState();
} }
class _OsmMapViewState extends State<_OsmMapView> { class _OsmMapViewState extends State<_OsmMapView>
with TickerProviderStateMixin {
late final MapController _mapController; late final MapController _mapController;
AnimationController? _animController;
ll.LatLng? _lastFollowPos; ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime; DateTime? _lastFollowTime;
Timer? _pendingFollowTimer;
static const int _followThrottleMs = 250;
@override @override
void initState() { void initState() {
@ -81,20 +80,53 @@ class _OsmMapViewState extends State<_OsmMapView> {
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude); _lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
} }
void _applyFollowMove(ll.LatLng target, double zoom) { void _animateCameraTo({
_lastFollowPos = target; required ll.LatLng target,
_lastFollowTime = DateTime.now(); required double targetZoom,
try { required double targetBearing,
_mapController.move(target, zoom); required Duration duration,
} catch (_) { Curve curve = Curves.easeOutCubic,
WidgetsBinding.instance.addPostFrameCallback((_) { }) {
if (mounted) { if (!mounted) return;
try {
_mapController.move(target, zoom); _animController?.stop();
} catch (_) {} _animController?.dispose();
} _animController = null;
});
} final startCenter = _mapController.camera.center;
final startZoom = _mapController.camera.zoom;
final startBearing = _mapController.camera.rotation;
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
final endBearing = startBearing + bearingDiff;
_animController = AnimationController(
vsync: this,
duration: duration,
);
final curved = CurvedAnimation(parent: _animController!, curve: curve);
final startLat = startCenter.latitude;
final startLng = startCenter.longitude;
final endLat = target.latitude;
final endLng = target.longitude;
_animController!.addListener(() {
if (!mounted) return;
final t = curved.value;
final lat = startLat + (endLat - startLat) * t;
final lng = startLng + (endLng - startLng) * t;
final z = startZoom + (targetZoom - startZoom) * t;
final b = startBearing + (endBearing - startBearing) * t;
try {
_mapController.move(ll.LatLng(lat, lng), z);
_mapController.rotate(normalizeAngle(b));
} catch (_) {}
});
_animController!.forward();
} }
@override @override
@ -111,62 +143,56 @@ class _OsmMapViewState extends State<_OsmMapView> {
? 1.0 ? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() + : (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs(); (centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001; final hasMoved = posDelta > 0.000005;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
final shouldMoveNow = justBecameFollowMode || final intervalMs = _lastFollowTime == null
_lastFollowPos == null || ? 250
zoomChanged || : now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
if (shouldMoveNow) { final isInitialOrLargeJump =
_pendingFollowTimer?.cancel(); justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
_pendingFollowTimer = null;
_applyFollowMove(centerLl, widget.zoom); if (isInitialOrLargeJump) {
} else if (hasMoved) { _animController?.stop();
_pendingFollowTimer?.cancel(); _lastFollowPos = centerLl;
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs); _lastFollowTime = now;
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () { try {
_pendingFollowTimer = null; _mapController.move(centerLl, widget.zoom);
if (mounted && widget.isFollowMode) { _mapController.rotate(normalizeAngle(widget.bearing));
final latestCenter = ll.LatLng( } catch (_) {}
widget.center.latitude, } else if (hasMoved || bearingChanged || zoomChanged) {
widget.center.longitude, _lastFollowPos = centerLl;
); _lastFollowTime = now;
_applyFollowMove(latestCenter, widget.zoom); _animateCameraTo(
} target: centerLl,
}); targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
);
} }
} else { } else {
_pendingFollowTimer?.cancel(); if (bearingChanged) {
_pendingFollowTimer = null; _animateCameraTo(
if (zoomChanged) { target: _mapController.camera.center,
WidgetsBinding.instance.addPostFrameCallback((_) { targetZoom: widget.zoom,
if (mounted) { targetBearing: widget.bearing,
try { duration: const Duration(milliseconds: 200),
_mapController.move(_mapController.camera.center, widget.zoom); curve: Curves.easeOutCubic,
} catch (_) {} );
} } else if (zoomChanged) {
}); try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
} }
} }
if (bearingChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.rotate(widget.bearing);
} catch (_) {}
}
});
}
} }
@override @override
void dispose() { void dispose() {
_pendingFollowTimer?.cancel(); _animController?.stop();
_animController?.dispose();
_animController = null;
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
import 'route_models.dart'; import 'route_models.dart';
@ -41,6 +42,31 @@ class AwsLocationRouteProvider implements RouteProvider {
@override @override
String get name => 'Amazon AWS Location'; String get name => 'Amazon AWS Location';
static String? _cleanAddressValue(dynamic value) {
final candidates = value is Map ? [value['Name'], value['Code']] : [value];
for (final candidate in candidates) {
final cleaned = candidate?.toString().trim();
if (cleaned != null && cleaned.isNotEmpty) return cleaned;
}
return null;
}
@visibleForTesting
static String? buildCoarseLocationLabel(Map<String, dynamic> address) {
final locality = _cleanAddressValue(
address['Locality'] ?? address['Municipality'],
);
final region = _cleanAddressValue(address['Region']);
final subRegion = _cleanAddressValue(address['SubRegion']);
final country = _cleanAddressValue(address['Country']);
final adminArea = region ?? subRegion ?? country;
if (locality != null && adminArea != null) {
return '$locality, $adminArea';
}
return locality ?? adminArea;
}
@override @override
Future<List<PlaceSearchResult>> searchPlaces( Future<List<PlaceSearchResult>> searchPlaces(
String query, { String query, {
@ -253,6 +279,47 @@ class AwsLocationRouteProvider implements RouteProvider {
} }
} }
/// Privacy-safe coarse reverse geocode for a geographic point.
///
/// Returns a coarse region label (e.g. "Saratoga, CA") suitable for privacy-safe
/// starting location labels without exposing house numbers, street names, or exact GPS coordinates.
///
/// Returns null if unavailable or if reverse-geocoding fails.
Future<String?> reverseGeocodeCoarse(AppLatLng position) async {
try {
final uri = Uri.parse(
'https://places.geo.$region.amazonaws.com/v2/reverse-geocode?key=$apiKey',
);
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'QueryPosition': [position.longitude, position.latitude],
'Filter': {
'IncludeCountries': ['USA'],
},
}),
)
.timeout(const Duration(seconds: 6));
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body);
final items = data['ResultItems'] as List<dynamic>? ?? [];
if (items.isEmpty) return null;
final first = items.first as Map<String, dynamic>;
final address = first['Address'] as Map<String, dynamic>?;
if (address == null) return null;
// Extract only coarse locality and administrative area.
return buildCoarseLocationLabel(address);
} catch (_) {
return null;
}
}
@override @override
Future<RouteCalculationResult> calculateRoute( Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, { List<RouteStop> stops, {

View File

@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../auth/auth_providers.dart'; import '../auth/auth_providers.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
import 'aws_location_route_provider.dart'; import 'aws_location_route_provider.dart';
@ -7,6 +8,19 @@ import 'route_models.dart';
import 'route_provider.dart'; import 'route_provider.dart';
import 'route_service.dart'; import 'route_service.dart';
/// One cached location read per planner visit; never starts GPS or asks permission.
final routePlannerLocationProvider = FutureProvider.autoDispose<AppLatLng?>((ref) async {
try {
final position = await Geolocator.getLastKnownPosition();
if (position == null || !RoutePlannerNotifier.isValidCoordinate(position.latitude, position.longitude)) {
return null;
}
return AppLatLng(position.latitude, position.longitude);
} catch (_) {
return null;
}
});
/// Provider for RouteService. /// Provider for RouteService.
final routeServiceProvider = Provider<RouteService>((ref) { final routeServiceProvider = Provider<RouteService>((ref) {
return RouteService(); return RouteService();

View File

@ -1,5 +1,6 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
DateTime? _parseDateTime(dynamic value) { DateTime? _parseDateTime(dynamic value) {
@ -137,6 +138,27 @@ enum FeedSnapshotType {
/// A recorded telemetry sample point attached to a shared social feed session. /// A recorded telemetry sample point attached to a shared social feed session.
@immutable @immutable
class FeedTelemetrySample { class FeedTelemetrySample {
/// Matches RideStateMachine._processStats 3.0 MPH moving gate and 65.0°
/// physical bound so stationary kickstand/dismount frames never enter
/// published lean traces or apex calculations.
static const double movingLeanGateSpeedMph = 3.0;
static const double maxPhysicalLeanDeg = 65.0;
static double applyMovingLeanGate({
required double? speedMph,
required double? leanAngleDeg,
}) {
if (speedMph == null ||
!speedMph.isFinite ||
speedMph < movingLeanGateSpeedMph ||
leanAngleDeg == null ||
!leanAngleDeg.isFinite ||
leanAngleDeg.abs() > maxPhysicalLeanDeg) {
return 0.0;
}
return leanAngleDeg;
}
final double progress; // 0.0 to 1.0 along the session final double progress; // 0.0 to 1.0 along the session
final double speedMph; final double speedMph;
final double leanAngleDeg; // signed degrees (-left, +right) final double leanAngleDeg; // signed degrees (-left, +right)
@ -144,6 +166,7 @@ class FeedTelemetrySample {
final double altitudeFt; final double altitudeFt;
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final DateTime? timestamp;
const FeedTelemetrySample({ const FeedTelemetrySample({
required this.progress, required this.progress,
@ -153,8 +176,27 @@ class FeedTelemetrySample {
this.altitudeFt = 0.0, this.altitudeFt = 0.0,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.timestamp,
}); });
FeedTelemetrySample withMovingLeanGate() {
final gatedLean = applyMovingLeanGate(
speedMph: speedMph,
leanAngleDeg: leanAngleDeg,
);
if (gatedLean == leanAngleDeg) return this;
return FeedTelemetrySample(
progress: progress,
speedMph: speedMph,
leanAngleDeg: gatedLean,
lateralG: lateralG,
altitudeFt: altitudeFt,
latitude: latitude,
longitude: longitude,
timestamp: timestamp,
);
}
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'progress': progress, 'progress': progress,
'speedMph': speedMph, 'speedMph': speedMph,
@ -163,6 +205,7 @@ class FeedTelemetrySample {
'altitudeFt': altitudeFt, 'altitudeFt': altitudeFt,
if (latitude != null) 'lat': latitude, if (latitude != null) 'lat': latitude,
if (longitude != null) 'lng': longitude, if (longitude != null) 'lng': longitude,
if (timestamp != null) 'timestamp': _dateTimeToFirestore(timestamp),
}; };
factory FeedTelemetrySample.fromJson(Map<String, dynamic> json) { factory FeedTelemetrySample.fromJson(Map<String, dynamic> json) {
@ -174,6 +217,7 @@ class FeedTelemetrySample {
altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0, altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0,
latitude: (json['lat'] as num?)?.toDouble(), latitude: (json['lat'] as num?)?.toDouble(),
longitude: (json['lng'] as num?)?.toDouble(), longitude: (json['lng'] as num?)?.toDouble(),
timestamp: _parseDateTime(json['timestamp']),
); );
} }
} }
@ -181,6 +225,11 @@ class FeedTelemetrySample {
/// A community social feed item (Stitch SOC-01). /// A community social feed item (Stitch SOC-01).
@immutable @immutable
class SocialFeedItem { class SocialFeedItem {
/// Minimum geodesic displacement required for GPS verification: 0.05 miles (~80.47 meters).
static const double minimumVerifiedDisplacementMiles = 0.05;
static const double minimumVerifiedDisplacementMeters =
minimumVerifiedDisplacementMiles * 1609.344;
final String id; final String id;
final int? rideId; final int? rideId;
final String authorUid; final String authorUid;
@ -257,17 +306,86 @@ class SocialFeedItem {
this.comments = const [], this.comments = const [],
}); });
/// Evaluates whether a set of ride metrics and GPS route coordinates satisfy /// Evaluates whether a set of ride metrics, GPS route coordinates, and
/// authentic GPS verification criteria. /// recorded telemetry samples satisfy authentic GPS verification criteria.
static bool verifyGpsCriteria({ static bool verifyGpsCriteria({
required bool isDemo, required bool isDemo,
required double distanceMiles, required double distanceMiles,
required int durationSeconds, required int durationSeconds,
required List<AppLatLng> routePoints, required List<AppLatLng> routePoints,
List<FeedTelemetrySample> telemetrySamples = const [],
}) { }) {
if (isDemo) return false; if (isDemo) return false;
if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false; if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false;
if (durationSeconds <= 0) return false; if (durationSeconds <= 0) return false;
if (telemetrySamples.length < 2) return false;
double? firstProgress;
double? prevProgress;
DateTime? firstTimestamp;
DateTime? prevTimestamp;
int timestampCount = 0;
final validSamplePoints = <AppLatLng>[];
for (final sample in telemetrySamples) {
if (!sample.progress.isFinite ||
sample.progress < 0.0 ||
sample.progress > 1.0 ||
!sample.speedMph.isFinite ||
sample.speedMph < 0.0 ||
!sample.leanAngleDeg.isFinite ||
!sample.lateralG.isFinite ||
!sample.altitudeFt.isFinite) {
return false;
}
if (prevProgress != null && sample.progress < prevProgress) {
return false;
}
firstProgress ??= sample.progress;
prevProgress = sample.progress;
if (sample.timestamp != null) {
if (prevTimestamp != null &&
sample.timestamp!.isBefore(prevTimestamp)) {
return false;
}
firstTimestamp ??= sample.timestamp;
prevTimestamp = sample.timestamp;
timestampCount++;
}
final lat = sample.latitude;
final lng = sample.longitude;
if (lat != null &&
lng != null &&
lat.isFinite &&
lng.isFinite &&
lat.abs() <= 90.0 &&
lng.abs() <= 180.0 &&
(lat != 0.0 || lng != 0.0)) {
validSamplePoints.add(AppLatLng(lat, lng));
}
}
// Require a positive recorded span across telemetry samples.
if (firstProgress == null ||
prevProgress == null ||
(prevProgress - firstProgress) <= 0.0) {
return false;
}
if (timestampCount > 0) {
if (timestampCount < 2 ||
firstTimestamp == null ||
prevTimestamp == null ||
prevTimestamp.difference(firstTimestamp).inMilliseconds <= 0) {
return false;
}
}
if (!_hasMinimumGeodesicDisplacement(validSamplePoints)) {
return false;
}
final validPoints = routePoints.where((p) { final validPoints = routePoints.where((p) {
return p.latitude.isFinite && return p.latitude.isFinite &&
@ -277,21 +395,35 @@ class SocialFeedItem {
(p.latitude != 0.0 || p.longitude != 0.0); (p.latitude != 0.0 || p.longitude != 0.0);
}).toList(); }).toList();
if (validPoints.length < 2) return false; return _hasMinimumGeodesicDisplacement(validPoints);
}
// Require at least two distinct GPS coordinates with meaningful displacement (~50m+). /// Require at least two coordinates separated by >= 0.05 miles (~80.47 m)
final first = validPoints.first; /// measured geodesically via [Geolocator.distanceBetween].
final hasDistinctPoint = validPoints.skip(1).any( static bool _hasMinimumGeodesicDisplacement(List<AppLatLng> points) {
(p) => if (points.length < 2) return false;
(p.latitude - first.latitude).abs() + for (int i = 0; i < points.length - 1; i++) {
(p.longitude - first.longitude).abs() > final origin = points[i];
0.0005, for (int j = i + 1; j < points.length; j++) {
final target = points[j];
final distMeters = Geolocator.distanceBetween(
origin.latitude,
origin.longitude,
target.latitude,
target.longitude,
); );
return hasDistinctPoint; if (distMeters.isFinite &&
distMeters >= minimumVerifiedDisplacementMeters) {
return true;
}
}
}
return false;
} }
/// True only when this post is flagged as GPS-verified AND backed by valid, /// True only when this post is flagged as GPS-verified AND backed by valid,
/// non-demo GPS route coordinates, positive distance, and positive duration. /// non-demo GPS route coordinates, recorded telemetry samples, positive
/// distance, and positive duration.
bool get hasVerifiedGps => bool get hasVerifiedGps =>
isGpsVerified && isGpsVerified &&
verifyGpsCriteria( verifyGpsCriteria(
@ -299,6 +431,7 @@ class SocialFeedItem {
distanceMiles: distanceMiles, distanceMiles: distanceMiles,
durationSeconds: durationSeconds, durationSeconds: durationSeconds,
routePoints: routePoints, routePoints: routePoints,
telemetrySamples: telemetrySamples,
); );
/// True when the post is a non-demo ride containing recorded route coordinates that can be plotted. /// True when the post is a non-demo ride containing recorded route coordinates that can be plotted.
@ -444,7 +577,8 @@ class SocialFeedItem {
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? []; final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
final samples = telemetryList final samples = telemetryList
.whereType<Map>() .whereType<Map>()
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s))) .map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s))
.withMovingLeanGate())
.toList(); .toList();
final commentList = json['comments'] as List<dynamic>? ?? []; final commentList = json['comments'] as List<dynamic>? ?? [];
@ -463,6 +597,7 @@ class SocialFeedItem {
distanceMiles: distanceMiles, distanceMiles: distanceMiles,
durationSeconds: durationSeconds, durationSeconds: durationSeconds,
routePoints: points, routePoints: points,
telemetrySamples: samples,
); );
return SocialFeedItem( return SocialFeedItem(
@ -474,7 +609,7 @@ class SocialFeedItem {
authorAvatarPreset: json['authorAvatarPreset'] as String?, authorAvatarPreset: json['authorAvatarPreset'] as String?,
authorBadge: json['authorBadge'] as String?, authorBadge: json['authorBadge'] as String?,
clubName: json['clubName'] as String?, clubName: json['clubName'] as String?,
locationName: json['locationName'] as String? ?? 'Canyon Route', locationName: (json['locationName'] as String? ?? '').trim(),
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(), timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve', rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle', bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',

View File

@ -504,11 +504,16 @@ class SocialFeedService {
(authoritativeRide.elevationGain * 3.28084).round(); (authoritativeRide.elevationGain * 3.28084).round();
} }
final gatedTelemetrySamples = telemetrySamples
.map((s) => s.withMovingLeanGate())
.toList(growable: false);
final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria( final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
isDemo: false, isDemo: false,
distanceMiles: resolvedDistance, distanceMiles: resolvedDistance,
durationSeconds: resolvedDurationSeconds, durationSeconds: resolvedDurationSeconds,
routePoints: routePoints, routePoints: routePoints,
telemetrySamples: gatedTelemetrySamples,
); );
final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification; final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification;
@ -537,7 +542,7 @@ class SocialFeedService {
isFeatured: isFeatured, isFeatured: isFeatured,
snapshotType: snapshotType, snapshotType: snapshotType,
routePoints: routePoints, routePoints: routePoints,
telemetrySamples: telemetrySamples, telemetrySamples: gatedTelemetrySamples,
isGpsVerified: effectiveGpsVerified, isGpsVerified: effectiveGpsVerified,
isDemo: false, isDemo: false,
fistBumpCount: 1, fistBumpCount: 1,

View File

@ -9,6 +9,29 @@ import 'package:motorcycle_dash/services/routes/route_provider.dart';
void main() { void main() {
group('AwsLocationRouteProvider Tests', () { group('AwsLocationRouteProvider Tests', () {
test('builds privacy-safe coarse labels from AWS address value shapes', () {
final mapRegion = AwsLocationRouteProvider.buildCoarseLocationLabel({
'Locality': 'Rome',
'Region': {'Code': 'GA', 'Name': 'Georgia'},
});
final stringRegion = AwsLocationRouteProvider.buildCoarseLocationLabel({
'Locality': 'Rome',
'Region': ' Georgia ',
});
final missingRegion = AwsLocationRouteProvider.buildCoarseLocationLabel({
'Locality': 'Rome',
'Region': {'Name': ' '},
});
expect(mapRegion, 'Rome, Georgia');
expect(stringRegion, 'Rome, Georgia');
expect(missingRegion, 'Rome');
for (final label in [mapRegion, stringRegion, missingRegion]) {
expect(label, isNot(contains('{')));
expect(label, isNot(matches(RegExp(r'\d+\.\d+,\s*-?\d+\.\d+'))));
}
});
test('instance properties and custom configuration', () { test('instance properties and custom configuration', () {
final provider = AwsLocationRouteProvider( final provider = AwsLocationRouteProvider(
apiKey: 'test-key', apiKey: 'test-key',

View File

@ -506,6 +506,38 @@ void main() {
); );
}, },
); );
testWidgets(
'Touring map orientation button toggles between North Up and Heading Up (#80)',
(tester) async {
await prefs.setString('dashboardLayout', 'touring');
configureViewport(tester, 400, 800);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
final orientBtn = find.byKey(
const ValueKey('dashboard_map_orientation_button'),
);
expect(orientBtn, findsOneWidget);
expect(find.text('NORTH UP'), findsOneWidget);
// Tap orientation button to switch to Heading Up
await tester.tap(orientBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('HEADING UP'), findsOneWidget);
// Tap again to switch back to North Up
await tester.tap(orientBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('NORTH UP'), findsOneWidget);
},
);
}); });
} }

View File

@ -20,6 +20,7 @@ import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart'; import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class _FakeAuth implements AuthService { class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {} @override Future<void> ensureBackendIdentity() async {}
@ -403,6 +404,106 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
testWidgets(
'Attach Saved Route sheet empty state does not overflow in landscape (800x360) with keyboard inset and portrait (#75)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
final fakeAuth = _FakeAuth(user: testUser);
for (final viewportSize in const [
Size(800, 360),
Size(640, 320),
Size(400, 800),
]) {
final isLandscape = viewportSize.width > viewportSize.height;
tester.view.physicalSize = viewportSize;
tester.view.devicePixelRatio = 1.0;
tester.view.resetViewInsets();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
groupRideStreamProvider('ride_101').overrideWith(
(ref) => Stream.value(testRide),
),
groupRideParticipantsProvider('ride_101').overrideWith(
(ref) => Stream.value(testParticipants),
),
groupRideLivePresenceProvider('ride_101').overrideWith(
(ref) => Stream.value(testPresences),
),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
userRoutesProvider('uid_lead').overrideWith(
(ref) => Stream.value(const []),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const GroupRideDetailScreen(rideId: 'ride_101'),
),
),
);
await tester.pumpAndSettle();
final attachBtn =
find.byKey(const ValueKey('attach_saved_route_button'));
await tester.scrollUntilVisible(
attachBtn,
150.0,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(attachBtn);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 300));
expect(tester.takeException(), isNull);
expect(find.text('ATTACH SAVED ROUTE'), findsWidgets);
expect(find.text('You have no saved routes yet.'), findsOneWidget);
expect(find.text('CREATE A ROUTE'), findsOneWidget);
final createBtnRect =
tester.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
expect(
createBtnRect.bottom,
lessThanOrEqualTo(viewportSize.height),
reason: 'CREATE A ROUTE button should fit inside $viewportSize',
);
if (isLandscape) {
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 200));
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text('CREATE A ROUTE'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final createBtnKeyboardRect = tester
.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
expect(
createBtnKeyboardRect.bottom,
lessThanOrEqualTo(viewportSize.height - 180 + 1.0),
reason:
'CREATE A ROUTE should remain above keyboard at $viewportSize',
);
}
// Dismiss sheet
Navigator.of(tester.element(find.text('CREATE A ROUTE'))).pop();
await tester.pumpAndSettle();
}
});
}); });
group('LiveGroupRideScreen Responsive Matrix', () { group('LiveGroupRideScreen Responsive Matrix', () {

View File

@ -18,6 +18,7 @@ import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/garage/garage_screen.dart'; import 'package:motorcycle_dash/features/garage/garage_screen.dart';
import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart'; import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart';
import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart'; import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart';
import 'package:motorcycle_dash/features/garage/widgets/motorcycle_photo.dart';
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService { class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
@override @override
@ -317,6 +318,109 @@ void main() {
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
}); });
} }
testWidgets('BUG-012: HistoryScreen ride cards show all bottom row telemetry without overflow on 800x360 landscape', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const HistoryScreen()));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('history_ride_card_1')), findsOneWidget);
expect(find.text('TRIP'), findsOneWidget);
expect(find.text('TIME'), findsOneWidget);
expect(find.text('TOP SPD'), findsOneWidget);
expect(find.text('MAX LEAN'), findsOneWidget);
expect(find.text('HARD BRK'), findsOneWidget);
expect(find.text('ELEV GAIN'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
});
group('Garage responsive regression coverage', () {
testWidgets('BUG-015: landscape garage card and Add Motorcycle remain reachable', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const GarageScreen()));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final grid = tester.widget<GridView>(find.byType(GridView));
final delegate = grid.gridDelegate as SliverGridDelegateWithMaxCrossAxisExtent;
expect(delegate.mainAxisExtent, lessThanOrEqualTo(320));
expect(tester.getSize(find.byType(MotorcyclePhoto).first).height, lessThanOrEqualTo(120));
final addMotorcycle = find.text('Add Motorcycle');
await tester.ensureVisible(addMotorcycle);
expect(addMotorcycle.hitTestable(), findsOneWidget);
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('SPECIFICATIONS'));
expect(tester.takeException(), isNull);
expect(tester.getTopLeft(find.text('SPECIFICATIONS')).dy, lessThan(360));
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
for (final keyboardInset in [0.0, 180.0]) {
testWidgets('BUG-016: landscape service dialog remains usable with keyboard inset $keyboardInset', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = FakeViewPadding(bottom: keyboardInset);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetViewInsets();
});
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Log Service'));
await tester.tap(find.text('Log Service'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Log New Service'), findsOneWidget);
final notesLabel = find.text('Notes (e.g. Brand, weight, shop)');
await tester.ensureVisible(notesLabel);
expect(tester.getBottomRight(notesLabel).dy, lessThan(360 - keyboardInset));
expect(find.text('Log New Service').hitTestable(), findsNothing);
expect(find.text('Cancel').hitTestable(), findsOneWidget);
expect(find.text('Save Record').hitTestable(), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Log New Service'), findsNothing);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
}
testWidgets('BUG-017: narrow portrait service type dropdown truncates label without overflow', (tester) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Log Service'));
await tester.tap(find.text('Log Service'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final dropdown = tester.widget<DropdownButton<String>>(find.byType(DropdownButton<String>).first);
expect(dropdown.isExpanded, isTrue);
final label = tester.widget<Text>(find.text('Engine Oil & Filter').first);
expect(label.maxLines, 1);
expect(label.overflow, TextOverflow.ellipsis);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
}); });
} }

View File

@ -43,7 +43,7 @@ void main() {
expect(find.text('@pacific-coast-riders'), findsOneWidget); expect(find.text('@pacific-coast-riders'), findsOneWidget);
expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget); expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget);
expect(find.text('42 members'), findsOneWidget); expect(find.text('42 members'), findsOneWidget);
expect(find.text('Request to Join'), findsOneWidget); expect(find.text('Request to Join'), findsNothing);
expect(find.text('NorCal'), findsOneWidget); expect(find.text('NorCal'), findsOneWidget);
expect(find.text('Sport Touring'), findsOneWidget); expect(find.text('Sport Touring'), findsOneWidget);
expect(find.text('OWNER'), findsOneWidget); expect(find.text('OWNER'), findsOneWidget);
@ -53,7 +53,7 @@ void main() {
expect(tapped, isTrue); expect(tapped, isTrue);
}); });
testWidgets('renders open admission badge correctly', (tester) async { testWidgets('renders open admission badge correctly with group_add_outlined icon', (tester) async {
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open); final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
await tester.pumpWidget( await tester.pumpWidget(
@ -68,6 +68,26 @@ void main() {
); );
expect(find.text('Open to Join'), findsOneWidget); expect(find.text('Open to Join'), findsOneWidget);
expect(find.byIcon(Icons.group_add_outlined), findsOneWidget);
expect(find.byIcon(Icons.lock_open_outlined), findsNothing);
});
for (final role in ClubRole.values) {
testWidgets('active ${role.name} has no admission prompt', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ClubCard(club: sampleClub, role: role)),
));
expect(find.text('Request to Join'), findsNothing);
});
}
testWidgets('non-member retains request admission prompt', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ClubCard(club: sampleClub)),
));
expect(find.text('Request to Join'), findsOneWidget);
}); });
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async { testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {

View File

@ -31,6 +31,53 @@ void main() {
expect(p.primaryAction, isNull); expect(p.primaryAction, isNull);
}); });
test('root owner cannot join even with a missing or stale member record', () {
for (final status in [null, ...ClubMemberStatus.values]) {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
member: status == null ? null : ClubMember(
uid: 'owner_uid',
clubId: baseClub.id,
role: ClubRole.member,
status: status,
updatedAt: DateTime(2026, 1, 1),
),
currentUserId: 'owner_uid',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.activeOwner);
expect(p.primaryAction, isNull);
expect(p.secondaryAction, isNull);
expect(p.canLeave, isFalse);
}
});
test('request admission actions respect active, pending and outsider states', () {
for (final role in ClubRole.values) {
for (final status in [null, ClubMemberStatus.active, ClubMemberStatus.requested]) {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
member: status == null ? null : ClubMember(
uid: 'rider_1',
clubId: baseClub.id,
role: role,
status: status,
updatedAt: DateTime(2026, 1, 1),
),
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.primaryAction, status == null
? ClubMembershipAction.requestToJoin
: status == ClubMemberStatus.requested
? ClubMembershipAction.withdrawRequest
: role == ClubRole.owner ? null : ClubMembershipAction.leaveClub);
}
}
});
test('resolves archived state when club.isArchived is true', () { test('resolves archived state when club.isArchived is true', () {
final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1)); final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1));
final p = ClubMembershipPresentation.resolve( final p = ClubMembershipPresentation.resolve(

View File

@ -225,5 +225,103 @@ void main() {
expect(find.text('Accept'), findsOneWidget); expect(find.text('Accept'), findsOneWidget);
expect(find.text('Decline'), findsOneWidget); expect(find.text('Decline'), findsOneWidget);
}); });
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('renders responsive layout without overflow on $size (#73)', (tester) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
const user = AuthUser(uid: 'alice', isAnonymous: false);
final club1 = Club(
id: 'club_1',
name: 'Bay Area Motos',
slug: 'bay-area-motos',
description: 'San Francisco & Bay Area motorcycle rides.',
ownerUid: 'alice',
memberCount: 15,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final club2 = Club(
id: 'club_2',
name: 'NorCal Twisties',
slug: 'norcal-twisties',
description: 'Carving Northern California canyon roads.',
ownerUid: 'bob',
memberCount: 24,
admissionPolicy: ClubAdmissionPolicy.request,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final member1 = ClubMember(
uid: 'alice',
clubId: 'club_1',
status: ClubMemberStatus.active,
role: ClubRole.owner,
updatedAt: DateTime(2026, 1, 1),
);
final member2 = ClubMember(
uid: 'alice',
clubId: 'club_2',
status: ClubMemberStatus.active,
role: ClubRole.member,
updatedAt: DateTime(2026, 1, 1),
);
final fakeService = FakeClubServiceForHub(
clubs: {'club_1': club1, 'club_2': club2},
handles: {'bay-area-motos': 'club_1', 'norcal-twisties': 'club_2'},
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
clubDetailProvider('club_1').overrideWith((ref) => Stream.value(club1)),
clubDetailProvider('club_2').overrideWith((ref) => Stream.value(club2)),
myClubsProvider.overrideWith((ref) => Stream.value([member1, member2])),
myClubInvitesProvider.overrideWith((ref) => Stream.value(const [])),
publicClubsProvider.overrideWith((ref) => Future.value([club1, club2])),
],
child: MediaQuery(
data: MediaQueryData(size: size),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubsHubScreen(),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final isLandscape = size.width > size.height;
if (isLandscape) {
// Verify GridView is used for memberships in landscape
expect(find.byType(GridView), findsOneWidget);
} else {
expect(find.byType(GridView), findsNothing);
}
// Tap Discover Tab
await tester.tap(find.text('DISCOVER'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
if (isLandscape) {
// Verify GridView is used for discovery in landscape
expect(find.byType(GridView), findsOneWidget);
} else {
expect(find.byType(GridView), findsNothing);
}
});
}
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@ -229,5 +229,125 @@ void main() {
); );
expect(find.text('Sign In / Register'), findsOneWidget); expect(find.text('Sign In / Register'), findsOneWidget);
}); });
testWidgets(
'landscape (800x360) with keyboard inset scrolls to lower fields and submit button, and portrait renders cleanly (#77)',
(tester) async {
const user = AuthUser(uid: 'alice', isAnonymous: false);
final fakeService = FakeCreateClubService();
// 1. Landscape (800x360) with simulated soft keyboard inset
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
tester.view.padding = const FakeViewPadding(top: 24);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
addTearDown(tester.view.resetPadding);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Scrollbar should be present for clear scroll affordance in landscape
expect(find.byType(Scrollbar), findsOneWidget);
// Two-column layout in landscape: Club Name and Club Handle share a row
final nameFinder = find.widgetWithText(
TextFormField,
'Club Name *',
skipOffstage: false,
);
final slugFinder = find.widgetWithText(
TextFormField,
'Club Handle / Slug *',
skipOffstage: false,
);
expect(nameFinder, findsOneWidget);
expect(slugFinder, findsOneWidget);
await tester.ensureVisible(nameFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getTopLeft(nameFinder).dx,
lessThan(tester.getTopLeft(slugFinder).dx),
);
// Enter Club Name & Handle
await tester.enterText(nameFinder, 'Canyon Carvers MC');
await tester.enterText(slugFinder, 'canyon-carvers-mc');
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.takeException(), isNull);
// Lower fields and submit button remain mounted and scrollable into the visible area above keyboard
final regionFinder = find.widgetWithText(
TextFormField,
'Home region (optional)',
skipOffstage: false,
);
final focusFinder = find.widgetWithText(
TextFormField,
'Motorcycle focus (optional)',
skipOffstage: false,
);
final submitFinder = find.byKey(
const ValueKey('create_club_submit_button'),
skipOffstage: false,
);
expect(regionFinder, findsOneWidget);
expect(focusFinder, findsOneWidget);
expect(submitFinder, findsOneWidget);
await tester.ensureVisible(regionFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getRect(regionFinder).bottom,
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
);
await tester.enterText(regionFinder, 'SoCal');
await tester.enterText(focusFinder, 'Sport bikes');
await tester.pump();
// Scroll to and tap the primary submit button while keyboard inset is active
await tester.ensureVisible(submitFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getRect(submitFinder).bottom,
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
);
await tester.tap(submitFinder);
await tester.pump();
expect(fakeService.lastCreatedClub, isNotNull);
expect(fakeService.lastCreatedClub!.name, equals('Canyon Carvers MC'));
expect(fakeService.lastCreatedClub!.homeRegion, equals('SoCal'));
// 2. Portrait (400x800) with keyboard inset check
tester.view.physicalSize = const Size(400, 800);
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
}); });
} }

View File

@ -1130,5 +1130,94 @@ void main() {
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE')); final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
expect(publishBtn.onPressed, isNull); expect(publishBtn.onPressed, isNull);
}); });
testWidgets(
'renders compact two-column layout in landscape (640x360 and 800x360) with keyboard-safe scrolling and single-column in portrait (#70)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
for (final landscapeSize in const [Size(640, 360), Size(800, 360)]) {
tester.view.physicalSize = landscapeSize;
tester.view.devicePixelRatio = 1.0;
tester.view.resetViewInsets();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Two-column layout: 3. MEETING LOCATION is to the right of 1. RIDE DETAILS
final detailsTopLeft = tester.getTopLeft(find.text('1. RIDE DETAILS'));
final meetingTopLeft =
tester.getTopLeft(find.text('3. MEETING LOCATION'));
expect(
meetingTopLeft.dx,
greaterThan(detailsTopLeft.dx + 200),
reason: 'Expected two-column layout at $landscapeSize',
);
// Compact vertical spacing in landscape so section 2 starts higher up
final scheduleTopLeft =
tester.getTopLeft(find.text('2. DATE & SCHEDULE'));
expect(
scheduleTopLeft.dy,
lessThan(225.0),
reason: 'Expected compact vertical spacing at $landscapeSize',
);
// Simulate keyboard inset in landscape and focus a lower field
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final notesField =
find.widgetWithText(TextFormField, 'Meeting Notes (optional)');
await tester.ensureVisible(notesField);
await tester.pumpAndSettle();
await tester.enterText(notesField, 'Meet by the north pumps');
await tester.pumpAndSettle();
expect(find.text('Meet by the north pumps'), findsOneWidget);
final notesRect = tester.getRect(notesField);
expect(
notesRect.bottom,
lessThanOrEqualTo(landscapeSize.height - 180 + 1.0),
);
final publishBtn =
find.widgetWithText(ElevatedButton, 'PUBLISH GROUP RIDE');
await tester.ensureVisible(publishBtn);
await tester.pumpAndSettle();
expect(publishBtn, findsOneWidget);
expect(tester.takeException(), isNull);
}
// Portrait check with keyboard inset
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final publishBtnPortrait =
find.widgetWithText(ElevatedButton, 'PUBLISH GROUP RIDE');
await tester.ensureVisible(publishBtnPortrait);
await tester.pumpAndSettle();
expect(publishBtnPortrait, findsOneWidget);
expect(tester.takeException(), isNull);
});
}); });
} }

View File

@ -20,6 +20,8 @@ import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart';
import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/quick_squad_feed_button.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
@ -166,5 +168,81 @@ void main() {
final edgeInsets = paddingWidget.padding as EdgeInsets; final edgeInsets = paddingWidget.padding as EdgeInsets;
expect(edgeInsets.right, greaterThanOrEqualTo(16.0)); expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
}); });
testWidgets('BUG-007: Short landscape cockpit mount includes incident report and squad buttons with balanced layout', (tester) async {
configureViewport(tester, 800, 360);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byType(QuickSwarmReportButton), findsOneWidget);
expect(find.byType(QuickSquadFeedButton), findsOneWidget);
expect(find.byType(SpeedReadout), findsOneWidget);
});
testWidgets('BUG-007 / #54: Start Session button text is fully visible without clipping in minimal landscape (map hidden)', (tester) async {
await prefs.setString('dashboardLayout', 'minimal');
for (final size in [const Size(640, 360), const Size(800, 360), const Size(840, 390)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
final startBtnText = find.text('Start Session');
expect(startBtnText, findsOneWidget);
// Verify button text has positive width and fits cleanly
final textRenderBox = tester.renderObject(startBtnText) as RenderBox;
expect(textRenderBox.size.width, greaterThan(0));
expect(textRenderBox.size.height, greaterThan(0));
}
tester.view.resetPhysicalSize();
});
testWidgets('BUG-007 / #54: Start Session button text is fully visible in sport and touring landscape layouts', (tester) async {
for (final layout in ['sport', 'touring']) {
await prefs.setString('dashboardLayout', layout);
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('Start Session'), findsOneWidget);
}
}
tester.view.resetPhysicalSize();
});
testWidgets('BUG-007 / #54: LEAN ANGLE title does not overlap rider graphic across landscape viewports', (tester) async {
await prefs.setString('dashboardLayout', 'sport');
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
final titleFinder = find.text('LEAN ANGLE');
final graphicFinder = find.byKey(const ValueKey('lean_angle_rider_graphic'));
expect(titleFinder, findsOneWidget);
expect(graphicFinder, findsOneWidget);
final titleRect = tester.getRect(titleFinder);
final graphicRect = tester.getRect(graphicFinder);
// Prove no overlap between title rect and rider graphic rect
expect(titleRect.overlaps(graphicRect), isFalse, reason: 'Title overlaps rider graphic at ${size.width}x${size.height}');
// Prove title is positioned strictly above graphic
expect(titleRect.bottom, lessThanOrEqualTo(graphicRect.top), reason: 'Title bottom must be above or at graphic top at ${size.width}x${size.height}');
}
tester.view.resetPhysicalSize();
});
}); });
} }

View File

@ -129,6 +129,137 @@ void main() {
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget); expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
expect(find.text('3'), findsOneWidget); expect(find.text('3'), findsOneWidget);
}); });
testWidgets(
'Join Group Ride dialog renders without overflow in landscape with keyboard inset and in portrait (#69)',
(tester) async {
const user = AuthUser(
uid: 'alice',
email: 'alice@example.com',
isAnonymous: false,
);
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
upcomingGroupRidesProvider.overrideWith(
(ref) => Stream.value([]),
),
],
child: const MaterialApp(
home: GroupRidesHubScreen(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('group_rides_join_appbar_button')),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final fieldFinder = find.byKey(const ValueKey('join_ride_id_field'));
final submitFinder =
find.byKey(const ValueKey('join_ride_dialog_submit_button'));
await tester.ensureVisible(fieldFinder);
await tester.pumpAndSettle();
await tester.enterText(fieldFinder, 'ride_test_123');
await tester.pumpAndSettle();
expect(find.text('ride_test_123'), findsOneWidget);
await tester.ensureVisible(submitFinder);
await tester.pumpAndSettle();
expect(submitFinder, findsOneWidget);
expect(find.text('CANCEL'), findsOneWidget);
expect(tester.takeException(), isNull);
// Dismiss and verify portrait as well
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
tester.view.physicalSize = const Size(400, 800);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('group_rides_join_appbar_button')),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(fieldFinder, findsOneWidget);
expect(submitFinder, findsOneWidget);
});
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('ride card renders without overflow on $size (#68)',
(tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const user = AuthUser(
uid: 'alice',
email: 'alice@example.com',
isAnonymous: false,
);
final now = DateTime.now();
final ride = GroupRide(
id: 'ride_1',
organizerUid: 'alice',
organizerHandle: 'alice_lead',
organizerDisplayName: 'Alice Lead',
title: 'Sunday Morning Twisties',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
participantCount: 3,
meetingPoint: const GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Dahlonega Chevron',
),
destination: const GroupRideLocation(
latitude: 34.5,
longitude: -83.8,
displayName: 'Blood Mountain Summit',
),
createdAt: now,
updatedAt: now,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
upcomingGroupRidesProvider.overrideWith(
(ref) => Stream.value([ride]),
),
],
child: const MaterialApp(
home: GroupRidesHubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('group_ride_card_ride_1')), findsOneWidget);
expect(find.byKey(const ValueKey('manage_ride_ride_1')), findsOneWidget);
expect(find.byKey(const ValueKey('open_hub_ride_1')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}); });
group('CreateGroupRideScreen Widget Tests', () { group('CreateGroupRideScreen Widget Tests', () {

View File

@ -329,5 +329,70 @@ void main() {
); );
expect(find.text('3 RIDERS'), findsOneWidget); expect(find.text('3 RIDERS'), findsOneWidget);
}); });
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('renders landscape controls and panel without overflow on $size (#74)',
(tester) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
const user = AuthUser(
uid: 'alice',
email: 'alice@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
groupRideStreamProvider('ride_live_test')
.overrideWith((ref) => Stream.value(testRide)),
groupRideParticipantsProvider('ride_live_test')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_live_test')
.overrideWith((ref) => Stream.value(presences)),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: MediaQuery(
data: MediaQueryData(size: size),
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_live_test'),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Verify key controls are present and reachable
expect(
find.byKey(const ValueKey('live_ride_broadcast_switch')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('live_ride_recenter_button')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('live_ride_roster_button')),
findsOneWidget,
);
// Status bar items
expect(find.text('RIDING'), findsWidgets);
expect(find.text('STOPPED'), findsOneWidget);
expect(find.text('NEED FUEL'), findsWidgets);
expect(find.text('REGROUP'), findsOneWidget);
final isLandscape = size.width > size.height;
if (isLandscape) {
// In landscape, verify side-by-side pack telemetry stream panel is present
expect(find.text('PACK TELEMETRY STREAM'), findsOneWidget);
}
});
}
}); });
} }

View File

@ -0,0 +1,172 @@
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/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/map/map_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<HttpClientRequest> getUrl(Uri url) async => _MockHttpRequest();
@override
Future<HttpClientRequest> 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<HttpClientResponse> 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<List<int>> listen(
void Function(List<int> event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return Stream<List<int>>.value(_transparent1x1Png).listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
}
}
final _transparent1x1Png = <int>[
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 sample ride and telemetry breadcrumbs
await db.into(db.rides).insert(
RidesCompanion.insert(
id: const drift.Value(1),
startTime: DateTime(2026, 1, 1, 10, 0),
distanceMiles: const drift.Value(15.2),
topSpeedMph: const drift.Value(75.0),
movingTime: const drift.Value(900),
stoppedTime: const drift.Value(60),
),
);
for (int i = 0; i < 10; i++) {
await db.into(db.telemetrySamples).insert(
TelemetrySamplesCompanion.insert(
rideId: 1,
timestamp: DateTime(2026, 1, 1, 10, 0, i * 5),
latitude: drift.Value(37.3861 + (i * 0.002)),
longitude: drift.Value(-122.2641 + (i * 0.002)),
speed: drift.Value(35.0 + i),
leanAngle: drift.Value(12.0),
),
);
}
});
tearDown(() async {
HttpOverrides.global = null;
await db.close();
});
Widget buildSubject() {
return ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const MapScreen(rideId: 1),
),
);
}
testWidgets('Portrait: renders both appbar action and floating replay action button', (tester) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildSubject());
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('play_replay_appbar_button')), findsOneWidget);
expect(find.byKey(const ValueKey('fire_off_ride_preview_button')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Landscape: removes duplicate floating replay action to keep map unobstructed (#60)', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildSubject());
await tester.pumpAndSettle();
// Appbar replay action remains accessible
expect(find.byKey(const ValueKey('play_replay_appbar_button')), findsOneWidget);
// Floating button is hidden in landscape
expect(find.byKey(const ValueKey('fire_off_ride_preview_button')), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
}

View File

@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Map Smoothness & Orientation Angle Normalization (#79 & #80)', () {
test('shortestAngleDiff correctly calculates minimal circular angular distance', () {
// Direct small adjustments
expect(shortestAngleDiff(10.0, 20.0), closeTo(10.0, 0.001));
expect(shortestAngleDiff(20.0, 10.0), closeTo(-10.0, 0.001));
// Circular transition crossing 0° / 360°
// Moving from 359° to 1° should be +2° clockwise, NOT -358° counter-clockwise
expect(shortestAngleDiff(359.0, 1.0), closeTo(2.0, 0.001));
// Moving from 1° to 359° should be -2° counter-clockwise, NOT +358° clockwise
expect(shortestAngleDiff(1.0, 359.0), closeTo(-2.0, 0.001));
// 350° to 10° should be +20°
expect(shortestAngleDiff(350.0, 10.0), closeTo(20.0, 0.001));
// 10° to 350° should be -20°
expect(shortestAngleDiff(10.0, 350.0), closeTo(-20.0, 0.001));
// Half-circle
expect(shortestAngleDiff(0.0, 180.0).abs(), closeTo(180.0, 0.001));
});
test('normalizeAngle correctly constrains values to [0.0, 360.0)', () {
expect(normalizeAngle(0.0), closeTo(0.0, 0.001));
expect(normalizeAngle(360.0), closeTo(0.0, 0.001));
expect(normalizeAngle(365.0), closeTo(5.0, 0.001));
expect(normalizeAngle(-10.0), closeTo(350.0, 0.001));
expect(normalizeAngle(-370.0), closeTo(350.0, 0.001));
expect(normalizeAngle(720.0), closeTo(0.0, 0.001));
});
});
group('MapOrientation State & Persistence (#80)', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('MapOrientationNotifier toggles and persists orientation', () async {
final notifier = MapOrientationNotifier();
expect(notifier.state, MapOrientation.northUp);
await notifier.toggle();
expect(notifier.state, MapOrientation.headingUp);
await notifier.toggle();
expect(notifier.state, MapOrientation.northUp);
await notifier.setOrientation(MapOrientation.headingUp);
expect(notifier.state, MapOrientation.headingUp);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('selected_map_orientation'), 'headingUp');
});
});
}

View File

@ -118,6 +118,22 @@ void main() {
expect(find.text('0–100 MPH'), findsOneWidget); expect(find.text('0–100 MPH'), findsOneWidget);
}); });
testWidgets('BUG-009: Renders cleanly without clipping on 800x360 landscape phone', (tester) async {
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget);
expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget);
expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget);
expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget);
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
expect(find.text('Auto Launch'), findsOneWidget);
expect(find.text('Countdown'), findsOneWidget);
expect(find.text('0–60 MPH'), findsOneWidget);
expect(find.text('0–100 MPH'), findsOneWidget);
});
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async { testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -198,6 +214,21 @@ void main() {
expect(find.text('Tap below to initiate countdown'), findsOneWidget); expect(find.text('Tap below to initiate countdown'), findsOneWidget);
}); });
testWidgets('BUG-010: Countdown READY renders without 68px overflow on 800x360 landscape phone', (tester) async {
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
await tester.pumpAndSettle();
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
await tester.pump(const Duration(milliseconds: 20));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget);
expect(find.text('Start Countdown'), findsOneWidget);
});
testWidgets('8. READY survives widget rebuild', (tester) async { testWidgets('8. READY survives widget rebuild', (tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
/// Regression for Issue #52 / BUG-005 follow-up: the "Forensic CSV & PDF Export" Pro
/// upsell sheet showed "BOTTOM OVERFLOWED BY 90 PIXELS" in landscape on a Pixel 8a
/// (~914x411 dp) because showProUpgradeSheet built a non-scrolling Column inside a default
/// modal bottom sheet, which Flutter caps at 9/16 of the screen height.
class _FakeRideStateMachine extends StateNotifier<RideSession>
implements RideStateMachine {
_FakeRideStateMachine(super.initialState);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _pumpHost(
WidgetTester tester, {
required Size viewport,
double textScale = 1.0,
}) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
rideStateMachineProvider.overrideWith(
(ref) => _FakeRideStateMachine(RideSession(state: RideState.idle)),
),
],
child: MediaQuery(
data: MediaQueryData(
size: viewport,
textScaler: TextScaler.linear(textScale),
),
child: MaterialApp(
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => ElevatedButton(
onPressed: () => showProUpgradeSheet(
context,
ref,
feature: MotorSignalFeature.csvExport,
),
child: const Text('Open upsell'),
),
),
),
),
),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final viewport in const [
Size(914, 411), // Pixel 8a landscape
Size(800, 360),
Size(780, 360),
]) {
for (final scale in const [1.0, 1.3]) {
testWidgets(
'Pro upgrade sheet has no bottom overflow in landscape '
'${viewport.width.toInt()}x${viewport.height.toInt()} (text scale $scale)',
(tester) async {
await _pumpHost(tester, viewport: viewport, textScale: scale);
await tester.tap(find.text('Open upsell'));
await tester.pumpAndSettle();
expect(find.text('Forensic CSV & PDF Export'), findsOneWidget);
expect(find.text('VIEW MOTORSIGNAL PRO'), findsOneWidget);
expect(find.text('NOT NOW'), findsOneWidget);
// No RenderFlex overflow exception.
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets('NOT NOW is reachable and dismisses the sheet in landscape', (
tester,
) async {
await _pumpHost(tester, viewport: const Size(800, 360), textScale: 1.3);
await tester.tap(find.text('Open upsell'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('NOT NOW'));
await tester.tap(find.text('NOT NOW'));
await tester.pumpAndSettle();
expect(find.text('VIEW MOTORSIGNAL PRO'), findsNothing);
expect(tester.takeException(), isNull);
});
}

View File

@ -329,5 +329,18 @@ void main() {
expect(alerts.reports, isEmpty); expect(alerts.reports, isEmpty);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('BUG-008: renders cleanly without overflow on 800x360 landscape phone', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await open(tester);
expect(tester.takeException(), isNull);
expect(find.text('REPORT ROAD INCIDENT'), findsOneWidget);
expect(find.text('Police'), findsOneWidget);
expect(find.text('Road Hazard'), findsOneWidget);
expect(find.text('Weather Hazard'), findsOneWidget);
});
} }

View File

@ -165,4 +165,63 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget); expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
}); });
testWidgets('BUG-014 (#61): ReplayScreen renders responsive horizontal split in 800x360 landscape without overflow', (tester) async {
tester.view.physicalSize = const Size(800, 360);
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);
// Verifies both map and breakdown table are rendered concurrently in viewport
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
expect(find.text('SPEED'), findsOneWidget);
expect(find.text('LEAN'), findsOneWidget);
expect(find.text('G'), findsOneWidget);
expect(find.byType(Slider), findsOneWidget);
expect(find.byType(Row), findsWidgets);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('BUG-014 (#61): ReplayScreen renders without overflow on 640x320 compact landscape', (tester) async {
tester.view.physicalSize = const Size(640, 320);
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);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
} }

View File

@ -0,0 +1,211 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/routes/route_planner_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
import 'package:motorcycle_dash/services/map/open_street_map_provider.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class FakeMapProvider extends Fake implements OpenStreetMapProvider {
@override
Widget buildMapView({
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
required MapConfig config,
required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction,
}) => const SizedBox.expand();
}
void main() {
const riderPosition = AppLatLng(33.749, -84.388);
const overview = AppLatLng(39.5, -98.35);
Future<ProviderContainer> openPlanner(
WidgetTester tester,
Future<AppLatLng?> location, {
bool realMap = false,
bool initiallyFollowing = true,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith(
(ref) => const AuthUser(uid: 'rider'),
),
routePlannerLocationProvider.overrideWith((ref) => location),
mapFollowStateProvider.overrideWith(
(ref) => MapFollowNotifier()..setFollow(initiallyFollowing),
),
if (!realMap)
openStreetMapProviderInstance.overrideWithValue(FakeMapProvider()),
],
child: const MaterialApp(home: RoutePlannerScreen()),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(
tester.element(find.byType(RoutePlannerScreen)),
);
}
UnifiedMapView map(WidgetTester tester) =>
tester.widget(find.byType(UnifiedMapView));
for (final size in [const Size(360, 640), const Size(800, 360)]) {
testWidgets(
'known rider position centers the planner at $size without adding a stop',
(tester) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
final container = await openPlanner(
tester,
Future.value(riderPosition),
);
expect(map(tester).center, riderPosition);
expect(map(tester).zoom, 12);
expect(container.read(routePlannerNotifierProvider).stops, isEmpty);
},
);
}
testWidgets('pending or absent location shows an overview without blocking', (
tester,
) async {
final location = Completer<AppLatLng?>();
await openPlanner(tester, location.future);
expect(find.text('ROUTE PLANNER'), findsOneWidget);
expect(map(tester).center, overview);
expect(map(tester).zoom, 3);
location.complete(null);
await tester.pumpAndSettle();
expect(map(tester).center, overview);
expect(map(tester).zoom, 3);
});
testWidgets('cached position arriving later updates the overview', (
tester,
) async {
final location = Completer<AppLatLng?>();
await openPlanner(tester, location.future);
expect(map(tester).zoom, 3);
location.complete(riderPosition);
await tester.pumpAndSettle();
expect(map(tester).center, riderPosition);
expect(map(tester).zoom, 12);
});
testWidgets(
'previously panned map does not prevent initial cached centering',
(tester) async {
final location = Completer<AppLatLng?>();
await openPlanner(
tester,
location.future,
realMap: true,
initiallyFollowing: false,
);
final controller = tester
.widget<FlutterMap>(find.byType(FlutterMap))
.mapController!;
expect(controller.camera.center.latitude, overview.latitude);
location.complete(riderPosition);
await tester.pumpAndSettle();
expect(
controller.camera.center.latitude,
closeTo(riderPosition.latitude, 0.00001),
);
expect(
controller.camera.center.longitude,
closeTo(riderPosition.longitude, 0.00001),
);
expect(controller.camera.zoom, 12);
},
);
testWidgets('route stops take priority over a late cached rider position', (
tester,
) async {
final location = Completer<AppLatLng?>();
final container = await openPlanner(tester, location.future);
container
.read(routePlannerNotifierProvider.notifier)
.addStop(displayName: 'Route start', latitude: 35.6, longitude: -83.5);
await tester.pumpAndSettle();
expect(map(tester).center, const AppLatLng(35.6, -83.5));
expect(map(tester).zoom, 12);
location.complete(riderPosition);
await tester.pumpAndSettle();
expect(map(tester).center, const AppLatLng(35.6, -83.5));
expect(map(tester).zoom, 12);
});
test(
'location source only reads cache and tolerates unavailable GPS',
() async {
const channel = MethodChannel('flutter.baseflow.com/geolocator');
final calls = <String>[];
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
throw PlatformException(code: 'PERMISSION_DENIED');
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(await container.read(routePlannerLocationProvider.future), isNull);
expect(calls, ['getLastKnownPosition']);
},
);
for (final position in [riderPosition, null, const AppLatLng(0, 0)]) {
test(
'cached location source validates $position without starting GPS',
() async {
const channel = MethodChannel('flutter.baseflow.com/geolocator');
final calls = <String>[];
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return position == null
? null
: {
'latitude': position.latitude,
'longitude': position.longitude,
};
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(
await container.read(routePlannerLocationProvider.future),
position == riderPosition ? riderPosition : null,
);
expect(calls, ['getLastKnownPosition']);
},
);
}
}

View File

@ -225,4 +225,77 @@ void main() {
// No RenderFlex overflow // No RenderFlex overflow
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets(
'RoutePlannerScreen landscape (800x360) with status bar and keyboard inset scrolls cleanly and portrait renders cleanly (#76)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewInsets);
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.padding = const FakeViewPadding(top: 24);
tester.view.resetViewInsets();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => const AuthUser(
uid: 'alice',
isAnonymous: false,
)),
isProSubscriberProvider.overrideWith((ref) => false),
],
child: const MaterialApp(
home: RoutePlannerScreen(),
),
),
);
await tester.pumpAndSettle();
// No 2.5px overflow on landscape with status bar padding
expect(tester.takeException(), isNull);
// Simulate keyboard opening in landscape (260.5px inset)
tester.view.viewInsets = const FakeViewPadding(bottom: 200);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final titleField = find.byType(TextField).first;
await tester.ensureVisible(titleField);
await tester.pumpAndSettle();
await tester.enterText(titleField, 'Cherohala Skyway');
await tester.pumpAndSettle();
expect(find.text('Cherohala Skyway'), findsOneWidget);
expect(tester.takeException(), isNull);
// Scroll down to ADD STOP button and open Add Stop dialog with keyboard inset
final addStopBtn =
find.byKey(const ValueKey('route_planner_add_stop_button'));
await tester.ensureVisible(addStopBtn);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.tap(addStopBtn);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Add Route Stop'), findsOneWidget);
await tester.ensureVisible(find.text('CANCEL'));
await tester.pumpAndSettle();
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
// Portrait check with keyboard inset
tester.view.physicalSize = const Size(400, 800);
tester.view.padding = const FakeViewPadding(top: 24);
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('SAVE'), findsOneWidget);
});
} }

View File

@ -47,7 +47,7 @@ void main() {
// 3. We land directly on Community Feed! // 3. We land directly on Community Feed!
expect(find.byType(SocialFeedScreen), findsOneWidget); expect(find.byType(SocialFeedScreen), findsOneWidget);
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget); expect(find.text('SQUAD FEED'), findsOneWidget);
// 4. Tap 'Hub' toggle in AppBar to view Rider Hub // 4. Tap 'Hub' toggle in AppBar to view Rider Hub
final hubToggle = find.byKey(const ValueKey('social_tab_hub')); final hubToggle = find.byKey(const ValueKey('social_tab_hub'));
@ -63,6 +63,6 @@ void main() {
await tester.tap(feedToggle); await tester.tap(feedToggle);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget); expect(find.text('SQUAD FEED'), findsOneWidget);
}); });
} }

View File

@ -78,7 +78,7 @@ void main() {
// Verify header and filter components render cleanly // Verify header and filter components render cleanly
expect(find.text('Community Feed'), findsOneWidget); expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget); expect(find.text('SQUAD FEED'), findsOneWidget);
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget); expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
expect(find.text('SHARE RECENT SESSION'), findsOneWidget); expect(find.text('SHARE RECENT SESSION'), findsOneWidget);