Compare commits
5 Commits
master
...
apk-fix-bu
| Author | SHA1 | Date |
|---|---|---|
|
|
eb4cd74f02 | |
|
|
1820739a49 | |
|
|
265ee83e03 | |
|
|
07bd567cdb | |
|
|
1d702102fc |
|
|
@ -1,36 +0,0 @@
|
|||
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 }}
|
||||
|
|
@ -39,32 +39,12 @@ 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) {
|
||||
const p = normalizePath(filePath);
|
||||
if (!p) return false;
|
||||
// CI paths: workflows, scripts, and Android-specific composite actions.
|
||||
return (
|
||||
p === '.github/workflows/android-build.yml' ||
|
||||
p.startsWith('.github/scripts/') ||
|
||||
p.startsWith('.github/actions/flutter-ci-setup/')
|
||||
p.startsWith('.github/scripts/')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +53,6 @@ function classifyFiles(fileList) {
|
|||
return {
|
||||
android_changed: false,
|
||||
app_changed: false,
|
||||
native_build_changed: false,
|
||||
appFiles: [],
|
||||
ciFiles: [],
|
||||
unrelatedFiles: []
|
||||
|
|
@ -83,7 +62,6 @@ function classifyFiles(fileList) {
|
|||
const appFiles = [];
|
||||
const ciFiles = [];
|
||||
const unrelatedFiles = [];
|
||||
const nativeBuildFiles = [];
|
||||
|
||||
for (const rawFile of fileList) {
|
||||
const file = normalizePath(rawFile);
|
||||
|
|
@ -96,21 +74,14 @@ function classifyFiles(fileList) {
|
|||
} else {
|
||||
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 android_changed = appFiles.length > 0 || ciFiles.length > 0;
|
||||
const native_build_changed = nativeBuildFiles.length > 0;
|
||||
|
||||
return {
|
||||
android_changed,
|
||||
app_changed,
|
||||
native_build_changed,
|
||||
appFiles,
|
||||
ciFiles,
|
||||
unrelatedFiles
|
||||
|
|
@ -153,7 +124,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
return {
|
||||
android_changed: true,
|
||||
app_changed: true,
|
||||
native_build_changed: true,
|
||||
reason: 'Manual workflow_dispatch; full pipeline authorized',
|
||||
appFiles: [],
|
||||
ciFiles: [],
|
||||
|
|
@ -165,7 +135,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) {
|
||||
return {
|
||||
android_changed: true,
|
||||
native_build_changed: true,
|
||||
app_changed: true,
|
||||
reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`,
|
||||
appFiles: [],
|
||||
|
|
@ -178,7 +147,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) {
|
||||
return {
|
||||
android_changed: true,
|
||||
native_build_changed: true,
|
||||
app_changed: true,
|
||||
reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`,
|
||||
appFiles: [],
|
||||
|
|
@ -191,7 +159,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
if (baseSha.trim() === headSha.trim()) {
|
||||
return {
|
||||
android_changed: false,
|
||||
native_build_changed: false,
|
||||
app_changed: false,
|
||||
reason: 'Base SHA and Head SHA are identical; no changes detected',
|
||||
appFiles: [],
|
||||
|
|
@ -207,7 +174,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
} catch (err) {
|
||||
return {
|
||||
android_changed: true,
|
||||
native_build_changed: true,
|
||||
app_changed: true,
|
||||
reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`,
|
||||
appFiles: [],
|
||||
|
|
@ -245,7 +211,6 @@ function runCli() {
|
|||
|
||||
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] native_build_changed=${result.native_build_changed}`);
|
||||
console.log(`[detect-android-changes] Reason: ${result.reason}`);
|
||||
|
||||
if (result.appFiles && result.appFiles.length > 0) {
|
||||
|
|
@ -273,7 +238,7 @@ function runCli() {
|
|||
if (githubOutput) {
|
||||
fs.appendFileSync(
|
||||
githubOutput,
|
||||
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\nnative_build_changed=${result.native_build_changed}\n`
|
||||
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\n`
|
||||
);
|
||||
console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`);
|
||||
}
|
||||
|
|
@ -291,7 +256,6 @@ module.exports = {
|
|||
isZeroSha,
|
||||
isAppPath,
|
||||
isCiPath,
|
||||
isNativeBuildPath,
|
||||
classifyFiles,
|
||||
getChangedFiles,
|
||||
detectAndroidChanges
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const {
|
|||
isZeroSha,
|
||||
isAppPath,
|
||||
isCiPath,
|
||||
isNativeBuildPath,
|
||||
classifyFiles,
|
||||
getChangedFiles,
|
||||
detectAndroidChanges
|
||||
|
|
@ -72,37 +71,14 @@ describe('File Path Classification', () => {
|
|||
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.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
|
||||
assert.strictEqual(isCiPath('.github/workflows/website-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/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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
@ -169,7 +145,6 @@ describe('classifyFiles Scenarios', () => {
|
|||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, false);
|
||||
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
|
||||
});
|
||||
|
||||
|
|
@ -193,7 +168,6 @@ describe('classifyFiles Scenarios', () => {
|
|||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, true);
|
||||
});
|
||||
|
||||
it('classifies pubspec change as true', () => {
|
||||
|
|
@ -210,9 +184,7 @@ describe('classifyFiles Scenarios', () => {
|
|||
|
||||
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']).native_build_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', () => {
|
||||
|
|
@ -267,26 +239,6 @@ describe('classifyFiles Scenarios', () => {
|
|||
assert.strictEqual(result.android_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', () => {
|
||||
|
|
@ -301,7 +253,6 @@ describe('detectAndroidChanges Workflow Event Logic', () => {
|
|||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, true);
|
||||
assert.match(result.reason, /Manual workflow_dispatch/);
|
||||
});
|
||||
|
||||
|
|
@ -615,271 +566,6 @@ describe('Workflow Scenario Matrix Verification', () => {
|
|||
|
||||
assert.strictEqual(result.android_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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ jobs:
|
|||
outputs:
|
||||
android_changed: ${{ steps.detector.outputs.android_changed }}
|
||||
app_changed: ${{ steps.detector.outputs.app_changed }}
|
||||
native_build_changed: ${{ steps.detector.outputs.native_build_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -50,54 +49,8 @@ jobs:
|
|||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node .github/scripts/detect-android-changes.cjs
|
||||
|
||||
analyze:
|
||||
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
|
||||
analyze-and-build:
|
||||
name: Analyze, test and debug build
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
|
||||
|
|
@ -108,11 +61,18 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up Flutter CI
|
||||
uses: ./.github/actions/flutter-ci-setup
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
setup-java: 'true'
|
||||
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
|
||||
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: Free runner disk space
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -170,6 +130,8 @@ jobs:
|
|||
flutter --version
|
||||
flutter doctor -v
|
||||
java -version
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
- name: Generate Drift sources
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
- name: Commit regenerated Drift sources
|
||||
|
|
@ -186,6 +148,10 @@ jobs:
|
|||
git add lib/database/database.g.dart
|
||||
git commit -m 'Regenerate Drift database source'
|
||||
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
|
||||
shell: bash
|
||||
run: flutter build apk --debug
|
||||
|
|
@ -218,31 +184,11 @@ jobs:
|
|||
if-no-files-found: error
|
||||
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:
|
||||
name: Build signed release APK and AAB
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect, analyze-and-build]
|
||||
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.native_build_changed == 'true'))
|
||||
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.app_changed == 'true'))
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -223,8 +223,6 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
finishLabel ?? 'Finish Ride',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
|
|
@ -240,6 +238,7 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
// Ready / Standby state: Single prominent start button
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
|
|
@ -254,24 +253,18 @@ class HybridRidingActionBar extends ConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||
),
|
||||
onPressed: () {
|
||||
notifier.startRide();
|
||||
},
|
||||
icon: Icon(Icons.play_arrow_rounded, size: compact ? 22 : 26, color: colors.brandCyan),
|
||||
label: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'Start Session',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan),
|
||||
label: const Text(
|
||||
'Start Session',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -138,48 +138,45 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
|
|||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: () {
|
||||
final isShortRail = constraints.maxHeight < 400;
|
||||
return NavigationRail(
|
||||
minWidth: isShortRail ? 52 : 58,
|
||||
groupAlignment: isShortRail ? -1.0 : 0.0,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: _onDestinationSelected,
|
||||
backgroundColor: colors.surfaceCanvas,
|
||||
indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
|
||||
selectedIconTheme: IconThemeData(
|
||||
color: colors.brandCyan,
|
||||
size: isShortRail ? 19 : 21,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(
|
||||
color: colors.textMuted,
|
||||
size: isShortRail ? 17 : 19,
|
||||
),
|
||||
selectedLabelTextStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isShortRail ? 9.0 : 10.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.brandCyan,
|
||||
),
|
||||
unselectedLabelTextStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isShortRail ? 8.5 : 9.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.textMuted,
|
||||
),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: kAppNavDestinations.map((dest) {
|
||||
return NavigationRailDestination(
|
||||
padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
|
||||
icon: Icon(dest.icon, key: dest.railKey),
|
||||
selectedIcon: dest.selectedIcon != null
|
||||
? Icon(dest.selectedIcon, key: dest.railKey)
|
||||
: null,
|
||||
label: Text(dest.label),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}(),
|
||||
child: NavigationRail(
|
||||
minWidth: 58,
|
||||
groupAlignment: 0.0,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: _onDestinationSelected,
|
||||
backgroundColor: colors.surfaceCanvas,
|
||||
indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
|
||||
selectedIconTheme: IconThemeData(
|
||||
color: colors.brandCyan,
|
||||
size: 21,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(
|
||||
color: colors.textMuted,
|
||||
size: 19,
|
||||
),
|
||||
selectedLabelTextStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 10.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.brandCyan,
|
||||
),
|
||||
unselectedLabelTextStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.textMuted,
|
||||
),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: kAppNavDestinations.map((dest) {
|
||||
return NavigationRailDestination(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
icon: Icon(dest.icon, key: dest.railKey),
|
||||
selectedIcon: dest.selectedIcon != null
|
||||
? Icon(dest.selectedIcon, key: dest.railKey)
|
||||
: null,
|
||||
label: Text(dest.label),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -81,14 +81,11 @@ class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
|
|||
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
|
||||
final isFresh = vehicleCoords.isGpsFresh;
|
||||
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(
|
||||
id: 'dash_vehicle',
|
||||
position: activeCenter,
|
||||
heading: isNorthUp ? vehicleCoords.heading : 0.0,
|
||||
heading: vehicleCoords.heading,
|
||||
iconWidget: Icon(
|
||||
Icons.navigation,
|
||||
color: isFresh
|
||||
|
|
@ -159,66 +156,11 @@ class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
|
|||
key: const ValueKey('dash_map_x'),
|
||||
center: activeCenter,
|
||||
zoom: widget.zoom,
|
||||
bearing: effectiveBearing,
|
||||
bearing: 0.0,
|
||||
markers: mapMarkers,
|
||||
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))
|
||||
Positioned(
|
||||
top: 10,
|
||||
|
|
|
|||
|
|
@ -233,7 +233,6 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
// Header
|
||||
Text(
|
||||
'LEAN ANGLE',
|
||||
key: const ValueKey('lean_angle_title'),
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: compact ? 9 : 11,
|
||||
|
|
@ -241,25 +240,22 @@ class LeanAngleIndicator extends ConsumerWidget {
|
|||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 3 : 6),
|
||||
SizedBox(height: compact ? 1 : 3),
|
||||
|
||||
// Motorcycle Leaning Silhouette with Stable Horizon
|
||||
RepaintBoundary(
|
||||
child: SizedBox(
|
||||
key: const ValueKey('lean_angle_rider_graphic'),
|
||||
width: gaugeWidth,
|
||||
height: gaugeHeight,
|
||||
child: ClipRect(
|
||||
child: CustomPaint(
|
||||
size: Size(gaugeWidth, gaugeHeight),
|
||||
painter: _MotorcycleLeanPainter(
|
||||
leanAngle: displayLean,
|
||||
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
|
||||
accentColor: colors.brandCyan,
|
||||
horizonColor: isDark ? Colors.white24 : Colors.black26,
|
||||
tickColor: colors.brandCyan,
|
||||
isDark: isDark,
|
||||
),
|
||||
child: CustomPaint(
|
||||
size: Size(gaugeWidth, gaugeHeight),
|
||||
painter: _MotorcycleLeanPainter(
|
||||
leanAngle: displayLean,
|
||||
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
|
||||
accentColor: colors.brandCyan,
|
||||
horizonColor: isDark ? Colors.white24 : Colors.black26,
|
||||
tickColor: colors.brandCyan,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -430,10 +426,6 @@ class _MotorcycleLeanPainter extends CustomPainter {
|
|||
// 2. Draw Leaning Motorcycle Silhouette
|
||||
canvas.save();
|
||||
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);
|
||||
canvas.rotate(clampedLean * math.pi / 180.0);
|
||||
|
||||
|
|
|
|||
|
|
@ -326,29 +326,17 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DashboardLayoutSwitcher(),
|
||||
SizedBox(width: 4),
|
||||
QuickSwarmReportButton(compact: true),
|
||||
SizedBox(width: 4),
|
||||
QuickSquadFeedButton(compact: true),
|
||||
SizedBox(width: 4),
|
||||
GPSStatusIndicator(showLabel: false),
|
||||
SizedBox(width: 4),
|
||||
HeadingIndicator(compact: true),
|
||||
SizedBox(width: 4),
|
||||
RideStatusChip(),
|
||||
],
|
||||
),
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
DashboardLayoutSwitcher(),
|
||||
GPSStatusIndicator(showLabel: false),
|
||||
HeadingIndicator(compact: true),
|
||||
RideStatusChip(),
|
||||
],
|
||||
),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: SpeedReadout(size: SpeedReadoutSize.compact),
|
||||
),
|
||||
const Center(
|
||||
child: SpeedReadout(size: SpeedReadoutSize.compact),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
|
|
@ -398,7 +386,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 164,
|
||||
width: 140,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
@ -475,10 +463,6 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
children: [
|
||||
DashboardLayoutSwitcher(),
|
||||
SizedBox(width: 4),
|
||||
QuickSwarmReportButton(compact: true),
|
||||
SizedBox(width: 4),
|
||||
QuickSquadFeedButton(compact: true),
|
||||
SizedBox(width: 4),
|
||||
GPSStatusIndicator(),
|
||||
SizedBox(width: 4),
|
||||
HeadingIndicator(compact: true),
|
||||
|
|
@ -488,9 +472,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
const NavigationManeuverCard(compact: true),
|
||||
const Expanded(
|
||||
child: Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
||||
),
|
||||
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
||||
const RideActionButton(compact: true),
|
||||
const SizedBox(height: 4),
|
||||
const PerformanceLaunchShortcut(compact: true),
|
||||
|
|
|
|||
|
|
@ -168,17 +168,13 @@ Future<void> showProUpgradeSheet(
|
|||
|
||||
await showModalBottomSheet<void>(
|
||||
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),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
|
|
@ -112,17 +112,14 @@ class GarageScreen extends ConsumerWidget {
|
|||
if (isMultiColumn) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: responsive.isShortViewport ? 600 : 550,
|
||||
mainAxisExtent: responsive.isShortViewport ? 300 : 420,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 550,
|
||||
mainAxisExtent: 420,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: bikes.length + 1,
|
||||
itemCount: bikes.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == bikes.length) {
|
||||
return _buildAddMotorcycleCard(context, ref, bikes);
|
||||
}
|
||||
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true);
|
||||
},
|
||||
);
|
||||
|
|
@ -130,11 +127,8 @@ class GarageScreen extends ConsumerWidget {
|
|||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: bikes.length + 1,
|
||||
itemCount: bikes.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == bikes.length) {
|
||||
return _buildAddMotorcycleCard(context, ref, bikes);
|
||||
}
|
||||
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false);
|
||||
},
|
||||
);
|
||||
|
|
@ -142,6 +136,19 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -166,27 +173,6 @@ class GarageScreen extends ConsumerWidget {
|
|||
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(
|
||||
BuildContext context,
|
||||
MotorSignalThemeColors colors,
|
||||
|
|
@ -320,7 +306,7 @@ class GarageScreen extends ConsumerWidget {
|
|||
year: bike.year,
|
||||
make: bike.makeName,
|
||||
model: bike.modelName,
|
||||
height: isGrid ? 100 : 150,
|
||||
height: 150,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
|
|||
|
|
@ -1207,29 +1207,17 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 560,
|
||||
maxHeight: size.height - viewInsets.bottom - 48,
|
||||
),
|
||||
title: const Text('Log New Service'),
|
||||
content: Column(
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedType,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Service Type'),
|
||||
items: serviceTypes
|
||||
.map((t) => DropdownMenuItem(
|
||||
value: t,
|
||||
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
))
|
||||
.toList(),
|
||||
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => selectedType = val);
|
||||
},
|
||||
|
|
@ -1280,6 +1268,7 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
|
|
@ -1351,14 +1340,8 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
|||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedType,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Service Type'),
|
||||
items: serviceTypes
|
||||
.map((t) => DropdownMenuItem(
|
||||
value: t,
|
||||
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
))
|
||||
.toList(),
|
||||
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => selectedType = val);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -378,11 +378,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
|
|
@ -391,112 +388,91 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
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(
|
||||
child: Padding(
|
||||
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,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'USE EXISTING ROUTE',
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'USE EXISTING ROUTE',
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
||||
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
routesAsync.when(
|
||||
loading: () => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child:
|
||||
CircularProgressIndicator(color: colors.cyberCyan),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
||||
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||
error: (err, _) => Text(
|
||||
'Failed to load routes: $err',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
routesAsync.when(
|
||||
loading: () => Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isCompact ? 12 : 24),
|
||||
child: CircularProgressIndicator(
|
||||
color: colors.cyberCyan),
|
||||
),
|
||||
),
|
||||
error: (err, _) => Text(
|
||||
'Failed to load routes: $err',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: isCompact ? 12 : 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No saved routes found.',
|
||||
style: TextStyle(color: onSurfaceVariant),
|
||||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No saved routes found.',
|
||||
style: TextStyle(color: onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: availableHeight * 0.5,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final route = routes[idx];
|
||||
return ListTile(
|
||||
dense: isCompact,
|
||||
leading: Icon(Icons.alt_route,
|
||||
color: colors.cyberCyan),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
_applySavedRoute(route);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final route = routes[idx];
|
||||
return ListTile(
|
||||
leading: Icon(Icons.alt_route,
|
||||
color: colors.cyberCyan),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
_applySavedRoute(route);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -533,34 +509,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
final theme = Theme.of(ctx);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
final screenSize = MediaQuery.sizeOf(ctx);
|
||||
final isCompactDialog = screenSize.height < 480;
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
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(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
|
|
@ -569,8 +520,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
||||
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -591,7 +541,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Custom Stop Label (Optional)',
|
||||
|
|
@ -640,7 +589,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: notesCtrl,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Notes (optional)',
|
||||
|
|
@ -750,14 +698,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
elevation: 0,
|
||||
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
|
||||
title: Text(
|
||||
widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
|
||||
style: const TextStyle(
|
||||
|
|
@ -793,37 +738,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, snapshot) {
|
||||
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||
(snapshot.isLandscape && snapshot.width >= 600);
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, snapshot) {
|
||||
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||
(snapshot.isLandscape && snapshot.width >= 720);
|
||||
|
||||
if (isWide) {
|
||||
return _buildWideForm(
|
||||
context,
|
||||
colors,
|
||||
snapshot,
|
||||
canSubmit,
|
||||
clubAsync,
|
||||
memberAsync,
|
||||
clubValidationError,
|
||||
);
|
||||
}
|
||||
return _buildCompactForm(
|
||||
context,
|
||||
colors,
|
||||
snapshot,
|
||||
canSubmit,
|
||||
clubAsync,
|
||||
memberAsync,
|
||||
clubValidationError,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isWide) {
|
||||
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
||||
}
|
||||
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -838,65 +764,52 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
AsyncValue<ClubMember?>? memberAsync,
|
||||
String? clubValidationError,
|
||||
) {
|
||||
final isCompact = snapshot.isLandscape || snapshot.isShortViewport;
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.clubId != null)
|
||||
_buildClubAffiliationHeader(
|
||||
colors, clubAsync, memberAsync, clubValidationError),
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Left Column: Details, Schedule, Privacy
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRideDetailsSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildScheduleSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildPrivacySection(colors, isCompact: isCompact),
|
||||
],
|
||||
),
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.clubId != null)
|
||||
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Left Column: Details, Schedule, Privacy
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRideDetailsSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildScheduleSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrivacySection(colors),
|
||||
],
|
||||
),
|
||||
SizedBox(width: colGap),
|
||||
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMeetingPointSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildDestinationAndStopsSection(colors,
|
||||
isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildMapPreviewSection(colors, snapshot),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildSubmitButton(colors, canSubmit),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMeetingPointSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildDestinationAndStopsSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildMapPreviewSection(colors, snapshot),
|
||||
const SizedBox(height: 24),
|
||||
_buildSubmitButton(colors, canSubmit),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: isCompact ? 16 : 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -910,37 +823,27 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
AsyncValue<ClubMember?>? memberAsync,
|
||||
String? clubValidationError,
|
||||
) {
|
||||
final isCompact = snapshot.isShortViewport;
|
||||
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: [
|
||||
if (widget.clubId != null)
|
||||
_buildClubAffiliationHeader(
|
||||
colors, clubAsync, memberAsync, clubValidationError),
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
_buildRideDetailsSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildScheduleSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildMeetingPointSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildDestinationAndStopsSection(colors, isCompact: isCompact),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildMapPreviewSection(colors, snapshot),
|
||||
SizedBox(height: sectionGap),
|
||||
_buildPrivacySection(colors, isCompact: isCompact),
|
||||
SizedBox(height: isCompact ? 16 : 32),
|
||||
_buildSubmitButton(colors, canSubmit),
|
||||
SizedBox(height: isCompact ? 16 : 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
if (widget.clubId != null)
|
||||
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
_buildRideDetailsSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildScheduleSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildMeetingPointSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildDestinationAndStopsSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildMapPreviewSection(colors, snapshot),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrivacySection(colors),
|
||||
const SizedBox(height: 32),
|
||||
_buildSubmitButton(colors, canSubmit),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -955,8 +858,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
|
||||
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -990,8 +893,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
|
||||
if (validationError != null) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.raceRed.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -1034,8 +937,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
final member = memberAsync.value;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.all(14),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -1098,7 +1001,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
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.',
|
||||
style: TextStyle(
|
||||
|
|
@ -1114,7 +1017,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
|
||||
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.raceRed.withValues(alpha: 0.15),
|
||||
|
|
@ -1136,25 +1039,19 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildRideDetailsSection(
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||
Widget _buildRideDetailsSection(MotorSignalThemeColors colors) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader('1. RIDE DETAILS', colors),
|
||||
SizedBox(height: isCompact ? 6 : 10),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Ride Title',
|
||||
'e.g. Blood Mountain Loop',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().length < 3) {
|
||||
|
|
@ -1163,58 +1060,50 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
return null;
|
||||
},
|
||||
),
|
||||
SizedBox(height: fieldGap),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
maxLines: isCompact ? 2 : 3,
|
||||
maxLines: 3,
|
||||
decoration: _inputDecoration(
|
||||
'Description & Route Notes',
|
||||
'Pace, skill level, gear recommendations...',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScheduleSection(
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
Widget _buildScheduleSection(MotorSignalThemeColors colors) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader('2. DATE & SCHEDULE', colors),
|
||||
SizedBox(height: isCompact ? 6 : 10),
|
||||
const SizedBox(height: 10),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isCompact ? 10 : 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
dense: isCompact,
|
||||
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.calendar_month, color: colors.cyberCyan),
|
||||
title: Text('Date',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: isCompact ? 12 : 13)),
|
||||
fontSize: 13)),
|
||||
subtitle: Text(
|
||||
'${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isCompact ? 14 : 16,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
trailing: TextButton(
|
||||
|
|
@ -1236,16 +1125,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
color: colors.outlineVariant,
|
||||
height: isCompact ? 8 : 16,
|
||||
),
|
||||
Divider(color: colors.outlineVariant),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
dense: isCompact,
|
||||
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Meetup Arrival',
|
||||
style: TextStyle(
|
||||
|
|
@ -1273,8 +1157,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
dense: isCompact,
|
||||
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Kickstands Up (KSU)',
|
||||
style: TextStyle(
|
||||
|
|
@ -1310,16 +1192,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMeetingPointSection(
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||
Widget _buildMeetingPointSection(MotorSignalThemeColors colors) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader('3. MEETING LOCATION', colors),
|
||||
SizedBox(height: isCompact ? 6 : 10),
|
||||
const SizedBox(height: 10),
|
||||
AddressAutocompleteField(
|
||||
labelText: 'Meeting Place Address / Location *',
|
||||
hintText: 'Search meetup address or landmark...',
|
||||
|
|
@ -1335,16 +1213,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_triggerRouteCalculation();
|
||||
},
|
||||
),
|
||||
SizedBox(height: fieldGap),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _meetupNameController,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Meeting Place Name',
|
||||
'e.g. Dahlonega Square / Shell Station',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
|
|
@ -1353,38 +1229,30 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
return null;
|
||||
},
|
||||
),
|
||||
SizedBox(height: fieldGap),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _meetupNotesController,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Meeting Notes (optional)',
|
||||
'e.g. Park on the north side near pumps',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDestinationAndStopsSection(
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) {
|
||||
final canAdd = _canAddStop;
|
||||
final totalStops = _totalPlannedStops;
|
||||
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader('4. DESTINATION & STOPS', colors),
|
||||
SizedBox(height: isCompact ? 4 : 10),
|
||||
const SizedBox(height: 10),
|
||||
SwitchListTile(
|
||||
dense: isCompact,
|
||||
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeThumbColor: colors.cyberCyan,
|
||||
title: Text('Specific Destination',
|
||||
|
|
@ -1398,7 +1266,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
},
|
||||
),
|
||||
if (_hasDestination) ...[
|
||||
SizedBox(height: isCompact ? 4 : 8),
|
||||
const SizedBox(height: 8),
|
||||
AddressAutocompleteField(
|
||||
labelText: 'Destination Address / Location *',
|
||||
hintText: 'Search destination address or scenic peak...',
|
||||
|
|
@ -1414,24 +1282,22 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_triggerRouteCalculation();
|
||||
},
|
||||
),
|
||||
SizedBox(height: fieldGap),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _destNameController,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Destination Name',
|
||||
'e.g. Brasstown Bald Summit',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: isCompact ? 10 : 16),
|
||||
const SizedBox(height: 16),
|
||||
// USE EXISTING ROUTE Card
|
||||
Container(
|
||||
margin: EdgeInsets.only(bottom: isCompact ? 8 : 12),
|
||||
padding: EdgeInsets.all(isCompact ? 10 : 12),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
|
|
@ -1483,27 +1349,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
},
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showSelectRouteSheet(colors),
|
||||
icon: const Icon(Icons.alt_route, size: 14),
|
||||
label: const Text(
|
||||
'USE EXISTING ROUTE',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showSelectRouteSheet(colors),
|
||||
icon: const Icon(Icons.alt_route, size: 14),
|
||||
label: const Text('USE EXISTING ROUTE'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1577,7 +1437,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
for (int i = 0; i < _stops.length; i++)
|
||||
ListTile(
|
||||
key: ValueKey('stop_$i'),
|
||||
dense: isCompact,
|
||||
tileColor: colors.surfaceContainerHigh,
|
||||
onTap: () => _addStopDialog(colors, i),
|
||||
leading: CircleAvatar(
|
||||
|
|
@ -1716,9 +1575,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_destinationPlace?.latLng ??
|
||||
const AppLatLng(34.5378, -83.9849);
|
||||
|
||||
final mapHeight = snapshot.isShortViewport
|
||||
? 130.0
|
||||
: (snapshot.isLandscape ? 160.0 : 210.0);
|
||||
final mapHeight = snapshot.isLandscape ? 170.0 : 210.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -1745,7 +1602,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: snapshot.isShortViewport ? 6 : 10),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
|
|
@ -1861,80 +1718,54 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacySection(
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
Widget _buildPrivacySection(MotorSignalThemeColors colors) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader('5. PRIVACY & PARTICIPATION', colors),
|
||||
SizedBox(height: isCompact ? 6 : 10),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<GroupRideVisibility>(
|
||||
initialValue: _visibility,
|
||||
isExpanded: true,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Visibility',
|
||||
'',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
decoration: _inputDecoration('Visibility', '', colors),
|
||||
items: GroupRideVisibility.values.map((v) {
|
||||
return DropdownMenuItem(
|
||||
value: v,
|
||||
child: Text(
|
||||
v.label,
|
||||
style: TextStyle(color: onSurface),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: Text(v.label, style: TextStyle(color: onSurface)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _visibility = val);
|
||||
},
|
||||
),
|
||||
SizedBox(height: fieldGap),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<GroupRideJoinPolicy>(
|
||||
initialValue: _joinPolicy,
|
||||
isExpanded: true,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Join Policy',
|
||||
'',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
decoration: _inputDecoration('Join Policy', '', colors),
|
||||
items: GroupRideJoinPolicy.values.map((p) {
|
||||
return DropdownMenuItem(
|
||||
value: p,
|
||||
child: Text(
|
||||
p.label,
|
||||
style: TextStyle(color: onSurface),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: Text(p.label, style: TextStyle(color: onSurface)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _joinPolicy = val);
|
||||
},
|
||||
),
|
||||
SizedBox(height: fieldGap),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
keyboardType: TextInputType.number,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: _inputDecoration(
|
||||
'Participant Limit (optional)',
|
||||
'Leave blank for no limit (min 2)',
|
||||
colors,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
onChanged: (val) {
|
||||
final parsed = int.tryParse(val.trim());
|
||||
|
|
@ -1995,16 +1826,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
InputDecoration _inputDecoration(
|
||||
String label,
|
||||
String hint,
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
return InputDecoration(
|
||||
isDense: isCompact,
|
||||
contentPadding: isCompact
|
||||
? const EdgeInsets.symmetric(horizontal: 12, vertical: 10)
|
||||
: null,
|
||||
labelText: label,
|
||||
hintText: hint.isNotEmpty ? hint : null,
|
||||
labelStyle: TextStyle(color: onSurfaceVariant, fontSize: 13),
|
||||
|
|
|
|||
|
|
@ -1439,7 +1439,6 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
|
|
@ -1448,180 +1447,156 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
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(
|
||||
child: Padding(
|
||||
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,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ATTACH SAVED ROUTE',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ATTACH SAVED ROUTE',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
routesAsync.when(
|
||||
loading: () => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child:
|
||||
CircularProgressIndicator(color: colors.cyberCyan),
|
||||
),
|
||||
SizedBox(height: isCompact ? 8 : 12),
|
||||
routesAsync.when(
|
||||
loading: () => Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isCompact ? 12 : 24),
|
||||
child: CircularProgressIndicator(
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (err, _) => Text(
|
||||
'Failed to load routes: $err',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: isCompact ? 8 : 24,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'You have no saved routes yet.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 8 : 12),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
context.push(
|
||||
'/routes/planner?groupRideId=${ride.id}',
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: colors.onBrand,
|
||||
),
|
||||
child: const Text('CREATE A ROUTE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: availableHeight * 0.55,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final route = routes[idx];
|
||||
return ListTile(
|
||||
dense: isCompact,
|
||||
leading: Icon(
|
||||
Icons.alt_route,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
),
|
||||
error: (err, _) => Text(
|
||||
'Failed to load routes: $err',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'You have no saved routes yet.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.4),
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
onTap: () async {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
try {
|
||||
final user =
|
||||
ref.read(currentUserProvider);
|
||||
if (user == null || user.uid.isEmpty) {
|
||||
throw StateError(
|
||||
'Sign-in required to attach route.',
|
||||
);
|
||||
}
|
||||
await ref
|
||||
.read(routeServiceProvider)
|
||||
.attachRouteToGroupRide(
|
||||
groupRideId: ride.id,
|
||||
route: route,
|
||||
callerUid: user.uid,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Attached "${route.title}" to group ride.',
|
||||
),
|
||||
backgroundColor:
|
||||
colors.launchGreen,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to attach route: $e',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
context.push(
|
||||
'/routes/planner?groupRideId=${ride.id}',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: colors.onBrand,
|
||||
),
|
||||
child: const Text('CREATE A ROUTE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final route = routes[idx];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.alt_route,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetCtx);
|
||||
try {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null || user.uid.isEmpty) {
|
||||
throw StateError(
|
||||
'Sign-in required to attach route.');
|
||||
}
|
||||
await ref
|
||||
.read(routeServiceProvider)
|
||||
.attachRouteToGroupRide(
|
||||
groupRideId: ride.id,
|
||||
route: route,
|
||||
callerUid: user.uid,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Attached "${route.title}" to group ride.',
|
||||
),
|
||||
backgroundColor:
|
||||
colors.launchGreen,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to attach route: $e',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -115,17 +115,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
if (!isAuthed) {
|
||||
return _buildAccountRequiredNotice(context, colors);
|
||||
}
|
||||
final showBanner = snapshot.height >= 160;
|
||||
return Column(
|
||||
children: [
|
||||
if (showBanner)
|
||||
_buildMemberProfileBanner(
|
||||
context,
|
||||
ref,
|
||||
user,
|
||||
colors,
|
||||
isCompact: snapshot.isShortViewport,
|
||||
),
|
||||
_buildMemberProfileBanner(context, ref, user, colors),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
|
|
@ -159,9 +151,8 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AuthUser user,
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
final profileAsync = ref.watch(myRiderProfileProvider);
|
||||
final isPro = ref.watch(isProSubscriberProvider);
|
||||
final profile = profileAsync.valueOrNull;
|
||||
|
|
@ -186,10 +177,7 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: isCompact ? 8.0 : 12.0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
|
|
@ -427,18 +415,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
mainAxisExtent: 300,
|
||||
mainAxisExtent: 260,
|
||||
),
|
||||
itemCount: rides.length,
|
||||
itemBuilder: (context, index) {
|
||||
final ride = rides[index];
|
||||
return _buildRideCard(
|
||||
context,
|
||||
ride,
|
||||
currentUid,
|
||||
colors,
|
||||
inGrid: true,
|
||||
);
|
||||
return _buildRideCard(context, ride, currentUid, colors);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -497,18 +479,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
mainAxisExtent: 300,
|
||||
mainAxisExtent: 260,
|
||||
),
|
||||
itemCount: myRides.length,
|
||||
itemBuilder: (context, index) {
|
||||
final ride = myRides[index];
|
||||
return _buildRideCard(
|
||||
context,
|
||||
ride,
|
||||
currentUid,
|
||||
colors,
|
||||
inGrid: true,
|
||||
);
|
||||
return _buildRideCard(context, ride, currentUid, colors);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -587,10 +563,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
|
|
@ -663,113 +638,61 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
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,
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: isCompactVertical ? 8 : 24,
|
||||
),
|
||||
titlePadding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
isCompactVertical ? 12 : 20,
|
||||
20,
|
||||
isCompactVertical ? 6 : 12,
|
||||
),
|
||||
contentPadding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
isCompactVertical ? 8 : 16,
|
||||
),
|
||||
actionsPadding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
isCompactVertical ? 8 : 14,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.group_add,
|
||||
color: colors.cyberCyan,
|
||||
size: isCompactVertical ? 20 : 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Join Group Ride',
|
||||
style: TextStyle(
|
||||
fontSize: isCompactVertical ? 16 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter the Ride ID or shared link:',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
SizedBox(height: isCompactVertical ? 8 : 12),
|
||||
TextField(
|
||||
key: const ValueKey('join_ride_id_field'),
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
scrollPadding: const EdgeInsets.fromLTRB(20, 20, 20, 80),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g. ride_1790320722076_4btn4W',
|
||||
isDense: isCompactVertical,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: isCompactVertical ? 8 : 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.group_add, color: colors.cyberCyan, size: 24),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Join Group Ride',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||
child: const Text('CANCEL'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter the Ride ID or shared link:',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: const ValueKey('join_ride_dialog_submit_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: colors.onBrand,
|
||||
),
|
||||
onPressed: () {
|
||||
final raw = controller.text.trim();
|
||||
if (raw.isEmpty) return;
|
||||
final rideId = raw.contains('/') ? raw.split('/').last : raw;
|
||||
Navigator.of(dialogCtx).pop();
|
||||
context.push('/group-rides/$rideId');
|
||||
},
|
||||
child: const Text(
|
||||
'VIEW RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('join_ride_id_field'),
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g. ride_1790320722076_4btn4W',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: const ValueKey('join_ride_dialog_submit_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: colors.onBrand,
|
||||
),
|
||||
onPressed: () {
|
||||
final raw = controller.text.trim();
|
||||
if (raw.isEmpty) return;
|
||||
final rideId = raw.contains('/') ? raw.split('/').last : raw;
|
||||
Navigator.of(dialogCtx).pop();
|
||||
context.push('/group-rides/$rideId');
|
||||
},
|
||||
child: const Text('VIEW RIDE', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -777,14 +700,13 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
BuildContext context,
|
||||
GroupRide ride,
|
||||
String currentUid,
|
||||
MotorSignalThemeColors colors, {
|
||||
bool inGrid = false,
|
||||
}) {
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
final isOrganizer = ride.organizerUid == currentUid;
|
||||
final isParticipant = ride.accessUids.contains(currentUid);
|
||||
|
||||
return Card(
|
||||
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: colors.surfaceCardPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -1002,12 +924,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'MANAGE RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'MANAGE RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1026,12 +945,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'OPEN HUB',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'OPEN HUB',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1055,12 +971,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'RIDE DETAILS',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'RIDE DETAILS',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1081,12 +994,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'LEAVE PACK',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'LEAVE PACK',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1109,12 +1019,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'RIDE DETAILS',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'RIDE DETAILS',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1133,12 +1040,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'JOIN RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'JOIN RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -295,10 +295,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
body: ResponsiveLayout(
|
||||
builder: (context, snapshot) {
|
||||
final isShort = snapshot.height < 450;
|
||||
final isLandscape =
|
||||
snapshot.isLandscape && snapshot.width >= 560;
|
||||
final isLandscapeWide =
|
||||
snapshot.isLandscape && snapshot.width >= 720;
|
||||
|
||||
Widget buildMapStack({required bool inLandscape}) => Stack(
|
||||
final mapStack = Stack(
|
||||
children: [
|
||||
// 1. Unified Map
|
||||
Positioned.fill(
|
||||
|
|
@ -336,7 +336,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
|
||||
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point
|
||||
Positioned(
|
||||
bottom: inLandscape ? (isShort ? 94 : 108) : 8,
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: FittedBox(
|
||||
|
|
@ -360,7 +360,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
Positioned(
|
||||
top: 54,
|
||||
left: 8,
|
||||
right: inLandscape ? snapshot.width * 0.35 : 8,
|
||||
right: isLandscapeWide ? snapshot.width * 0.35 : 8,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -386,17 +386,15 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
],
|
||||
);
|
||||
|
||||
if (isLandscape) {
|
||||
final leftFlex = snapshot.width >= 720 ? 65 : 60;
|
||||
final rightFlex = 100 - leftFlex;
|
||||
if (isLandscapeWide) {
|
||||
return Row(
|
||||
children: [
|
||||
// Left: Tactical Map Canvas
|
||||
Expanded(
|
||||
flex: leftFlex,
|
||||
flex: 65,
|
||||
child: Stack(
|
||||
children: [
|
||||
buildMapStack(inLandscape: true),
|
||||
mapStack,
|
||||
Positioned(
|
||||
bottom: isShort ? 6 : 10,
|
||||
left: 8,
|
||||
|
|
@ -437,7 +435,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
),
|
||||
// Right: Pack Telemetry Stream & Formation Safety Metrics
|
||||
Expanded(
|
||||
flex: rightFlex,
|
||||
flex: 35,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
|
|
@ -479,7 +477,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
// Top Section: Map Canvas with HUDs
|
||||
Expanded(
|
||||
flex: 52,
|
||||
child: buildMapStack(inLandscape: false),
|
||||
child: mapStack,
|
||||
),
|
||||
|
||||
// Cockpit Navigation Tab Bar
|
||||
|
|
@ -1021,7 +1019,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
];
|
||||
|
||||
return Container(
|
||||
height: isShort ? 38 : 46,
|
||||
height: isShort ? 40 : 46,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
|
|
@ -1030,84 +1028,71 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final statusItems = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int index = 0; index < statuses.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 4),
|
||||
() {
|
||||
final (status, label, icon) = statuses[index];
|
||||
final isSelected = currentStatus == status;
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int index = 0; index < statuses.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 6),
|
||||
() {
|
||||
final (status, label, icon) = statuses[index];
|
||||
final isSelected = currentStatus == status;
|
||||
|
||||
Color activeColor = colors.cyberCyan;
|
||||
if (status == LivePresenceStatus.needFuel) {
|
||||
activeColor = Colors.orangeAccent;
|
||||
}
|
||||
if (status == LivePresenceStatus.regrouping) {
|
||||
activeColor = colors.warningAmber;
|
||||
}
|
||||
if (status == LivePresenceStatus.arrived) {
|
||||
activeColor = colors.launchGreen;
|
||||
}
|
||||
Color activeColor = colors.cyberCyan;
|
||||
if (status == LivePresenceStatus.needFuel) {
|
||||
activeColor = Colors.orangeAccent;
|
||||
}
|
||||
if (status == LivePresenceStatus.regrouping) {
|
||||
activeColor = colors.warningAmber;
|
||||
}
|
||||
if (status == LivePresenceStatus.arrived) {
|
||||
activeColor = colors.launchGreen;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => onStatusSelected(status),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isShort ? 6 : 8,
|
||||
vertical: isShort ? 3 : 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? activeColor.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected ? activeColor : Colors.transparent,
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: isShort ? 14 : 16,
|
||||
color: isSelected ? activeColor : Colors.white60,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? activeColor : Colors.white70,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.w500,
|
||||
fontSize: isShort ? 10 : 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => onStatusSelected(status),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? activeColor.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected ? activeColor : Colors.transparent,
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
],
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected ? activeColor : Colors.white60,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? activeColor : Colors.white70,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
],
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: statusItems,
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1124,190 +1109,165 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
required MotorSignalThemeColors colors,
|
||||
bool isShort = false,
|
||||
}) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.maxWidth;
|
||||
final isCompact = availableWidth < 420;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isCompact = screenWidth < 360;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 8,
|
||||
vertical: isShort ? 4 : 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
boxShadow: const [
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Broadcast state info & switch
|
||||
Row(
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 8,
|
||||
vertical: isShort ? 4 : 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Ghost mode / Broadcast switch
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
broadcastState.isBroadcasting
|
||||
? Icons.radar
|
||||
: Icons.location_off,
|
||||
color: broadcastState.isBroadcasting
|
||||
? colors.cyberCyan
|
||||
: Colors.white38,
|
||||
size: isCompact ? 18 : 20,
|
||||
),
|
||||
SizedBox(width: isCompact ? 4 : 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Text(
|
||||
broadcastState.isBroadcasting
|
||||
? Icons.radar
|
||||
: Icons.location_off,
|
||||
color: broadcastState.isBroadcasting
|
||||
? colors.cyberCyan
|
||||
: Colors.white38,
|
||||
size: isCompact ? 16 : 20,
|
||||
? (broadcastState.isPaused
|
||||
? 'PAUSED'
|
||||
: (broadcastState.isBackground
|
||||
? 'BACKGROUND'
|
||||
: 'SHARING LIVE'))
|
||||
: 'GHOST MODE',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? colors.warningAmber
|
||||
: colors.cyberCyan)
|
||||
: Colors.white60,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isCompact ? 10 : 11,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
SizedBox(width: isCompact ? 4 : 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? 'PAUSED'
|
||||
: (broadcastState.isBackground
|
||||
? 'BACKGROUND'
|
||||
: 'SHARING LIVE'))
|
||||
: 'GHOST MODE',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? colors.warningAmber
|
||||
: colors.cyberCyan)
|
||||
: Colors.white60,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isCompact ? 10 : 11,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (!isShort)
|
||||
Text(
|
||||
broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? 'Reconnecting...'
|
||||
: (broadcastState.isBackground
|
||||
? 'Foreground active'
|
||||
: 'Broadcasting'))
|
||||
: 'Location paused',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: isCompact ? 8.5 : 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: isCompact ? 4 : 6),
|
||||
Transform.scale(
|
||||
scale: isCompact ? 0.8 : 1.0,
|
||||
child: Switch(
|
||||
key: const ValueKey('live_ride_broadcast_switch'),
|
||||
value: broadcastState.isOptedIn,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
activeThumbColor: colors.cyberCyan,
|
||||
onChanged: (val) {
|
||||
if (val && !broadcastState.isMasterAllowed) {
|
||||
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
||||
}
|
||||
broadcastController.setOptIn(val);
|
||||
},
|
||||
Text(
|
||||
broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? 'Reconnecting...'
|
||||
: (broadcastState.isBackground
|
||||
? 'Foreground active'
|
||||
: 'Broadcasting'))
|
||||
: 'Location paused',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: isCompact ? 9 : 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(width: isCompact ? 6 : 10),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Recenter Map Button
|
||||
IconButton(
|
||||
key: const ValueKey('live_ride_recenter_button'),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
padding: EdgeInsets.all(isCompact ? 4 : 8),
|
||||
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white70,
|
||||
size: isCompact ? 16 : 20,
|
||||
),
|
||||
tooltip: 'Center on Me',
|
||||
onPressed: () {
|
||||
if (broadcastState.lastPosition != null) {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
broadcastState.lastPosition!.latitude,
|
||||
broadcastState.lastPosition!.longitude,
|
||||
);
|
||||
_mapZoom = 15.0;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
ride.meetingPoint.latitude,
|
||||
ride.meetingPoint.longitude,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(width: isCompact ? 4 : 6),
|
||||
|
||||
// Roster Opener Button
|
||||
ElevatedButton.icon(
|
||||
key: const ValueKey('live_ride_roster_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 10,
|
||||
vertical: isCompact ? 4 : 8,
|
||||
),
|
||||
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||
),
|
||||
icon: const Icon(Icons.format_list_bulleted, size: 14),
|
||||
label: Text(
|
||||
'${participants.length}',
|
||||
style: TextStyle(
|
||||
fontSize: isCompact ? 10 : 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: () => _showRosterSheet(
|
||||
context: context,
|
||||
participants: participants,
|
||||
presences: presences,
|
||||
leadPresence: leadPresence,
|
||||
currentUid: currentUid,
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
key: const ValueKey('live_ride_broadcast_switch'),
|
||||
value: broadcastState.isOptedIn,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
activeThumbColor: colors.cyberCyan,
|
||||
onChanged: (val) {
|
||||
if (val && !broadcastState.isMasterAllowed) {
|
||||
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
||||
}
|
||||
broadcastController.setOptIn(val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
SizedBox(width: isCompact ? 4 : 8),
|
||||
|
||||
// Recenter Map Button (min 48x48 tap target)
|
||||
IconButton(
|
||||
key: const ValueKey('live_ride_recenter_button'),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
padding: EdgeInsets.all(isCompact ? 6 : 8),
|
||||
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white70,
|
||||
size: isCompact ? 18 : 20,
|
||||
),
|
||||
tooltip: 'Center on Me',
|
||||
onPressed: () {
|
||||
if (broadcastState.lastPosition != null) {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
broadcastState.lastPosition!.latitude,
|
||||
broadcastState.lastPosition!.longitude,
|
||||
);
|
||||
_mapZoom = 15.0;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
ride.meetingPoint.latitude,
|
||||
ride.meetingPoint.longitude,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(width: isCompact ? 4 : 6),
|
||||
|
||||
// Roster Opener Button
|
||||
ElevatedButton.icon(
|
||||
key: const ValueKey('live_ride_roster_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 10,
|
||||
vertical: isCompact ? 6 : 8,
|
||||
),
|
||||
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
|
||||
),
|
||||
icon: const Icon(Icons.format_list_bulleted, size: 16),
|
||||
label: Text(
|
||||
'${participants.length}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () => _showRosterSheet(
|
||||
context: context,
|
||||
participants: participants,
|
||||
presences: presences,
|
||||
leadPresence: leadPresence,
|
||||
currentUid: currentUid,
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2211,16 +2171,16 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.stream, size: 13, color: colors.cyberCyan),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: Text(
|
||||
'PACK TELEMETRY STREAM',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan,
|
||||
fontSize: 10,
|
||||
|
|
|
|||
|
|
@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
|||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 600,
|
||||
mainAxisExtent: 196,
|
||||
mainAxisExtent: 175,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: rides.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildRideCard(context, rides[index], colors, isGrid: true);
|
||||
return _buildRideCard(context, rides[index], colors);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -154,12 +154,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildRideCard(
|
||||
BuildContext context,
|
||||
Ride ride,
|
||||
MotorSignalThemeColors colors, {
|
||||
bool isGrid = false,
|
||||
}) {
|
||||
Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) {
|
||||
final bikeName = (ride.motorcycleYear != null &&
|
||||
ride.motorcycleMake != null &&
|
||||
ride.motorcycleModel != null)
|
||||
|
|
@ -174,7 +169,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
|||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: isGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6),
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
bool _showCornering = true;
|
||||
|
||||
double _currentZoom = 17.0;
|
||||
bool _isNorthUp = true;
|
||||
String _destinationQuery = '';
|
||||
late final TextEditingController _searchController;
|
||||
Timer? _searchDebounce;
|
||||
|
|
@ -906,7 +907,6 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
|
||||
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||
final layerSettings = ref.watch(mapLayerSettingsProvider);
|
||||
final isNorthUp = ref.watch(mapOrientationProvider) == MapOrientation.northUp;
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
|
|
@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
),
|
||||
),
|
||||
|
||||
// Bottom Center: FIRE OFF REPLAY floating button (portrait only; in landscape play_replay_appbar_button is used)
|
||||
if (_routePoints.isNotEmpty && !responsive.isLandscape)
|
||||
// Bottom Center: FIRE OFF REPLAY floating button
|
||||
if (_routePoints.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 20,
|
||||
|
|
@ -1084,7 +1084,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
final motorcycleMarker = AppMapMarker(
|
||||
id: 'motorcycle_live',
|
||||
position: currentPos,
|
||||
heading: isNorthUp ? currentHeading : 0.0,
|
||||
heading: _isNorthUp ? currentHeading : 0.0,
|
||||
iconWidget: Icon(
|
||||
Icons.navigation,
|
||||
color: colors.cyberCyan,
|
||||
|
|
@ -1189,7 +1189,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
key: const ValueKey('live_map_view'),
|
||||
center: currentPos,
|
||||
zoom: _currentZoom,
|
||||
bearing: isNorthUp ? 0.0 : currentHeading,
|
||||
bearing: _isNorthUp ? 0.0 : currentHeading,
|
||||
markers: mapMarkers,
|
||||
polylines: livePolylines,
|
||||
),
|
||||
|
|
@ -1280,10 +1280,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
const SizedBox(height: 8),
|
||||
_HudIconButton(
|
||||
key: const ValueKey('compass_orient_button'),
|
||||
icon: isNorthUp ? Icons.explore : Icons.navigation,
|
||||
tooltip: isNorthUp ? 'North Up' : 'Heading Up',
|
||||
color: isNorthUp ? Colors.white70 : colors.cyberCyan,
|
||||
onTap: () => ref.read(mapOrientationProvider.notifier).toggle(),
|
||||
icon: _isNorthUp ? Icons.explore : Icons.navigation,
|
||||
tooltip: _isNorthUp ? 'North Up' : 'Heading Up',
|
||||
color: _isNorthUp ? Colors.white70 : colors.cyberCyan,
|
||||
onTap: () => setState(() => _isNorthUp = !_isNorthUp),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_HudIconButton(
|
||||
|
|
|
|||
|
|
@ -182,23 +182,21 @@ class _SetupView extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLandscape = snapshot.isLandscape;
|
||||
final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport;
|
||||
|
||||
if (isLandscape) {
|
||||
if (isShortLandscape) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildModeSelector(context, isLandscape: true),
|
||||
const SizedBox(height: 8),
|
||||
_buildTargetSelector(context, isLandscape: true),
|
||||
_buildModeSelector(context),
|
||||
const SizedBox(height: 12),
|
||||
_buildTargetSelector(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -206,13 +204,12 @@ class _SetupView extends StatelessWidget {
|
|||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildExplanationCard(context, isLandscape: true),
|
||||
const SizedBox(height: 10),
|
||||
_buildArmButton(context, isLandscape: true),
|
||||
_buildExplanationCard(context),
|
||||
const SizedBox(height: 12),
|
||||
_buildArmButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -239,7 +236,7 @@ class _SetupView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildModeSelector(BuildContext context, {bool isLandscape = false}) {
|
||||
Widget _buildModeSelector(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -247,13 +244,13 @@ class _SetupView extends StatelessWidget {
|
|||
'START MODE',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.0,
|
||||
color: isDark ? Colors.white60 : Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 6 : 8),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -266,7 +263,6 @@ class _SetupView extends StatelessWidget {
|
|||
onTap: () => onModeChanged(PerformanceLaunchMode.auto),
|
||||
colors: colors,
|
||||
isDark: isDark,
|
||||
isLandscape: isLandscape,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -280,7 +276,6 @@ class _SetupView extends StatelessWidget {
|
|||
onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
|
||||
colors: colors,
|
||||
isDark: isDark,
|
||||
isLandscape: isLandscape,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -289,7 +284,7 @@ class _SetupView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTargetSelector(BuildContext context, {bool isLandscape = false}) {
|
||||
Widget _buildTargetSelector(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -297,13 +292,13 @@ class _SetupView extends StatelessWidget {
|
|||
'BENCHMARK TARGET',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.0,
|
||||
color: isDark ? Colors.white60 : Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 6 : 8),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -316,7 +311,6 @@ class _SetupView extends StatelessWidget {
|
|||
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
|
||||
colors: colors,
|
||||
isDark: isDark,
|
||||
isLandscape: isLandscape,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -330,7 +324,6 @@ class _SetupView extends StatelessWidget {
|
|||
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
|
||||
colors: colors,
|
||||
isDark: isDark,
|
||||
isLandscape: isLandscape,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -339,13 +332,13 @@ class _SetupView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildExplanationCard(BuildContext context, {bool isLandscape = false}) {
|
||||
Widget _buildExplanationCard(BuildContext context) {
|
||||
final text = selectedMode == PerformanceLaunchMode.auto
|
||||
? '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.';
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isLandscape ? 10 : 14),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -356,17 +349,17 @@ class _SetupView extends StatelessWidget {
|
|||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: isLandscape ? 18 : 20,
|
||||
size: 20,
|
||||
color: colors.velocityBlue,
|
||||
),
|
||||
SizedBox(width: isLandscape ? 8 : 10),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: isLandscape ? 11.5 : 13,
|
||||
height: 1.3,
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: isDark ? Colors.white70 : Colors.black87,
|
||||
),
|
||||
),
|
||||
|
|
@ -376,16 +369,15 @@ class _SetupView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildArmButton(BuildContext context, {bool isLandscape = false}) {
|
||||
Widget _buildArmButton(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: isLandscape ? 44 : 52,
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
key: const ValueKey('btn_arm_launch'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.launchGreen,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
elevation: 2,
|
||||
),
|
||||
onPressed: onArm,
|
||||
|
|
@ -393,7 +385,7 @@ class _SetupView extends StatelessWidget {
|
|||
'Arm Launch',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
|
|
@ -411,7 +403,6 @@ class _SelectionButton extends StatelessWidget {
|
|||
final VoidCallback onTap;
|
||||
final MotorSignalThemeColors colors;
|
||||
final bool isDark;
|
||||
final bool isLandscape;
|
||||
|
||||
const _SelectionButton({
|
||||
super.key,
|
||||
|
|
@ -422,7 +413,6 @@ class _SelectionButton extends StatelessWidget {
|
|||
required this.onTap,
|
||||
required this.colors,
|
||||
required this.isDark,
|
||||
this.isLandscape = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -438,10 +428,7 @@ class _SelectionButton extends StatelessWidget {
|
|||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 8 : 12,
|
||||
vertical: isLandscape ? 8 : 12,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -452,31 +439,27 @@ class _SelectionButton extends StatelessWidget {
|
|||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: isLandscape ? 18 : 22,
|
||||
size: 22,
|
||||
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 4 : 8),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 13 : 15,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: isLandscape ? 10 : 11,
|
||||
fontSize: 11,
|
||||
color: isDark ? Colors.white60 : Colors.black54,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -525,48 +508,44 @@ class _WaitingView extends ConsumerWidget {
|
|||
statusColor = colors.velocityBlue;
|
||||
}
|
||||
|
||||
final isLandscape = snapshot.isLandscape;
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.all(isLandscape ? 16 : 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor),
|
||||
SizedBox(height: isLandscape ? 10 : 20),
|
||||
Text(
|
||||
statusText,
|
||||
key: const ValueKey('waiting_status_text'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 15 : 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.0,
|
||||
color: statusColor,
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(statusIcon, size: 54, color: statusColor),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
statusText,
|
||||
key: const ValueKey('waiting_status_text'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.0,
|
||||
color: statusColor,
|
||||
),
|
||||
SizedBox(height: isLandscape ? 6 : 12),
|
||||
Text(
|
||||
'Current speed: ${speed.toStringAsFixed(1)} MPH',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: isLandscape ? 12 : 14,
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Current speed: ${speed.toStringAsFixed(1)} MPH',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
SizedBox(height: isLandscape ? 16 : 32),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('btn_cancel_waiting'),
|
||||
onPressed: () {
|
||||
ref.read(performanceLaunchProvider.notifier).reset();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('btn_cancel_waiting'),
|
||||
onPressed: () {
|
||||
ref.read(performanceLaunchProvider.notifier).reset();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -591,118 +570,108 @@ class _ReadyView extends ConsumerWidget {
|
|||
final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode));
|
||||
final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
|
||||
final isCountdown = mode == PerformanceLaunchMode.countdown;
|
||||
final isLandscape = snapshot.isLandscape;
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 32 : 20,
|
||||
vertical: isLandscape ? 8 : 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 16 : 24,
|
||||
vertical: isLandscape ? 4 : 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.launchGreen.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.launchGreen, width: 2.0),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: colors.launchGreen, size: isLandscape ? 20 : 28),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'READY',
|
||||
key: const ValueKey('ready_status_badge'),
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 20 : 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 2.0,
|
||||
color: colors.launchGreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.launchGreen.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.launchGreen, width: 2.0),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 8 : 20),
|
||||
Text(
|
||||
target.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 18 : 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 4 : 10),
|
||||
Text(
|
||||
isCountdown
|
||||
? 'Tap below to initiate countdown'
|
||||
: 'Accelerate when ready',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: isLandscape ? 13 : 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 12 : 40),
|
||||
if (isCountdown) ...[
|
||||
SizedBox(
|
||||
height: isLandscape ? 44 : 56,
|
||||
child: ElevatedButton(
|
||||
key: const ValueKey('btn_start_countdown'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.velocityBlue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(performanceLaunchProvider.notifier).startCountdown();
|
||||
},
|
||||
child: const Text(
|
||||
'Start Countdown',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: colors.launchGreen, size: 28),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'READY',
|
||||
key: const ValueKey('ready_status_badge'),
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 2.0,
|
||||
color: colors.launchGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: isLandscape ? 8 : 16),
|
||||
],
|
||||
Center(
|
||||
child: TextButton(
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
target.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
isCountdown
|
||||
? 'Tap below to initiate countdown'
|
||||
: 'Accelerate when ready',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
if (isCountdown) ...[
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
key: const ValueKey('btn_start_countdown'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.velocityBlue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(performanceLaunchProvider.notifier).reset();
|
||||
ref.read(performanceLaunchProvider.notifier).startCountdown();
|
||||
},
|
||||
child: Text(
|
||||
'Cancel & Change Setup',
|
||||
child: const Text(
|
||||
'Start Countdown',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white60 : Colors.black54,
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
ref.read(performanceLaunchProvider.notifier).reset();
|
||||
},
|
||||
child: Text(
|
||||
'Cancel & Change Setup',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white60 : Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import '../../database/database.dart';
|
|||
import '../../services/map/map_provider_interface.dart';
|
||||
import '../../services/map/unified_map_view.dart';
|
||||
import '../../core/theme/theme_extensions.dart';
|
||||
import '../../core/responsive/responsive_layout.dart';
|
||||
|
||||
/// Corner performance metrics and apex grading for forensic telemetry replay.
|
||||
class CornerAnalysis {
|
||||
|
|
@ -475,120 +474,117 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: ResponsiveLayout(
|
||||
builder: (context, snapshot) {
|
||||
final isLandscape = snapshot.isLandscape;
|
||||
|
||||
final mapWidget = Stack(
|
||||
children: [
|
||||
UnifiedMapView(
|
||||
center: currentPos,
|
||||
zoom: _zoom,
|
||||
markers: allMarkers,
|
||||
polylines: polylines,
|
||||
isFollowModeOverride: _isFollowingRider,
|
||||
onUserInteraction: () {
|
||||
if (_isFollowingRider) {
|
||||
setState(() => _isFollowingRider = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
||||
Positioned(
|
||||
right: isLandscape ? 10 : 14,
|
||||
bottom: isLandscape ? 10 : 14,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_isFollowingRider) ...[
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_recenter_button'),
|
||||
icon: Icons.my_location,
|
||||
tooltip: 'Recenter on Rider',
|
||||
color: colors.cyberCyan,
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
||||
isCompact: isLandscape,
|
||||
onTap: () => setState(() => _isFollowingRider = true),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 6 : 8),
|
||||
],
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_zoom_in_button'),
|
||||
icon: Icons.add,
|
||||
tooltip: 'Zoom In',
|
||||
isCompact: isLandscape,
|
||||
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 6 : 8),
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_zoom_out_button'),
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Zoom Out',
|
||||
isCompact: isLandscape,
|
||||
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
||||
),
|
||||
],
|
||||
body: Column(
|
||||
children: [
|
||||
// Map View (with multi-color lean heatmap and telemetry HUD overlay)
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Stack(
|
||||
children: [
|
||||
UnifiedMapView(
|
||||
center: currentPos,
|
||||
zoom: _zoom,
|
||||
markers: allMarkers,
|
||||
polylines: polylines,
|
||||
isFollowModeOverride: _isFollowingRider,
|
||||
onUserInteraction: () {
|
||||
if (_isFollowingRider) {
|
||||
setState(() => _isFollowingRider = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// Telemetry Overlay
|
||||
Positioned(
|
||||
top: isLandscape ? 8 : 14,
|
||||
left: isLandscape ? 8 : 14,
|
||||
right: isLandscape ? 8 : 14,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph', isLandscape: isLandscape),
|
||||
SizedBox(width: isLandscape ? 6 : 8),
|
||||
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°', isLandscape: isLandscape),
|
||||
SizedBox(width: isLandscape ? 6 : 8),
|
||||
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G', isLandscape: isLandscape),
|
||||
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
||||
Positioned(
|
||||
right: 14,
|
||||
bottom: 14,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_isFollowingRider) ...[
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_recenter_button'),
|
||||
icon: Icons.my_location,
|
||||
tooltip: 'Recenter on Rider',
|
||||
color: colors.cyberCyan,
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
||||
onTap: () => setState(() => _isFollowingRider = true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_zoom_in_button'),
|
||||
icon: Icons.add,
|
||||
tooltip: 'Zoom In',
|
||||
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ReplayMapButton(
|
||||
key: const ValueKey('replay_zoom_out_button'),
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Zoom Out',
|
||||
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Heatmap Legend Overlay
|
||||
Positioned(
|
||||
bottom: isLandscape ? 8 : 10,
|
||||
left: isLandscape ? 8 : 14,
|
||||
right: isLandscape ? 60 : 14,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
// Telemetry Overlay
|
||||
Positioned(
|
||||
top: 14,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildLegendItem(Colors.amberAccent, '≤20°'),
|
||||
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
|
||||
const SizedBox(width: 8),
|
||||
_buildLegendItem(Colors.cyanAccent, '20°-35°'),
|
||||
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
|
||||
const SizedBox(width: 8),
|
||||
_buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
|
||||
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final playbackControlsBar = Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 12 : 16,
|
||||
vertical: isLandscape ? 4 : 8,
|
||||
// Heatmap Legend Overlay
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildLegendItem(Colors.amberAccent, '≤20°'),
|
||||
const SizedBox(width: 8),
|
||||
_buildLegendItem(Colors.cyanAccent, '20°-35°'),
|
||||
const SizedBox(width: 8),
|
||||
_buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Playback Controls Bar
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -600,10 +596,10 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
child: Text(
|
||||
currentSample.timestamp.toLocal().toString().split('.')[0],
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.cyanAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontSize: 12,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
),
|
||||
|
|
@ -613,42 +609,30 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: isLandscape ? 10 : 11,
|
||||
fontSize: 11,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
min: 0,
|
||||
max: maxSliderValue,
|
||||
onChanged: _telemetry.length > 1
|
||||
? (val) {
|
||||
setState(() {
|
||||
_currentIndex = val.toInt();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
activeColor: Colors.cyanAccent,
|
||||
),
|
||||
Slider(
|
||||
value: currentSliderValue,
|
||||
min: 0,
|
||||
max: maxSliderValue,
|
||||
onChanged: _telemetry.length > 1
|
||||
? (val) {
|
||||
setState(() {
|
||||
_currentIndex = val.toInt();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
activeColor: Colors.cyanAccent,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
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),
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
tooltip: 'Restart',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
|
|
@ -656,46 +640,35 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
});
|
||||
},
|
||||
),
|
||||
SizedBox(width: isLandscape ? 8 : 16),
|
||||
const SizedBox(width: 16),
|
||||
FloatingActionButton.small(
|
||||
onPressed: _togglePlayback,
|
||||
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: isLandscape ? 18 : 20),
|
||||
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
),
|
||||
SizedBox(width: isLandscape ? 8 : 16),
|
||||
const SizedBox(width: 16),
|
||||
TextButton(
|
||||
style: isLandscape
|
||||
? TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
)
|
||||
: null,
|
||||
onPressed: _changeSpeed,
|
||||
child: Text(
|
||||
'${_playbackSpeed}x',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: isLandscape ? 12 : 14,
|
||||
),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
|
||||
Widget buildCornerTable() {
|
||||
return Container(
|
||||
// Corner Entry / Apex / Exit Breakdown Table
|
||||
Expanded(
|
||||
flex: _corners.isEmpty ? 3 : 4,
|
||||
child: Container(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 10 : 14,
|
||||
vertical: isLandscape ? 6 : 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -705,7 +678,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
'CORNER APEX BREAKDOWN & GRADING',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 10 : 11,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: colors.cyberCyan,
|
||||
|
|
@ -717,7 +690,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
Text(
|
||||
_corners.isEmpty ? '' : 'Tap row to seek apex',
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 9 : 10,
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
|
|
@ -739,7 +712,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
hasLeanData
|
||||
? Icons.straighten_rounded
|
||||
: Icons.sensors_off_rounded,
|
||||
size: isLandscape ? 22 : 26,
|
||||
size: 26,
|
||||
color: hasLeanData
|
||||
? colors.cyberCyan.withValues(alpha: 0.7)
|
||||
: colors.warningAmber.withValues(alpha: 0.8),
|
||||
|
|
@ -751,7 +724,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
|
||||
fontFamily: 'JetBrains Mono',
|
||||
|
|
@ -759,14 +732,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: isLandscape ? 320 : 280),
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(
|
||||
hasLeanData
|
||||
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
|
||||
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 10 : 11,
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
|
|
@ -798,49 +771,43 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 10 : 14,
|
||||
vertical: isLandscape ? 6 : 9,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
color: isCurrent
|
||||
? colors.cyberCyan.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
// Corner ID & Direction
|
||||
SizedBox(
|
||||
width: isLandscape ? 65 : 75,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'T${corner.id}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: isLandscape ? 12 : 13,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Corner ID & Direction
|
||||
SizedBox(
|
||||
width: 75,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'T${corner.id}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 13,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 10 : 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: corner.direction == 'Left'
|
||||
? Colors.blueAccent
|
||||
: Colors.greenAccent,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: corner.direction == 'Left'
|
||||
? Colors.blueAccent
|
||||
: Colors.greenAccent,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Entry Speed
|
||||
Column(
|
||||
),
|
||||
// Entry Speed
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -853,16 +820,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
),
|
||||
Text(
|
||||
'${corner.entrySpeed.toStringAsFixed(0)} mph',
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: isLandscape ? 10 : 14),
|
||||
// Apex Speed & Lean
|
||||
Column(
|
||||
),
|
||||
// Apex Speed & Lean
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -876,7 +841,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
Text(
|
||||
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.cyberCyan,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
|
|
@ -884,9 +849,10 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: isLandscape ? 10 : 14),
|
||||
// Exit Speed & Delta
|
||||
Column(
|
||||
),
|
||||
// Exit Speed & Delta
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -900,34 +866,33 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
Text(
|
||||
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 11 : 12,
|
||||
fontSize: 12,
|
||||
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: isLandscape ? 10 : 14),
|
||||
// Grade Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: corner.getGradeColor().withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
corner.grade,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: corner.getGradeColor(),
|
||||
),
|
||||
),
|
||||
// Grade Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: corner.getGradeColor().withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
corner.grade,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
color: corner.getGradeColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -936,46 +901,9 @@ 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -998,28 +926,18 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildOverlayStat(String label, String value, {bool isLandscape = false}) {
|
||||
Widget _buildOverlayStat(String label, String value) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 10 : 16,
|
||||
vertical: isLandscape ? 4 : 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(isLandscape ? 8 : 12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: isLandscape ? 8.5 : 10, color: Colors.white70)),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isLandscape ? 15 : 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.cyanAccent,
|
||||
),
|
||||
),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)),
|
||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -1032,7 +950,6 @@ class _ReplayMapButton extends StatelessWidget {
|
|||
final VoidCallback onTap;
|
||||
final Color? color;
|
||||
final Color? backgroundColor;
|
||||
final bool isCompact;
|
||||
|
||||
const _ReplayMapButton({
|
||||
super.key,
|
||||
|
|
@ -1041,13 +958,10 @@ class _ReplayMapButton extends StatelessWidget {
|
|||
required this.onTap,
|
||||
this.color,
|
||||
this.backgroundColor,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = isCompact ? 36.0 : 48.0;
|
||||
final iconSize = isCompact ? 18.0 : 24.0;
|
||||
return Material(
|
||||
color: backgroundColor ?? Colors.black87,
|
||||
shape: RoundedRectangleBorder(
|
||||
|
|
@ -1060,12 +974,12 @@ class _ReplayMapButton extends StatelessWidget {
|
|||
child: Tooltip(
|
||||
message: tooltip,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color ?? Colors.white,
|
||||
size: iconSize,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import '../../core/responsive/viewport_size.dart';
|
|||
import '../../core/theme/theme_extensions.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import '../../services/map/map_provider_interface.dart';
|
||||
import '../../services/map/map_providers.dart';
|
||||
import '../../services/map/unified_map_view.dart';
|
||||
import '../../services/routes/navigation_handoff.dart';
|
||||
import '../../services/routes/route_models.dart';
|
||||
|
|
@ -44,8 +43,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||
_loadInitialData();
|
||||
});
|
||||
}
|
||||
|
|
@ -151,33 +148,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
context: context,
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final isCompactDialog = screenSize.height < 480;
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
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(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
|
|
@ -189,8 +161,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -225,7 +196,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Stop Name *',
|
||||
|
|
@ -596,16 +566,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const maxAllowed = RouteService.maxTechnicalStops;
|
||||
final isAtLimit = state.stops.length >= maxAllowed;
|
||||
|
||||
final lastKnownLocation = ref.watch(routePlannerLocationProvider).valueOrNull;
|
||||
|
||||
// Route context takes priority over cached rider location and the US overview.
|
||||
// Center map on road geometry, first stop, or default
|
||||
final mapCenter = state.geometry.isNotEmpty
|
||||
? state.geometry.first
|
||||
: (state.stops.isNotEmpty
|
||||
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
|
||||
: lastKnownLocation ?? const AppLatLng(39.5, -98.35));
|
||||
final mapZoom = state.geometry.isNotEmpty || state.stops.isNotEmpty || lastKnownLocation != null
|
||||
? 12.0 : 3.0;
|
||||
: const AppLatLng(37.7749, -122.4194));
|
||||
|
||||
// Build markers
|
||||
final markers = state.stops.map((stop) {
|
||||
|
|
@ -663,14 +629,11 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
elevation: 0,
|
||||
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
|
||||
onPressed: () => context.pop(),
|
||||
|
|
@ -719,18 +682,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
final isNarrow = snapshot.width < 360;
|
||||
final isExpandedLayout =
|
||||
snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||
(snapshot.isLandscape && snapshot.width >= 600);
|
||||
(snapshot.isLandscape && snapshot.width >= 720);
|
||||
|
||||
if (isExpandedLayout) {
|
||||
return _buildExpandedLayout(
|
||||
context,
|
||||
colors,
|
||||
snapshot,
|
||||
state,
|
||||
maxAllowed,
|
||||
isAtLimit,
|
||||
mapCenter,
|
||||
mapZoom,
|
||||
markers,
|
||||
polylines,
|
||||
isNarrow,
|
||||
|
|
@ -745,7 +706,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
maxAllowed,
|
||||
isAtLimit,
|
||||
mapCenter,
|
||||
mapZoom,
|
||||
markers,
|
||||
polylines,
|
||||
isNarrow,
|
||||
|
|
@ -758,20 +718,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
Widget _buildExpandedLayout(
|
||||
BuildContext context,
|
||||
MotorSignalThemeColors colors,
|
||||
ResponsiveSnapshot snapshot,
|
||||
RoutePlannerState state,
|
||||
int maxAllowed,
|
||||
bool isAtLimit,
|
||||
AppLatLng mapCenter,
|
||||
double mapZoom,
|
||||
List<AppMapMarker> markers,
|
||||
List<AppPolyline> polylines,
|
||||
bool isNarrow,
|
||||
) {
|
||||
final isCompactHeight = snapshot.height < 500;
|
||||
final isVeryCompactHeight = snapshot.height < 360;
|
||||
final showDisclaimer = snapshot.height >= 260;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
@ -789,29 +743,22 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
UnifiedMapView(
|
||||
key: const ValueKey('route_planner_map_expanded'),
|
||||
center: mapCenter,
|
||||
zoom: mapZoom,
|
||||
zoom: 12.0,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
),
|
||||
Positioned(
|
||||
top: isCompactHeight ? 6 : 12,
|
||||
left: isCompactHeight ? 8 : 12,
|
||||
right: isCompactHeight ? 8 : 12,
|
||||
child: _buildHudOverlay(
|
||||
colors,
|
||||
state,
|
||||
maxAllowed,
|
||||
isAtLimit,
|
||||
isCompactHeight,
|
||||
),
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: _buildDisclaimerText(),
|
||||
),
|
||||
if (showDisclaimer)
|
||||
Positioned(
|
||||
bottom: 6,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: _buildDisclaimerText(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -820,56 +767,23 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
// Right Column: Form inputs + Stop list
|
||||
Expanded(
|
||||
flex: 6,
|
||||
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: [
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildRouteInfoInputs(colors, false),
|
||||
),
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: _buildStopControls(colors, state, isNarrow, false),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStopsList(colors, state, false, false),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildRouteInfoInputs(colors, false),
|
||||
),
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: _buildStopControls(colors, state, isNarrow, false),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStopsList(colors, state, false, false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -883,7 +797,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
int maxAllowed,
|
||||
bool isAtLimit,
|
||||
AppLatLng mapCenter,
|
||||
double mapZoom,
|
||||
List<AppMapMarker> markers,
|
||||
List<AppPolyline> polylines,
|
||||
bool isNarrow,
|
||||
|
|
@ -916,65 +829,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
showDisclaimer = false;
|
||||
}
|
||||
|
||||
if (isCompactHeight) {
|
||||
return _buildStopsList(
|
||||
colors,
|
||||
state,
|
||||
true,
|
||||
isVeryCompactHeight,
|
||||
header: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
if (mapHeight > 0)
|
||||
SizedBox(
|
||||
height: mapHeight,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
UnifiedMapView(
|
||||
key: const ValueKey('route_planner_map_compact'),
|
||||
center: mapCenter,
|
||||
zoom: 12.0,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: _buildHudOverlay(
|
||||
colors,
|
||||
state,
|
||||
maxAllowed,
|
||||
isAtLimit,
|
||||
true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: _buildRouteInfoInputs(colors, true),
|
||||
),
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: _buildStopControls(colors, state, isNarrow, true),
|
||||
),
|
||||
if (state.stops.isEmpty)
|
||||
_buildEmptyStopsContent(
|
||||
colors,
|
||||
true,
|
||||
isVeryCompactHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
|
|
@ -989,12 +843,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
UnifiedMapView(
|
||||
key: const ValueKey('route_planner_map_compact'),
|
||||
center: mapCenter,
|
||||
zoom: mapZoom,
|
||||
zoom: 12.0,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
top: isCompactHeight ? 4 : 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: _buildHudOverlay(
|
||||
|
|
@ -1002,7 +856,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
state,
|
||||
maxAllowed,
|
||||
isAtLimit,
|
||||
false,
|
||||
isCompactHeight,
|
||||
),
|
||||
),
|
||||
if (showDisclaimer)
|
||||
|
|
@ -1018,16 +872,24 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
|
||||
// Route Info Inputs
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: _buildRouteInfoInputs(colors, false),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
isCompactHeight ? 4 : 12,
|
||||
16,
|
||||
isCompactHeight ? 2 : 4,
|
||||
),
|
||||
child: _buildRouteInfoInputs(colors, isCompactHeight),
|
||||
),
|
||||
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
|
||||
// Stop List Controls
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: _buildStopControls(colors, state, isNarrow, false),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: isCompactHeight ? 4 : 8,
|
||||
),
|
||||
child: _buildStopControls(colors, state, isNarrow, isCompactHeight),
|
||||
),
|
||||
|
||||
// Reorderable Stop List
|
||||
|
|
@ -1035,7 +897,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
child: _buildStopsList(
|
||||
colors,
|
||||
state,
|
||||
false,
|
||||
isCompactHeight,
|
||||
isVeryCompactHeight,
|
||||
),
|
||||
),
|
||||
|
|
@ -1210,7 +1072,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -1302,17 +1163,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
colors: colors,
|
||||
isCompact: isCompactHeight,
|
||||
),
|
||||
SizedBox(height: isCompactHeight ? 6 : 8),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
key: const ValueKey('route_planner_add_stop_button'),
|
||||
|
|
@ -1322,20 +1181,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: EdgeInsets.symmetric(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: isCompactHeight ? 6 : 8,
|
||||
vertical: 8,
|
||||
),
|
||||
minimumSize: isCompactHeight ? const Size(0, 34) : null,
|
||||
tapTargetSize: isCompactHeight
|
||||
? MaterialTapTargetSize.shrinkWrap
|
||||
: null,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
key: const ValueKey('route_planner_current_gps_button'),
|
||||
onPressed: _addCurrentLocationStop,
|
||||
|
|
@ -1350,14 +1206,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
padding: EdgeInsets.symmetric(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: isCompactHeight ? 6 : 8,
|
||||
vertical: 8,
|
||||
),
|
||||
minimumSize: isCompactHeight ? const Size(0, 34) : null,
|
||||
tapTargetSize: isCompactHeight
|
||||
? MaterialTapTargetSize.shrinkWrap
|
||||
: null,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
|
@ -1396,74 +1248,56 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyStopsContent(
|
||||
MotorSignalThemeColors colors,
|
||||
bool isCompactHeight,
|
||||
bool isVeryCompactHeight,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isVeryCompactHeight) ...[
|
||||
Icon(
|
||||
Icons.alt_route,
|
||||
size: isCompactHeight ? 28 : 48,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.2),
|
||||
),
|
||||
SizedBox(height: isCompactHeight ? 4 : 12),
|
||||
],
|
||||
Text(
|
||||
'No stops added yet.',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: isCompactHeight ? 13 : 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: isCompactHeight ? 11 : 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStopsList(
|
||||
MotorSignalThemeColors colors,
|
||||
RoutePlannerState state,
|
||||
bool isCompactHeight,
|
||||
bool isVeryCompactHeight, {
|
||||
Widget? header,
|
||||
}) {
|
||||
bool isVeryCompactHeight,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
if (header == null && state.stops.isEmpty) {
|
||||
if (state.stops.isEmpty) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: _buildEmptyStopsContent(
|
||||
colors,
|
||||
isCompactHeight,
|
||||
isVeryCompactHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isVeryCompactHeight) ...[
|
||||
Icon(
|
||||
Icons.alt_route,
|
||||
size: isCompactHeight ? 32 : 48,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.2),
|
||||
),
|
||||
SizedBox(height: isCompactHeight ? 6 : 12),
|
||||
],
|
||||
Text(
|
||||
'No stops added yet.',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ReorderableListView.builder(
|
||||
header: header,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
|
|
|
|||
|
|
@ -302,7 +302,6 @@ class _AddressAutocompleteFieldState
|
|||
focusNode: _focusNode,
|
||||
enabled: widget.enabled && !_isResolving,
|
||||
autofocus: widget.autofocus,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
style: TextStyle(color: onSurface, fontSize: 14),
|
||||
onChanged: _onTextChanged,
|
||||
validator: (val) {
|
||||
|
|
|
|||
|
|
@ -353,10 +353,8 @@ class _ClubDetailScreenState extends ConsumerState<ClubDetailScreen> {
|
|||
),
|
||||
_buildPill(
|
||||
icon: club.admissionPolicy == ClubAdmissionPolicy.open
|
||||
? Icons.group_add_outlined
|
||||
: (club.admissionPolicy == ClubAdmissionPolicy.request
|
||||
? Icons.how_to_reg_outlined
|
||||
: Icons.mail_outline),
|
||||
? Icons.lock_open_outlined
|
||||
: Icons.how_to_reg_outlined,
|
||||
label: club.admissionPolicy.label,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
|
|
|
|||
|
|
@ -119,9 +119,9 @@ class ClubMembershipPresentation {
|
|||
);
|
||||
}
|
||||
|
||||
// Root ownership remains authoritative when the member record is missing or stale.
|
||||
if (club.ownerUid == currentUserId || member?.isActive == true) {
|
||||
final isOwner = member?.role == ClubRole.owner || club.ownerUid == currentUserId;
|
||||
// Authenticated user with an active membership
|
||||
if (member != null && member.status == ClubMemberStatus.active) {
|
||||
final isOwner = member.role == ClubRole.owner || club.ownerUid == currentUserId;
|
||||
if (isOwner) {
|
||||
return const ClubMembershipPresentation(
|
||||
state: ClubMembershipPresentationState.activeOwner,
|
||||
|
|
@ -135,7 +135,7 @@ class ClubMembershipPresentation {
|
|||
);
|
||||
}
|
||||
|
||||
if (member?.role == ClubRole.admin) {
|
||||
if (member.role == ClubRole.admin) {
|
||||
return const ClubMembershipPresentation(
|
||||
state: ClubMembershipPresentationState.activeAdmin,
|
||||
statusBadge: 'ADMIN',
|
||||
|
|
|
|||
|
|
@ -356,45 +356,11 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
|||
),
|
||||
),
|
||||
),
|
||||
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: 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],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
for (final member in memberships)
|
||||
ClubMemberCard(
|
||||
key: ValueKey('my_club_${member.clubId}'),
|
||||
member: member,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
@ -605,19 +571,9 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
|||
),
|
||||
],
|
||||
),
|
||||
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!,
|
||||
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
ClubCard(
|
||||
club: _searchedClub!,
|
||||
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -675,46 +631,16 @@ 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(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: clubs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final club = clubs[index];
|
||||
return ClubCard(
|
||||
key: ValueKey('discover_club_${club.id}'),
|
||||
club: club,
|
||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||
);
|
||||
},
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: clubs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final club = clubs[index];
|
||||
return ClubCard(
|
||||
key: ValueKey('discover_club_${club.id}'),
|
||||
club: club,
|
||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -178,17 +178,12 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
final mediaSize = MediaQuery.sizeOf(context);
|
||||
final isCompactScreen = mediaSize.height < 500;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: isCompactScreen ? 44 : null,
|
||||
title: Text(
|
||||
title: const Text(
|
||||
'Create Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isCompactScreen ? 16 : null,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
|
|
@ -196,419 +191,332 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
|||
body: SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
final isCompact =
|
||||
responsive.isLandscape || responsive.isShortViewport;
|
||||
final isTwoColumn =
|
||||
responsive.isLandscape && responsive.width >= 600;
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
final nameField = TextFormField(
|
||||
controller: _nameController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Club Name *',
|
||||
hintText: 'e.g. Ridge Runners MC',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.cyberCyan,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (val) {
|
||||
final clean = val?.trim() ?? '';
|
||||
if (clean.length < 3) {
|
||||
return 'Name must be at least 3 characters';
|
||||
}
|
||||
if (clean.length > 50) {
|
||||
return 'Name cannot exceed 50 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
final slugField = SlugInputField(
|
||||
controller: _slugController,
|
||||
enabled: !_isSubmitting,
|
||||
onValidationChanged: (isValid) {
|
||||
setState(() {
|
||||
_isSlugValidAndAvailable = isValid;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
final regionSection = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel(
|
||||
'Home base',
|
||||
'Where this club primarily rides or organizes from.',
|
||||
colors,
|
||||
),
|
||||
SizedBox(height: isCompact ? 6 : 8),
|
||||
TextFormField(
|
||||
controller: _regionController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Home region (optional)',
|
||||
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
|
||||
prefixIcon: const Icon(Icons.location_on_outlined),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final focusSection = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel(
|
||||
'Rider community',
|
||||
'Name the motorcycle types or riding style this club is for.',
|
||||
colors,
|
||||
),
|
||||
SizedBox(height: isCompact ? 6 : 8),
|
||||
TextFormField(
|
||||
controller: _focusController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Motorcycle focus (optional)',
|
||||
hintText: 'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
|
||||
prefixIcon: const Icon(Icons.two_wheeler_outlined),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final visibilitySection = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Club Visibility',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 6 : 8),
|
||||
_buildVisibilitySelector(colors, theme),
|
||||
],
|
||||
);
|
||||
|
||||
final admissionSection = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Admission Policy',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 6 : 8),
|
||||
_buildAdmissionSelector(colors, theme),
|
||||
],
|
||||
);
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 700),
|
||||
constraints: const BoxConstraints(maxWidth: 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,
|
||||
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: [
|
||||
// Header card explaining Club identity
|
||||
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
|
||||
if (_submissionError != null) ...[
|
||||
Container(
|
||||
padding: EdgeInsets.all(isCompact ? 10.0 : 14.0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
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,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Club Name *',
|
||||
hintText: 'e.g. Ridge Runners MC',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.cyberCyan,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 12 : 20),
|
||||
validator: (val) {
|
||||
final clean = val?.trim() ?? '';
|
||||
if (clean.length < 3) {
|
||||
return 'Name must be at least 3 characters';
|
||||
}
|
||||
if (clean.length > 50) {
|
||||
return 'Name cannot exceed 50 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// 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),
|
||||
),
|
||||
// Slug / Handle with live debounce & availability checking
|
||||
SlugInputField(
|
||||
controller: _slugController,
|
||||
enabled: !_isSubmitting,
|
||||
onValidationChanged: (isValid) {
|
||||
setState(() {
|
||||
_isSlugValidAndAvailable = isValid;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descController,
|
||||
enabled: !_isSubmitting,
|
||||
maxLines: 3,
|
||||
maxLength: 500,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description (Optional)',
|
||||
hintText:
|
||||
'Share your club mission, riding style, or club guidelines...',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
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,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.cyberCyan,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Give optional discovery fields enough room for a clear
|
||||
// label and example on narrow phone layouts.
|
||||
_buildFieldLabel(
|
||||
'Home base',
|
||||
'Where this club primarily rides or organizes from.',
|
||||
colors,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _regionController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Home region (optional)',
|
||||
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
|
||||
prefixIcon: const Icon(Icons.location_on_outlined),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_buildFieldLabel(
|
||||
'Rider community',
|
||||
'Name the motorcycle types or riding style this club is for.',
|
||||
colors,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _focusController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Motorcycle focus (optional)',
|
||||
hintText:
|
||||
'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
|
||||
prefixIcon: const Icon(Icons.two_wheeler_outlined),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Visibility Selection
|
||||
Text(
|
||||
'Club Visibility',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildVisibilitySelector(colors, theme),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Admission Policy Selection
|
||||
Text(
|
||||
'Admission Policy',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildAdmissionSelector(colors, theme),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Submit Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('create_club_submit_button'),
|
||||
onPressed:
|
||||
(_isSubmitting || !_isSlugValidAndAvailable)
|
||||
? null
|
||||
: _submitForm,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 12 : 16),
|
||||
],
|
||||
|
||||
// Club Name & Slug / Handle
|
||||
if (isTwoColumn)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: nameField),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: slugField),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
nameField,
|
||||
const SizedBox(height: 18),
|
||||
slugField,
|
||||
],
|
||||
SizedBox(height: isCompact ? 12 : 18),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descController,
|
||||
enabled: !_isSubmitting,
|
||||
maxLines: isCompact ? 2 : 3,
|
||||
maxLength: 500,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
scrollPadding:
|
||||
const EdgeInsets.only(bottom: 120, top: 24),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description (Optional)',
|
||||
hintText:
|
||||
'Share your club mission, riding style, or club guidelines...',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.outlineVariant,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colors.cyberCyan,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 10 : 14),
|
||||
|
||||
// Home base & Rider community
|
||||
if (isTwoColumn)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: regionSection),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: focusSection),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
regionSection,
|
||||
const SizedBox(height: 18),
|
||||
focusSection,
|
||||
],
|
||||
SizedBox(height: isCompact ? 14 : 24),
|
||||
|
||||
// Visibility & Admission Policy Selection
|
||||
if (isTwoColumn)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: visibilitySection),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: admissionSection),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
visibilitySection,
|
||||
const SizedBox(height: 20),
|
||||
admissionSection,
|
||||
],
|
||||
SizedBox(height: isCompact ? 16 : 32),
|
||||
|
||||
// Submit Button
|
||||
SizedBox(
|
||||
height: isCompact ? 46 : 50,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('create_club_submit_button'),
|
||||
onPressed:
|
||||
(_isSubmitting || !_isSlugValidAndAvailable)
|
||||
? null
|
||||
: _submitForm,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.black,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Create Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Create Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isCompact ? 12 : 32),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -625,19 +533,12 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
|||
) {
|
||||
return DropdownButtonFormField<ClubVisibility>(
|
||||
initialValue: _visibility,
|
||||
isExpanded: true,
|
||||
decoration: _choiceDecoration('Who can find this club', colors),
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
items: ClubVisibility.values
|
||||
.map(
|
||||
(value) => DropdownMenuItem(
|
||||
value: value,
|
||||
child: Text(
|
||||
value.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
(value) => DropdownMenuItem(value: value, child: Text(value.label)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: _isSubmitting
|
||||
|
|
@ -654,19 +555,12 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
|||
) {
|
||||
return DropdownButtonFormField<ClubAdmissionPolicy>(
|
||||
initialValue: _admissionPolicy,
|
||||
isExpanded: true,
|
||||
decoration: _choiceDecoration('How riders join', colors),
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
items: ClubAdmissionPolicy.values
|
||||
.map(
|
||||
(value) => DropdownMenuItem(
|
||||
value: value,
|
||||
child: Text(
|
||||
value.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
(value) => DropdownMenuItem(value: value, child: Text(value.label)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: _isSubmitting
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ class ClubCard extends StatelessWidget {
|
|||
final Club club;
|
||||
final ClubRole? role;
|
||||
final VoidCallback? onTap;
|
||||
final bool inGrid;
|
||||
|
||||
const ClubCard({
|
||||
super.key,
|
||||
required this.club,
|
||||
this.role,
|
||||
this.onTap,
|
||||
this.inGrid = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -26,7 +24,7 @@ class ClubCard extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
|
|
@ -129,8 +127,7 @@ class ClubCard extends StatelessWidget {
|
|||
),
|
||||
|
||||
// Admission policy badge
|
||||
if (role == null)
|
||||
_buildAdmissionBadge(context, club.admissionPolicy, colors),
|
||||
_buildAdmissionBadge(context, club.admissionPolicy, colors),
|
||||
|
||||
// Region badge if available
|
||||
if (club.homeRegion != null && club.homeRegion!.trim().isNotEmpty)
|
||||
|
|
@ -218,7 +215,7 @@ class ClubCard extends StatelessWidget {
|
|||
switch (policy) {
|
||||
case ClubAdmissionPolicy.open:
|
||||
color = colors.launchGreen;
|
||||
icon = Icons.group_add_outlined;
|
||||
icon = Icons.lock_open_outlined;
|
||||
break;
|
||||
case ClubAdmissionPolicy.request:
|
||||
color = colors.cyberCyan;
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ import 'club_card.dart';
|
|||
/// Club document in real time.
|
||||
class ClubMemberCard extends ConsumerWidget {
|
||||
final ClubMember member;
|
||||
final bool inGrid;
|
||||
|
||||
const ClubMemberCard({
|
||||
super.key,
|
||||
required this.member,
|
||||
this.inGrid = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -30,7 +28,6 @@ class ClubMemberCard extends ConsumerWidget {
|
|||
return ClubCard(
|
||||
club: club,
|
||||
role: member.role,
|
||||
inGrid: inGrid,
|
||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||
);
|
||||
},
|
||||
|
|
@ -43,7 +40,7 @@ class ClubMemberCard extends ConsumerWidget {
|
|||
final colors = context.motorColors;
|
||||
|
||||
return Container(
|
||||
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@ class _SlugInputFieldState extends ConsumerState<SlugInputField> {
|
|||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
|
||||
_LowerCaseTextFormatter(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -103,12 +103,10 @@ class _FeedCommentsBottomSheetState
|
|||
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 sendBg = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
|
||||
final sendFg = AppColors.onBrand;
|
||||
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
|
||||
final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
||||
final sendFg = isLight ? AppColors.onBrand : const Color(0xFF00363D);
|
||||
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
final feedItems = ref.watch(socialFeedNotifierProvider);
|
||||
|
|
@ -126,9 +124,9 @@ class _FeedCommentsBottomSheetState
|
|||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
color: colors.surfaceCardPrimary,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: colors.surfaceBorder),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.glowShadow,
|
||||
|
|
@ -149,7 +147,7 @@ class _FeedCommentsBottomSheetState
|
|||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
color: colors.surfaceBorder,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
|
@ -171,7 +169,7 @@ class _FeedCommentsBottomSheetState
|
|||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryTextColor,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -179,7 +177,7 @@ class _FeedCommentsBottomSheetState
|
|||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: colors.surfaceCardSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: accentColor.withValues(alpha: 0.35),
|
||||
|
|
@ -207,7 +205,7 @@ class _FeedCommentsBottomSheetState
|
|||
],
|
||||
),
|
||||
),
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
Divider(color: colors.surfaceBorder, height: 1),
|
||||
|
||||
// Comment List or Empty
|
||||
Flexible(
|
||||
|
|
@ -230,7 +228,7 @@ class _FeedCommentsBottomSheetState
|
|||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primaryTextColor,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
|
@ -258,10 +256,10 @@ class _FeedCommentsBottomSheetState
|
|||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: colors.surfaceCardSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant,
|
||||
color: colors.surfaceBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -271,7 +269,7 @@ class _FeedCommentsBottomSheetState
|
|||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
backgroundColor: colors.surfaceCardPrimary,
|
||||
child: Icon(
|
||||
Icons.sports_motorsports_rounded,
|
||||
size: 16,
|
||||
|
|
@ -291,7 +289,7 @@ class _FeedCommentsBottomSheetState
|
|||
AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primaryTextColor,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -329,7 +327,7 @@ class _FeedCommentsBottomSheetState
|
|||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: primaryTextColor,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -340,7 +338,7 @@ class _FeedCommentsBottomSheetState
|
|||
),
|
||||
),
|
||||
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
Divider(color: colors.surfaceBorder, height: 1),
|
||||
|
||||
// Comment Input Field (Glove Friendly)
|
||||
Padding(
|
||||
|
|
@ -351,10 +349,10 @@ class _FeedCommentsBottomSheetState
|
|||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: colors.surfaceCardSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant,
|
||||
color: colors.surfaceBorder,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
|
|
@ -362,7 +360,7 @@ class _FeedCommentsBottomSheetState
|
|||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: primaryTextColor,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Add comment or pace tip...',
|
||||
|
|
|
|||
|
|
@ -58,26 +58,19 @@ class _FeedTelemetryInspectorModalState
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 mediaQuery = MediaQuery.of(context);
|
||||
final maxHeight = mediaQuery.size.height * 0.90;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF0F1318),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border(
|
||||
top: BorderSide(color: colors.outlineVariant, width: 1.5),
|
||||
left: BorderSide(color: colors.outlineVariant, width: 1.5),
|
||||
right: BorderSide(color: colors.outlineVariant, width: 1.5),
|
||||
top: BorderSide(color: Color(0xFF222933), width: 1.5),
|
||||
left: BorderSide(color: Color(0xFF222933), width: 1.5),
|
||||
right: BorderSide(color: Color(0xFF222933), width: 1.5),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
|
|
@ -93,7 +86,7 @@ class _FeedTelemetryInspectorModalState
|
|||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
color: const Color(0xFF333E4D),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
|
@ -107,15 +100,15 @@ class _FeedTelemetryInspectorModalState
|
|||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withValues(alpha: 0.15),
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: accentColor.withValues(alpha: 0.35),
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
child: const Icon(
|
||||
Icons.insights_rounded,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
|
|
@ -130,11 +123,11 @@ class _FeedTelemetryInspectorModalState
|
|||
child: Text(
|
||||
item.authorDisplayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -146,27 +139,19 @@ class _FeedTelemetryInspectorModalState
|
|||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (isLight
|
||||
? AppColors.semanticWarningLight
|
||||
: AppColors.hazardAmber)
|
||||
.withValues(alpha: 0.2),
|
||||
color: AppColors.hazardAmber.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: (isLight
|
||||
? AppColors.semanticWarningLight
|
||||
: AppColors.hazardAmber)
|
||||
.withValues(alpha: 0.4),
|
||||
color: AppColors.hazardAmber.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item.authorBadge!,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isLight
|
||||
? AppColors.semanticWarningLight
|
||||
: AppColors.hazardAmber,
|
||||
color: AppColors.hazardAmber,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -176,24 +161,24 @@ class _FeedTelemetryInspectorModalState
|
|||
Text(
|
||||
'${item.rideTitle} • ${item.bikeModel}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF8B98A5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: secondaryTextColor),
|
||||
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
const Divider(color: Color(0xFF1D242E), height: 1),
|
||||
|
||||
// Scrollable Content: Metrics + Graph + Replay Trigger
|
||||
Flexible(
|
||||
|
|
@ -203,23 +188,23 @@ class _FeedTelemetryInspectorModalState
|
|||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 2x3 Telemetry Insets Grid
|
||||
_buildMetricsGrid(item, colors, isLight, secondaryTextColor, accentColor, primaryTextColor),
|
||||
_buildMetricsGrid(item, colors),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Trace Selector Tabs
|
||||
_buildTraceSelector(colors, secondaryTextColor, accentColor, primaryTextColor),
|
||||
_buildTraceSelector(colors),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Interactive Telemetry Graph Canvas
|
||||
_buildInteractiveGraphCard(item, colors, isLight, secondaryTextColor, accentColor, primaryTextColor),
|
||||
_buildInteractiveGraphCard(item, colors),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Scrubber Details Pill
|
||||
_buildScrubberReadout(item, colors, secondaryTextColor, accentColor, primaryTextColor),
|
||||
_buildScrubberReadout(item, colors),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Launch Full Replay Button
|
||||
_buildReplayButton(context, colors, accentColor, primaryTextColor),
|
||||
_buildReplayButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -230,25 +215,13 @@ class _FeedTelemetryInspectorModalState
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Widget _buildMetricsGrid(SocialFeedItem item, MotorSignalThemeColors colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: const Color(0xFF141A22),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF222B38)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
|
|
@ -259,37 +232,34 @@ class _FeedTelemetryInspectorModalState
|
|||
label: 'PEAK LEAN',
|
||||
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
|
||||
subtext: '${item.maxLeanSide} APEX',
|
||||
accentColor: accentColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: AppColors.electricCyan,
|
||||
icon: Icons.rotate_right_rounded,
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 48, color: colors.outlineVariant),
|
||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
||||
Expanded(
|
||||
child: _buildMetricCell(
|
||||
label: 'DISTANCE',
|
||||
value: item.distanceMiles.toStringAsFixed(1),
|
||||
subtext: 'MILES',
|
||||
accentColor: primaryTextColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: Colors.white,
|
||||
icon: Icons.route_rounded,
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 48, color: colors.outlineVariant),
|
||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
||||
Expanded(
|
||||
child: _buildMetricCell(
|
||||
label: 'DURATION',
|
||||
value: _formatDuration(item.durationSeconds),
|
||||
subtext: 'MOVING',
|
||||
accentColor: primaryTextColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: Colors.white,
|
||||
icon: Icons.timer_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Divider(color: colors.outlineVariant, height: 1),
|
||||
const Divider(color: Color(0xFF222B38), height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
|
|
@ -300,30 +270,29 @@ class _FeedTelemetryInspectorModalState
|
|||
subtext: item.paceFactor.contains(' ')
|
||||
? item.paceFactor.split(' ').sublist(1).join(' ')
|
||||
: 'PACE',
|
||||
accentColor: successColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: const Color(0xFF00E676),
|
||||
icon: Icons.speed_rounded,
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 48, color: colors.outlineVariant),
|
||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
||||
Expanded(
|
||||
child: _buildMetricCell(
|
||||
label: 'ELEVATION',
|
||||
value: '+${item.elevationFt}',
|
||||
subtext: 'FEET GAIN',
|
||||
accentColor: primaryTextColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: Colors.white,
|
||||
icon: Icons.terrain_rounded,
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 48, color: colors.outlineVariant),
|
||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
||||
Expanded(
|
||||
child: _buildMetricCell(
|
||||
label: 'HAZARDS',
|
||||
value: '${item.hazardCount}',
|
||||
subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED',
|
||||
accentColor: item.hazardCount == 0 ? successColor : warnColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: item.hazardCount == 0
|
||||
? const Color(0xFF00E676)
|
||||
: AppColors.hazardAmber,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
),
|
||||
),
|
||||
|
|
@ -339,7 +308,6 @@ class _FeedTelemetryInspectorModalState
|
|||
required String value,
|
||||
required String subtext,
|
||||
required Color accentColor,
|
||||
required Color secondaryTextColor,
|
||||
required IconData icon,
|
||||
}) {
|
||||
return Padding(
|
||||
|
|
@ -349,18 +317,18 @@ class _FeedTelemetryInspectorModalState
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 12, color: secondaryTextColor),
|
||||
Icon(icon, size: 12, color: const Color(0xFF6B7A8D)),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 8.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF7B8B9E),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -382,12 +350,12 @@ class _FeedTelemetryInspectorModalState
|
|||
),
|
||||
Text(
|
||||
subtext,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 8.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF5D6C7E),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -395,18 +363,13 @@ class _FeedTelemetryInspectorModalState
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTraceSelector(
|
||||
MotorSignalThemeColors colors,
|
||||
Color secondaryTextColor,
|
||||
Color accentColor,
|
||||
Color primaryTextColor,
|
||||
) {
|
||||
Widget _buildTraceSelector(MotorSignalThemeColors colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: const Color(0xFF141920),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF222B38)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -414,28 +377,16 @@ class _FeedTelemetryInspectorModalState
|
|||
tab: _InspectorTraceTab.leanApex,
|
||||
label: 'LEAN & APEX',
|
||||
icon: Icons.rotate_left_rounded,
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
primaryTextColor: primaryTextColor,
|
||||
),
|
||||
_buildTraceTabItem(
|
||||
tab: _InspectorTraceTab.speedGForce,
|
||||
label: 'SPEED & G-FORCE',
|
||||
icon: Icons.show_chart_rounded,
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
primaryTextColor: primaryTextColor,
|
||||
),
|
||||
_buildTraceTabItem(
|
||||
tab: _InspectorTraceTab.elevation,
|
||||
label: 'ELEVATION',
|
||||
icon: Icons.terrain_rounded,
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
primaryTextColor: primaryTextColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -446,10 +397,6 @@ class _FeedTelemetryInspectorModalState
|
|||
required _InspectorTraceTab tab,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required MotorSignalThemeColors colors,
|
||||
required Color secondaryTextColor,
|
||||
required Color accentColor,
|
||||
required Color primaryTextColor,
|
||||
}) {
|
||||
final isSelected = _selectedTab == tab;
|
||||
return Expanded(
|
||||
|
|
@ -460,10 +407,10 @@ class _FeedTelemetryInspectorModalState
|
|||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colors.surfaceContainer : Colors.transparent,
|
||||
color: isSelected ? const Color(0xFF202A36) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(color: accentColor.withValues(alpha: 0.4))
|
||||
? Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -472,7 +419,7 @@ class _FeedTelemetryInspectorModalState
|
|||
Icon(
|
||||
icon,
|
||||
size: 13,
|
||||
color: isSelected ? accentColor : secondaryTextColor,
|
||||
color: isSelected ? AppColors.electricCyan : const Color(0xFF6B7A8D),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
|
|
@ -484,7 +431,7 @@ class _FeedTelemetryInspectorModalState
|
|||
fontSize: 10,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
color: isSelected ? primaryTextColor : secondaryTextColor,
|
||||
color: isSelected ? Colors.white : const Color(0xFF7A8B9E),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -498,65 +445,51 @@ class _FeedTelemetryInspectorModalState
|
|||
Widget _buildInteractiveGraphCard(
|
||||
SocialFeedItem item,
|
||||
MotorSignalThemeColors colors,
|
||||
bool isLight,
|
||||
Color secondaryTextColor,
|
||||
Color accentColor,
|
||||
Color primaryTextColor,
|
||||
) {
|
||||
final hasRecorded = item.hasRecordedTelemetry;
|
||||
return Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: isLight
|
||||
? AppColors.lightSurfaceContainerHighest
|
||||
: AppColors.darkSurface,
|
||||
color: const Color(0xFF0C1015),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF222B38)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, graphConstraints) {
|
||||
final graphWidth =
|
||||
graphConstraints.maxWidth > 0 ? graphConstraints.maxWidth : 340.0;
|
||||
return GestureDetector(
|
||||
onHorizontalDragUpdate: hasRecorded
|
||||
? (details) {
|
||||
setState(() {
|
||||
_scrubFraction =
|
||||
(details.localPosition.dx / graphWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onTapDown: hasRecorded
|
||||
? (details) {
|
||||
setState(() {
|
||||
_scrubFraction =
|
||||
(details.localPosition.dx / graphWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: CustomPaint(
|
||||
size: const Size(double.infinity, 220),
|
||||
painter: _TelemetryWaveformPainter(
|
||||
tab: _selectedTab,
|
||||
item: item,
|
||||
scrubFraction: _scrubFraction,
|
||||
accentColor: accentColor,
|
||||
gridColor: colors.outlineVariant,
|
||||
baselineColor: colors.outline,
|
||||
badgeBgColor: isLight
|
||||
? AppColors.lightSurfaceContainerHighest
|
||||
: AppColors.darkSurfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
GestureDetector(
|
||||
onHorizontalDragUpdate: hasRecorded
|
||||
? (details) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
final localX = details.localPosition.dx;
|
||||
setState(() {
|
||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onTapDown: hasRecorded
|
||||
? (details) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
final localX = details.localPosition.dx;
|
||||
setState(() {
|
||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: CustomPaint(
|
||||
size: const Size(double.infinity, 220),
|
||||
painter: _TelemetryWaveformPainter(
|
||||
tab: _selectedTab,
|
||||
item: item,
|
||||
scrubFraction: _scrubFraction,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!hasRecorded)
|
||||
Center(
|
||||
|
|
@ -568,22 +501,19 @@ class _FeedTelemetryInspectorModalState
|
|||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (isLight
|
||||
? AppColors.lightSurface
|
||||
: AppColors.darkSurfaceContainer)
|
||||
.withValues(alpha: 0.92),
|
||||
color: const Color(0xFF141A22).withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF2A3647)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.sensors_off_rounded,
|
||||
size: 24,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF8B98A5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'NO RECORDED TELEMETRY SAMPLES',
|
||||
textAlign: TextAlign.center,
|
||||
|
|
@ -592,17 +522,17 @@ class _FeedTelemetryInspectorModalState
|
|||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.5,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Detailed multi-trace waveforms require recorded ride telemetry points.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF8B98A5),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -618,9 +548,6 @@ class _FeedTelemetryInspectorModalState
|
|||
Widget _buildScrubberReadout(
|
||||
SocialFeedItem item,
|
||||
MotorSignalThemeColors colors,
|
||||
Color secondaryTextColor,
|
||||
Color accentColor,
|
||||
Color primaryTextColor,
|
||||
) {
|
||||
final hasRecorded = item.hasRecordedTelemetry;
|
||||
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
|
||||
|
|
@ -632,9 +559,9 @@ class _FeedTelemetryInspectorModalState
|
|||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: const Color(0xFF141920),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF222B38)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
|
|
@ -644,10 +571,10 @@ class _FeedTelemetryInspectorModalState
|
|||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.touch_app_rounded,
|
||||
size: 14,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
|
|
@ -656,12 +583,12 @@ class _FeedTelemetryInspectorModalState
|
|||
? 'POINT: $scrubDist MI ($scrubPct%)'
|
||||
: 'TELEMETRY TRACE UNAVAILABLE',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -671,11 +598,11 @@ class _FeedTelemetryInspectorModalState
|
|||
const SizedBox(width: 8),
|
||||
Text(
|
||||
hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 8.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF5D6C7E),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -684,11 +611,11 @@ class _FeedTelemetryInspectorModalState
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH', colors, secondaryTextColor, primaryTextColor),
|
||||
Container(width: 1, height: 24, color: colors.outlineVariant),
|
||||
_buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°', colors, secondaryTextColor, primaryTextColor),
|
||||
Container(width: 1, height: 24, color: colors.outlineVariant),
|
||||
_buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G', colors, secondaryTextColor, primaryTextColor),
|
||||
_buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH'),
|
||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
||||
_buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°'),
|
||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
||||
_buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G'),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -711,87 +638,69 @@ class _FeedTelemetryInspectorModalState
|
|||
final a = samples[lowerIdx];
|
||||
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 lean = (aLean + (bLean - aLean) * t).round();
|
||||
final lean = (a.leanAngleDeg.abs() +
|
||||
(b.leanAngleDeg.abs() - a.leanAngleDeg.abs()) * t)
|
||||
.round();
|
||||
final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t)
|
||||
.toStringAsFixed(2);
|
||||
|
||||
return (speed, lean, latG);
|
||||
}
|
||||
|
||||
Widget _buildScrubStat(
|
||||
String label,
|
||||
String value,
|
||||
MotorSignalThemeColors colors,
|
||||
Color secondaryTextColor,
|
||||
Color primaryTextColor,
|
||||
) {
|
||||
Widget _buildScrubStat(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 8.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF6B7A8D),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReplayButton(
|
||||
BuildContext context,
|
||||
MotorSignalThemeColors colors,
|
||||
Color accentColor,
|
||||
Color primaryTextColor,
|
||||
) {
|
||||
Widget _buildReplayButton(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
backgroundColor: const Color(0xFF161E28),
|
||||
side: BorderSide(
|
||||
color: accentColor.withValues(alpha: 0.5),
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.5),
|
||||
width: 1.5,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
icon: const Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
label: const Text(
|
||||
'OPEN FULL RIDE REPLAY',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.8,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
|
|
@ -809,19 +718,11 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
final _InspectorTraceTab tab;
|
||||
final SocialFeedItem item;
|
||||
final double scrubFraction;
|
||||
final Color accentColor;
|
||||
final Color gridColor;
|
||||
final Color baselineColor;
|
||||
final Color badgeBgColor;
|
||||
|
||||
_TelemetryWaveformPainter({
|
||||
required this.tab,
|
||||
required this.item,
|
||||
required this.scrubFraction,
|
||||
required this.accentColor,
|
||||
required this.gridColor,
|
||||
required this.baselineColor,
|
||||
required this.badgeBgColor,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -831,7 +732,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
|
||||
// Draw subtle grid lines
|
||||
final gridPaint = Paint()
|
||||
..color = gridColor
|
||||
..color = const Color(0xFF19222E)
|
||||
..strokeWidth = 1.0;
|
||||
|
||||
for (var i = 1; i <= 4; i++) {
|
||||
|
|
@ -862,7 +763,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
// Draw Scrub Cursor Line & Indicator
|
||||
final scrubX = (w * scrubFraction).clamp(0.0, w);
|
||||
final scrubPaint = Paint()
|
||||
..color = accentColor
|
||||
..color = AppColors.electricCyan
|
||||
..strokeWidth = 1.8;
|
||||
|
||||
canvas.drawLine(Offset(scrubX, 0), Offset(scrubX, h), scrubPaint);
|
||||
|
|
@ -881,7 +782,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
|
||||
// Baseline (0° upright)
|
||||
final baselinePaint = Paint()
|
||||
..color = baselineColor
|
||||
..color = const Color(0xFF2A3647)
|
||||
..strokeWidth = 1.2
|
||||
..style = PaintingStyle.stroke;
|
||||
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
|
||||
|
|
@ -895,10 +796,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
final s = samples[i];
|
||||
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||
final x = t * w;
|
||||
final leanAngle = FeedTelemetrySample.applyMovingLeanGate(
|
||||
speedMph: s.speedMph,
|
||||
leanAngleDeg: s.leanAngleDeg,
|
||||
);
|
||||
final leanAngle = s.leanAngleDeg.clamp(-65.0, 65.0);
|
||||
final y = midY - (leanAngle / 65.0) * (h * 0.38);
|
||||
final pt = Offset(x, y);
|
||||
if (i == 0) {
|
||||
|
|
@ -906,8 +804,8 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
if (leanAngle.abs() >= peakAbsLean) {
|
||||
peakAbsLean = leanAngle.abs();
|
||||
if (s.leanAngleDeg.abs() >= peakAbsLean) {
|
||||
peakAbsLean = s.leanAngleDeg.abs();
|
||||
peakIndex = i;
|
||||
peakPoint = pt;
|
||||
}
|
||||
|
|
@ -920,25 +818,25 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
..close();
|
||||
|
||||
final fillPaint = Paint()
|
||||
..shader = LinearGradient(
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
accentColor.withValues(alpha: 0.20),
|
||||
accentColor.withValues(alpha: 0.02),
|
||||
Color(0x3300F2FE),
|
||||
Color(0x0500F2FE),
|
||||
],
|
||||
).createShader(Rect.fromLTWH(0, 0, w, h));
|
||||
canvas.drawPath(fillPath, fillPaint);
|
||||
|
||||
// Draw Trace Line
|
||||
final linePaint = Paint()
|
||||
..color = accentColor
|
||||
..color = AppColors.electricCyan
|
||||
..strokeWidth = 2.5
|
||||
..style = PaintingStyle.stroke;
|
||||
canvas.drawPath(path, linePaint);
|
||||
|
||||
if (peakIndex >= 0 && peakAbsLean > 0) {
|
||||
final markerColor = AppColors.semanticSuccess;
|
||||
const markerColor = Color(0xFF00E676);
|
||||
final dotPaint = Paint()..color = markerColor;
|
||||
canvas.drawCircle(peakPoint, 5.0, dotPaint);
|
||||
|
||||
|
|
@ -1113,7 +1011,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
Rect.fromLTWH(offset.dx - 4, offset.dy - 2, painter.width + 8, painter.height + 4),
|
||||
const Radius.circular(4),
|
||||
);
|
||||
final bgPaint = Paint()..color = badgeBgColor;
|
||||
final bgPaint = Paint()..color = const Color(0xFF0F151D);
|
||||
final borderPaint = Paint()
|
||||
..color = color.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.stroke
|
||||
|
|
@ -1128,10 +1026,6 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
bool shouldRepaint(covariant _TelemetryWaveformPainter oldDelegate) {
|
||||
return oldDelegate.tab != tab ||
|
||||
oldDelegate.scrubFraction != scrubFraction ||
|
||||
oldDelegate.item != item ||
|
||||
oldDelegate.accentColor != accentColor ||
|
||||
oldDelegate.gridColor != gridColor ||
|
||||
oldDelegate.baselineColor != baselineColor ||
|
||||
oldDelegate.badgeBgColor != badgeBgColor;
|
||||
oldDelegate.item != item;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../core/theme/theme_extensions.dart';
|
||||
|
||||
/// Glove-friendly quick launcher button for the Squad Community Feed.
|
||||
/// Glove-friendly quick launcher button for the Squad & Canyon Community Feed.
|
||||
///
|
||||
/// Designed to sit directly in the Dashboard status bar for instantaneous
|
||||
/// 1-tap access to live rider activity, route snapshots, and kudos.
|
||||
|
|
@ -15,20 +14,15 @@ class QuickSquadFeedButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
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) {
|
||||
return IconButton(
|
||||
key: const ValueKey('dash_quick_feed_button'),
|
||||
icon: Icon(
|
||||
icon: const Icon(
|
||||
Icons.dynamic_feed_rounded,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
size: 19,
|
||||
),
|
||||
tooltip: 'Squad Feed',
|
||||
tooltip: 'Squad & Canyon Feed',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => context.push('/social/feed'),
|
||||
|
|
@ -42,10 +36,10 @@ class QuickSquadFeedButton extends StatelessWidget {
|
|||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
color: const Color(0xFF181C21),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant,
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.3),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
|
|
@ -55,26 +49,26 @@ class QuickSquadFeedButton extends StatelessWidget {
|
|||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.electricCyan,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accentColor.withValues(alpha: 0.4),
|
||||
color: AppColors.electricCyan,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
const Text(
|
||||
'FEED',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.6,
|
||||
color: textColor,
|
||||
color: Color(0xFFC3F5FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@ import '../../../../core/theme/theme_extensions.dart';
|
|||
import '../../../../database/database.dart';
|
||||
import '../../../../services/auth/auth_providers.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_providers.dart';
|
||||
import '../../../garage/garage_providers.dart';
|
||||
|
||||
/// Modal dialog / bottom sheet allowing riders to share their recent ride
|
||||
/// or recorded session to the Community Social Feed.
|
||||
/// or canyon session to the Community Social Feed.
|
||||
class ShareSessionModal extends ConsumerStatefulWidget {
|
||||
final Ride? initialRide;
|
||||
final List<TelemetrySample>? initialTelemetrySamples;
|
||||
|
|
@ -47,11 +46,11 @@ class ShareSessionModal extends ConsumerStatefulWidget {
|
|||
|
||||
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController = TextEditingController(text: 'Morning Ride');
|
||||
final _titleController = TextEditingController(text: 'Morning Canyon Carve');
|
||||
final _locationController =
|
||||
TextEditingController(text: 'Location unavailable');
|
||||
TextEditingController(text: 'Skyline Blvd & Highway 9');
|
||||
final _bikeController = TextEditingController();
|
||||
RidingDiscipline _selectedDiscipline = RidingDiscipline.touring;
|
||||
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
List<Ride> _recentRides = [];
|
||||
|
|
@ -142,11 +141,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
if (widget.initialRide != null) {
|
||||
if (widget.initialTelemetrySamples == null) {
|
||||
await _loadSamplesForRide(widget.initialRide!);
|
||||
} else {
|
||||
await _resolveStartingLocation(
|
||||
widget.initialTelemetrySamples!,
|
||||
widget.initialRide!.id,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _loadRecentRides();
|
||||
|
|
@ -217,27 +211,17 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
final sampleStep =
|
||||
(samples.length / 60).ceil().clamp(1, samples.length);
|
||||
final totalIdx = math.max(1, samples.length - 1);
|
||||
FeedTelemetrySample buildSample(int index) {
|
||||
final s = samples[index];
|
||||
return FeedTelemetrySample(
|
||||
progress: (index / totalIdx).clamp(0.0, 1.0),
|
||||
speedMph: s.speed ?? 0.0,
|
||||
leanAngleDeg: FeedTelemetrySample.applyMovingLeanGate(
|
||||
speedMph: s.speed,
|
||||
leanAngleDeg: s.leanAngle,
|
||||
),
|
||||
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),
|
||||
for (int i = 0; i < samples.length; i += sampleStep)
|
||||
FeedTelemetrySample(
|
||||
progress: (i / totalIdx).clamp(0.0, 1.0),
|
||||
speedMph: samples[i].speed ?? 0.0,
|
||||
leanAngleDeg: samples[i].leanAngle ?? 0.0,
|
||||
lateralG: samples[i].lateralG ?? 0.0,
|
||||
altitudeFt: (samples[i].altitude ?? 0.0) * 3.28084,
|
||||
latitude: samples[i].latitude,
|
||||
longitude: samples[i].longitude,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -245,46 +229,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
_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 {
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
|
|
@ -296,7 +240,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
setState(() {
|
||||
_extractSamples(samples);
|
||||
});
|
||||
await _resolveStartingLocation(samples, ride.id);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -319,11 +262,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
}
|
||||
|
||||
Future<void> _handlePublish() async {
|
||||
if (!_formKey.currentState!.validate() ||
|
||||
_isSubmitting ||
|
||||
_selectedRide == null) {
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate() || _isSubmitting) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
|
|
@ -333,7 +272,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
final authorDisplayName = user?.displayName ?? 'Apex Rider';
|
||||
final authorHandle = user?.email?.split('@').first ?? 'apex_rider';
|
||||
|
||||
final ride = _selectedRide!;
|
||||
final ride = _selectedRide;
|
||||
|
||||
await ref.read(socialFeedServiceProvider).shareRecentRide(
|
||||
authorUid: authorUid,
|
||||
|
|
@ -341,10 +280,12 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
authorHandle: authorHandle,
|
||||
authorBadge: 'PRO',
|
||||
clubName: 'Bay Area Twisties Club',
|
||||
locationName: _locationController.text.trim(),
|
||||
locationName: _locationController.text.trim().isNotEmpty
|
||||
? _locationController.text.trim()
|
||||
: 'Skyline Blvd & Highway 9',
|
||||
rideTitle: _titleController.text.trim().isNotEmpty
|
||||
? _titleController.text.trim()
|
||||
: 'Morning Ride',
|
||||
: 'Morning Canyon Carve',
|
||||
bikeModel: _bikeController.text.trim().isNotEmpty
|
||||
? _bikeController.text.trim()
|
||||
: '2024 Ducati Streetfighter V4',
|
||||
|
|
@ -360,33 +301,29 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
routePoints: _selectedRoutePoints,
|
||||
telemetrySamples: _selectedTelemetrySamples,
|
||||
authoritativeRide: ride,
|
||||
rideId: ride.id,
|
||||
rideId: ride?.id,
|
||||
);
|
||||
|
||||
// Reset filter to All Activity so published session is immediately displayed
|
||||
ref.read(feedFilterStateProvider.notifier).state = FeedFilter.allActivity;
|
||||
|
||||
if (mounted) {
|
||||
final colors = context.motorColors;
|
||||
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
backgroundColor: const Color(0xFF1D2025),
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.semanticSuccess, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
children: const [
|
||||
Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.electricCyan, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ride session published to Community Feed!',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
color: isLight
|
||||
? AppColors.lightOnSurface
|
||||
: AppColors.darkOnSurface,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -405,32 +342,22 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 buttonFg = AppColors.onBrand;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isLandscape = size.width > size.height && size.height <= 500;
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: size.height * (isLandscape ? 0.98 : 0.9),
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
color: const Color(0xFF101419),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
boxShadow: [
|
||||
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: colors.glowShadow,
|
||||
color: Colors.black87,
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, -10),
|
||||
offset: Offset(0, -10),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -444,19 +371,14 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
children: [
|
||||
// Modal Header & Handle
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
isLandscape ? 8 : 12,
|
||||
20,
|
||||
isLandscape ? 8 : 12,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -475,12 +397,12 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withValues(alpha: 0.15),
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
child: const Icon(
|
||||
Icons.add_circle_outline_rounded,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
|
|
@ -489,23 +411,23 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
'Share Recent Session',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
const Text(
|
||||
'Publish telemetry & route to community feed',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
color: AppColors.darkOnSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -515,8 +437,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded,
|
||||
color: secondaryTextColor),
|
||||
icon: const Icon(Icons.close_rounded,
|
||||
color: Color(0xFF849396)),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
|
|
@ -534,25 +456,25 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
|
||||
// Recent Database Rides Selector
|
||||
if (_recentRides.isNotEmpty) ...[
|
||||
_buildFieldLabel('SELECT RECORDED SESSION', colors, secondaryTextColor),
|
||||
_buildFieldLabel('SELECT RECORDED SESSION', colors),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: const Color(0xFF161B22),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Ride>(
|
||||
key: const ValueKey('share_session_ride_picker'),
|
||||
value: _selectedRide,
|
||||
isExpanded: true,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: TextStyle(
|
||||
dropdownColor: const Color(0xFF161B22),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
items: _recentRides.map((ride) {
|
||||
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
|
||||
|
|
@ -561,7 +483,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
value: ride,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.two_wheeler_rounded, size: 16, color: accentColor),
|
||||
const Icon(Icons.two_wheeler_rounded, size: 16, color: AppColors.electricCyan),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -583,64 +505,56 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
],
|
||||
|
||||
// Ride Title Field
|
||||
_buildFieldLabel('SESSION / ROUTE TITLE', colors, secondaryTextColor),
|
||||
_buildFieldLabel('SESSION / ROUTE TITLE', colors),
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 14,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: _buildInputDecoration(
|
||||
hint: 'e.g. Sunday Skyline & Pescadero Carve',
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
hint: 'e.g. Sunday Skyline & Pescadero Carve'),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Enter a ride title' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Motorcycle Model Field
|
||||
_buildFieldLabel('MOTORCYCLE MODEL', colors, secondaryTextColor),
|
||||
_buildFieldLabel('MOTORCYCLE MODEL', colors),
|
||||
TextFormField(
|
||||
controller: _bikeController,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 14,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: _buildInputDecoration(
|
||||
hint: 'e.g. 2024 Ducati Streetfighter V4',
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
hint: 'e.g. 2024 Ducati Streetfighter V4'),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Enter motorcycle model' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Riding Discipline / Style Field
|
||||
_buildFieldLabel('RIDING DISCIPLINE / STYLE', colors, secondaryTextColor),
|
||||
_buildFieldLabel('RIDING DISCIPLINE / STYLE', colors),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: const Color(0xFF161B22),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF263238)),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<RidingDiscipline>(
|
||||
value: _selectedDiscipline,
|
||||
isExpanded: true,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: TextStyle(
|
||||
dropdownColor: const Color(0xFF161B22),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
items: RidingDiscipline.values
|
||||
.where((d) => d != RidingDiscipline.all)
|
||||
|
|
@ -659,33 +573,29 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Starting Location Field (Privacy-safe)
|
||||
_buildFieldLabel('STARTING LOCATION', colors, secondaryTextColor),
|
||||
// Location Field
|
||||
_buildFieldLabel('LOCATION / CANYON', colors),
|
||||
TextFormField(
|
||||
controller: _locationController,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 14,
|
||||
color: primaryTextColor,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: _buildInputDecoration(
|
||||
hint: 'e.g. Saratoga, CA or Trailhead',
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
hint: "e.g. Alice's Restaurant / Skyline"),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Enter starting location' : null,
|
||||
v == null || v.trim().isEmpty ? 'Enter location' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
size: 13,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
|
|
@ -694,12 +604,12 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
? 'RECORDED TELEMETRY (READ-ONLY)'
|
||||
: 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.7,
|
||||
color: accentColor,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -707,7 +617,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
key: const ValueKey('share_session_readonly_telemetry_grid'),
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildReadOnlyTelemetryTile(
|
||||
|
|
@ -715,8 +624,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
label: 'DIST (MI)',
|
||||
value: _authoritativeDistanceMiles.toStringAsFixed(1),
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
|
@ -726,8 +633,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
label: 'TIME (MINS)',
|
||||
value: _authoritativeDurationMinutes.toString(),
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
|
@ -737,8 +642,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
label: 'LEAN (DEG)',
|
||||
value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°',
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
highlight: true,
|
||||
),
|
||||
),
|
||||
|
|
@ -753,8 +656,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
label: 'LEAN APEX SIDE',
|
||||
value: _authoritativeLeanSide,
|
||||
colors: colors,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
|
@ -764,28 +665,62 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
label: 'PACE FACTOR',
|
||||
value: _authoritativePaceFactor,
|
||||
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 in portrait mode)
|
||||
if (!isLandscape)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: _buildPublishButton(accentColor, buttonFg),
|
||||
// Publish CTA Button (pinned at modal bottom)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.electricCyan,
|
||||
foregroundColor: const Color(0xFF00363D),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 4,
|
||||
),
|
||||
onPressed: _isSubmitting ? null : _handlePublish,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Color(0xFF00363D),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.share_rounded, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'PUBLISH TO COMMUNITY FEED',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -794,78 +729,25 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildPublishButton(Color accentColor, Color buttonFg) {
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: accentColor,
|
||||
foregroundColor: buttonFg,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 4,
|
||||
),
|
||||
onPressed:
|
||||
(_isSubmitting || _selectedRide == null) ? null : _handlePublish,
|
||||
child: _isSubmitting
|
||||
? SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: buttonFg,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.share_rounded, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'PUBLISH TO COMMUNITY FEED',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadOnlyTelemetryTile({
|
||||
required Key key,
|
||||
required String label,
|
||||
required String value,
|
||||
required MotorSignalThemeColors colors,
|
||||
required Color secondaryTextColor,
|
||||
required Color accentColor,
|
||||
bool highlight = false,
|
||||
}) {
|
||||
final primaryTextColor = colors.surfaceContainer == AppColors.darkSurfaceContainer
|
||||
? AppColors.darkOnSurface
|
||||
: AppColors.lightOnSurface;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel(label, colors, secondaryTextColor),
|
||||
_buildFieldLabel(label, colors),
|
||||
Container(
|
||||
key: key,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
color: const Color(0xFF14181E),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
border: Border.all(color: const Color(0xFF263238)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -878,14 +760,14 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: highlight ? accentColor : primaryTextColor,
|
||||
color: highlight ? AppColors.electricCyan : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
size: 13,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -894,53 +776,44 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldLabel(
|
||||
String label,
|
||||
MotorSignalThemeColors colors,
|
||||
Color secondaryTextColor,
|
||||
) {
|
||||
Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF94A3B8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _buildInputDecoration({
|
||||
required String hint,
|
||||
required MotorSignalThemeColors colors,
|
||||
required Color secondaryTextColor,
|
||||
required Color accentColor,
|
||||
}) {
|
||||
InputDecoration _buildInputDecoration({required String hint}) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
hintStyle: const TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: secondaryTextColor,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
fillColor: const Color(0xFF161B22),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
borderSide: const BorderSide(color: Color(0xFF263238)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
borderSide: const BorderSide(color: Color(0xFF263238)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: accentColor, width: 1.5),
|
||||
borderSide: const BorderSide(color: AppColors.electricCyan, width: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
|
|||
key: const ValueKey('social_card_feed'),
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
title: 'Community Feed',
|
||||
subtitle: 'Squad telemetry, route snapshots, paces & kudos',
|
||||
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos',
|
||||
colors: colors,
|
||||
onTap: () => setState(() => _currentTab = SocialHubTab.feed),
|
||||
),
|
||||
|
|
@ -276,7 +276,7 @@ class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
|
|||
key: const ValueKey('social_card_feed'),
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
title: 'Community Feed',
|
||||
subtitle: 'Squad telemetry, route snapshots, paces & kudos',
|
||||
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos',
|
||||
colors: colors,
|
||||
onTap: () => setState(() => _currentTab = SocialHubTab.feed),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -152,13 +152,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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);
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
|
|
@ -171,12 +165,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
isLandscape ? 8 : 12,
|
||||
16,
|
||||
isLandscape ? 8 : 16,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -191,17 +180,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isLandscape ? 6 : 12),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.campaign, color: AppColors.cyberCyan, size: isLandscape ? 20 : 24),
|
||||
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'REPORT ROAD INCIDENT',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 14 : 16,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
color: Colors.white,
|
||||
|
|
@ -210,27 +199,23 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
IconButton(
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isLandscape) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'1-tap broadcast to all nearby riders & pack members.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'1-tap broadcast to all nearby riders & pack members.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isMovingFast) ...[
|
||||
SizedBox(height: isLandscape ? 6 : 10),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -238,7 +223,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 16),
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -254,23 +239,22 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: isLandscape ? 8 : 14),
|
||||
const SizedBox(height: 14),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _kReportCategories.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisExtent: isLandscape ? 56.0 : 70.0,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 2.2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _kReportCategories[index];
|
||||
return _buildGridCard(item, isLandscape: isLandscape);
|
||||
return _buildGridCard(item);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -282,17 +266,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildGridCard(_ReportItem item, {bool isLandscape = false}) {
|
||||
Widget _buildGridCard(_ReportItem item) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: item.color.withValues(alpha: 0.12),
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLandscape ? 6 : 10,
|
||||
vertical: isLandscape ? 4 : 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
|
||||
),
|
||||
elevation: 0,
|
||||
|
|
@ -301,14 +282,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(isLandscape ? 5 : 7),
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: item.color.withValues(alpha: 0.22),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(item.icon, color: item.color, size: isLandscape ? 18 : 22),
|
||||
child: Icon(item.icon, color: item.color, size: 22),
|
||||
),
|
||||
SizedBox(width: isLandscape ? 6 : 8),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -318,22 +299,20 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
item.title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: isLandscape ? 11.5 : 13,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isLandscape) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.subtitle,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white70),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.subtitle,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white70),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
import '../routes/aws_location_route_provider.dart';
|
||||
import 'map_provider_interface.dart';
|
||||
import 'map_providers.dart';
|
||||
|
||||
/// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite).
|
||||
class AwsMapProvider implements AppMapProvider {
|
||||
|
|
@ -99,12 +99,13 @@ class _AwsMapView extends StatefulWidget {
|
|||
State<_AwsMapView> createState() => _AwsMapViewState();
|
||||
}
|
||||
|
||||
class _AwsMapViewState extends State<_AwsMapView>
|
||||
with TickerProviderStateMixin {
|
||||
class _AwsMapViewState extends State<_AwsMapView> {
|
||||
late final MapController _mapController;
|
||||
AnimationController? _animController;
|
||||
ll.LatLng? _lastFollowPos;
|
||||
DateTime? _lastFollowTime;
|
||||
Timer? _pendingFollowTimer;
|
||||
|
||||
static const int _followThrottleMs = 250;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -113,53 +114,20 @@ class _AwsMapViewState extends State<_AwsMapView>
|
|||
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||
}
|
||||
|
||||
void _animateCameraTo({
|
||||
required ll.LatLng target,
|
||||
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 {
|
||||
_mapController.move(ll.LatLng(lat, lng), z);
|
||||
_mapController.rotate(normalizeAngle(b));
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
_animController!.forward();
|
||||
void _applyFollowMove(ll.LatLng target, double zoom) {
|
||||
_lastFollowPos = target;
|
||||
_lastFollowTime = DateTime.now();
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -177,56 +145,62 @@ class _AwsMapViewState extends State<_AwsMapView>
|
|||
? 1.0
|
||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||
final hasMoved = posDelta > 0.000005;
|
||||
final hasMoved = posDelta > 0.00001;
|
||||
final elapsedMs = _lastFollowTime == null
|
||||
? _followThrottleMs + 1
|
||||
: now.difference(_lastFollowTime!).inMilliseconds;
|
||||
|
||||
final intervalMs = _lastFollowTime == null
|
||||
? 250
|
||||
: now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
|
||||
final shouldMoveNow = justBecameFollowMode ||
|
||||
_lastFollowPos == null ||
|
||||
zoomChanged ||
|
||||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
||||
|
||||
final isInitialOrLargeJump =
|
||||
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
|
||||
|
||||
if (isInitialOrLargeJump) {
|
||||
_animController?.stop();
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
try {
|
||||
_mapController.move(centerLl, widget.zoom);
|
||||
_mapController.rotate(normalizeAngle(widget.bearing));
|
||||
} catch (_) {}
|
||||
} else if (hasMoved || bearingChanged || zoomChanged) {
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
_animateCameraTo(
|
||||
target: centerLl,
|
||||
targetZoom: widget.zoom,
|
||||
targetBearing: widget.bearing,
|
||||
duration: Duration(milliseconds: intervalMs),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
if (shouldMoveNow) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
_applyFollowMove(centerLl, widget.zoom);
|
||||
} else if (hasMoved) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
|
||||
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
|
||||
_pendingFollowTimer = null;
|
||||
if (mounted && widget.isFollowMode) {
|
||||
final latestCenter = ll.LatLng(
|
||||
widget.center.latitude,
|
||||
widget.center.longitude,
|
||||
);
|
||||
_applyFollowMove(latestCenter, widget.zoom);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (bearingChanged) {
|
||||
_animateCameraTo(
|
||||
target: _mapController.camera.center,
|
||||
targetZoom: widget.zoom,
|
||||
targetBearing: widget.bearing,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} else if (zoomChanged) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.rotate(widget.bearing);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animController?.stop();
|
||||
_animController?.dispose();
|
||||
_animController = null;
|
||||
_pendingFollowTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps;
|
||||
import '../../core/theme/map_style.dart';
|
||||
import 'map_provider_interface.dart';
|
||||
import 'map_providers.dart';
|
||||
|
||||
class GoogleMapProvider implements AppMapProvider {
|
||||
@override
|
||||
|
|
@ -88,7 +87,7 @@ class _GoogleMapViewState extends State<_GoogleMapView> {
|
|||
gmaps.CameraPosition(
|
||||
target: target,
|
||||
zoom: zoom,
|
||||
bearing: normalizeAngle(bearing),
|
||||
bearing: bearing,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -94,69 +94,3 @@ final mapFollowStateProvider =
|
|||
StateNotifierProvider<MapFollowNotifier, bool>((ref) {
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
import 'map_provider_interface.dart';
|
||||
import 'map_providers.dart';
|
||||
|
||||
class OpenStreetMapProvider implements AppMapProvider {
|
||||
@override
|
||||
|
|
@ -66,12 +66,13 @@ class _OsmMapView extends StatefulWidget {
|
|||
State<_OsmMapView> createState() => _OsmMapViewState();
|
||||
}
|
||||
|
||||
class _OsmMapViewState extends State<_OsmMapView>
|
||||
with TickerProviderStateMixin {
|
||||
class _OsmMapViewState extends State<_OsmMapView> {
|
||||
late final MapController _mapController;
|
||||
AnimationController? _animController;
|
||||
ll.LatLng? _lastFollowPos;
|
||||
DateTime? _lastFollowTime;
|
||||
Timer? _pendingFollowTimer;
|
||||
|
||||
static const int _followThrottleMs = 250;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -80,53 +81,20 @@ class _OsmMapViewState extends State<_OsmMapView>
|
|||
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||
}
|
||||
|
||||
void _animateCameraTo({
|
||||
required ll.LatLng target,
|
||||
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 {
|
||||
_mapController.move(ll.LatLng(lat, lng), z);
|
||||
_mapController.rotate(normalizeAngle(b));
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
_animController!.forward();
|
||||
void _applyFollowMove(ll.LatLng target, double zoom) {
|
||||
_lastFollowPos = target;
|
||||
_lastFollowTime = DateTime.now();
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -143,56 +111,62 @@ class _OsmMapViewState extends State<_OsmMapView>
|
|||
? 1.0
|
||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||
final hasMoved = posDelta > 0.000005;
|
||||
final hasMoved = posDelta > 0.00001;
|
||||
final elapsedMs = _lastFollowTime == null
|
||||
? _followThrottleMs + 1
|
||||
: now.difference(_lastFollowTime!).inMilliseconds;
|
||||
|
||||
final intervalMs = _lastFollowTime == null
|
||||
? 250
|
||||
: now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
|
||||
final shouldMoveNow = justBecameFollowMode ||
|
||||
_lastFollowPos == null ||
|
||||
zoomChanged ||
|
||||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
||||
|
||||
final isInitialOrLargeJump =
|
||||
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
|
||||
|
||||
if (isInitialOrLargeJump) {
|
||||
_animController?.stop();
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
try {
|
||||
_mapController.move(centerLl, widget.zoom);
|
||||
_mapController.rotate(normalizeAngle(widget.bearing));
|
||||
} catch (_) {}
|
||||
} else if (hasMoved || bearingChanged || zoomChanged) {
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
_animateCameraTo(
|
||||
target: centerLl,
|
||||
targetZoom: widget.zoom,
|
||||
targetBearing: widget.bearing,
|
||||
duration: Duration(milliseconds: intervalMs),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
if (shouldMoveNow) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
_applyFollowMove(centerLl, widget.zoom);
|
||||
} else if (hasMoved) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
|
||||
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
|
||||
_pendingFollowTimer = null;
|
||||
if (mounted && widget.isFollowMode) {
|
||||
final latestCenter = ll.LatLng(
|
||||
widget.center.latitude,
|
||||
widget.center.longitude,
|
||||
);
|
||||
_applyFollowMove(latestCenter, widget.zoom);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (bearingChanged) {
|
||||
_animateCameraTo(
|
||||
target: _mapController.camera.center,
|
||||
targetZoom: widget.zoom,
|
||||
targetBearing: widget.bearing,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} else if (zoomChanged) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.rotate(widget.bearing);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animController?.stop();
|
||||
_animController?.dispose();
|
||||
_animController = null;
|
||||
_pendingFollowTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../map/map_provider_interface.dart';
|
||||
import 'route_models.dart';
|
||||
|
|
@ -42,31 +41,6 @@ class AwsLocationRouteProvider implements RouteProvider {
|
|||
@override
|
||||
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
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
|
|
@ -279,47 +253,6 @@ 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
|
||||
Future<RouteCalculationResult> calculateRoute(
|
||||
List<RouteStop> stops, {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../auth/auth_providers.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
import 'aws_location_route_provider.dart';
|
||||
|
|
@ -8,19 +7,6 @@ import 'route_models.dart';
|
|||
import 'route_provider.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.
|
||||
final routeServiceProvider = Provider<RouteService>((ref) {
|
||||
return RouteService();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
|
||||
DateTime? _parseDateTime(dynamic value) {
|
||||
|
|
@ -138,27 +137,6 @@ enum FeedSnapshotType {
|
|||
/// A recorded telemetry sample point attached to a shared social feed session.
|
||||
@immutable
|
||||
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 speedMph;
|
||||
final double leanAngleDeg; // signed degrees (-left, +right)
|
||||
|
|
@ -166,7 +144,6 @@ class FeedTelemetrySample {
|
|||
final double altitudeFt;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final DateTime? timestamp;
|
||||
|
||||
const FeedTelemetrySample({
|
||||
required this.progress,
|
||||
|
|
@ -176,27 +153,8 @@ class FeedTelemetrySample {
|
|||
this.altitudeFt = 0.0,
|
||||
this.latitude,
|
||||
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() => {
|
||||
'progress': progress,
|
||||
'speedMph': speedMph,
|
||||
|
|
@ -205,7 +163,6 @@ class FeedTelemetrySample {
|
|||
'altitudeFt': altitudeFt,
|
||||
if (latitude != null) 'lat': latitude,
|
||||
if (longitude != null) 'lng': longitude,
|
||||
if (timestamp != null) 'timestamp': _dateTimeToFirestore(timestamp),
|
||||
};
|
||||
|
||||
factory FeedTelemetrySample.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -217,7 +174,6 @@ class FeedTelemetrySample {
|
|||
altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0,
|
||||
latitude: (json['lat'] as num?)?.toDouble(),
|
||||
longitude: (json['lng'] as num?)?.toDouble(),
|
||||
timestamp: _parseDateTime(json['timestamp']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -225,11 +181,6 @@ class FeedTelemetrySample {
|
|||
/// A community social feed item (Stitch SOC-01).
|
||||
@immutable
|
||||
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 int? rideId;
|
||||
final String authorUid;
|
||||
|
|
@ -306,86 +257,17 @@ class SocialFeedItem {
|
|||
this.comments = const [],
|
||||
});
|
||||
|
||||
/// Evaluates whether a set of ride metrics, GPS route coordinates, and
|
||||
/// recorded telemetry samples satisfy authentic GPS verification criteria.
|
||||
/// Evaluates whether a set of ride metrics and GPS route coordinates satisfy
|
||||
/// authentic GPS verification criteria.
|
||||
static bool verifyGpsCriteria({
|
||||
required bool isDemo,
|
||||
required double distanceMiles,
|
||||
required int durationSeconds,
|
||||
required List<AppLatLng> routePoints,
|
||||
List<FeedTelemetrySample> telemetrySamples = const [],
|
||||
}) {
|
||||
if (isDemo) return false;
|
||||
if (!distanceMiles.isFinite || distanceMiles <= 0.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) {
|
||||
return p.latitude.isFinite &&
|
||||
|
|
@ -395,35 +277,21 @@ class SocialFeedItem {
|
|||
(p.latitude != 0.0 || p.longitude != 0.0);
|
||||
}).toList();
|
||||
|
||||
return _hasMinimumGeodesicDisplacement(validPoints);
|
||||
}
|
||||
if (validPoints.length < 2) return false;
|
||||
|
||||
/// Require at least two coordinates separated by >= 0.05 miles (~80.47 m)
|
||||
/// measured geodesically via [Geolocator.distanceBetween].
|
||||
static bool _hasMinimumGeodesicDisplacement(List<AppLatLng> points) {
|
||||
if (points.length < 2) return false;
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
final origin = points[i];
|
||||
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,
|
||||
// Require at least two distinct GPS coordinates with meaningful displacement (~50m+).
|
||||
final first = validPoints.first;
|
||||
final hasDistinctPoint = validPoints.skip(1).any(
|
||||
(p) =>
|
||||
(p.latitude - first.latitude).abs() +
|
||||
(p.longitude - first.longitude).abs() >
|
||||
0.0005,
|
||||
);
|
||||
if (distMeters.isFinite &&
|
||||
distMeters >= minimumVerifiedDisplacementMeters) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return hasDistinctPoint;
|
||||
}
|
||||
|
||||
/// True only when this post is flagged as GPS-verified AND backed by valid,
|
||||
/// non-demo GPS route coordinates, recorded telemetry samples, positive
|
||||
/// distance, and positive duration.
|
||||
/// non-demo GPS route coordinates, positive distance, and positive duration.
|
||||
bool get hasVerifiedGps =>
|
||||
isGpsVerified &&
|
||||
verifyGpsCriteria(
|
||||
|
|
@ -431,7 +299,6 @@ class SocialFeedItem {
|
|||
distanceMiles: distanceMiles,
|
||||
durationSeconds: durationSeconds,
|
||||
routePoints: routePoints,
|
||||
telemetrySamples: telemetrySamples,
|
||||
);
|
||||
|
||||
/// True when the post is a non-demo ride containing recorded route coordinates that can be plotted.
|
||||
|
|
@ -577,8 +444,7 @@ class SocialFeedItem {
|
|||
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
|
||||
final samples = telemetryList
|
||||
.whereType<Map>()
|
||||
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s))
|
||||
.withMovingLeanGate())
|
||||
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s)))
|
||||
.toList();
|
||||
|
||||
final commentList = json['comments'] as List<dynamic>? ?? [];
|
||||
|
|
@ -597,7 +463,6 @@ class SocialFeedItem {
|
|||
distanceMiles: distanceMiles,
|
||||
durationSeconds: durationSeconds,
|
||||
routePoints: points,
|
||||
telemetrySamples: samples,
|
||||
);
|
||||
|
||||
return SocialFeedItem(
|
||||
|
|
@ -609,7 +474,7 @@ class SocialFeedItem {
|
|||
authorAvatarPreset: json['authorAvatarPreset'] as String?,
|
||||
authorBadge: json['authorBadge'] as String?,
|
||||
clubName: json['clubName'] as String?,
|
||||
locationName: (json['locationName'] as String? ?? '').trim(),
|
||||
locationName: json['locationName'] as String? ?? 'Canyon Route',
|
||||
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
|
||||
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
|
||||
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
|
||||
|
|
|
|||
|
|
@ -504,16 +504,11 @@ class SocialFeedService {
|
|||
(authoritativeRide.elevationGain * 3.28084).round();
|
||||
}
|
||||
|
||||
final gatedTelemetrySamples = telemetrySamples
|
||||
.map((s) => s.withMovingLeanGate())
|
||||
.toList(growable: false);
|
||||
|
||||
final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
|
||||
isDemo: false,
|
||||
distanceMiles: resolvedDistance,
|
||||
durationSeconds: resolvedDurationSeconds,
|
||||
routePoints: routePoints,
|
||||
telemetrySamples: gatedTelemetrySamples,
|
||||
);
|
||||
final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification;
|
||||
|
||||
|
|
@ -542,7 +537,7 @@ class SocialFeedService {
|
|||
isFeatured: isFeatured,
|
||||
snapshotType: snapshotType,
|
||||
routePoints: routePoints,
|
||||
telemetrySamples: gatedTelemetrySamples,
|
||||
telemetrySamples: telemetrySamples,
|
||||
isGpsVerified: effectiveGpsVerified,
|
||||
isDemo: false,
|
||||
fistBumpCount: 1,
|
||||
|
|
|
|||
|
|
@ -9,29 +9,6 @@ import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
|||
|
||||
void main() {
|
||||
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', () {
|
||||
final provider = AwsLocationRouteProvider(
|
||||
apiKey: 'test-key',
|
||||
|
|
|
|||
|
|
@ -506,38 +506,6 @@ 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
|||
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||
|
||||
class _FakeAuth implements AuthService {
|
||||
@override Future<void> ensureBackendIdentity() async {}
|
||||
|
|
@ -404,106 +403,6 @@ void main() {
|
|||
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', () {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import 'package:motorcycle_dash/features/records/records_service.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/add_motorcycle_screen.dart';
|
||||
import 'package:motorcycle_dash/features/garage/widgets/motorcycle_photo.dart';
|
||||
|
||||
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
|
||||
@override
|
||||
|
|
@ -318,109 +317,6 @@ void main() {
|
|||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ void main() {
|
|||
expect(find.text('@pacific-coast-riders'), findsOneWidget);
|
||||
expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget);
|
||||
expect(find.text('42 members'), findsOneWidget);
|
||||
expect(find.text('Request to Join'), findsNothing);
|
||||
expect(find.text('Request to Join'), findsOneWidget);
|
||||
expect(find.text('NorCal'), findsOneWidget);
|
||||
expect(find.text('Sport Touring'), findsOneWidget);
|
||||
expect(find.text('OWNER'), findsOneWidget);
|
||||
|
|
@ -53,7 +53,7 @@ void main() {
|
|||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders open admission badge correctly with group_add_outlined icon', (tester) async {
|
||||
testWidgets('renders open admission badge correctly', (tester) async {
|
||||
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -68,26 +68,6 @@ void main() {
|
|||
);
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -31,53 +31,6 @@ void main() {
|
|||
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', () {
|
||||
final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1));
|
||||
final p = ClubMembershipPresentation.resolve(
|
||||
|
|
|
|||
|
|
@ -225,103 +225,5 @@ void main() {
|
|||
expect(find.text('Accept'), 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
|
|
@ -229,125 +229,5 @@ void main() {
|
|||
);
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1130,94 +1130,5 @@ void main() {
|
|||
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ 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/location_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';
|
||||
|
||||
void main() {
|
||||
|
|
@ -168,81 +166,5 @@ void main() {
|
|||
final edgeInsets = paddingWidget.padding as EdgeInsets;
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,137 +129,6 @@ void main() {
|
|||
expect(find.textContaining('Dahlonega Chevron'), 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', () {
|
||||
|
|
|
|||
|
|
@ -329,70 +329,5 @@ void main() {
|
|||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
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));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -118,22 +118,6 @@ void main() {
|
|||
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 {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -214,21 +198,6 @@ void main() {
|
|||
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 {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -329,18 +329,5 @@ void main() {
|
|||
expect(alerts.reports, isEmpty);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,63 +165,4 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -225,77 +225,4 @@ void main() {
|
|||
// No RenderFlex overflow
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ void main() {
|
|||
|
||||
// 3. We land directly on Community Feed!
|
||||
expect(find.byType(SocialFeedScreen), findsOneWidget);
|
||||
expect(find.text('SQUAD FEED'), findsOneWidget);
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
|
||||
// 4. Tap 'Hub' toggle in AppBar to view Rider Hub
|
||||
final hubToggle = find.byKey(const ValueKey('social_tab_hub'));
|
||||
|
|
@ -63,6 +63,6 @@ void main() {
|
|||
await tester.tap(feedToggle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SQUAD FEED'), findsOneWidget);
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ void main() {
|
|||
|
||||
// Verify header and filter components render cleanly
|
||||
expect(find.text('Community Feed'), findsOneWidget);
|
||||
expect(find.text('SQUAD FEED'), findsOneWidget);
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
|
||||
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue