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,13 +254,18 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'Start Session', 'Start Session',
maxLines: 1,
softWrap: false,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 17, fontSize: 17,
@ -268,6 +274,7 @@ class HybridRidingActionBar extends ConsumerWidget {
), ),
), ),
), ),
),
); );
} }
} }

View File

@ -138,37 +138,39 @@ 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(
minWidth: isShortRail ? 52 : 58,
groupAlignment: isShortRail ? -1.0 : 0.0,
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: _onDestinationSelected, onDestinationSelected: _onDestinationSelected,
backgroundColor: colors.surfaceCanvas, backgroundColor: colors.surfaceCanvas,
indicatorColor: colors.brandCyan.withValues(alpha: 0.14), indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
selectedIconTheme: IconThemeData( selectedIconTheme: IconThemeData(
color: colors.brandCyan, color: colors.brandCyan,
size: 21, size: isShortRail ? 19 : 21,
), ),
unselectedIconTheme: IconThemeData( unselectedIconTheme: IconThemeData(
color: colors.textMuted, color: colors.textMuted,
size: 19, size: isShortRail ? 17 : 19,
), ),
selectedLabelTextStyle: TextStyle( selectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 10.0, fontSize: isShortRail ? 9.0 : 10.0,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: colors.brandCyan, color: colors.brandCyan,
), ),
unselectedLabelTextStyle: TextStyle( unselectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 9.5, fontSize: isShortRail ? 8.5 : 9.5,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: colors.textMuted, color: colors.textMuted,
), ),
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: kAppNavDestinations.map((dest) { destinations: kAppNavDestinations.map((dest) {
return NavigationRailDestination( return NavigationRailDestination(
padding: const EdgeInsets.symmetric(vertical: 4), padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
icon: Icon(dest.icon, key: dest.railKey), icon: Icon(dest.icon, key: dest.railKey),
selectedIcon: dest.selectedIcon != null selectedIcon: dest.selectedIcon != null
? Icon(dest.selectedIcon, key: dest.railKey) ? Icon(dest.selectedIcon, key: dest.railKey)
@ -176,7 +178,8 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
label: Text(dest.label), label: Text(dest.label),
); );
}).toList(), }).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,13 +241,15 @@ 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: ClipRect(
child: CustomPaint( child: CustomPaint(
size: Size(gaugeWidth, gaugeHeight), size: Size(gaugeWidth, gaugeHeight),
painter: _MotorcycleLeanPainter( painter: _MotorcycleLeanPainter(
@ -260,6 +263,7 @@ class LeanAngleIndicator extends ConsumerWidget {
), ),
), ),
), ),
),
SizedBox(height: compact ? 1 : 3), SizedBox(height: compact ? 1 : 3),
@ -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,18 +326,30 @@ 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,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
DashboardLayoutSwitcher(), DashboardLayoutSwitcher(),
SizedBox(width: 4),
QuickSwarmReportButton(compact: true),
SizedBox(width: 4),
QuickSquadFeedButton(compact: true),
SizedBox(width: 4),
GPSStatusIndicator(showLabel: false), GPSStatusIndicator(showLabel: false),
SizedBox(width: 4),
HeadingIndicator(compact: true), HeadingIndicator(compact: true),
SizedBox(width: 4),
RideStatusChip(), RideStatusChip(),
], ],
), ),
const Center( ),
const Expanded(
child: Center(
child: SpeedReadout(size: SpeedReadoutSize.compact), child: SpeedReadout(size: SpeedReadoutSize.compact),
), ),
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
@ -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,9 +391,26 @@ 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: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: availableHeight * 0.9,
maxWidth: 560,
),
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
isCompact ? 12 : 16,
16,
isCompact ? 12 : 16,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -403,18 +423,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
fontSize: 16, fontSize: 16,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
Text( Text(
'Pre-fills title, meetup location, intermediate waypoints, and destination.', 'Pre-fills title, meetup location, intermediate waypoints, and destination.',
style: TextStyle(color: onSurfaceVariant, fontSize: 12), style: TextStyle(color: onSurfaceVariant, fontSize: 12),
), ),
const SizedBox(height: 12), const SizedBox(height: 10),
routesAsync.when( routesAsync.when(
loading: () => Center( loading: () => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: EdgeInsets.all(isCompact ? 12 : 24),
child: child: CircularProgressIndicator(
CircularProgressIndicator(color: colors.cyberCyan), color: colors.cyberCyan),
), ),
), ),
error: (err, _) => Text( error: (err, _) => Text(
@ -424,7 +444,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
data: (routes) { data: (routes) {
if (routes.isEmpty) { if (routes.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 24), padding: EdgeInsets.symmetric(
vertical: isCompact ? 12 : 24),
child: Center( child: Center(
child: Text( child: Text(
'No saved routes found.', 'No saved routes found.',
@ -435,7 +456,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
} }
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4, maxHeight: availableHeight * 0.5,
), ),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
@ -443,6 +464,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
itemBuilder: (ctx, idx) { itemBuilder: (ctx, idx) {
final route = routes[idx]; final route = routes[idx];
return ListTile( return ListTile(
dense: isCompact,
leading: Icon(Icons.alt_route, leading: Icon(Icons.alt_route,
color: colors.cyberCyan), color: colors.cyberCyan),
title: Text( title: Text(
@ -469,6 +491,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
], ],
), ),
), ),
),
),
); );
}, },
); );
@ -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,20 +793,39 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
], ],
), ),
body: Form( body: SafeArea(
top: false,
child: Form(
key: _formKey, key: _formKey,
child: ResponsiveLayout( child: ResponsiveLayout(
builder: (context, snapshot) { builder: (context, snapshot) {
final isWide = snapshot.sizeClass == WindowSizeClass.expanded || final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720); (snapshot.isLandscape && snapshot.width >= 600);
if (isWide) { if (isWide) {
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError); return _buildWideForm(
context,
colors,
snapshot,
canSubmit,
clubAsync,
memberAsync,
clubValidationError,
);
} }
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError); return _buildCompactForm(
context,
colors,
snapshot,
canSubmit,
clubAsync,
memberAsync,
clubValidationError,
);
}, },
), ),
), ),
),
); );
} }
@ -764,13 +838,24 @@ 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;
final outerPadding = isCompact
? const EdgeInsets.symmetric(horizontal: 16, vertical: 10)
: const EdgeInsets.all(24);
final colGap = isCompact ? 16.0 : 24.0;
return Scrollbar(
thumbVisibility: isCompact,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: outerPadding,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.clubId != null) if (widget.clubId != null)
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError), _buildClubAffiliationHeader(
colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -781,36 +866,38 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildRideDetailsSection(colors), _buildRideDetailsSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildScheduleSection(colors), _buildScheduleSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildPrivacySection(colors), _buildPrivacySection(colors, isCompact: isCompact),
], ],
), ),
), ),
const SizedBox(width: 24), SizedBox(width: colGap),
// 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), _buildMeetingPointSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildDestinationAndStopsSection(colors), _buildDestinationAndStopsSection(colors,
const SizedBox(height: 24), isCompact: isCompact),
SizedBox(height: sectionGap),
_buildMapPreviewSection(colors, snapshot), _buildMapPreviewSection(colors, snapshot),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildSubmitButton(colors, canSubmit), _buildSubmitButton(colors, canSubmit),
], ],
), ),
), ),
], ],
), ),
const SizedBox(height: 32), SizedBox(height: isCompact ? 16 : 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;
return Scrollbar(
thumbVisibility: isCompact,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.all(isCompact ? 14 : 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.clubId != null) if (widget.clubId != null)
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError), _buildClubAffiliationHeader(
colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
_buildRideDetailsSection(colors), _buildRideDetailsSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildScheduleSection(colors), _buildScheduleSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_buildMeetingPointSection(colors), _buildMeetingPointSection(colors, isCompact: isCompact),
const SizedBox(height: 24), SizedBox(height: sectionGap),
_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),
_buildPrivacySection(colors), _buildPrivacySection(colors, isCompact: isCompact),
const SizedBox(height: 32), SizedBox(height: isCompact ? 16 : 32),
_buildSubmitButton(colors, canSubmit), _buildSubmitButton(colors, canSubmit),
const SizedBox(height: 32), 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,16 +1483,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
}, },
) )
else else
ElevatedButton.icon( Flexible(
child: ElevatedButton.icon(
onPressed: () => _showSelectRouteSheet(colors), onPressed: () => _showSelectRouteSheet(colors),
icon: const Icon(Icons.alt_route, size: 14), icon: const Icon(Icons.alt_route, size: 14),
label: const Text('USE EXISTING ROUTE'), label: const Text(
'USE EXISTING ROUTE',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
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: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 10, vertical: 6), horizontal: 10, vertical: 6),
minimumSize: Size.zero, minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle( textStyle: const TextStyle(
@ -1367,6 +1506,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
), ),
), ),
),
], ],
), ),
), ),
@ -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,9 +1448,24 @@ 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: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: availableHeight * 0.9,
maxWidth: 560,
),
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompact ? 10 : 16,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -1462,13 +1478,14 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
fontSize: 16, fontSize: 16,
), ),
), ),
const SizedBox(height: 12), SizedBox(height: isCompact ? 8 : 12),
routesAsync.when( routesAsync.when(
loading: () => Center( loading: () => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: EdgeInsets.all(isCompact ? 12 : 24),
child: child: CircularProgressIndicator(
CircularProgressIndicator(color: colors.cyberCyan), color: colors.cyberCyan,
),
), ),
), ),
error: (err, _) => Text( error: (err, _) => Text(
@ -1478,9 +1495,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
data: (routes) { data: (routes) {
if (routes.isEmpty) { if (routes.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 24), padding: EdgeInsets.symmetric(
vertical: isCompact ? 8 : 24,
),
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'You have no saved routes yet.', 'You have no saved routes yet.',
@ -1490,7 +1510,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
.onSurfaceVariant, .onSurfaceVariant,
), ),
), ),
const SizedBox(height: 12), SizedBox(height: isCompact ? 8 : 12),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
Navigator.pop(sheetCtx); Navigator.pop(sheetCtx);
@ -1511,7 +1531,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
} }
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4, maxHeight: availableHeight * 0.55,
), ),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
@ -1519,6 +1539,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
itemBuilder: (ctx, idx) { itemBuilder: (ctx, idx) {
final route = routes[idx]; final route = routes[idx];
return ListTile( return ListTile(
dense: isCompact,
leading: Icon( leading: Icon(
Icons.alt_route, Icons.alt_route,
color: colors.cyberCyan, color: colors.cyberCyan,
@ -1551,10 +1572,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
onTap: () async { onTap: () async {
Navigator.pop(sheetCtx); Navigator.pop(sheetCtx);
try { try {
final user = ref.read(currentUserProvider); final user =
ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) { if (user == null || user.uid.isEmpty) {
throw StateError( throw StateError(
'Sign-in required to attach route.'); 'Sign-in required to attach route.',
);
} }
await ref await ref
.read(routeServiceProvider) .read(routeServiceProvider)
@ -1599,6 +1622,8 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
], ],
), ),
), ),
),
),
); );
}, },
); );

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,19 +663,59 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
final controller = TextEditingController(); final controller = TextEditingController();
showDialog( showDialog(
context: context, context: context,
builder: (dialogCtx) => AlertDialog( builder: (dialogCtx) {
final size = MediaQuery.sizeOf(dialogCtx);
final viewInsets = MediaQuery.viewInsetsOf(dialogCtx);
final availableHeight = size.height - viewInsets.bottom;
final isCompactVertical = availableHeight < 280;
return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainerHigh, 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( title: Row(
children: [ children: [
Icon(Icons.group_add, color: colors.cyberCyan, size: 24), Icon(
Icons.group_add,
color: colors.cyberCyan,
size: isCompactVertical ? 20 : 24,
),
const SizedBox(width: 10), const SizedBox(width: 10),
const Text( Flexible(
child: Text(
'Join Group Ride', 'Join Group Ride',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: isCompactVertical ? 16 : 18,
fontWeight: FontWeight.bold,
),
),
), ),
], ],
), ),
content: Column( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -658,19 +723,27 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
'Enter the Ride ID or shared link:', 'Enter the Ride ID or shared link:',
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 12), SizedBox(height: isCompactVertical ? 8 : 12),
TextField( TextField(
key: const ValueKey('join_ride_id_field'), key: const ValueKey('join_ride_id_field'),
controller: controller, controller: controller,
autofocus: true, autofocus: true,
scrollPadding: const EdgeInsets.fromLTRB(20, 20, 20, 80),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'e.g. ride_1790320722076_4btn4W', hintText: 'e.g. ride_1790320722076_4btn4W',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), isDense: isCompactVertical,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: isCompactVertical ? 8 : 10,
),
), ),
), ),
], ],
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(), onPressed: () => Navigator.of(dialogCtx).pop(),
@ -689,10 +762,14 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
Navigator.of(dialogCtx).pop(); Navigator.of(dialogCtx).pop();
context.push('/group-rides/$rideId'); context.push('/group-rides/$rideId');
}, },
child: const Text('VIEW RIDE', style: TextStyle(fontWeight: FontWeight.bold)), 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,13 +1002,16 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'MANAGE RIDE', 'MANAGE RIDE',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
), ),
), ),
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: SizedBox( child: SizedBox(
@ -945,13 +1026,16 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'OPEN HUB', 'OPEN HUB',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
), ),
), ),
), ),
),
], ],
), ),
] else if (isParticipant) ...[ ] else if (isParticipant) ...[
@ -971,13 +1055,16 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'RIDE DETAILS', 'RIDE DETAILS',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
), ),
), ),
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: SizedBox( child: SizedBox(
@ -994,13 +1081,16 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'LEAVE PACK', 'LEAVE PACK',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
), ),
), ),
), ),
),
], ],
), ),
] else ...[ ] else ...[
@ -1019,13 +1109,16 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'RIDE DETAILS', 'RIDE DETAILS',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
), ),
), ),
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: SizedBox( child: SizedBox(
@ -1040,13 +1133,16 @@ 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(
fit: BoxFit.scaleDown,
child: Text(
'JOIN RIDE', 'JOIN RIDE',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), 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,14 +1030,13 @@ 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: 6), if (index > 0) const SizedBox(width: 4),
() { () {
final (status, label, icon) = statuses[index]; final (status, label, icon) = statuses[index];
final isSelected = currentStatus == status; final isSelected = currentStatus == status;
@ -1055,7 +1056,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
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(
horizontal: isShort ? 6 : 8,
vertical: isShort ? 3 : 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? activeColor.withValues(alpha: 0.2) ? activeColor.withValues(alpha: 0.2)
@ -1071,10 +1075,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
children: [ children: [
Icon( Icon(
icon, icon,
size: 16, size: isShort ? 14 : 16,
color: isSelected ? activeColor : Colors.white60, color: isSelected ? activeColor : Colors.white60,
), ),
const SizedBox(width: 5), const SizedBox(width: 4),
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
@ -1082,7 +1086,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
fontWeight: isSelected fontWeight: isSelected
? FontWeight.bold ? FontWeight.bold
: FontWeight.w500, : FontWeight.w500,
fontSize: 11, fontSize: isShort ? 10 : 11,
), ),
), ),
], ],
@ -1092,7 +1096,18 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
}(), }(),
], ],
], ],
);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: statusItems,
), ),
);
},
), ),
); );
} }
@ -1109,8 +1124,10 @@ 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(
@ -1125,11 +1142,19 @@ 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(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: availableWidth > 16 ? availableWidth - 16 : 0,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
// Ghost mode / Broadcast switch // Broadcast state info & switch
Expanded( Row(
child: Row( mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
broadcastState.isBroadcasting broadcastState.isBroadcasting
@ -1138,11 +1163,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
color: broadcastState.isBroadcasting color: broadcastState.isBroadcasting
? colors.cyberCyan ? colors.cyberCyan
: Colors.white38, : Colors.white38,
size: isCompact ? 18 : 20, size: isCompact ? 16 : 20,
), ),
SizedBox(width: isCompact ? 4 : 8), SizedBox(width: isCompact ? 4 : 8),
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -1167,6 +1191,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
if (!isShort)
Text( Text(
broadcastState.isBroadcasting broadcastState.isBroadcasting
? (broadcastState.isPaused ? (broadcastState.isPaused
@ -1179,13 +1204,15 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: Colors.white38, color: Colors.white38,
fontSize: isCompact ? 9 : 10, fontSize: isCompact ? 8.5 : 10,
), ),
), ),
], ],
), ),
), SizedBox(width: isCompact ? 4 : 6),
Switch( Transform.scale(
scale: isCompact ? 0.8 : 1.0,
child: Switch(
key: const ValueKey('live_ride_broadcast_switch'), key: const ValueKey('live_ride_broadcast_switch'),
value: broadcastState.isOptedIn, value: broadcastState.isOptedIn,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -1197,24 +1224,28 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
broadcastController.setOptIn(val); broadcastController.setOptIn(val);
}, },
), ),
),
], ],
), ),
),
SizedBox(width: isCompact ? 4 : 8), SizedBox(width: isCompact ? 6 : 10),
// Recenter Map Button (min 48x48 tap target) // Action buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Recenter Map Button
IconButton( IconButton(
key: const ValueKey('live_ride_recenter_button'), key: const ValueKey('live_ride_recenter_button'),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
padding: EdgeInsets.all(isCompact ? 6 : 8), padding: EdgeInsets.all(isCompact ? 4 : 8),
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48), minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
), ),
icon: Icon( icon: Icon(
Icons.my_location, Icons.my_location,
color: Colors.white70, color: Colors.white70,
size: isCompact ? 18 : 20, size: isCompact ? 16 : 20,
), ),
tooltip: 'Center on Me', tooltip: 'Center on Me',
onPressed: () { onPressed: () {
@ -1248,14 +1279,17 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
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 ? 40 : 48, isCompact ? 40 : 48), minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
), ),
icon: const Icon(Icons.format_list_bulleted, size: 16), icon: const Icon(Icons.format_list_bulleted, size: 14),
label: Text( label: Text(
'${participants.length}', '${participants.length}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: isCompact ? 10 : 11,
fontWeight: FontWeight.bold,
),
), ),
onPressed: () => _showRosterSheet( onPressed: () => _showRosterSheet(
context: context, context: context,
@ -1268,6 +1302,12 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
), ),
], ],
), ),
],
),
),
),
);
},
); );
} }
@ -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,36 +525,39 @@ class _WaitingView extends ConsumerWidget {
statusColor = colors.velocityBlue; statusColor = colors.velocityBlue;
} }
return Padding( final isLandscape = snapshot.isLandscape;
padding: const EdgeInsets.all(24), return Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.all(isLandscape ? 16 : 24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(statusIcon, size: 54, color: statusColor), Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor),
const SizedBox(height: 20), SizedBox(height: isLandscape ? 10 : 20),
Text( Text(
statusText, statusText,
key: const ValueKey('waiting_status_text'), key: const ValueKey('waiting_status_text'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 18, fontSize: isLandscape ? 15 : 18,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: 1.0, letterSpacing: 1.0,
color: statusColor, color: statusColor,
), ),
), ),
const SizedBox(height: 12), SizedBox(height: isLandscape ? 6 : 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: 14, fontSize: isLandscape ? 12 : 14,
color: isDark ? Colors.white70 : Colors.black54, color: isDark ? Colors.white70 : Colors.black54,
), ),
), ),
const SizedBox(height: 32), SizedBox(height: isLandscape ? 16 : 32),
OutlinedButton( OutlinedButton(
key: const ValueKey('btn_cancel_waiting'), key: const ValueKey('btn_cancel_waiting'),
onPressed: () { onPressed: () {
@ -547,6 +567,7 @@ class _WaitingView extends ConsumerWidget {
), ),
], ],
), ),
),
); );
} }
} }
@ -570,16 +591,25 @@ 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(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 32 : 20,
vertical: isLandscape ? 8 : 16,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Center( Center(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 16 : 24,
vertical: isLandscape ? 4 : 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15), color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@ -588,14 +618,14 @@ class _ReadyView extends ConsumerWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_circle, color: colors.launchGreen, size: 28), Icon(Icons.check_circle, color: colors.launchGreen, size: isLandscape ? 20 : 28),
const SizedBox(width: 10), const SizedBox(width: 8),
Text( Text(
'READY', 'READY',
key: const ValueKey('ready_status_badge'), key: const ValueKey('ready_status_badge'),
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 28, fontSize: isLandscape ? 20 : 28,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 2.0, letterSpacing: 2.0,
color: colors.launchGreen, color: colors.launchGreen,
@ -605,18 +635,18 @@ class _ReadyView extends ConsumerWidget {
), ),
), ),
), ),
const SizedBox(height: 20), SizedBox(height: isLandscape ? 8 : 20),
Text( Text(
target.label, target.label,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 22, fontSize: isLandscape ? 18 : 22,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
), ),
), ),
const SizedBox(height: 10), SizedBox(height: isLandscape ? 4 : 10),
Text( Text(
isCountdown isCountdown
? 'Tap below to initiate countdown' ? 'Tap below to initiate countdown'
@ -624,15 +654,15 @@ class _ReadyView extends ConsumerWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 16, fontSize: isLandscape ? 13 : 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black54, color: isDark ? Colors.white70 : Colors.black54,
), ),
), ),
const SizedBox(height: 40), SizedBox(height: isLandscape ? 12 : 40),
if (isCountdown) ...[ if (isCountdown) ...[
SizedBox( SizedBox(
height: 56, height: isLandscape ? 44 : 56,
child: ElevatedButton( child: ElevatedButton(
key: const ValueKey('btn_start_countdown'), key: const ValueKey('btn_start_countdown'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -656,7 +686,7 @@ class _ReadyView extends ConsumerWidget {
), ),
), ),
), ),
const SizedBox(height: 16), SizedBox(height: isLandscape ? 8 : 16),
], ],
Center( Center(
child: TextButton( child: TextButton(
@ -673,6 +703,7 @@ class _ReadyView extends ConsumerWidget {
), ),
], ],
), ),
),
); );
} }
} }

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,12 +475,11 @@ 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,
@ -495,8 +495,8 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
// Map Navigation Controls (Zoom In, Zoom Out, Recenter) // Map Navigation Controls (Zoom In, Zoom Out, Recenter)
Positioned( Positioned(
right: 14, right: isLandscape ? 10 : 14,
bottom: 14, bottom: isLandscape ? 10 : 14,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -507,21 +507,24 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
tooltip: 'Recenter on Rider', tooltip: 'Recenter on Rider',
color: colors.cyberCyan, color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25), backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
isCompact: isLandscape,
onTap: () => setState(() => _isFollowingRider = true), onTap: () => setState(() => _isFollowingRider = true),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 6 : 8),
], ],
_ReplayMapButton( _ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'), key: const ValueKey('replay_zoom_in_button'),
icon: Icons.add, icon: Icons.add,
tooltip: 'Zoom In', tooltip: 'Zoom In',
isCompact: isLandscape,
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)), onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 6 : 8),
_ReplayMapButton( _ReplayMapButton(
key: const ValueKey('replay_zoom_out_button'), key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove, icon: Icons.remove,
tooltip: 'Zoom Out', tooltip: 'Zoom Out',
isCompact: isLandscape,
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)), onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
), ),
], ],
@ -529,20 +532,20 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
// Telemetry Overlay // Telemetry Overlay
Positioned( Positioned(
top: 14, top: isLandscape ? 8 : 14,
left: 14, left: isLandscape ? 8 : 14,
right: 14, right: isLandscape ? 8 : 14,
child: Center( child: Center(
child: FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'), _buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph', isLandscape: isLandscape),
const SizedBox(width: 8), SizedBox(width: isLandscape ? 6 : 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'), _buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°', isLandscape: isLandscape),
const SizedBox(width: 8), SizedBox(width: isLandscape ? 6 : 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'), _buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G', isLandscape: isLandscape),
], ],
), ),
), ),
@ -550,9 +553,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
// Heatmap Legend Overlay // Heatmap Legend Overlay
Positioned( Positioned(
bottom: 10, bottom: isLandscape ? 8 : 10,
left: 14, left: isLandscape ? 8 : 14,
right: 14, right: isLandscape ? 60 : 14,
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: FittedBox( child: FittedBox(
@ -579,12 +582,13 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
), ),
], ],
), );
),
// 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,13 +613,21 @@ 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(
data: isLandscape
? SliderTheme.of(context).copyWith(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
)
: SliderTheme.of(context),
child: Slider(
value: currentSliderValue, value: currentSliderValue,
min: 0, min: 0,
max: maxSliderValue, max: maxSliderValue,
@ -628,11 +640,15 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
: null, : null,
activeColor: Colors.cyanAccent, 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,30 +798,36 @@ 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: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row( child: Row(
children: [ children: [
// Corner ID & Direction // Corner ID & Direction
SizedBox( SizedBox(
width: 75, width: isLandscape ? 65 : 75,
child: Row( child: Row(
children: [ children: [
Text( Text(
'T${corner.id}', 'T${corner.id}',
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
fontSize: 13, 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: 11, fontSize: isLandscape ? 10 : 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: corner.direction == 'Left' color: corner.direction == 'Left'
? Colors.blueAccent ? Colors.blueAccent
@ -805,9 +838,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
], ],
), ),
), ),
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,14 +900,14 @@ 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),
@ -895,15 +929,53 @@ 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,20 +789,27 @@ 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,
state,
maxAllowed,
isAtLimit,
isCompactHeight,
), ),
),
if (showDisclaimer)
Positioned( Positioned(
bottom: 8, bottom: 6,
left: 12, left: 8,
right: 12, right: 8,
child: _buildDisclaimerText(), child: _buildDisclaimerText(),
), ),
], ],
@ -767,7 +820,40 @@ 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
? _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 4),
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: [ children: [
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
Padding( Padding(
@ -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,11 +916,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
showDisclaimer = false; showDisclaimer = false;
} }
return Column( if (isCompactHeight) {
return _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
// Map Preview Section
if (mapHeight > 0) if (mapHeight > 0)
SizedBox( SizedBox(
height: mapHeight, height: mapHeight,
@ -848,7 +941,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
polylines: polylines, polylines: polylines,
), ),
Positioned( Positioned(
top: isCompactHeight ? 4 : 8, top: 4,
left: 8, left: 8,
right: 8, right: 8,
child: _buildHudOverlay( child: _buildHudOverlay(
@ -856,7 +949,60 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
state, state,
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
isCompactHeight, 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(
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
// Map Preview Section
if (mapHeight > 0)
SizedBox(
height: mapHeight,
width: double.infinity,
child: Stack(
children: [
UnifiedMapView(
key: const ValueKey('route_planner_map_compact'),
center: mapCenter,
zoom: mapZoom,
markers: markers,
polylines: polylines,
),
Positioned(
top: 8,
left: 8,
right: 8,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
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,38 +1396,32 @@ 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 theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (state.stops.isEmpty) { return Padding(
return Center( padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!isVeryCompactHeight) ...[ if (!isVeryCompactHeight) ...[
Icon( Icon(
Icons.alt_route, Icons.alt_route,
size: isCompactHeight ? 32 : 48, size: isCompactHeight ? 28 : 48,
color: onSurfaceVariant.withValues(alpha: 0.2), color: onSurfaceVariant.withValues(alpha: 0.2),
), ),
SizedBox(height: isCompactHeight ? 6 : 12), SizedBox(height: isCompactHeight ? 4 : 12),
], ],
Text( Text(
'No stops added yet.', 'No stops added yet.',
style: TextStyle( style: TextStyle(
color: onSurfaceVariant, color: onSurfaceVariant,
fontSize: 14, fontSize: isCompactHeight ? 13 : 14,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -1287,17 +1429,41 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
'Tap "Add Stop" or "Current GPS" to begin planning.', 'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle( style: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.6), color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 12, fontSize: isCompactHeight ? 11 : 12,
), ),
), ),
], ],
), ),
);
}
Widget _buildStopsList(
MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight,
bool isVeryCompactHeight, {
Widget? header,
}) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (header == null && state.stops.isEmpty) {
return Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: _buildEmptyStopsContent(
colors,
isCompactHeight,
isVeryCompactHeight,
), ),
), ),
); );
} }
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,10 +356,44 @@ 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,10 +605,20 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
), ),
], ],
), ),
ClubCard( LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLandscape ? 400 : double.infinity,
),
child: ClubCard(
club: _searchedClub!, club: _searchedClub!,
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'), onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
), ),
);
},
),
], ],
); );
} }
@ -631,6 +675,34 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
); );
} }
return LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
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: 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( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -645,6 +717,8 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
}, },
); );
}, },
);
},
loading: () => Center( loading: () => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),

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,104 +196,17 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
body: SafeArea( body: SafeArea(
child: ResponsiveLayout( child: ResponsiveLayout(
builder: (context, responsive) { builder: (context, responsive) {
return Center( final isCompact =
child: ConstrainedBox( responsive.isLandscape || responsive.isShortViewport;
constraints: const BoxConstraints(maxWidth: 700), final isTwoColumn =
child: Form( responsive.isLandscape && responsive.width >= 600;
key: _formKey, final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
child: ListView(
padding: const EdgeInsets.all(18.0),
children: [
// Header card explaining Club identity
Container(
padding: const EdgeInsets.all(14.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant,
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),
// Submission error alert if present final nameField = TextFormField(
if (_submissionError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.raceRed.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colors.raceRed,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_submissionError!,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: colors.raceRed,
),
),
),
],
),
),
const SizedBox(height: 16),
],
// Club Name
TextFormField(
controller: _nameController, controller: _nameController,
enabled: !_isSubmitting, enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Club Name *', labelText: 'Club Name *',
hintText: 'e.g. Ridge Runners MC', hintText: 'e.g. Ridge Runners MC',
@ -326,11 +244,9 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
} }
return null; return null;
}, },
), );
const SizedBox(height: 18),
// Slug / Handle with live debounce & availability checking final slugField = SlugInputField(
SlugInputField(
controller: _slugController, controller: _slugController,
enabled: !_isSubmitting, enabled: !_isSubmitting,
onValidationChanged: (isValid) { onValidationChanged: (isValid) {
@ -338,16 +254,253 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
_isSlugValidAndAvailable = isValid; _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(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 700),
child: Form(
key: _formKey,
child: Scrollbar(
thumbVisibility: isCompact,
child: ListView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
isCompact ? 16.0 : 18.0,
isCompact ? 10.0 : 18.0,
isCompact ? 16.0 : 18.0,
(isCompact ? 16.0 : 32.0) +
(bottomInset > 0 ? 16.0 : 0.0),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header card explaining Club identity
Container(
padding: EdgeInsets.all(isCompact ? 10.0 : 14.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant,
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
color: colors.cyberCyan,
size: isCompact ? 20 : 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,
),
),
],
),
),
],
),
),
SizedBox(height: isCompact ? 12 : 20),
// Submission error alert if present
if (_submissionError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.raceRed.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colors.raceRed,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_submissionError!,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: colors.raceRed,
),
),
),
],
),
),
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), const SizedBox(height: 18),
slugField,
],
SizedBox(height: isCompact ? 12 : 18),
// Description // Description
TextFormField( TextFormField(
controller: _descController, controller: _descController,
enabled: !_isSubmitting, enabled: !_isSubmitting,
maxLines: 3, maxLines: isCompact ? 2 : 3,
maxLength: 500, maxLength: 500,
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
scrollPadding:
const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Description (Optional)', labelText: 'Description (Optional)',
hintText: hintText:
@ -377,109 +530,45 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
), ),
), ),
), ),
const SizedBox(height: 14), SizedBox(height: isCompact ? 10 : 14),
// Give optional discovery fields enough room for a clear // Home base & Rider community
// label and example on narrow phone layouts. if (isTwoColumn)
_buildFieldLabel( Row(
'Home base', crossAxisAlignment: CrossAxisAlignment.start,
'Where this club primarily rides or organizes from.', children: [
colors, Expanded(child: regionSection),
), const SizedBox(width: 16),
const SizedBox(height: 8), Expanded(child: focusSection),
TextFormField( ],
controller: _regionController, )
enabled: !_isSubmitting, else ...[
textCapitalization: TextCapitalization.words, regionSection,
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), const SizedBox(height: 18),
_buildFieldLabel( focusSection,
'Rider community', ],
'Name the motorcycle types or riding style this club is for.', SizedBox(height: isCompact ? 14 : 24),
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 // Visibility & Admission Policy Selection
Text( if (isTwoColumn)
'Club Visibility', Row(
style: TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
fontFamily: AppTypography.fontHeadline, children: [
fontSize: 13, Expanded(child: visibilitySection),
fontWeight: FontWeight.w700, const SizedBox(width: 16),
color: colors.cyberCyan, Expanded(child: admissionSection),
), ],
), )
const SizedBox(height: 8), else ...[
_buildVisibilitySelector(colors, theme), visibilitySection,
const SizedBox(height: 20), const SizedBox(height: 20),
admissionSection,
// Admission Policy Selection ],
Text( SizedBox(height: isCompact ? 16 : 32),
'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 // Submit Button
SizedBox( SizedBox(
height: 50, height: isCompact ? 46 : 50,
child: FilledButton( child: FilledButton(
key: const ValueKey('create_club_submit_button'), key: const ValueKey('create_club_submit_button'),
onPressed: onPressed:
@ -515,9 +604,12 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
), ),
), ),
), ),
const SizedBox(height: 32), 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,6 +129,7 @@ class ClubCard extends StatelessWidget {
), ),
// Admission policy badge // Admission policy badge
if (role == null)
_buildAdmissionBadge(context, club.admissionPolicy, colors), _buildAdmissionBadge(context, club.admissionPolicy, colors),
// Region badge if available // Region badge if available
@ -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,42 +498,48 @@ 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(
builder: (context, graphConstraints) {
final graphWidth =
graphConstraints.maxWidth > 0 ? graphConstraints.maxWidth : 340.0;
return GestureDetector(
onHorizontalDragUpdate: hasRecorded onHorizontalDragUpdate: hasRecorded
? (details) { ? (details) {
final box = context.findRenderObject() as RenderBox?;
if (box != null) {
final localX = details.localPosition.dx;
setState(() { setState(() {
_scrubFraction = (localX / 340).clamp(0.0, 1.0); _scrubFraction =
(details.localPosition.dx / graphWidth)
.clamp(0.0, 1.0);
}); });
} }
}
: null, : null,
onTapDown: hasRecorded onTapDown: hasRecorded
? (details) { ? (details) {
final box = context.findRenderObject() as RenderBox?;
if (box != null) {
final localX = details.localPosition.dx;
setState(() { setState(() {
_scrubFraction = (localX / 340).clamp(0.0, 1.0); _scrubFraction =
(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),
@ -488,8 +547,16 @@ class _FeedTelemetryInspectorModalState
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,38 +764,58 @@ 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)
if (!isLandscape)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox( child: _buildPublishButton(accentColor, buttonFg),
),
],
),
),
),
),
);
}
Widget _buildPublishButton(Color accentColor, Color buttonFg) {
return SizedBox(
height: 52, height: 52,
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan, backgroundColor: accentColor,
foregroundColor: const Color(0xFF00363D), foregroundColor: buttonFg,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
elevation: 4, elevation: 4,
), ),
onPressed: _isSubmitting ? null : _handlePublish, onPressed:
(_isSubmitting || _selectedRide == null) ? null : _handlePublish,
child: _isSubmitting child: _isSubmitting
? const SizedBox( ? SizedBox(
width: 22, width: 22,
height: 22, height: 22,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: Color(0xFF00363D), color: buttonFg,
), ),
) )
: Row( : Row(
@ -719,13 +838,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
], ],
), ),
), ),
),
),
],
),
),
),
),
); );
} }
@ -734,20 +846,26 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
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,10 +210,13 @@ 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(),
), ),
], ],
), ),
if (!isLandscape) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'1-tap broadcast to all nearby riders & pack members.', '1-tap broadcast to all nearby riders & pack members.',
@ -212,10 +226,11 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
color: Colors.white70, 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,13 +318,14 @@ 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,
), ),
if (!isLandscape) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
item.subtitle, item.subtitle,
@ -314,6 +334,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
overflow: TextOverflow.ellipsis, 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,
required double targetBearing,
required Duration duration,
Curve curve = Curves.easeOutCubic,
}) {
if (!mounted) return;
_animController?.stop();
_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 { try {
_mapController.move(target, zoom); _mapController.move(ll.LatLng(lat, lng), z);
} catch (_) { _mapController.rotate(normalizeAngle(b));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(target, zoom);
} catch (_) {} } 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;
_animateCameraTo(
target: centerLl,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
); );
_applyFollowMove(latestCenter, widget.zoom);
}
});
} }
} 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,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else if (zoomChanged) {
try { try {
_mapController.move(_mapController.camera.center, widget.zoom); _mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {} } 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,
required double targetBearing,
required Duration duration,
Curve curve = Curves.easeOutCubic,
}) {
if (!mounted) return;
_animController?.stop();
_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 { try {
_mapController.move(target, zoom); _mapController.move(ll.LatLng(lat, lng), z);
} catch (_) { _mapController.rotate(normalizeAngle(b));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(target, zoom);
} catch (_) {} } 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;
_animateCameraTo(
target: centerLl,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
); );
_applyFollowMove(latestCenter, widget.zoom);
}
});
} }
} 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,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else if (zoomChanged) {
try { try {
_mapController.move(_mapController.camera.center, widget.zoom); _mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {} } 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);