Compare commits
10 Commits
1fdca019fb
...
a573b2d275
| Author | SHA1 | Date |
|---|---|---|
|
|
a573b2d275 | |
|
|
32059f9b8a | |
|
|
48f1057ff0 | |
|
|
b1abb6b086 | |
|
|
70553e04a4 | |
|
|
8fc267d5fd | |
|
|
63df8f9a50 | |
|
|
2524cd066e | |
|
|
74943a8420 | |
|
|
3f64083bdd |
|
|
@ -39,12 +39,32 @@ function isAppPath(filePath) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNativeBuildPath(filePath) {
|
||||||
|
const p = normalizePath(filePath);
|
||||||
|
if (!p) return false;
|
||||||
|
// Native build requires: Android code, CI-critical files, or build config.
|
||||||
|
// Only CI files that affect build behavior trigger release builds; doc/comment changes do not.
|
||||||
|
// Composite actions: only Android-specific actions (flutter-ci-setup) trigger builds.
|
||||||
|
return (
|
||||||
|
p.startsWith('android/') ||
|
||||||
|
p === '.github/workflows/android-build.yml' ||
|
||||||
|
p.startsWith('.github/scripts/') ||
|
||||||
|
p.startsWith('.github/actions/flutter-ci-setup/') ||
|
||||||
|
p === 'pubspec.yaml' ||
|
||||||
|
p === 'pubspec.lock' ||
|
||||||
|
p === 'analysis_options.yaml' ||
|
||||||
|
p === 'build.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isCiPath(filePath) {
|
function isCiPath(filePath) {
|
||||||
const p = normalizePath(filePath);
|
const p = normalizePath(filePath);
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
|
// CI paths: workflows, scripts, and Android-specific composite actions.
|
||||||
return (
|
return (
|
||||||
p === '.github/workflows/android-build.yml' ||
|
p === '.github/workflows/android-build.yml' ||
|
||||||
p.startsWith('.github/scripts/')
|
p.startsWith('.github/scripts/') ||
|
||||||
|
p.startsWith('.github/actions/flutter-ci-setup/')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +73,7 @@ function classifyFiles(fileList) {
|
||||||
return {
|
return {
|
||||||
android_changed: false,
|
android_changed: false,
|
||||||
app_changed: false,
|
app_changed: false,
|
||||||
|
native_build_changed: false,
|
||||||
appFiles: [],
|
appFiles: [],
|
||||||
ciFiles: [],
|
ciFiles: [],
|
||||||
unrelatedFiles: []
|
unrelatedFiles: []
|
||||||
|
|
@ -62,6 +83,7 @@ function classifyFiles(fileList) {
|
||||||
const appFiles = [];
|
const appFiles = [];
|
||||||
const ciFiles = [];
|
const ciFiles = [];
|
||||||
const unrelatedFiles = [];
|
const unrelatedFiles = [];
|
||||||
|
const nativeBuildFiles = [];
|
||||||
|
|
||||||
for (const rawFile of fileList) {
|
for (const rawFile of fileList) {
|
||||||
const file = normalizePath(rawFile);
|
const file = normalizePath(rawFile);
|
||||||
|
|
@ -74,14 +96,21 @@ function classifyFiles(fileList) {
|
||||||
} else {
|
} else {
|
||||||
unrelatedFiles.push(file);
|
unrelatedFiles.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track files that trigger native builds (Android code, CI, config)
|
||||||
|
if (isNativeBuildPath(file)) {
|
||||||
|
nativeBuildFiles.push(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app_changed = appFiles.length > 0;
|
const app_changed = appFiles.length > 0;
|
||||||
const android_changed = appFiles.length > 0 || ciFiles.length > 0;
|
const android_changed = appFiles.length > 0 || ciFiles.length > 0;
|
||||||
|
const native_build_changed = nativeBuildFiles.length > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
android_changed,
|
android_changed,
|
||||||
app_changed,
|
app_changed,
|
||||||
|
native_build_changed,
|
||||||
appFiles,
|
appFiles,
|
||||||
ciFiles,
|
ciFiles,
|
||||||
unrelatedFiles
|
unrelatedFiles
|
||||||
|
|
@ -124,6 +153,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
||||||
return {
|
return {
|
||||||
android_changed: true,
|
android_changed: true,
|
||||||
app_changed: true,
|
app_changed: true,
|
||||||
|
native_build_changed: true,
|
||||||
reason: 'Manual workflow_dispatch; full pipeline authorized',
|
reason: 'Manual workflow_dispatch; full pipeline authorized',
|
||||||
appFiles: [],
|
appFiles: [],
|
||||||
ciFiles: [],
|
ciFiles: [],
|
||||||
|
|
@ -135,6 +165,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
||||||
if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) {
|
if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) {
|
||||||
return {
|
return {
|
||||||
android_changed: true,
|
android_changed: true,
|
||||||
|
native_build_changed: true,
|
||||||
app_changed: true,
|
app_changed: true,
|
||||||
reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`,
|
reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`,
|
||||||
appFiles: [],
|
appFiles: [],
|
||||||
|
|
@ -147,6 +178,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
||||||
if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) {
|
if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) {
|
||||||
return {
|
return {
|
||||||
android_changed: true,
|
android_changed: true,
|
||||||
|
native_build_changed: true,
|
||||||
app_changed: true,
|
app_changed: true,
|
||||||
reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`,
|
reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`,
|
||||||
appFiles: [],
|
appFiles: [],
|
||||||
|
|
@ -159,6 +191,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
||||||
if (baseSha.trim() === headSha.trim()) {
|
if (baseSha.trim() === headSha.trim()) {
|
||||||
return {
|
return {
|
||||||
android_changed: false,
|
android_changed: false,
|
||||||
|
native_build_changed: false,
|
||||||
app_changed: false,
|
app_changed: false,
|
||||||
reason: 'Base SHA and Head SHA are identical; no changes detected',
|
reason: 'Base SHA and Head SHA are identical; no changes detected',
|
||||||
appFiles: [],
|
appFiles: [],
|
||||||
|
|
@ -174,6 +207,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
android_changed: true,
|
android_changed: true,
|
||||||
|
native_build_changed: true,
|
||||||
app_changed: true,
|
app_changed: true,
|
||||||
reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`,
|
reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`,
|
||||||
appFiles: [],
|
appFiles: [],
|
||||||
|
|
@ -211,6 +245,7 @@ function runCli() {
|
||||||
|
|
||||||
console.log(`[detect-android-changes] android_changed=${result.android_changed}`);
|
console.log(`[detect-android-changes] android_changed=${result.android_changed}`);
|
||||||
console.log(`[detect-android-changes] app_changed=${result.app_changed}`);
|
console.log(`[detect-android-changes] app_changed=${result.app_changed}`);
|
||||||
|
console.log(`[detect-android-changes] native_build_changed=${result.native_build_changed}`);
|
||||||
console.log(`[detect-android-changes] Reason: ${result.reason}`);
|
console.log(`[detect-android-changes] Reason: ${result.reason}`);
|
||||||
|
|
||||||
if (result.appFiles && result.appFiles.length > 0) {
|
if (result.appFiles && result.appFiles.length > 0) {
|
||||||
|
|
@ -238,7 +273,7 @@ function runCli() {
|
||||||
if (githubOutput) {
|
if (githubOutput) {
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
githubOutput,
|
githubOutput,
|
||||||
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\n`
|
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\nnative_build_changed=${result.native_build_changed}\n`
|
||||||
);
|
);
|
||||||
console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`);
|
console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`);
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +291,7 @@ module.exports = {
|
||||||
isZeroSha,
|
isZeroSha,
|
||||||
isAppPath,
|
isAppPath,
|
||||||
isCiPath,
|
isCiPath,
|
||||||
|
isNativeBuildPath,
|
||||||
classifyFiles,
|
classifyFiles,
|
||||||
getChangedFiles,
|
getChangedFiles,
|
||||||
detectAndroidChanges
|
detectAndroidChanges
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const {
|
||||||
isZeroSha,
|
isZeroSha,
|
||||||
isAppPath,
|
isAppPath,
|
||||||
isCiPath,
|
isCiPath,
|
||||||
|
isNativeBuildPath,
|
||||||
classifyFiles,
|
classifyFiles,
|
||||||
getChangedFiles,
|
getChangedFiles,
|
||||||
detectAndroidChanges
|
detectAndroidChanges
|
||||||
|
|
@ -71,14 +72,37 @@ describe('File Path Classification', () => {
|
||||||
assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true);
|
assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true);
|
||||||
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true);
|
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true);
|
||||||
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true);
|
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true);
|
||||||
|
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/action.yml'), true);
|
||||||
|
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/index.js'), true);
|
||||||
|
|
||||||
// Negative matches for Android CI paths
|
// Negative matches for Android CI paths
|
||||||
assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false);
|
assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false);
|
||||||
assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false);
|
assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false);
|
||||||
assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false);
|
assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false);
|
||||||
assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false);
|
assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false);
|
||||||
|
assert.strictEqual(isCiPath('.github/actions/website-deploy/action.yml'), false);
|
||||||
|
assert.strictEqual(isCiPath('.github/actions/other-action/index.js'), false);
|
||||||
assert.strictEqual(isCiPath('.github/pull_request_template.md'), false);
|
assert.strictEqual(isCiPath('.github/pull_request_template.md'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('identifies native build paths correctly', () => {
|
||||||
|
assert.strictEqual(isNativeBuildPath('android/app/build.gradle'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/workflows/android-build.yml'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/scripts/detect-android-changes.cjs'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/action.yml'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/index.js'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('pubspec.yaml'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('pubspec.lock'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('analysis_options.yaml'), true);
|
||||||
|
assert.strictEqual(isNativeBuildPath('build.yaml'), true);
|
||||||
|
|
||||||
|
// Negative matches for native build paths
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/workflows/website-ci.yml'), false);
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/actions/website-deploy/action.yml'), false);
|
||||||
|
assert.strictEqual(isNativeBuildPath('.github/actions/other-action/action.yml'), false);
|
||||||
|
assert.strictEqual(isNativeBuildPath('lib/main.dart'), false);
|
||||||
|
assert.strictEqual(isNativeBuildPath('website/app/page.tsx'), false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('classifyFiles Scenarios', () => {
|
describe('classifyFiles Scenarios', () => {
|
||||||
|
|
@ -145,6 +169,7 @@ describe('classifyFiles Scenarios', () => {
|
||||||
const result = classifyFiles(files);
|
const result = classifyFiles(files);
|
||||||
assert.strictEqual(result.android_changed, true);
|
assert.strictEqual(result.android_changed, true);
|
||||||
assert.strictEqual(result.app_changed, true);
|
assert.strictEqual(result.app_changed, true);
|
||||||
|
assert.strictEqual(result.native_build_changed, false);
|
||||||
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
|
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -168,6 +193,7 @@ describe('classifyFiles Scenarios', () => {
|
||||||
const result = classifyFiles(files);
|
const result = classifyFiles(files);
|
||||||
assert.strictEqual(result.android_changed, true);
|
assert.strictEqual(result.android_changed, true);
|
||||||
assert.strictEqual(result.app_changed, true);
|
assert.strictEqual(result.app_changed, true);
|
||||||
|
assert.strictEqual(result.native_build_changed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies pubspec change as true', () => {
|
it('classifies pubspec change as true', () => {
|
||||||
|
|
@ -184,7 +210,9 @@ describe('classifyFiles Scenarios', () => {
|
||||||
|
|
||||||
it('classifies analysis_options and build.yaml changes as true', () => {
|
it('classifies analysis_options and build.yaml changes as true', () => {
|
||||||
assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true);
|
assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true);
|
||||||
|
assert.strictEqual(classifyFiles(['analysis_options.yaml']).native_build_changed, true);
|
||||||
assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true);
|
assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true);
|
||||||
|
assert.strictEqual(classifyFiles(['build.yaml']).native_build_changed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies assets change as true', () => {
|
it('classifies assets change as true', () => {
|
||||||
|
|
@ -239,6 +267,26 @@ describe('classifyFiles Scenarios', () => {
|
||||||
assert.strictEqual(result.android_changed, false);
|
assert.strictEqual(result.android_changed, false);
|
||||||
assert.strictEqual(result.app_changed, false);
|
assert.strictEqual(result.app_changed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('classifies composite action (flutter-ci-setup) change as android_changed=true, native_build_changed=true', () => {
|
||||||
|
const files = ['.github/actions/flutter-ci-setup/action.yml'];
|
||||||
|
const result = classifyFiles(files);
|
||||||
|
assert.strictEqual(result.android_changed, true);
|
||||||
|
assert.strictEqual(result.app_changed, false);
|
||||||
|
assert.strictEqual(result.native_build_changed, true);
|
||||||
|
assert.strictEqual(result.ciFiles.length, 1);
|
||||||
|
assert.deepStrictEqual(result.ciFiles, ['.github/actions/flutter-ci-setup/action.yml']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies non-Android composite action (e.g., website-deploy) as false', () => {
|
||||||
|
const files = ['.github/actions/website-deploy/action.yml'];
|
||||||
|
const result = classifyFiles(files);
|
||||||
|
assert.strictEqual(result.android_changed, false);
|
||||||
|
assert.strictEqual(result.app_changed, false);
|
||||||
|
assert.strictEqual(result.native_build_changed, false);
|
||||||
|
assert.strictEqual(result.ciFiles.length, 0);
|
||||||
|
assert.strictEqual(result.unrelatedFiles.length, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('detectAndroidChanges Workflow Event Logic', () => {
|
describe('detectAndroidChanges Workflow Event Logic', () => {
|
||||||
|
|
@ -253,6 +301,7 @@ describe('detectAndroidChanges Workflow Event Logic', () => {
|
||||||
});
|
});
|
||||||
assert.strictEqual(result.android_changed, true);
|
assert.strictEqual(result.android_changed, true);
|
||||||
assert.strictEqual(result.app_changed, true);
|
assert.strictEqual(result.app_changed, true);
|
||||||
|
assert.strictEqual(result.native_build_changed, true);
|
||||||
assert.match(result.reason, /Manual workflow_dispatch/);
|
assert.match(result.reason, /Manual workflow_dispatch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -566,6 +615,271 @@ describe('Workflow Scenario Matrix Verification', () => {
|
||||||
|
|
||||||
assert.strictEqual(result.android_changed, true);
|
assert.strictEqual(result.android_changed, true);
|
||||||
assert.strictEqual(result.app_changed, true);
|
assert.strictEqual(result.app_changed, true);
|
||||||
|
assert.strictEqual(result.native_build_changed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Release Job Gating (native_build_changed output)', () => {
|
||||||
|
const baseSha = '0000000000000000000000000000000000000001';
|
||||||
|
const headSha = '0000000000000000000000000000000000000002';
|
||||||
|
|
||||||
|
it('Dart-only changes skip release job (native_build_changed=false)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart', 'assets/icons/app.png'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, false, 'Dart-only changes should skip release');
|
||||||
|
assert.strictEqual(result.app_changed, true, 'app_changed should still be true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Android code changes trigger release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'Android changes should trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CI-only changes (.github/) trigger release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['.github/workflows/other.yml', '.github/scripts/helper.sh'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'CI changes should trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Config file changes (pubspec.yaml) trigger release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'pubspec.yaml changes should trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Build config changes (build.yaml) trigger release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['build.yaml'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'build.yaml changes should trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Mixed Dart and Android changes trigger release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['lib/main.dart', 'android/app/build.gradle'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'Mixed changes should trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manual dispatch always triggers release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'workflow_dispatch',
|
||||||
|
baseSha: '',
|
||||||
|
headSha: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'Manual dispatch should always trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Invalid base SHA fails safe to trigger release (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha: 'invalid',
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['lib/main.dart'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'Invalid SHA should fail safe to trigger release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Unrelated changes (docs/, website/) skip release job (native_build_changed=false)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/index.html'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, false, 'Unrelated changes should skip release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dependency changes (pubspec.lock) trigger release job (native_build_changed=true)', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['pubspec.lock'] });
|
||||||
|
|
||||||
|
assert.strictEqual(result.native_build_changed, true, 'pubspec.lock changes should trigger release');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitHub Actions Workflow Output Wiring and Release Job Conditions', () => {
|
||||||
|
const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a';
|
||||||
|
const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424';
|
||||||
|
|
||||||
|
it('detector outputs native_build_changed for GitHub Actions output file', () => {
|
||||||
|
// Simulate the detector writing to GITHUB_OUTPUT
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['lib/main.dart'] });
|
||||||
|
|
||||||
|
// The detector MUST set native_build_changed=false for Dart-only changes
|
||||||
|
assert.strictEqual(result.native_build_changed, false, 'Dart-only should set native_build_changed=false');
|
||||||
|
assert.strictEqual(result.android_changed, true, 'But android_changed=true still drives heavy build');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: Dart-only changes skip release', () => {
|
||||||
|
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] });
|
||||||
|
|
||||||
|
// Release job condition evaluates to false
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, false, 'Dart-only should output false');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for Dart-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: Android changes run release', () => {
|
||||||
|
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
|
||||||
|
|
||||||
|
// Release job condition evaluates to true
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, true, 'Android changes should output true');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for Android changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: CI-critical changes run release', () => {
|
||||||
|
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] });
|
||||||
|
|
||||||
|
// Release job condition evaluates to true
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, true, 'CI-critical changes should output true');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for CI-critical changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: Config changes run release', () => {
|
||||||
|
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
|
||||||
|
|
||||||
|
// Release job condition evaluates to true
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, true, 'Config changes should output true');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for config changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: Unrelated changes skip release', () => {
|
||||||
|
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/app.tsx'] });
|
||||||
|
|
||||||
|
// Release job condition evaluates to false
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, false, 'Unrelated changes should output false');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for unrelated changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: Composite action changes run release', () => {
|
||||||
|
// flutter-ci-setup is a composite action used by analysis, test, and debug APK jobs
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['.github/actions/flutter-ci-setup/action.yml'] });
|
||||||
|
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, true, 'Composite action changes should output true');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for composite action changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow release condition evaluates correctly: Non-Android composite action (website-deploy) skips release', () => {
|
||||||
|
const result = detectAndroidChanges({
|
||||||
|
eventName: 'push',
|
||||||
|
baseSha,
|
||||||
|
headSha
|
||||||
|
}, { getChangedFilesFn: () => ['.github/actions/website-deploy/action.yml'] });
|
||||||
|
|
||||||
|
const nativeBuildChanged = result.native_build_changed;
|
||||||
|
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||||
|
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||||
|
|
||||||
|
assert.strictEqual(nativeBuildChanged, false, 'Non-Android composite actions should output false');
|
||||||
|
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for non-Android actions');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Workflow YAML Verification', () => {
|
||||||
|
it('verifies android-build.yml defines jobs.detect.outputs.native_build_changed', () => {
|
||||||
|
const workflowPath = '.github/workflows/android-build.yml';
|
||||||
|
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
|
||||||
|
|
||||||
|
// Check that the workflow file exists and contains the output mapping
|
||||||
|
assert.ok(workflowContent.includes('native_build_changed'),
|
||||||
|
'Workflow must expose native_build_changed output from detector step');
|
||||||
|
assert.ok(workflowContent.includes('steps.detector.outputs.native_build_changed'),
|
||||||
|
'Workflow must reference detector output with ${{ steps.detector.outputs.native_build_changed }}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies release job condition includes native_build_changed check', () => {
|
||||||
|
const workflowPath = '.github/workflows/android-build.yml';
|
||||||
|
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
|
||||||
|
|
||||||
|
// Check that release job condition references the output
|
||||||
|
assert.ok(workflowContent.includes("needs.detect.outputs.native_build_changed == 'true'"),
|
||||||
|
'Release job condition must check native_build_changed output');
|
||||||
|
assert.ok(workflowContent.includes('refs/heads/master'),
|
||||||
|
'Release job condition must ensure it only runs on master branch');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
android_changed: ${{ steps.detector.outputs.android_changed }}
|
android_changed: ${{ steps.detector.outputs.android_changed }}
|
||||||
app_changed: ${{ steps.detector.outputs.app_changed }}
|
app_changed: ${{ steps.detector.outputs.app_changed }}
|
||||||
|
native_build_changed: ${{ steps.detector.outputs.native_build_changed }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
@ -241,7 +242,7 @@ jobs:
|
||||||
name: Build signed release APK and AAB
|
name: Build signed release APK and AAB
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [detect, analyze-and-build]
|
needs: [detect, analyze-and-build]
|
||||||
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.app_changed == 'true'))
|
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.native_build_changed == 'true'))
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,8 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Text(
|
||||||
finishLabel ?? 'Finish Ride',
|
finishLabel ?? 'Finish Ride',
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|
@ -238,7 +240,6 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Ready / Standby state: Single prominent start button
|
// Ready / Standby state: Single prominent start button
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -253,18 +254,24 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
notifier.startRide();
|
notifier.startRide();
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan),
|
icon: Icon(Icons.play_arrow_rounded, size: compact ? 22 : 26, color: colors.brandCyan),
|
||||||
label: const Text(
|
label: const FittedBox(
|
||||||
'Start Session',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(
|
child: Text(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
'Start Session',
|
||||||
fontSize: 17,
|
maxLines: 1,
|
||||||
fontWeight: FontWeight.w700,
|
softWrap: false,
|
||||||
letterSpacing: 0.5,
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -138,45 +138,48 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
|
||||||
minHeight: constraints.maxHeight,
|
minHeight: constraints.maxHeight,
|
||||||
),
|
),
|
||||||
child: IntrinsicHeight(
|
child: IntrinsicHeight(
|
||||||
child: NavigationRail(
|
child: () {
|
||||||
minWidth: 58,
|
final isShortRail = constraints.maxHeight < 400;
|
||||||
groupAlignment: 0.0,
|
return NavigationRail(
|
||||||
selectedIndex: selectedIndex,
|
minWidth: isShortRail ? 52 : 58,
|
||||||
onDestinationSelected: _onDestinationSelected,
|
groupAlignment: isShortRail ? -1.0 : 0.0,
|
||||||
backgroundColor: colors.surfaceCanvas,
|
selectedIndex: selectedIndex,
|
||||||
indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
|
onDestinationSelected: _onDestinationSelected,
|
||||||
selectedIconTheme: IconThemeData(
|
backgroundColor: colors.surfaceCanvas,
|
||||||
color: colors.brandCyan,
|
indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
|
||||||
size: 21,
|
selectedIconTheme: IconThemeData(
|
||||||
),
|
color: colors.brandCyan,
|
||||||
unselectedIconTheme: IconThemeData(
|
size: isShortRail ? 19 : 21,
|
||||||
color: colors.textMuted,
|
),
|
||||||
size: 19,
|
unselectedIconTheme: IconThemeData(
|
||||||
),
|
color: colors.textMuted,
|
||||||
selectedLabelTextStyle: TextStyle(
|
size: isShortRail ? 17 : 19,
|
||||||
fontFamily: AppTypography.fontHeadline,
|
),
|
||||||
fontSize: 10.0,
|
selectedLabelTextStyle: TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
color: colors.brandCyan,
|
fontSize: isShortRail ? 9.0 : 10.0,
|
||||||
),
|
fontWeight: FontWeight.w700,
|
||||||
unselectedLabelTextStyle: TextStyle(
|
color: colors.brandCyan,
|
||||||
fontFamily: AppTypography.fontHeadline,
|
),
|
||||||
fontSize: 9.5,
|
unselectedLabelTextStyle: TextStyle(
|
||||||
fontWeight: FontWeight.w500,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
color: colors.textMuted,
|
fontSize: isShortRail ? 8.5 : 9.5,
|
||||||
),
|
fontWeight: FontWeight.w500,
|
||||||
labelType: NavigationRailLabelType.all,
|
color: colors.textMuted,
|
||||||
destinations: kAppNavDestinations.map((dest) {
|
),
|
||||||
return NavigationRailDestination(
|
labelType: NavigationRailLabelType.all,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
destinations: kAppNavDestinations.map((dest) {
|
||||||
icon: Icon(dest.icon, key: dest.railKey),
|
return NavigationRailDestination(
|
||||||
selectedIcon: dest.selectedIcon != null
|
padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
|
||||||
? Icon(dest.selectedIcon, key: dest.railKey)
|
icon: Icon(dest.icon, key: dest.railKey),
|
||||||
: null,
|
selectedIcon: dest.selectedIcon != null
|
||||||
label: Text(dest.label),
|
? Icon(dest.selectedIcon, key: dest.railKey)
|
||||||
);
|
: null,
|
||||||
}).toList(),
|
label: Text(dest.label),
|
||||||
),
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
// Header
|
// Header
|
||||||
Text(
|
Text(
|
||||||
'LEAN ANGLE',
|
'LEAN ANGLE',
|
||||||
|
key: const ValueKey('lean_angle_title'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: onSurfaceVariant,
|
color: onSurfaceVariant,
|
||||||
fontSize: compact ? 9 : 11,
|
fontSize: compact ? 9 : 11,
|
||||||
|
|
@ -240,22 +241,25 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 1 : 3),
|
SizedBox(height: compact ? 3 : 6),
|
||||||
|
|
||||||
// Motorcycle Leaning Silhouette with Stable Horizon
|
// Motorcycle Leaning Silhouette with Stable Horizon
|
||||||
RepaintBoundary(
|
RepaintBoundary(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
key: const ValueKey('lean_angle_rider_graphic'),
|
||||||
width: gaugeWidth,
|
width: gaugeWidth,
|
||||||
height: gaugeHeight,
|
height: gaugeHeight,
|
||||||
child: CustomPaint(
|
child: ClipRect(
|
||||||
size: Size(gaugeWidth, gaugeHeight),
|
child: CustomPaint(
|
||||||
painter: _MotorcycleLeanPainter(
|
size: Size(gaugeWidth, gaugeHeight),
|
||||||
leanAngle: displayLean,
|
painter: _MotorcycleLeanPainter(
|
||||||
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
|
leanAngle: displayLean,
|
||||||
accentColor: colors.brandCyan,
|
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
|
||||||
horizonColor: isDark ? Colors.white24 : Colors.black26,
|
accentColor: colors.brandCyan,
|
||||||
tickColor: colors.brandCyan,
|
horizonColor: isDark ? Colors.white24 : Colors.black26,
|
||||||
isDark: isDark,
|
tickColor: colors.brandCyan,
|
||||||
|
isDark: isDark,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -426,6 +430,10 @@ class _MotorcycleLeanPainter extends CustomPainter {
|
||||||
// 2. Draw Leaning Motorcycle Silhouette
|
// 2. Draw Leaning Motorcycle Silhouette
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.translate(contactPoint.dx, contactPoint.dy);
|
canvas.translate(contactPoint.dx, contactPoint.dy);
|
||||||
|
// Scale silhouette to fit comfortably below the top edge of the gauge
|
||||||
|
final availableTopSpace = horizonY - 4.0;
|
||||||
|
final double scale = (availableTopSpace / 47.5).clamp(0.5, 0.95);
|
||||||
|
canvas.scale(scale);
|
||||||
final clampedLean = leanAngle.clamp(-60.0, 60.0);
|
final clampedLean = leanAngle.clamp(-60.0, 60.0);
|
||||||
canvas.rotate(clampedLean * math.pi / 180.0);
|
canvas.rotate(clampedLean * math.pi / 180.0);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -326,17 +326,29 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Row(
|
const FittedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
child: Row(
|
||||||
DashboardLayoutSwitcher(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
GPSStatusIndicator(showLabel: false),
|
children: [
|
||||||
HeadingIndicator(compact: true),
|
DashboardLayoutSwitcher(),
|
||||||
RideStatusChip(),
|
SizedBox(width: 4),
|
||||||
],
|
QuickSwarmReportButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
QuickSquadFeedButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
GPSStatusIndicator(showLabel: false),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
HeadingIndicator(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
RideStatusChip(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Center(
|
const Expanded(
|
||||||
child: SpeedReadout(size: SpeedReadoutSize.compact),
|
child: Center(
|
||||||
|
child: SpeedReadout(size: SpeedReadoutSize.compact),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
|
@ -386,7 +398,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
|
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 140,
|
width: 164,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -463,6 +475,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
DashboardLayoutSwitcher(),
|
DashboardLayoutSwitcher(),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
|
QuickSwarmReportButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
QuickSquadFeedButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
GPSStatusIndicator(),
|
GPSStatusIndicator(),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
HeadingIndicator(compact: true),
|
HeadingIndicator(compact: true),
|
||||||
|
|
@ -472,7 +488,9 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const NavigationManeuverCard(compact: true),
|
const NavigationManeuverCard(compact: true),
|
||||||
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
const Expanded(
|
||||||
|
child: Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
||||||
|
),
|
||||||
const RideActionButton(compact: true),
|
const RideActionButton(compact: true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const PerformanceLaunchShortcut(compact: true),
|
const PerformanceLaunchShortcut(compact: true),
|
||||||
|
|
|
||||||
|
|
@ -112,14 +112,17 @@ class GarageScreen extends ConsumerWidget {
|
||||||
if (isMultiColumn) {
|
if (isMultiColumn) {
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 550,
|
maxCrossAxisExtent: responsive.isShortViewport ? 600 : 550,
|
||||||
mainAxisExtent: 420,
|
mainAxisExtent: responsive.isShortViewport ? 300 : 420,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
),
|
),
|
||||||
itemCount: bikes.length,
|
itemCount: bikes.length + 1,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == bikes.length) {
|
||||||
|
return _buildAddMotorcycleCard(context, ref, bikes);
|
||||||
|
}
|
||||||
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true);
|
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -127,8 +130,11 @@ class GarageScreen extends ConsumerWidget {
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
itemCount: bikes.length,
|
itemCount: bikes.length + 1,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == bikes.length) {
|
||||||
|
return _buildAddMotorcycleCard(context, ref, bikes);
|
||||||
|
}
|
||||||
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false);
|
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -136,19 +142,6 @@ class GarageScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty
|
|
||||||
? FloatingActionButton.extended(
|
|
||||||
key: const ValueKey('garage_add_motorcycle_fab'),
|
|
||||||
backgroundColor: colors.cyberCyan,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
icon: const Icon(Icons.two_wheeler),
|
|
||||||
label: const Text(
|
|
||||||
'Add Motorcycle',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.2),
|
|
||||||
),
|
|
||||||
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +166,27 @@ class GarageScreen extends ConsumerWidget {
|
||||||
context.push('/garage/add');
|
context.push('/garage/add');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAddMotorcycleCard(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
List<GarageMotorcycle> bikes,
|
||||||
|
) {
|
||||||
|
final colors = context.motorColors;
|
||||||
|
return Card(
|
||||||
|
child: Center(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Motorcycle'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colors.cyberCyan,
|
||||||
|
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)),
|
||||||
|
),
|
||||||
|
onPressed: () => _onAddMotorcycle(context, ref, bikes),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(
|
Widget _buildEmptyState(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
|
|
@ -306,7 +320,7 @@ class GarageScreen extends ConsumerWidget {
|
||||||
year: bike.year,
|
year: bike.year,
|
||||||
make: bike.makeName,
|
make: bike.makeName,
|
||||||
model: bike.modelName,
|
model: bike.modelName,
|
||||||
height: 150,
|
height: isGrid ? 100 : 150,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
|
||||||
|
|
@ -1207,17 +1207,29 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 560,
|
||||||
|
maxHeight: size.height - viewInsets.bottom - 48,
|
||||||
|
),
|
||||||
title: const Text('Log New Service'),
|
title: const Text('Log New Service'),
|
||||||
content: SingleChildScrollView(
|
content: Column(
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: selectedType,
|
initialValue: selectedType,
|
||||||
|
isExpanded: true,
|
||||||
decoration: const InputDecoration(labelText: 'Service Type'),
|
decoration: const InputDecoration(labelText: 'Service Type'),
|
||||||
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
items: serviceTypes
|
||||||
|
.map((t) => DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setState(() => selectedType = val);
|
if (val != null) setState(() => selectedType = val);
|
||||||
},
|
},
|
||||||
|
|
@ -1268,7 +1280,6 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
|
@ -1340,8 +1351,14 @@ class MotorcycleDetailScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: selectedType,
|
initialValue: selectedType,
|
||||||
|
isExpanded: true,
|
||||||
decoration: const InputDecoration(labelText: 'Service Type'),
|
decoration: const InputDecoration(labelText: 'Service Type'),
|
||||||
items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
items: serviceTypes
|
||||||
|
.map((t) => DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setState(() => selectedType = val);
|
if (val != null) setState(() => selectedType = val);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -378,8 +378,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
final onSurface = theme.colorScheme.onSurface;
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
|
@ -388,91 +391,112 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final routesAsync = ref.watch(userRoutesProvider(user.uid));
|
final routesAsync = ref.watch(userRoutesProvider(user.uid));
|
||||||
|
final viewportSize = MediaQuery.sizeOf(sheetCtx);
|
||||||
|
final viewInsets = MediaQuery.viewInsetsOf(sheetCtx);
|
||||||
|
final isCompact = viewportSize.height < 480 || viewInsets.bottom > 0;
|
||||||
|
final availableHeight = (viewportSize.height - viewInsets.bottom)
|
||||||
|
.clamp(120.0, viewportSize.height);
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: BoxConstraints(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
maxHeight: availableHeight * 0.9,
|
||||||
children: [
|
maxWidth: 560,
|
||||||
Text(
|
),
|
||||||
'USE EXISTING ROUTE',
|
child: SingleChildScrollView(
|
||||||
style: TextStyle(
|
padding: EdgeInsets.fromLTRB(
|
||||||
color: onSurface,
|
16,
|
||||||
fontWeight: FontWeight.bold,
|
isCompact ? 12 : 16,
|
||||||
fontSize: 16,
|
16,
|
||||||
),
|
isCompact ? 12 : 16,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
child: Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
children: [
|
||||||
),
|
Text(
|
||||||
const SizedBox(height: 12),
|
'USE EXISTING ROUTE',
|
||||||
routesAsync.when(
|
style: TextStyle(
|
||||||
loading: () => Center(
|
color: onSurface,
|
||||||
child: Padding(
|
fontWeight: FontWeight.bold,
|
||||||
padding: const EdgeInsets.all(24),
|
fontSize: 16,
|
||||||
child:
|
|
||||||
CircularProgressIndicator(color: colors.cyberCyan),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (err, _) => Text(
|
const SizedBox(height: 6),
|
||||||
'Failed to load routes: $err',
|
Text(
|
||||||
style: TextStyle(color: colors.raceRed),
|
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
||||||
|
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
data: (routes) {
|
const SizedBox(height: 10),
|
||||||
if (routes.isEmpty) {
|
routesAsync.when(
|
||||||
return Padding(
|
loading: () => Center(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
child: Padding(
|
||||||
child: Center(
|
padding: EdgeInsets.all(isCompact ? 12 : 24),
|
||||||
child: Text(
|
child: CircularProgressIndicator(
|
||||||
'No saved routes found.',
|
color: colors.cyberCyan),
|
||||||
style: TextStyle(color: onSurfaceVariant),
|
),
|
||||||
|
),
|
||||||
|
error: (err, _) => Text(
|
||||||
|
'Failed to load routes: $err',
|
||||||
|
style: TextStyle(color: colors.raceRed),
|
||||||
|
),
|
||||||
|
data: (routes) {
|
||||||
|
if (routes.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: isCompact ? 12 : 24),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No saved routes found.',
|
||||||
|
style: TextStyle(color: onSurfaceVariant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: availableHeight * 0.5,
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: routes.length,
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final route = routes[idx];
|
||||||
|
return ListTile(
|
||||||
|
dense: isCompact,
|
||||||
|
leading: Icon(Icons.alt_route,
|
||||||
|
color: colors.cyberCyan),
|
||||||
|
title: Text(
|
||||||
|
route.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurface,
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurfaceVariant, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetCtx);
|
||||||
|
_applySavedRoute(route);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return ConstrainedBox(
|
),
|
||||||
constraints: BoxConstraints(
|
],
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
),
|
||||||
),
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: routes.length,
|
|
||||||
itemBuilder: (ctx, idx) {
|
|
||||||
final route = routes[idx];
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(Icons.alt_route,
|
|
||||||
color: colors.cyberCyan),
|
|
||||||
title: Text(
|
|
||||||
route.title,
|
|
||||||
style: TextStyle(
|
|
||||||
color: onSurface,
|
|
||||||
fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetCtx);
|
|
||||||
_applySavedRoute(route);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,9 +533,34 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
final theme = Theme.of(ctx);
|
final theme = Theme.of(ctx);
|
||||||
final onSurface = theme.colorScheme.onSurface;
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
final screenSize = MediaQuery.sizeOf(ctx);
|
||||||
|
final isCompactDialog = screenSize.height < 480;
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
|
insetPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: isCompactDialog ? 8 : 24,
|
||||||
|
),
|
||||||
|
titlePadding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isCompactDialog ? 12 : 20,
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isCompactDialog ? 10 : 16,
|
||||||
|
20,
|
||||||
|
isCompactDialog ? 8 : 16,
|
||||||
|
),
|
||||||
|
actionsPadding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
isCompactDialog ? 8 : 16,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
|
@ -520,7 +569,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
||||||
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
|
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: SingleChildScrollView(
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -541,6 +591,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: onSurface),
|
style: TextStyle(color: onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Custom Stop Label (Optional)',
|
labelText: 'Custom Stop Label (Optional)',
|
||||||
|
|
@ -589,6 +640,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: notesCtrl,
|
controller: notesCtrl,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: onSurface),
|
style: TextStyle(color: onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Notes (optional)',
|
labelText: 'Notes (optional)',
|
||||||
|
|
@ -698,11 +750,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surfaceContainer,
|
backgroundColor: colors.surfaceContainer,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
|
widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
@ -738,18 +793,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Form(
|
body: SafeArea(
|
||||||
key: _formKey,
|
top: false,
|
||||||
child: ResponsiveLayout(
|
child: Form(
|
||||||
builder: (context, snapshot) {
|
key: _formKey,
|
||||||
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
child: ResponsiveLayout(
|
||||||
(snapshot.isLandscape && snapshot.width >= 720);
|
builder: (context, snapshot) {
|
||||||
|
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||||
|
(snapshot.isLandscape && snapshot.width >= 600);
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
return _buildWideForm(
|
||||||
}
|
context,
|
||||||
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
colors,
|
||||||
},
|
snapshot,
|
||||||
|
canSubmit,
|
||||||
|
clubAsync,
|
||||||
|
memberAsync,
|
||||||
|
clubValidationError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildCompactForm(
|
||||||
|
context,
|
||||||
|
colors,
|
||||||
|
snapshot,
|
||||||
|
canSubmit,
|
||||||
|
clubAsync,
|
||||||
|
memberAsync,
|
||||||
|
clubValidationError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -764,52 +838,65 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
AsyncValue<ClubMember?>? memberAsync,
|
AsyncValue<ClubMember?>? memberAsync,
|
||||||
String? clubValidationError,
|
String? clubValidationError,
|
||||||
) {
|
) {
|
||||||
return SingleChildScrollView(
|
final isCompact = snapshot.isLandscape || snapshot.isShortViewport;
|
||||||
padding: const EdgeInsets.all(24),
|
final sectionGap = isCompact ? 12.0 : 24.0;
|
||||||
child: Column(
|
final outerPadding = isCompact
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
? const EdgeInsets.symmetric(horizontal: 16, vertical: 10)
|
||||||
children: [
|
: const EdgeInsets.all(24);
|
||||||
if (widget.clubId != null)
|
final colGap = isCompact ? 16.0 : 24.0;
|
||||||
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
return Scrollbar(
|
||||||
Row(
|
thumbVisibility: isCompact,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
// Left Column: Details, Schedule, Privacy
|
padding: outerPadding,
|
||||||
Expanded(
|
child: Column(
|
||||||
flex: 5,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (widget.clubId != null)
|
||||||
children: [
|
_buildClubAffiliationHeader(
|
||||||
_buildRideDetailsSection(colors),
|
colors, clubAsync, memberAsync, clubValidationError),
|
||||||
const SizedBox(height: 24),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
_buildScheduleSection(colors),
|
Row(
|
||||||
const SizedBox(height: 24),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_buildPrivacySection(colors),
|
children: [
|
||||||
],
|
// Left Column: Details, Schedule, Privacy
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildRideDetailsSection(colors, isCompact: isCompact),
|
||||||
|
SizedBox(height: sectionGap),
|
||||||
|
_buildScheduleSection(colors, isCompact: isCompact),
|
||||||
|
SizedBox(height: sectionGap),
|
||||||
|
_buildPrivacySection(colors, isCompact: isCompact),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: colGap),
|
||||||
const SizedBox(width: 24),
|
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
|
||||||
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
|
Expanded(
|
||||||
Expanded(
|
flex: 5,
|
||||||
flex: 5,
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
_buildMeetingPointSection(colors, isCompact: isCompact),
|
||||||
_buildMeetingPointSection(colors),
|
SizedBox(height: sectionGap),
|
||||||
const SizedBox(height: 24),
|
_buildDestinationAndStopsSection(colors,
|
||||||
_buildDestinationAndStopsSection(colors),
|
isCompact: isCompact),
|
||||||
const SizedBox(height: 24),
|
SizedBox(height: sectionGap),
|
||||||
_buildMapPreviewSection(colors, snapshot),
|
_buildMapPreviewSection(colors, snapshot),
|
||||||
const SizedBox(height: 24),
|
SizedBox(height: sectionGap),
|
||||||
_buildSubmitButton(colors, canSubmit),
|
_buildSubmitButton(colors, canSubmit),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
SizedBox(height: isCompact ? 16 : 32),
|
||||||
const SizedBox(height: 32),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -823,27 +910,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
AsyncValue<ClubMember?>? memberAsync,
|
AsyncValue<ClubMember?>? memberAsync,
|
||||||
String? clubValidationError,
|
String? clubValidationError,
|
||||||
) {
|
) {
|
||||||
return ListView(
|
final isCompact = snapshot.isShortViewport;
|
||||||
padding: const EdgeInsets.all(20),
|
final sectionGap = isCompact ? 14.0 : 24.0;
|
||||||
children: [
|
return Scrollbar(
|
||||||
if (widget.clubId != null)
|
thumbVisibility: isCompact,
|
||||||
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
child: SingleChildScrollView(
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
_buildRideDetailsSection(colors),
|
padding: EdgeInsets.all(isCompact ? 14 : 20),
|
||||||
const SizedBox(height: 24),
|
child: Column(
|
||||||
_buildScheduleSection(colors),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const SizedBox(height: 24),
|
children: [
|
||||||
_buildMeetingPointSection(colors),
|
if (widget.clubId != null)
|
||||||
const SizedBox(height: 24),
|
_buildClubAffiliationHeader(
|
||||||
_buildDestinationAndStopsSection(colors),
|
colors, clubAsync, memberAsync, clubValidationError),
|
||||||
const SizedBox(height: 24),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
_buildMapPreviewSection(colors, snapshot),
|
_buildRideDetailsSection(colors, isCompact: isCompact),
|
||||||
const SizedBox(height: 24),
|
SizedBox(height: sectionGap),
|
||||||
_buildPrivacySection(colors),
|
_buildScheduleSection(colors, isCompact: isCompact),
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: sectionGap),
|
||||||
_buildSubmitButton(colors, canSubmit),
|
_buildMeetingPointSection(colors, isCompact: isCompact),
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: sectionGap),
|
||||||
],
|
_buildDestinationAndStopsSection(colors, isCompact: isCompact),
|
||||||
|
SizedBox(height: sectionGap),
|
||||||
|
_buildMapPreviewSection(colors, snapshot),
|
||||||
|
SizedBox(height: sectionGap),
|
||||||
|
_buildPrivacySection(colors, isCompact: isCompact),
|
||||||
|
SizedBox(height: isCompact ? 16 : 32),
|
||||||
|
_buildSubmitButton(colors, canSubmit),
|
||||||
|
SizedBox(height: isCompact ? 16 : 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -858,8 +955,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
|
||||||
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
|
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -893,8 +990,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
|
||||||
if (validationError != null) {
|
if (validationError != null) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.raceRed.withValues(alpha: 0.12),
|
color: colors.raceRed.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -937,8 +1034,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
final member = memberAsync.value;
|
final member = memberAsync.value;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -1001,7 +1098,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.',
|
'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
@ -1017,7 +1114,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
|
||||||
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
|
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.raceRed.withValues(alpha: 0.15),
|
color: colors.raceRed.withValues(alpha: 0.15),
|
||||||
|
|
@ -1039,19 +1136,25 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRideDetailsSection(MotorSignalThemeColors colors) {
|
Widget _buildRideDetailsSection(
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
|
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('1. RIDE DETAILS', colors),
|
_buildSectionHeader('1. RIDE DETAILS', colors),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isCompact ? 6 : 10),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
'Ride Title',
|
'Ride Title',
|
||||||
'e.g. Blood Mountain Loop',
|
'e.g. Blood Mountain Loop',
|
||||||
colors,
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
),
|
),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().length < 3) {
|
if (v == null || v.trim().length < 3) {
|
||||||
|
|
@ -1060,50 +1163,58 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: fieldGap),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _descriptionController,
|
controller: _descriptionController,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
maxLines: 3,
|
maxLines: isCompact ? 2 : 3,
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
'Description & Route Notes',
|
'Description & Route Notes',
|
||||||
'Pace, skill level, gear recommendations...',
|
'Pace, skill level, gear recommendations...',
|
||||||
colors,
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScheduleSection(MotorSignalThemeColors colors) {
|
Widget _buildScheduleSection(
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('2. DATE & SCHEDULE', colors),
|
_buildSectionHeader('2. DATE & SCHEDULE', colors),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isCompact ? 6 : 10),
|
||||||
Card(
|
Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: EdgeInsets.all(isCompact ? 10 : 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
|
dense: isCompact,
|
||||||
|
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Icon(Icons.calendar_month, color: colors.cyberCyan),
|
leading: Icon(Icons.calendar_month, color: colors.cyberCyan),
|
||||||
title: Text('Date',
|
title: Text('Date',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
fontSize: 13)),
|
fontSize: isCompact ? 12 : 13)),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}',
|
'${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: isCompact ? 14 : 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
|
|
@ -1125,11 +1236,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Divider(color: colors.outlineVariant),
|
Divider(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
height: isCompact ? 8 : 16,
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
dense: isCompact,
|
||||||
|
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text('Meetup Arrival',
|
title: Text('Meetup Arrival',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
@ -1157,6 +1273,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
dense: isCompact,
|
||||||
|
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text('Kickstands Up (KSU)',
|
title: Text('Kickstands Up (KSU)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
@ -1192,12 +1310,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMeetingPointSection(MotorSignalThemeColors colors) {
|
Widget _buildMeetingPointSection(
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
|
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('3. MEETING LOCATION', colors),
|
_buildSectionHeader('3. MEETING LOCATION', colors),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isCompact ? 6 : 10),
|
||||||
AddressAutocompleteField(
|
AddressAutocompleteField(
|
||||||
labelText: 'Meeting Place Address / Location *',
|
labelText: 'Meeting Place Address / Location *',
|
||||||
hintText: 'Search meetup address or landmark...',
|
hintText: 'Search meetup address or landmark...',
|
||||||
|
|
@ -1213,14 +1335,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_triggerRouteCalculation();
|
_triggerRouteCalculation();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: fieldGap),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _meetupNameController,
|
controller: _meetupNameController,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
'Meeting Place Name',
|
'Meeting Place Name',
|
||||||
'e.g. Dahlonega Square / Shell Station',
|
'e.g. Dahlonega Square / Shell Station',
|
||||||
colors,
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
),
|
),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().isEmpty) {
|
if (v == null || v.trim().isEmpty) {
|
||||||
|
|
@ -1229,30 +1353,38 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: fieldGap),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _meetupNotesController,
|
controller: _meetupNotesController,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
'Meeting Notes (optional)',
|
'Meeting Notes (optional)',
|
||||||
'e.g. Park on the north side near pumps',
|
'e.g. Park on the north side near pumps',
|
||||||
colors,
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) {
|
Widget _buildDestinationAndStopsSection(
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
final canAdd = _canAddStop;
|
final canAdd = _canAddStop;
|
||||||
final totalStops = _totalPlannedStops;
|
final totalStops = _totalPlannedStops;
|
||||||
|
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('4. DESTINATION & STOPS', colors),
|
_buildSectionHeader('4. DESTINATION & STOPS', colors),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isCompact ? 4 : 10),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
dense: isCompact,
|
||||||
|
visualDensity: isCompact ? VisualDensity.compact : null,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
activeThumbColor: colors.cyberCyan,
|
activeThumbColor: colors.cyberCyan,
|
||||||
title: Text('Specific Destination',
|
title: Text('Specific Destination',
|
||||||
|
|
@ -1266,7 +1398,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_hasDestination) ...[
|
if (_hasDestination) ...[
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isCompact ? 4 : 8),
|
||||||
AddressAutocompleteField(
|
AddressAutocompleteField(
|
||||||
labelText: 'Destination Address / Location *',
|
labelText: 'Destination Address / Location *',
|
||||||
hintText: 'Search destination address or scenic peak...',
|
hintText: 'Search destination address or scenic peak...',
|
||||||
|
|
@ -1282,22 +1414,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_triggerRouteCalculation();
|
_triggerRouteCalculation();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: fieldGap),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _destNameController,
|
controller: _destNameController,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
'Destination Name',
|
'Destination Name',
|
||||||
'e.g. Brasstown Bald Summit',
|
'e.g. Brasstown Bald Summit',
|
||||||
colors,
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: isCompact ? 10 : 16),
|
||||||
// USE EXISTING ROUTE Card
|
// USE EXISTING ROUTE Card
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: EdgeInsets.only(bottom: isCompact ? 8 : 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: EdgeInsets.all(isCompact ? 10 : 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
|
@ -1349,21 +1483,27 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ElevatedButton.icon(
|
Flexible(
|
||||||
onPressed: () => _showSelectRouteSheet(colors),
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.alt_route, size: 14),
|
onPressed: () => _showSelectRouteSheet(colors),
|
||||||
label: const Text('USE EXISTING ROUTE'),
|
icon: const Icon(Icons.alt_route, size: 14),
|
||||||
style: ElevatedButton.styleFrom(
|
label: const Text(
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
'USE EXISTING ROUTE',
|
||||||
foregroundColor: colors.cyberCyan,
|
overflow: TextOverflow.ellipsis,
|
||||||
side: BorderSide(color: colors.cyberCyan),
|
maxLines: 1,
|
||||||
padding:
|
),
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
style: ElevatedButton.styleFrom(
|
||||||
minimumSize: Size.zero,
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
foregroundColor: colors.cyberCyan,
|
||||||
textStyle: const TextStyle(
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
fontSize: 11,
|
padding: const EdgeInsets.symmetric(
|
||||||
fontWeight: FontWeight.bold,
|
horizontal: 10, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1437,6 +1577,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
for (int i = 0; i < _stops.length; i++)
|
for (int i = 0; i < _stops.length; i++)
|
||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey('stop_$i'),
|
key: ValueKey('stop_$i'),
|
||||||
|
dense: isCompact,
|
||||||
tileColor: colors.surfaceContainerHigh,
|
tileColor: colors.surfaceContainerHigh,
|
||||||
onTap: () => _addStopDialog(colors, i),
|
onTap: () => _addStopDialog(colors, i),
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
|
|
@ -1575,7 +1716,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_destinationPlace?.latLng ??
|
_destinationPlace?.latLng ??
|
||||||
const AppLatLng(34.5378, -83.9849);
|
const AppLatLng(34.5378, -83.9849);
|
||||||
|
|
||||||
final mapHeight = snapshot.isLandscape ? 170.0 : 210.0;
|
final mapHeight = snapshot.isShortViewport
|
||||||
|
? 130.0
|
||||||
|
: (snapshot.isLandscape ? 160.0 : 210.0);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -1602,7 +1745,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: snapshot.isShortViewport ? 6 : 10),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -1718,54 +1861,80 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPrivacySection(MotorSignalThemeColors colors) {
|
Widget _buildPrivacySection(
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final onSurface = theme.colorScheme.onSurface;
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
|
final fieldGap = isCompact ? 8.0 : 12.0;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('5. PRIVACY & PARTICIPATION', colors),
|
_buildSectionHeader('5. PRIVACY & PARTICIPATION', colors),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isCompact ? 6 : 10),
|
||||||
DropdownButtonFormField<GroupRideVisibility>(
|
DropdownButtonFormField<GroupRideVisibility>(
|
||||||
initialValue: _visibility,
|
initialValue: _visibility,
|
||||||
|
isExpanded: true,
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
style: TextStyle(color: onSurface),
|
style: TextStyle(color: onSurface),
|
||||||
decoration: _inputDecoration('Visibility', '', colors),
|
decoration: _inputDecoration(
|
||||||
|
'Visibility',
|
||||||
|
'',
|
||||||
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
|
),
|
||||||
items: GroupRideVisibility.values.map((v) {
|
items: GroupRideVisibility.values.map((v) {
|
||||||
return DropdownMenuItem(
|
return DropdownMenuItem(
|
||||||
value: v,
|
value: v,
|
||||||
child: Text(v.label, style: TextStyle(color: onSurface)),
|
child: Text(
|
||||||
|
v.label,
|
||||||
|
style: TextStyle(color: onSurface),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setState(() => _visibility = val);
|
if (val != null) setState(() => _visibility = val);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: fieldGap),
|
||||||
DropdownButtonFormField<GroupRideJoinPolicy>(
|
DropdownButtonFormField<GroupRideJoinPolicy>(
|
||||||
initialValue: _joinPolicy,
|
initialValue: _joinPolicy,
|
||||||
|
isExpanded: true,
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
style: TextStyle(color: onSurface),
|
style: TextStyle(color: onSurface),
|
||||||
decoration: _inputDecoration('Join Policy', '', colors),
|
decoration: _inputDecoration(
|
||||||
|
'Join Policy',
|
||||||
|
'',
|
||||||
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
|
),
|
||||||
items: GroupRideJoinPolicy.values.map((p) {
|
items: GroupRideJoinPolicy.values.map((p) {
|
||||||
return DropdownMenuItem(
|
return DropdownMenuItem(
|
||||||
value: p,
|
value: p,
|
||||||
child: Text(p.label, style: TextStyle(color: onSurface)),
|
child: Text(
|
||||||
|
p.label,
|
||||||
|
style: TextStyle(color: onSurface),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setState(() => _joinPolicy = val);
|
if (val != null) setState(() => _joinPolicy = val);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: fieldGap),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: onSurface),
|
style: TextStyle(color: onSurface),
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
'Participant Limit (optional)',
|
'Participant Limit (optional)',
|
||||||
'Leave blank for no limit (min 2)',
|
'Leave blank for no limit (min 2)',
|
||||||
colors,
|
colors,
|
||||||
|
isCompact: isCompact,
|
||||||
),
|
),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
final parsed = int.tryParse(val.trim());
|
final parsed = int.tryParse(val.trim());
|
||||||
|
|
@ -1826,11 +1995,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
InputDecoration _inputDecoration(
|
InputDecoration _inputDecoration(
|
||||||
String label,
|
String label,
|
||||||
String hint,
|
String hint,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors, {
|
||||||
) {
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
|
isDense: isCompact,
|
||||||
|
contentPadding: isCompact
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 12, vertical: 10)
|
||||||
|
: null,
|
||||||
labelText: label,
|
labelText: label,
|
||||||
hintText: hint.isNotEmpty ? hint : null,
|
hintText: hint.isNotEmpty ? hint : null,
|
||||||
labelStyle: TextStyle(color: onSurfaceVariant, fontSize: 13),
|
labelStyle: TextStyle(color: onSurfaceVariant, fontSize: 13),
|
||||||
|
|
|
||||||
|
|
@ -1439,6 +1439,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
|
@ -1447,156 +1448,180 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final routesAsync = ref.watch(userRoutesProvider(user.uid));
|
final routesAsync = ref.watch(userRoutesProvider(user.uid));
|
||||||
|
final screenSize = MediaQuery.sizeOf(sheetCtx);
|
||||||
|
final viewInsets = MediaQuery.viewInsetsOf(sheetCtx);
|
||||||
|
final isCompact = screenSize.height < 480 || viewInsets.bottom > 0;
|
||||||
|
final availableHeight = (screenSize.height - viewInsets.bottom)
|
||||||
|
.clamp(120.0, screenSize.height);
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: BoxConstraints(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
maxHeight: availableHeight * 0.9,
|
||||||
children: [
|
maxWidth: 560,
|
||||||
Text(
|
),
|
||||||
'ATTACH SAVED ROUTE',
|
child: SingleChildScrollView(
|
||||||
style: TextStyle(
|
padding: EdgeInsets.symmetric(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
horizontal: 16,
|
||||||
fontWeight: FontWeight.bold,
|
vertical: isCompact ? 10 : 16,
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
child: Column(
|
||||||
routesAsync.when(
|
mainAxisSize: MainAxisSize.min,
|
||||||
loading: () => Center(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(24),
|
Text(
|
||||||
child:
|
'ATTACH SAVED ROUTE',
|
||||||
CircularProgressIndicator(color: colors.cyberCyan),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: isCompact ? 8 : 12),
|
||||||
error: (err, _) => Text(
|
routesAsync.when(
|
||||||
'Failed to load routes: $err',
|
loading: () => Center(
|
||||||
style: TextStyle(color: colors.raceRed),
|
child: Padding(
|
||||||
),
|
padding: EdgeInsets.all(isCompact ? 12 : 24),
|
||||||
data: (routes) {
|
child: CircularProgressIndicator(
|
||||||
if (routes.isEmpty) {
|
color: colors.cyberCyan,
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'You have no saved routes yet.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(sheetCtx);
|
|
||||||
context.push(
|
|
||||||
'/routes/planner?groupRideId=${ride.id}',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colors.cyberCyan,
|
|
||||||
foregroundColor: colors.onBrand,
|
|
||||||
),
|
|
||||||
child: const Text('CREATE A ROUTE'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
return ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
|
||||||
),
|
),
|
||||||
child: ListView.builder(
|
error: (err, _) => Text(
|
||||||
shrinkWrap: true,
|
'Failed to load routes: $err',
|
||||||
itemCount: routes.length,
|
style: TextStyle(color: colors.raceRed),
|
||||||
itemBuilder: (ctx, idx) {
|
),
|
||||||
final route = routes[idx];
|
data: (routes) {
|
||||||
return ListTile(
|
if (routes.isEmpty) {
|
||||||
leading: Icon(
|
return Padding(
|
||||||
Icons.alt_route,
|
padding: EdgeInsets.symmetric(
|
||||||
color: colors.cyberCyan,
|
vertical: isCompact ? 8 : 24,
|
||||||
),
|
),
|
||||||
title: Text(
|
child: Center(
|
||||||
route.title,
|
child: Column(
|
||||||
style: TextStyle(
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: Theme.of(context)
|
children: [
|
||||||
.colorScheme
|
Text(
|
||||||
.onSurface,
|
'You have no saved routes yet.',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 8 : 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(sheetCtx);
|
||||||
|
context.push(
|
||||||
|
'/routes/planner?groupRideId=${ride.id}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.cyberCyan,
|
||||||
|
foregroundColor: colors.onBrand,
|
||||||
|
),
|
||||||
|
child: const Text('CREATE A ROUTE'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
|
||||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant
|
|
||||||
.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.pop(sheetCtx);
|
|
||||||
try {
|
|
||||||
final user = ref.read(currentUserProvider);
|
|
||||||
if (user == null || user.uid.isEmpty) {
|
|
||||||
throw StateError(
|
|
||||||
'Sign-in required to attach route.');
|
|
||||||
}
|
|
||||||
await ref
|
|
||||||
.read(routeServiceProvider)
|
|
||||||
.attachRouteToGroupRide(
|
|
||||||
groupRideId: ride.id,
|
|
||||||
route: route,
|
|
||||||
callerUid: user.uid,
|
|
||||||
);
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Attached "${route.title}" to group ride.',
|
|
||||||
),
|
|
||||||
backgroundColor:
|
|
||||||
colors.launchGreen,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Failed to attach route: $e',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
return ConstrainedBox(
|
||||||
);
|
constraints: BoxConstraints(
|
||||||
},
|
maxHeight: availableHeight * 0.55,
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: routes.length,
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final route = routes[idx];
|
||||||
|
return ListTile(
|
||||||
|
dense: isCompact,
|
||||||
|
leading: Icon(
|
||||||
|
Icons.alt_route,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
route.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetCtx);
|
||||||
|
try {
|
||||||
|
final user =
|
||||||
|
ref.read(currentUserProvider);
|
||||||
|
if (user == null || user.uid.isEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
'Sign-in required to attach route.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(routeServiceProvider)
|
||||||
|
.attachRouteToGroupRide(
|
||||||
|
groupRideId: ride.id,
|
||||||
|
route: route,
|
||||||
|
callerUid: user.uid,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Attached "${route.title}" to group ride.',
|
||||||
|
),
|
||||||
|
backgroundColor:
|
||||||
|
colors.launchGreen,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Failed to attach route: $e',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,17 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
if (!isAuthed) {
|
if (!isAuthed) {
|
||||||
return _buildAccountRequiredNotice(context, colors);
|
return _buildAccountRequiredNotice(context, colors);
|
||||||
}
|
}
|
||||||
|
final showBanner = snapshot.height >= 160;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildMemberProfileBanner(context, ref, user, colors),
|
if (showBanner)
|
||||||
|
_buildMemberProfileBanner(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
user,
|
||||||
|
colors,
|
||||||
|
isCompact: snapshot.isShortViewport,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
|
|
@ -151,8 +159,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
AuthUser user,
|
AuthUser user,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors, {
|
||||||
) {
|
bool isCompact = false,
|
||||||
|
}) {
|
||||||
final profileAsync = ref.watch(myRiderProfileProvider);
|
final profileAsync = ref.watch(myRiderProfileProvider);
|
||||||
final isPro = ref.watch(isProSubscriberProvider);
|
final isPro = ref.watch(isProSubscriberProvider);
|
||||||
final profile = profileAsync.valueOrNull;
|
final profile = profileAsync.valueOrNull;
|
||||||
|
|
@ -177,7 +186,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
|
bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16.0,
|
||||||
|
vertical: isCompact ? 8.0 : 12.0,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -415,12 +427,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
crossAxisCount: crossAxisCount,
|
crossAxisCount: crossAxisCount,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
mainAxisExtent: 260,
|
mainAxisExtent: 300,
|
||||||
),
|
),
|
||||||
itemCount: rides.length,
|
itemCount: rides.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final ride = rides[index];
|
final ride = rides[index];
|
||||||
return _buildRideCard(context, ride, currentUid, colors);
|
return _buildRideCard(
|
||||||
|
context,
|
||||||
|
ride,
|
||||||
|
currentUid,
|
||||||
|
colors,
|
||||||
|
inGrid: true,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -479,12 +497,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
crossAxisCount: crossAxisCount,
|
crossAxisCount: crossAxisCount,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
mainAxisExtent: 260,
|
mainAxisExtent: 300,
|
||||||
),
|
),
|
||||||
itemCount: myRides.length,
|
itemCount: myRides.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final ride = myRides[index];
|
final ride = myRides[index];
|
||||||
return _buildRideCard(context, ride, currentUid, colors);
|
return _buildRideCard(
|
||||||
|
context,
|
||||||
|
ride,
|
||||||
|
currentUid,
|
||||||
|
colors,
|
||||||
|
inGrid: true,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -563,9 +587,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
}) {
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -638,61 +663,113 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => AlertDialog(
|
builder: (dialogCtx) {
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
final size = MediaQuery.sizeOf(dialogCtx);
|
||||||
title: Row(
|
final viewInsets = MediaQuery.viewInsetsOf(dialogCtx);
|
||||||
children: [
|
final availableHeight = size.height - viewInsets.bottom;
|
||||||
Icon(Icons.group_add, color: colors.cyberCyan, size: 24),
|
final isCompactVertical = availableHeight < 280;
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text(
|
return AlertDialog(
|
||||||
'Join Group Ride',
|
scrollable: true,
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
|
insetPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: isCompactVertical ? 8 : 24,
|
||||||
|
),
|
||||||
|
titlePadding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isCompactVertical ? 12 : 20,
|
||||||
|
20,
|
||||||
|
isCompactVertical ? 6 : 12,
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
isCompactVertical ? 8 : 16,
|
||||||
|
),
|
||||||
|
actionsPadding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
isCompactVertical ? 8 : 14,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.group_add,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
size: isCompactVertical ? 20 : 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Join Group Ride',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: isCompactVertical ? 16 : 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Enter the Ride ID or shared link:',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompactVertical ? 8 : 12),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('join_ride_id_field'),
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
scrollPadding: const EdgeInsets.fromLTRB(20, 20, 20, 80),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'e.g. ride_1790320722076_4btn4W',
|
||||||
|
isDense: isCompactVertical,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: isCompactVertical ? 8 : 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
actions: [
|
||||||
content: Column(
|
TextButton(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: const Text('CANCEL'),
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Enter the Ride ID or shared link:',
|
|
||||||
style: TextStyle(fontSize: 13),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
ElevatedButton(
|
||||||
TextField(
|
key: const ValueKey('join_ride_dialog_submit_button'),
|
||||||
key: const ValueKey('join_ride_id_field'),
|
style: ElevatedButton.styleFrom(
|
||||||
controller: controller,
|
backgroundColor: colors.cyberCyan,
|
||||||
autofocus: true,
|
foregroundColor: colors.onBrand,
|
||||||
decoration: InputDecoration(
|
),
|
||||||
hintText: 'e.g. ride_1790320722076_4btn4W',
|
onPressed: () {
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
final raw = controller.text.trim();
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
if (raw.isEmpty) return;
|
||||||
|
final rideId = raw.contains('/') ? raw.split('/').last : raw;
|
||||||
|
Navigator.of(dialogCtx).pop();
|
||||||
|
context.push('/group-rides/$rideId');
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'VIEW RIDE',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
actions: [
|
},
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
|
||||||
child: const Text('CANCEL'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
key: const ValueKey('join_ride_dialog_submit_button'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colors.cyberCyan,
|
|
||||||
foregroundColor: colors.onBrand,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
final raw = controller.text.trim();
|
|
||||||
if (raw.isEmpty) return;
|
|
||||||
final rideId = raw.contains('/') ? raw.split('/').last : raw;
|
|
||||||
Navigator.of(dialogCtx).pop();
|
|
||||||
context.push('/group-rides/$rideId');
|
|
||||||
},
|
|
||||||
child: const Text('VIEW RIDE', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -700,13 +777,14 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
GroupRide ride,
|
GroupRide ride,
|
||||||
String currentUid,
|
String currentUid,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors, {
|
||||||
) {
|
bool inGrid = false,
|
||||||
|
}) {
|
||||||
final isOrganizer = ride.organizerUid == currentUid;
|
final isOrganizer = ride.organizerUid == currentUid;
|
||||||
final isParticipant = ride.accessUids.contains(currentUid);
|
final isParticipant = ride.accessUids.contains(currentUid);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12),
|
||||||
color: colors.surfaceCardPrimary,
|
color: colors.surfaceCardPrimary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -924,9 +1002,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'MANAGE RIDE',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'MANAGE RIDE',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -945,9 +1026,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'OPEN HUB',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'OPEN HUB',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -971,9 +1055,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'RIDE DETAILS',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'RIDE DETAILS',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -994,9 +1081,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'LEAVE PACK',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'LEAVE PACK',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1019,9 +1109,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'RIDE DETAILS',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'RIDE DETAILS',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1040,9 +1133,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'JOIN RIDE',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'JOIN RIDE',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -295,10 +295,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
body: ResponsiveLayout(
|
body: ResponsiveLayout(
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final isShort = snapshot.height < 450;
|
final isShort = snapshot.height < 450;
|
||||||
final isLandscapeWide =
|
final isLandscape =
|
||||||
snapshot.isLandscape && snapshot.width >= 720;
|
snapshot.isLandscape && snapshot.width >= 560;
|
||||||
|
|
||||||
final mapStack = Stack(
|
Widget buildMapStack({required bool inLandscape}) => Stack(
|
||||||
children: [
|
children: [
|
||||||
// 1. Unified Map
|
// 1. Unified Map
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|
@ -336,7 +336,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
|
|
||||||
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point
|
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 8,
|
bottom: inLandscape ? (isShort ? 94 : 108) : 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
|
|
@ -360,7 +360,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 54,
|
top: 54,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: isLandscapeWide ? snapshot.width * 0.35 : 8,
|
right: inLandscape ? snapshot.width * 0.35 : 8,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -386,15 +386,17 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLandscapeWide) {
|
if (isLandscape) {
|
||||||
|
final leftFlex = snapshot.width >= 720 ? 65 : 60;
|
||||||
|
final rightFlex = 100 - leftFlex;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Left: Tactical Map Canvas
|
// Left: Tactical Map Canvas
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 65,
|
flex: leftFlex,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
mapStack,
|
buildMapStack(inLandscape: true),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: isShort ? 6 : 10,
|
bottom: isShort ? 6 : 10,
|
||||||
left: 8,
|
left: 8,
|
||||||
|
|
@ -435,7 +437,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
),
|
),
|
||||||
// Right: Pack Telemetry Stream & Formation Safety Metrics
|
// Right: Pack Telemetry Stream & Formation Safety Metrics
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 35,
|
flex: rightFlex,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
|
|
@ -477,7 +479,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
// Top Section: Map Canvas with HUDs
|
// Top Section: Map Canvas with HUDs
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 52,
|
flex: 52,
|
||||||
child: mapStack,
|
child: buildMapStack(inLandscape: false),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Cockpit Navigation Tab Bar
|
// Cockpit Navigation Tab Bar
|
||||||
|
|
@ -1019,7 +1021,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
];
|
];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: isShort ? 40 : 46,
|
height: isShort ? 38 : 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
|
@ -1028,71 +1030,84 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: LayoutBuilder(
|
||||||
scrollDirection: Axis.horizontal,
|
builder: (context, constraints) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
final statusItems = Row(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
for (int index = 0; index < statuses.length; index++) ...[
|
||||||
for (int index = 0; index < statuses.length; index++) ...[
|
if (index > 0) const SizedBox(width: 4),
|
||||||
if (index > 0) const SizedBox(width: 6),
|
() {
|
||||||
() {
|
final (status, label, icon) = statuses[index];
|
||||||
final (status, label, icon) = statuses[index];
|
final isSelected = currentStatus == status;
|
||||||
final isSelected = currentStatus == status;
|
|
||||||
|
|
||||||
Color activeColor = colors.cyberCyan;
|
Color activeColor = colors.cyberCyan;
|
||||||
if (status == LivePresenceStatus.needFuel) {
|
if (status == LivePresenceStatus.needFuel) {
|
||||||
activeColor = Colors.orangeAccent;
|
activeColor = Colors.orangeAccent;
|
||||||
}
|
}
|
||||||
if (status == LivePresenceStatus.regrouping) {
|
if (status == LivePresenceStatus.regrouping) {
|
||||||
activeColor = colors.warningAmber;
|
activeColor = colors.warningAmber;
|
||||||
}
|
}
|
||||||
if (status == LivePresenceStatus.arrived) {
|
if (status == LivePresenceStatus.arrived) {
|
||||||
activeColor = colors.launchGreen;
|
activeColor = colors.launchGreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
onTap: () => onStatusSelected(status),
|
onTap: () => onStatusSelected(status),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: isShort ? 6 : 8,
|
||||||
color: isSelected
|
vertical: isShort ? 3 : 4,
|
||||||
? activeColor.withValues(alpha: 0.2)
|
),
|
||||||
: Colors.transparent,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(6),
|
color: isSelected
|
||||||
border: Border.all(
|
? activeColor.withValues(alpha: 0.2)
|
||||||
color: isSelected ? activeColor : Colors.transparent,
|
: Colors.transparent,
|
||||||
width: 1.2,
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? activeColor : Colors.transparent,
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: isShort ? 14 : 16,
|
||||||
|
color: isSelected ? activeColor : Colors.white60,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? activeColor : Colors.white70,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.w500,
|
||||||
|
fontSize: isShort ? 10 : 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
);
|
||||||
mainAxisSize: MainAxisSize.min,
|
}(),
|
||||||
children: [
|
],
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 16,
|
|
||||||
color: isSelected ? activeColor : Colors.white60,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? activeColor : Colors.white70,
|
|
||||||
fontWeight: isSelected
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.w500,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}(),
|
|
||||||
],
|
],
|
||||||
],
|
);
|
||||||
),
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: statusItems,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1109,165 +1124,190 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
required MotorSignalThemeColors colors,
|
required MotorSignalThemeColors colors,
|
||||||
bool isShort = false,
|
bool isShort = false,
|
||||||
}) {
|
}) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
return LayoutBuilder(
|
||||||
final isCompact = screenWidth < 360;
|
builder: (context, constraints) {
|
||||||
|
final availableWidth = constraints.maxWidth;
|
||||||
|
final isCompact = availableWidth < 420;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isCompact ? 6 : 8,
|
horizontal: isCompact ? 6 : 8,
|
||||||
vertical: isShort ? 4 : 6,
|
vertical: isShort ? 4 : 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: colors.outlineVariant),
|
border: Border.all(color: colors.outlineVariant),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: SingleChildScrollView(
|
||||||
children: [
|
scrollDirection: Axis.horizontal,
|
||||||
// Ghost mode / Broadcast switch
|
child: ConstrainedBox(
|
||||||
Expanded(
|
constraints: BoxConstraints(
|
||||||
child: Row(
|
minWidth: availableWidth > 16 ? availableWidth - 16 : 0,
|
||||||
children: [
|
),
|
||||||
Icon(
|
child: Row(
|
||||||
broadcastState.isBroadcasting
|
mainAxisSize: MainAxisSize.min,
|
||||||
? Icons.radar
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
: Icons.location_off,
|
children: [
|
||||||
color: broadcastState.isBroadcasting
|
// Broadcast state info & switch
|
||||||
? colors.cyberCyan
|
Row(
|
||||||
: Colors.white38,
|
|
||||||
size: isCompact ? 18 : 20,
|
|
||||||
),
|
|
||||||
SizedBox(width: isCompact ? 4 : 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Icon(
|
||||||
broadcastState.isBroadcasting
|
broadcastState.isBroadcasting
|
||||||
? (broadcastState.isPaused
|
? Icons.radar
|
||||||
? 'PAUSED'
|
: Icons.location_off,
|
||||||
: (broadcastState.isBackground
|
color: broadcastState.isBroadcasting
|
||||||
? 'BACKGROUND'
|
? colors.cyberCyan
|
||||||
: 'SHARING LIVE'))
|
: Colors.white38,
|
||||||
: 'GHOST MODE',
|
size: isCompact ? 16 : 20,
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: broadcastState.isBroadcasting
|
|
||||||
? (broadcastState.isPaused
|
|
||||||
? colors.warningAmber
|
|
||||||
: colors.cyberCyan)
|
|
||||||
: Colors.white60,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: isCompact ? 10 : 11,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
SizedBox(width: isCompact ? 4 : 8),
|
||||||
broadcastState.isBroadcasting
|
Column(
|
||||||
? (broadcastState.isPaused
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? 'Reconnecting...'
|
mainAxisSize: MainAxisSize.min,
|
||||||
: (broadcastState.isBackground
|
children: [
|
||||||
? 'Foreground active'
|
Text(
|
||||||
: 'Broadcasting'))
|
broadcastState.isBroadcasting
|
||||||
: 'Location paused',
|
? (broadcastState.isPaused
|
||||||
maxLines: 1,
|
? 'PAUSED'
|
||||||
overflow: TextOverflow.ellipsis,
|
: (broadcastState.isBackground
|
||||||
style: TextStyle(
|
? 'BACKGROUND'
|
||||||
color: Colors.white38,
|
: 'SHARING LIVE'))
|
||||||
fontSize: isCompact ? 9 : 10,
|
: 'GHOST MODE',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: broadcastState.isBroadcasting
|
||||||
|
? (broadcastState.isPaused
|
||||||
|
? colors.warningAmber
|
||||||
|
: colors.cyberCyan)
|
||||||
|
: Colors.white60,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: isCompact ? 10 : 11,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isShort)
|
||||||
|
Text(
|
||||||
|
broadcastState.isBroadcasting
|
||||||
|
? (broadcastState.isPaused
|
||||||
|
? 'Reconnecting...'
|
||||||
|
: (broadcastState.isBackground
|
||||||
|
? 'Foreground active'
|
||||||
|
: 'Broadcasting'))
|
||||||
|
: 'Location paused',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: isCompact ? 8.5 : 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(width: isCompact ? 4 : 6),
|
||||||
|
Transform.scale(
|
||||||
|
scale: isCompact ? 0.8 : 1.0,
|
||||||
|
child: Switch(
|
||||||
|
key: const ValueKey('live_ride_broadcast_switch'),
|
||||||
|
value: broadcastState.isOptedIn,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
activeThumbColor: colors.cyberCyan,
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val && !broadcastState.isMasterAllowed) {
|
||||||
|
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
||||||
|
}
|
||||||
|
broadcastController.setOptIn(val);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
key: const ValueKey('live_ride_broadcast_switch'),
|
|
||||||
value: broadcastState.isOptedIn,
|
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
activeThumbColor: colors.cyberCyan,
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val && !broadcastState.isMasterAllowed) {
|
|
||||||
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
|
||||||
}
|
|
||||||
broadcastController.setOptIn(val);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
SizedBox(width: isCompact ? 4 : 8),
|
SizedBox(width: isCompact ? 6 : 10),
|
||||||
|
|
||||||
// Recenter Map Button (min 48x48 tap target)
|
// Action buttons
|
||||||
IconButton(
|
Row(
|
||||||
key: const ValueKey('live_ride_recenter_button'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: IconButton.styleFrom(
|
children: [
|
||||||
backgroundColor: colors.surfaceContainer,
|
// Recenter Map Button
|
||||||
padding: EdgeInsets.all(isCompact ? 6 : 8),
|
IconButton(
|
||||||
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
|
key: const ValueKey('live_ride_recenter_button'),
|
||||||
),
|
style: IconButton.styleFrom(
|
||||||
icon: Icon(
|
backgroundColor: colors.surfaceContainer,
|
||||||
Icons.my_location,
|
padding: EdgeInsets.all(isCompact ? 4 : 8),
|
||||||
color: Colors.white70,
|
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||||
size: isCompact ? 18 : 20,
|
),
|
||||||
),
|
icon: Icon(
|
||||||
tooltip: 'Center on Me',
|
Icons.my_location,
|
||||||
onPressed: () {
|
color: Colors.white70,
|
||||||
if (broadcastState.lastPosition != null) {
|
size: isCompact ? 16 : 20,
|
||||||
setState(() {
|
),
|
||||||
_customMapCenter = AppLatLng(
|
tooltip: 'Center on Me',
|
||||||
broadcastState.lastPosition!.latitude,
|
onPressed: () {
|
||||||
broadcastState.lastPosition!.longitude,
|
if (broadcastState.lastPosition != null) {
|
||||||
);
|
setState(() {
|
||||||
_mapZoom = 15.0;
|
_customMapCenter = AppLatLng(
|
||||||
});
|
broadcastState.lastPosition!.latitude,
|
||||||
} else {
|
broadcastState.lastPosition!.longitude,
|
||||||
setState(() {
|
);
|
||||||
_customMapCenter = AppLatLng(
|
_mapZoom = 15.0;
|
||||||
ride.meetingPoint.latitude,
|
});
|
||||||
ride.meetingPoint.longitude,
|
} else {
|
||||||
);
|
setState(() {
|
||||||
});
|
_customMapCenter = AppLatLng(
|
||||||
}
|
ride.meetingPoint.latitude,
|
||||||
},
|
ride.meetingPoint.longitude,
|
||||||
),
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
SizedBox(width: isCompact ? 4 : 6),
|
SizedBox(width: isCompact ? 4 : 6),
|
||||||
|
|
||||||
// Roster Opener Button
|
// Roster Opener Button
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
key: const ValueKey('live_ride_roster_button'),
|
key: const ValueKey('live_ride_roster_button'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||||
foregroundColor: colors.cyberCyan,
|
foregroundColor: colors.cyberCyan,
|
||||||
side: BorderSide(color: colors.cyberCyan),
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isCompact ? 6 : 10,
|
horizontal: isCompact ? 6 : 10,
|
||||||
vertical: isCompact ? 6 : 8,
|
vertical: isCompact ? 4 : 8,
|
||||||
|
),
|
||||||
|
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.format_list_bulleted, size: 14),
|
||||||
|
label: Text(
|
||||||
|
'${participants.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: isCompact ? 10 : 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () => _showRosterSheet(
|
||||||
|
context: context,
|
||||||
|
participants: participants,
|
||||||
|
presences: presences,
|
||||||
|
leadPresence: leadPresence,
|
||||||
|
currentUid: currentUid,
|
||||||
|
colors: colors,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.format_list_bulleted, size: 16),
|
|
||||||
label: Text(
|
|
||||||
'${participants.length}',
|
|
||||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
onPressed: () => _showRosterSheet(
|
|
||||||
context: context,
|
|
||||||
participants: participants,
|
|
||||||
presences: presences,
|
|
||||||
leadPresence: leadPresence,
|
|
||||||
currentUid: currentUid,
|
|
||||||
colors: colors,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2171,16 +2211,16 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.stream, size: 13, color: colors.cyberCyan),
|
Icon(Icons.stream, size: 13, color: colors.cyberCyan),
|
||||||
const SizedBox(width: 5),
|
const SizedBox(width: 5),
|
||||||
Flexible(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'PACK TELEMETRY STREAM',
|
'PACK TELEMETRY STREAM',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
|
|
||||||
|
|
@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 600,
|
maxCrossAxisExtent: 600,
|
||||||
mainAxisExtent: 175,
|
mainAxisExtent: 196,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
),
|
),
|
||||||
itemCount: rides.length,
|
itemCount: rides.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return _buildRideCard(context, rides[index], colors);
|
return _buildRideCard(context, rides[index], colors, isGrid: true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +154,12 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) {
|
Widget _buildRideCard(
|
||||||
|
BuildContext context,
|
||||||
|
Ride ride,
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isGrid = false,
|
||||||
|
}) {
|
||||||
final bikeName = (ride.motorcycleYear != null &&
|
final bikeName = (ride.motorcycleYear != null &&
|
||||||
ride.motorcycleMake != null &&
|
ride.motorcycleMake != null &&
|
||||||
ride.motorcycleModel != null)
|
ride.motorcycleModel != null)
|
||||||
|
|
@ -169,7 +174,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
margin: isGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
|
|
||||||
|
|
@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom Center: FIRE OFF REPLAY floating button
|
// Bottom Center: FIRE OFF REPLAY floating button (portrait only; in landscape play_replay_appbar_button is used)
|
||||||
if (_routePoints.isNotEmpty)
|
if (_routePoints.isNotEmpty && !responsive.isLandscape)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
left: 20,
|
left: 20,
|
||||||
|
|
|
||||||
|
|
@ -182,21 +182,23 @@ class _SetupView extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport;
|
final isLandscape = snapshot.isLandscape;
|
||||||
|
|
||||||
if (isShortLandscape) {
|
if (isLandscape) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildModeSelector(context),
|
_buildModeSelector(context, isLandscape: true),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
_buildTargetSelector(context),
|
_buildTargetSelector(context, isLandscape: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -204,12 +206,13 @@ class _SetupView extends StatelessWidget {
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildExplanationCard(context),
|
_buildExplanationCard(context, isLandscape: true),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
_buildArmButton(context),
|
_buildArmButton(context, isLandscape: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -236,7 +239,7 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildModeSelector(BuildContext context) {
|
Widget _buildModeSelector(BuildContext context, {bool isLandscape = false}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -244,13 +247,13 @@ class _SetupView extends StatelessWidget {
|
||||||
'START MODE',
|
'START MODE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -263,6 +266,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onModeChanged(PerformanceLaunchMode.auto),
|
onTap: () => onModeChanged(PerformanceLaunchMode.auto),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -276,6 +280,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
|
onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -284,7 +289,7 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTargetSelector(BuildContext context) {
|
Widget _buildTargetSelector(BuildContext context, {bool isLandscape = false}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -292,13 +297,13 @@ class _SetupView extends StatelessWidget {
|
||||||
'BENCHMARK TARGET',
|
'BENCHMARK TARGET',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -311,6 +316,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
|
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -324,6 +330,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
|
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -332,13 +339,13 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExplanationCard(BuildContext context) {
|
Widget _buildExplanationCard(BuildContext context, {bool isLandscape = false}) {
|
||||||
final text = selectedMode == PerformanceLaunchMode.auto
|
final text = selectedMode == PerformanceLaunchMode.auto
|
||||||
? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).'
|
? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).'
|
||||||
: 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.';
|
: 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: EdgeInsets.all(isLandscape ? 10 : 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -349,17 +356,17 @@ class _SetupView extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.info_outline,
|
Icons.info_outline,
|
||||||
size: 20,
|
size: isLandscape ? 18 : 20,
|
||||||
color: colors.velocityBlue,
|
color: colors.velocityBlue,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
SizedBox(width: isLandscape ? 8 : 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: isLandscape ? 11.5 : 13,
|
||||||
height: 1.35,
|
height: 1.3,
|
||||||
color: isDark ? Colors.white70 : Colors.black87,
|
color: isDark ? Colors.white70 : Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -369,15 +376,16 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArmButton(BuildContext context) {
|
Widget _buildArmButton(BuildContext context, {bool isLandscape = false}) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 52,
|
height: isLandscape ? 44 : 52,
|
||||||
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
key: const ValueKey('btn_arm_launch'),
|
key: const ValueKey('btn_arm_launch'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colors.launchGreen,
|
backgroundColor: colors.launchGreen,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
),
|
),
|
||||||
onPressed: onArm,
|
onPressed: onArm,
|
||||||
|
|
@ -385,7 +393,7 @@ class _SetupView extends StatelessWidget {
|
||||||
'Arm Launch',
|
'Arm Launch',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
|
|
@ -403,6 +411,7 @@ class _SelectionButton extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final MotorSignalThemeColors colors;
|
final MotorSignalThemeColors colors;
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
|
final bool isLandscape;
|
||||||
|
|
||||||
const _SelectionButton({
|
const _SelectionButton({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -413,6 +422,7 @@ class _SelectionButton extends StatelessWidget {
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.colors,
|
required this.colors,
|
||||||
required this.isDark,
|
required this.isDark,
|
||||||
|
this.isLandscape = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -428,7 +438,10 @@ class _SelectionButton extends StatelessWidget {
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 8 : 12,
|
||||||
|
vertical: isLandscape ? 8 : 12,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bgColor,
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -439,27 +452,31 @@ class _SelectionButton extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 22,
|
size: isLandscape ? 18 : 22,
|
||||||
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
|
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 4 : 8),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 15,
|
fontSize: isLandscape ? 13 : 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -508,44 +525,48 @@ class _WaitingView extends ConsumerWidget {
|
||||||
statusColor = colors.velocityBlue;
|
statusColor = colors.velocityBlue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
final isLandscape = snapshot.isLandscape;
|
||||||
padding: const EdgeInsets.all(24),
|
return Center(
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
physics: const ClampingScrollPhysics(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
padding: EdgeInsets.all(isLandscape ? 16 : 24),
|
||||||
children: [
|
child: Column(
|
||||||
Icon(statusIcon, size: 54, color: statusColor),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const SizedBox(height: 20),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Text(
|
children: [
|
||||||
statusText,
|
Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor),
|
||||||
key: const ValueKey('waiting_status_text'),
|
SizedBox(height: isLandscape ? 10 : 20),
|
||||||
textAlign: TextAlign.center,
|
Text(
|
||||||
style: TextStyle(
|
statusText,
|
||||||
fontFamily: AppTypography.fontHeadline,
|
key: const ValueKey('waiting_status_text'),
|
||||||
fontSize: 18,
|
textAlign: TextAlign.center,
|
||||||
fontWeight: FontWeight.w800,
|
style: TextStyle(
|
||||||
letterSpacing: 1.0,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
color: statusColor,
|
fontSize: isLandscape ? 15 : 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
color: statusColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: isLandscape ? 6 : 12),
|
||||||
const SizedBox(height: 12),
|
Text(
|
||||||
Text(
|
'Current speed: ${speed.toStringAsFixed(1)} MPH',
|
||||||
'Current speed: ${speed.toStringAsFixed(1)} MPH',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontSize: isLandscape ? 12 : 14,
|
||||||
fontSize: 14,
|
color: isDark ? Colors.white70 : Colors.black54,
|
||||||
color: isDark ? Colors.white70 : Colors.black54,
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: isLandscape ? 16 : 32),
|
||||||
const SizedBox(height: 32),
|
OutlinedButton(
|
||||||
OutlinedButton(
|
key: const ValueKey('btn_cancel_waiting'),
|
||||||
key: const ValueKey('btn_cancel_waiting'),
|
onPressed: () {
|
||||||
onPressed: () {
|
ref.read(performanceLaunchProvider.notifier).reset();
|
||||||
ref.read(performanceLaunchProvider.notifier).reset();
|
},
|
||||||
},
|
child: const Text('Cancel'),
|
||||||
child: const Text('Cancel'),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -570,108 +591,118 @@ class _ReadyView extends ConsumerWidget {
|
||||||
final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode));
|
final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode));
|
||||||
final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
|
final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
|
||||||
final isCountdown = mode == PerformanceLaunchMode.countdown;
|
final isCountdown = mode == PerformanceLaunchMode.countdown;
|
||||||
|
final isLandscape = snapshot.isLandscape;
|
||||||
|
|
||||||
return Padding(
|
return Center(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
physics: const ClampingScrollPhysics(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: EdgeInsets.symmetric(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
horizontal: isLandscape ? 32 : 20,
|
||||||
children: [
|
vertical: isLandscape ? 8 : 16,
|
||||||
Center(
|
),
|
||||||
child: Container(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
color: colors.launchGreen.withValues(alpha: 0.15),
|
children: [
|
||||||
borderRadius: BorderRadius.circular(16),
|
Center(
|
||||||
border: Border.all(color: colors.launchGreen, width: 2.0),
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 16 : 24,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.launchGreen.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colors.launchGreen, width: 2.0),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: colors.launchGreen, size: isLandscape ? 20 : 28),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'READY',
|
||||||
|
key: const ValueKey('ready_status_badge'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: isLandscape ? 20 : 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 2.0,
|
||||||
|
color: colors.launchGreen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
SizedBox(height: isLandscape ? 8 : 20),
|
||||||
children: [
|
Text(
|
||||||
Icon(Icons.check_circle, color: colors.launchGreen, size: 28),
|
target.label,
|
||||||
const SizedBox(width: 10),
|
textAlign: TextAlign.center,
|
||||||
Text(
|
style: TextStyle(
|
||||||
'READY',
|
fontFamily: AppTypography.fontHeadline,
|
||||||
key: const ValueKey('ready_status_badge'),
|
fontSize: isLandscape ? 18 : 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isLandscape ? 4 : 10),
|
||||||
|
Text(
|
||||||
|
isCountdown
|
||||||
|
? 'Tap below to initiate countdown'
|
||||||
|
: 'Accelerate when ready',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontBody,
|
||||||
|
fontSize: isLandscape ? 13 : 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? Colors.white70 : Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isLandscape ? 12 : 40),
|
||||||
|
if (isCountdown) ...[
|
||||||
|
SizedBox(
|
||||||
|
height: isLandscape ? 44 : 56,
|
||||||
|
child: ElevatedButton(
|
||||||
|
key: const ValueKey('btn_start_countdown'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.velocityBlue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16)),
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(performanceLaunchProvider.notifier).startCountdown();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Start Countdown',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 28,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 2.0,
|
letterSpacing: 0.5,
|
||||||
color: colors.launchGreen,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
target.label,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontHeadline,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
isCountdown
|
|
||||||
? 'Tap below to initiate countdown'
|
|
||||||
: 'Accelerate when ready',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontBody,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isDark ? Colors.white70 : Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
if (isCountdown) ...[
|
|
||||||
SizedBox(
|
|
||||||
height: 56,
|
|
||||||
child: ElevatedButton(
|
|
||||||
key: const ValueKey('btn_start_countdown'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colors.velocityBlue,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16)),
|
|
||||||
elevation: 4,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isLandscape ? 8 : 16),
|
||||||
|
],
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(performanceLaunchProvider.notifier).startCountdown();
|
ref.read(performanceLaunchProvider.notifier).reset();
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Start Countdown',
|
'Cancel & Change Setup',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
],
|
||||||
Center(
|
),
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(performanceLaunchProvider.notifier).reset();
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Cancel & Change Setup',
|
|
||||||
style: TextStyle(
|
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../../database/database.dart';
|
||||||
import '../../services/map/map_provider_interface.dart';
|
import '../../services/map/map_provider_interface.dart';
|
||||||
import '../../services/map/unified_map_view.dart';
|
import '../../services/map/unified_map_view.dart';
|
||||||
import '../../core/theme/theme_extensions.dart';
|
import '../../core/theme/theme_extensions.dart';
|
||||||
|
import '../../core/responsive/responsive_layout.dart';
|
||||||
|
|
||||||
/// Corner performance metrics and apex grading for forensic telemetry replay.
|
/// Corner performance metrics and apex grading for forensic telemetry replay.
|
||||||
class CornerAnalysis {
|
class CornerAnalysis {
|
||||||
|
|
@ -474,117 +475,120 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: ResponsiveLayout(
|
||||||
children: [
|
builder: (context, snapshot) {
|
||||||
// Map View (with multi-color lean heatmap and telemetry HUD overlay)
|
final isLandscape = snapshot.isLandscape;
|
||||||
Expanded(
|
|
||||||
flex: 5,
|
final mapWidget = Stack(
|
||||||
child: Stack(
|
children: [
|
||||||
children: [
|
UnifiedMapView(
|
||||||
UnifiedMapView(
|
center: currentPos,
|
||||||
center: currentPos,
|
zoom: _zoom,
|
||||||
zoom: _zoom,
|
markers: allMarkers,
|
||||||
markers: allMarkers,
|
polylines: polylines,
|
||||||
polylines: polylines,
|
isFollowModeOverride: _isFollowingRider,
|
||||||
isFollowModeOverride: _isFollowingRider,
|
onUserInteraction: () {
|
||||||
onUserInteraction: () {
|
if (_isFollowingRider) {
|
||||||
if (_isFollowingRider) {
|
setState(() => _isFollowingRider = false);
|
||||||
setState(() => _isFollowingRider = false);
|
}
|
||||||
}
|
},
|
||||||
},
|
),
|
||||||
),
|
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
||||||
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
Positioned(
|
||||||
Positioned(
|
right: isLandscape ? 10 : 14,
|
||||||
right: 14,
|
bottom: isLandscape ? 10 : 14,
|
||||||
bottom: 14,
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
if (!_isFollowingRider) ...[
|
||||||
if (!_isFollowingRider) ...[
|
|
||||||
_ReplayMapButton(
|
|
||||||
key: const ValueKey('replay_recenter_button'),
|
|
||||||
icon: Icons.my_location,
|
|
||||||
tooltip: 'Recenter on Rider',
|
|
||||||
color: colors.cyberCyan,
|
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
|
||||||
onTap: () => setState(() => _isFollowingRider = true),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
_ReplayMapButton(
|
_ReplayMapButton(
|
||||||
key: const ValueKey('replay_zoom_in_button'),
|
key: const ValueKey('replay_recenter_button'),
|
||||||
icon: Icons.add,
|
icon: Icons.my_location,
|
||||||
tooltip: 'Zoom In',
|
tooltip: 'Recenter on Rider',
|
||||||
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
color: colors.cyberCyan,
|
||||||
),
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
||||||
const SizedBox(height: 8),
|
isCompact: isLandscape,
|
||||||
_ReplayMapButton(
|
onTap: () => setState(() => _isFollowingRider = true),
|
||||||
key: const ValueKey('replay_zoom_out_button'),
|
|
||||||
icon: Icons.remove,
|
|
||||||
tooltip: 'Zoom Out',
|
|
||||||
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
|
||||||
),
|
),
|
||||||
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
],
|
],
|
||||||
|
_ReplayMapButton(
|
||||||
|
key: const ValueKey('replay_zoom_in_button'),
|
||||||
|
icon: Icons.add,
|
||||||
|
tooltip: 'Zoom In',
|
||||||
|
isCompact: isLandscape,
|
||||||
|
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
||||||
|
),
|
||||||
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
|
_ReplayMapButton(
|
||||||
|
key: const ValueKey('replay_zoom_out_button'),
|
||||||
|
icon: Icons.remove,
|
||||||
|
tooltip: 'Zoom Out',
|
||||||
|
isCompact: isLandscape,
|
||||||
|
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Telemetry Overlay
|
||||||
|
Positioned(
|
||||||
|
top: isLandscape ? 8 : 14,
|
||||||
|
left: isLandscape ? 8 : 14,
|
||||||
|
right: isLandscape ? 8 : 14,
|
||||||
|
child: Center(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph', isLandscape: isLandscape),
|
||||||
|
SizedBox(width: isLandscape ? 6 : 8),
|
||||||
|
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°', isLandscape: isLandscape),
|
||||||
|
SizedBox(width: isLandscape ? 6 : 8),
|
||||||
|
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G', isLandscape: isLandscape),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Telemetry Overlay
|
),
|
||||||
Positioned(
|
// Heatmap Legend Overlay
|
||||||
top: 14,
|
Positioned(
|
||||||
left: 14,
|
bottom: isLandscape ? 8 : 10,
|
||||||
right: 14,
|
left: isLandscape ? 8 : 14,
|
||||||
child: Center(
|
right: isLandscape ? 60 : 14,
|
||||||
child: FittedBox(
|
child: Align(
|
||||||
fit: BoxFit.scaleDown,
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.8),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.white24),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
|
_buildLegendItem(Colors.amberAccent, '≤20°'),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
|
_buildLegendItem(Colors.cyanAccent, '20°-35°'),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
|
_buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Heatmap Legend Overlay
|
),
|
||||||
Positioned(
|
],
|
||||||
bottom: 10,
|
);
|
||||||
left: 14,
|
|
||||||
right: 14,
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.8),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.white24),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_buildLegendItem(Colors.amberAccent, '≤20°'),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_buildLegendItem(Colors.cyanAccent, '20°-35°'),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Playback Controls Bar
|
final playbackControlsBar = Container(
|
||||||
Container(
|
padding: EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
horizontal: isLandscape ? 12 : 16,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -596,10 +600,10 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
child: Text(
|
child: Text(
|
||||||
currentSample.timestamp.toLocal().toString().split('.')[0],
|
currentSample.timestamp.toLocal().toString().split('.')[0],
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.cyanAccent,
|
color: Colors.cyanAccent,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -609,30 +613,42 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
|
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Slider(
|
SliderTheme(
|
||||||
value: currentSliderValue,
|
data: isLandscape
|
||||||
min: 0,
|
? SliderTheme.of(context).copyWith(
|
||||||
max: maxSliderValue,
|
trackHeight: 2,
|
||||||
onChanged: _telemetry.length > 1
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
? (val) {
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||||||
setState(() {
|
)
|
||||||
_currentIndex = val.toInt();
|
: SliderTheme.of(context),
|
||||||
});
|
child: Slider(
|
||||||
}
|
value: currentSliderValue,
|
||||||
: null,
|
min: 0,
|
||||||
activeColor: Colors.cyanAccent,
|
max: maxSliderValue,
|
||||||
|
onChanged: _telemetry.length > 1
|
||||||
|
? (val) {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = val.toInt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
activeColor: Colors.cyanAccent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.restart_alt),
|
visualDensity: isLandscape ? VisualDensity.compact : null,
|
||||||
|
padding: isLandscape ? const EdgeInsets.all(4) : null,
|
||||||
|
constraints: isLandscape ? const BoxConstraints() : null,
|
||||||
|
icon: Icon(Icons.restart_alt, size: isLandscape ? 20 : 24),
|
||||||
tooltip: 'Restart',
|
tooltip: 'Restart',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -640,35 +656,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
SizedBox(width: isLandscape ? 8 : 16),
|
||||||
FloatingActionButton.small(
|
FloatingActionButton.small(
|
||||||
onPressed: _togglePlayback,
|
onPressed: _togglePlayback,
|
||||||
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
|
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: isLandscape ? 18 : 20),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
SizedBox(width: isLandscape ? 8 : 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
style: isLandscape
|
||||||
|
? TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onPressed: _changeSpeed,
|
onPressed: _changeSpeed,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${_playbackSpeed}x',
|
'${_playbackSpeed}x',
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono'),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: isLandscape ? 12 : 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
// Corner Entry / Apex / Exit Breakdown Table
|
Widget buildCornerTable() {
|
||||||
Expanded(
|
return Container(
|
||||||
flex: _corners.isEmpty ? 3 : 4,
|
|
||||||
child: Container(
|
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 10 : 14,
|
||||||
|
vertical: isLandscape ? 6 : 8,
|
||||||
|
),
|
||||||
color: theme.colorScheme.surfaceContainer,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
@ -678,7 +705,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
'CORNER APEX BREAKDOWN & GRADING',
|
'CORNER APEX BREAKDOWN & GRADING',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
|
|
@ -690,7 +717,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Text(
|
Text(
|
||||||
_corners.isEmpty ? '' : 'Tap row to seek apex',
|
_corners.isEmpty ? '' : 'Tap row to seek apex',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: isLandscape ? 9 : 10,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
@ -712,7 +739,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
hasLeanData
|
hasLeanData
|
||||||
? Icons.straighten_rounded
|
? Icons.straighten_rounded
|
||||||
: Icons.sensors_off_rounded,
|
: Icons.sensors_off_rounded,
|
||||||
size: 26,
|
size: isLandscape ? 22 : 26,
|
||||||
color: hasLeanData
|
color: hasLeanData
|
||||||
? colors.cyberCyan.withValues(alpha: 0.7)
|
? colors.cyberCyan.withValues(alpha: 0.7)
|
||||||
: colors.warningAmber.withValues(alpha: 0.8),
|
: colors.warningAmber.withValues(alpha: 0.8),
|
||||||
|
|
@ -724,7 +751,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
|
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
|
|
@ -732,14 +759,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
constraints: BoxConstraints(maxWidth: isLandscape ? 320 : 280),
|
||||||
child: Text(
|
child: Text(
|
||||||
hasLeanData
|
hasLeanData
|
||||||
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
|
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
|
||||||
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
|
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
),
|
),
|
||||||
|
|
@ -771,43 +798,49 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 10 : 14,
|
||||||
|
vertical: isLandscape ? 6 : 9,
|
||||||
|
),
|
||||||
color: isCurrent
|
color: isCurrent
|
||||||
? colors.cyberCyan.withValues(alpha: 0.12)
|
? colors.cyberCyan.withValues(alpha: 0.12)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
child: Row(
|
child: FittedBox(
|
||||||
children: [
|
fit: BoxFit.scaleDown,
|
||||||
// Corner ID & Direction
|
alignment: Alignment.centerLeft,
|
||||||
SizedBox(
|
child: Row(
|
||||||
width: 75,
|
children: [
|
||||||
child: Row(
|
// Corner ID & Direction
|
||||||
children: [
|
SizedBox(
|
||||||
Text(
|
width: isLandscape ? 65 : 75,
|
||||||
'T${corner.id}',
|
child: Row(
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontWeight: FontWeight.bold,
|
Text(
|
||||||
fontFamily: 'JetBrains Mono',
|
'T${corner.id}',
|
||||||
fontSize: 13,
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: isLandscape ? 12 : 13,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
const SizedBox(width: 4),
|
Text(
|
||||||
Text(
|
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
||||||
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontSize: isLandscape ? 10 : 11,
|
||||||
fontSize: 11,
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
color: corner.direction == 'Left'
|
||||||
color: corner.direction == 'Left'
|
? Colors.blueAccent
|
||||||
? Colors.blueAccent
|
: Colors.greenAccent,
|
||||||
: Colors.greenAccent,
|
fontFamily: 'JetBrains Mono',
|
||||||
fontFamily: 'JetBrains Mono',
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
// Entry Speed
|
// Entry Speed
|
||||||
Expanded(
|
Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -820,14 +853,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${corner.entrySpeed.toStringAsFixed(0)} mph',
|
'${corner.entrySpeed.toStringAsFixed(0)} mph',
|
||||||
style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'),
|
style: TextStyle(
|
||||||
|
fontSize: isLandscape ? 11 : 12,
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: isLandscape ? 10 : 14),
|
||||||
// Apex Speed & Lean
|
// Apex Speed & Lean
|
||||||
Expanded(
|
Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -841,7 +876,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Text(
|
Text(
|
||||||
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
|
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
|
|
@ -849,10 +884,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: isLandscape ? 10 : 14),
|
||||||
// Exit Speed & Delta
|
// Exit Speed & Delta
|
||||||
Expanded(
|
Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -866,33 +900,34 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Text(
|
Text(
|
||||||
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
|
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
|
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: isLandscape ? 10 : 14),
|
||||||
// Grade Badge
|
// Grade Badge
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: corner.getGradeColor().withValues(alpha: 0.15),
|
color: corner.getGradeColor().withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
|
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
corner.grade,
|
corner.grade,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: corner.getGradeColor(),
|
color: corner.getGradeColor(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -901,9 +936,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
],
|
|
||||||
|
if (isLandscape) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: mapWidget,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
playbackControlsBar,
|
||||||
|
Expanded(
|
||||||
|
child: buildCornerTable(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: mapWidget,
|
||||||
|
),
|
||||||
|
playbackControlsBar,
|
||||||
|
Expanded(
|
||||||
|
flex: _corners.isEmpty ? 3 : 4,
|
||||||
|
child: buildCornerTable(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -926,18 +998,28 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildOverlayStat(String label, String value) {
|
Widget _buildOverlayStat(String label, String value, {bool isLandscape = false}) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 10 : 16,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(isLandscape ? 8 : 12),
|
||||||
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)),
|
Text(label, style: TextStyle(fontSize: isLandscape ? 8.5 : 10, color: Colors.white70)),
|
||||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)),
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: isLandscape ? 15 : 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.cyanAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -950,6 +1032,7 @@ class _ReplayMapButton extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final Color? color;
|
final Color? color;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
final bool isCompact;
|
||||||
|
|
||||||
const _ReplayMapButton({
|
const _ReplayMapButton({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -958,10 +1041,13 @@ class _ReplayMapButton extends StatelessWidget {
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.color,
|
this.color,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
|
this.isCompact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final size = isCompact ? 36.0 : 48.0;
|
||||||
|
final iconSize = isCompact ? 18.0 : 24.0;
|
||||||
return Material(
|
return Material(
|
||||||
color: backgroundColor ?? Colors.black87,
|
color: backgroundColor ?? Colors.black87,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
@ -974,12 +1060,12 @@ class _ReplayMapButton extends StatelessWidget {
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 48,
|
width: size,
|
||||||
height: 48,
|
height: size,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
color: color ?? Colors.white,
|
color: color ?? Colors.white,
|
||||||
size: 24,
|
size: iconSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../../core/responsive/viewport_size.dart';
|
||||||
import '../../core/theme/theme_extensions.dart';
|
import '../../core/theme/theme_extensions.dart';
|
||||||
import '../../services/auth/auth_providers.dart';
|
import '../../services/auth/auth_providers.dart';
|
||||||
import '../../services/map/map_provider_interface.dart';
|
import '../../services/map/map_provider_interface.dart';
|
||||||
|
import '../../services/map/map_providers.dart';
|
||||||
import '../../services/map/unified_map_view.dart';
|
import '../../services/map/unified_map_view.dart';
|
||||||
import '../../services/routes/navigation_handoff.dart';
|
import '../../services/routes/navigation_handoff.dart';
|
||||||
import '../../services/routes/route_models.dart';
|
import '../../services/routes/route_models.dart';
|
||||||
|
|
@ -43,6 +44,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||||
_loadInitialData();
|
_loadInitialData();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -148,8 +151,33 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => StatefulBuilder(
|
builder: (dialogCtx) => StatefulBuilder(
|
||||||
builder: (context, setDialogState) {
|
builder: (context, setDialogState) {
|
||||||
|
final screenSize = MediaQuery.sizeOf(context);
|
||||||
|
final isCompactDialog = screenSize.height < 480;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
backgroundColor: colors.surfaceContainer,
|
backgroundColor: colors.surfaceContainer,
|
||||||
|
insetPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: isCompactDialog ? 8 : 24,
|
||||||
|
),
|
||||||
|
titlePadding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isCompactDialog ? 12 : 20,
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isCompactDialog ? 10 : 16,
|
||||||
|
20,
|
||||||
|
isCompactDialog ? 8 : 16,
|
||||||
|
),
|
||||||
|
actionsPadding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
isCompactDialog ? 8 : 16,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
|
@ -161,7 +189,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
content: SingleChildScrollView(
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -196,6 +225,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Stop Name *',
|
labelText: 'Stop Name *',
|
||||||
|
|
@ -566,12 +596,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const maxAllowed = RouteService.maxTechnicalStops;
|
const maxAllowed = RouteService.maxTechnicalStops;
|
||||||
final isAtLimit = state.stops.length >= maxAllowed;
|
final isAtLimit = state.stops.length >= maxAllowed;
|
||||||
|
|
||||||
// Center map on road geometry, first stop, or default
|
final lastKnownLocation = ref.watch(routePlannerLocationProvider).valueOrNull;
|
||||||
|
|
||||||
|
// Route context takes priority over cached rider location and the US overview.
|
||||||
final mapCenter = state.geometry.isNotEmpty
|
final mapCenter = state.geometry.isNotEmpty
|
||||||
? state.geometry.first
|
? state.geometry.first
|
||||||
: (state.stops.isNotEmpty
|
: (state.stops.isNotEmpty
|
||||||
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
|
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
|
||||||
: const AppLatLng(37.7749, -122.4194));
|
: lastKnownLocation ?? const AppLatLng(39.5, -98.35));
|
||||||
|
final mapZoom = state.geometry.isNotEmpty || state.stops.isNotEmpty || lastKnownLocation != null
|
||||||
|
? 12.0 : 3.0;
|
||||||
|
|
||||||
// Build markers
|
// Build markers
|
||||||
final markers = state.stops.map((stop) {
|
final markers = state.stops.map((stop) {
|
||||||
|
|
@ -629,11 +663,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surfaceContainer,
|
backgroundColor: colors.surfaceContainer,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
|
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
|
|
@ -682,16 +719,18 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
final isNarrow = snapshot.width < 360;
|
final isNarrow = snapshot.width < 360;
|
||||||
final isExpandedLayout =
|
final isExpandedLayout =
|
||||||
snapshot.sizeClass == WindowSizeClass.expanded ||
|
snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||||
(snapshot.isLandscape && snapshot.width >= 720);
|
(snapshot.isLandscape && snapshot.width >= 600);
|
||||||
|
|
||||||
if (isExpandedLayout) {
|
if (isExpandedLayout) {
|
||||||
return _buildExpandedLayout(
|
return _buildExpandedLayout(
|
||||||
context,
|
context,
|
||||||
colors,
|
colors,
|
||||||
|
snapshot,
|
||||||
state,
|
state,
|
||||||
maxAllowed,
|
maxAllowed,
|
||||||
isAtLimit,
|
isAtLimit,
|
||||||
mapCenter,
|
mapCenter,
|
||||||
|
mapZoom,
|
||||||
markers,
|
markers,
|
||||||
polylines,
|
polylines,
|
||||||
isNarrow,
|
isNarrow,
|
||||||
|
|
@ -706,6 +745,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
maxAllowed,
|
maxAllowed,
|
||||||
isAtLimit,
|
isAtLimit,
|
||||||
mapCenter,
|
mapCenter,
|
||||||
|
mapZoom,
|
||||||
markers,
|
markers,
|
||||||
polylines,
|
polylines,
|
||||||
isNarrow,
|
isNarrow,
|
||||||
|
|
@ -718,14 +758,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
Widget _buildExpandedLayout(
|
Widget _buildExpandedLayout(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
|
ResponsiveSnapshot snapshot,
|
||||||
RoutePlannerState state,
|
RoutePlannerState state,
|
||||||
int maxAllowed,
|
int maxAllowed,
|
||||||
bool isAtLimit,
|
bool isAtLimit,
|
||||||
AppLatLng mapCenter,
|
AppLatLng mapCenter,
|
||||||
|
double mapZoom,
|
||||||
List<AppMapMarker> markers,
|
List<AppMapMarker> markers,
|
||||||
List<AppPolyline> polylines,
|
List<AppPolyline> polylines,
|
||||||
bool isNarrow,
|
bool isNarrow,
|
||||||
) {
|
) {
|
||||||
|
final isCompactHeight = snapshot.height < 500;
|
||||||
|
final isVeryCompactHeight = snapshot.height < 360;
|
||||||
|
final showDisclaimer = snapshot.height >= 260;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -743,22 +789,29 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
UnifiedMapView(
|
UnifiedMapView(
|
||||||
key: const ValueKey('route_planner_map_expanded'),
|
key: const ValueKey('route_planner_map_expanded'),
|
||||||
center: mapCenter,
|
center: mapCenter,
|
||||||
zoom: 12.0,
|
zoom: mapZoom,
|
||||||
markers: markers,
|
markers: markers,
|
||||||
polylines: polylines,
|
polylines: polylines,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 12,
|
top: isCompactHeight ? 6 : 12,
|
||||||
left: 12,
|
left: isCompactHeight ? 8 : 12,
|
||||||
right: 12,
|
right: isCompactHeight ? 8 : 12,
|
||||||
child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false),
|
child: _buildHudOverlay(
|
||||||
),
|
colors,
|
||||||
Positioned(
|
state,
|
||||||
bottom: 8,
|
maxAllowed,
|
||||||
left: 12,
|
isAtLimit,
|
||||||
right: 12,
|
isCompactHeight,
|
||||||
child: _buildDisclaimerText(),
|
),
|
||||||
),
|
),
|
||||||
|
if (showDisclaimer)
|
||||||
|
Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
child: _buildDisclaimerText(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -767,23 +820,56 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
// Right Column: Form inputs + Stop list
|
// Right Column: Form inputs + Stop list
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 6,
|
flex: 6,
|
||||||
child: Column(
|
child: isCompactHeight
|
||||||
children: [
|
? _buildStopsList(
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
colors,
|
||||||
Padding(
|
state,
|
||||||
padding: const EdgeInsets.all(16),
|
true,
|
||||||
child: _buildRouteInfoInputs(colors, false),
|
isVeryCompactHeight,
|
||||||
),
|
header: Column(
|
||||||
Divider(color: colors.outlineVariant, height: 1),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Padding(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
padding: const EdgeInsets.all(12),
|
children: [
|
||||||
child: _buildStopControls(colors, state, isNarrow, false),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
),
|
Padding(
|
||||||
Expanded(
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 4),
|
||||||
child: _buildStopsList(colors, state, false, false),
|
child: _buildRouteInfoInputs(colors, true),
|
||||||
),
|
),
|
||||||
],
|
Divider(color: colors.outlineVariant, height: 1),
|
||||||
),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_buildStopControls(colors, state, isNarrow, true),
|
||||||
|
),
|
||||||
|
if (state.stops.isEmpty)
|
||||||
|
_buildEmptyStopsContent(
|
||||||
|
colors,
|
||||||
|
true,
|
||||||
|
isVeryCompactHeight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildRouteInfoInputs(colors, false),
|
||||||
|
),
|
||||||
|
Divider(color: colors.outlineVariant, height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: _buildStopControls(colors, state, isNarrow, false),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStopsList(colors, state, false, false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -797,6 +883,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
int maxAllowed,
|
int maxAllowed,
|
||||||
bool isAtLimit,
|
bool isAtLimit,
|
||||||
AppLatLng mapCenter,
|
AppLatLng mapCenter,
|
||||||
|
double mapZoom,
|
||||||
List<AppMapMarker> markers,
|
List<AppMapMarker> markers,
|
||||||
List<AppPolyline> polylines,
|
List<AppPolyline> polylines,
|
||||||
bool isNarrow,
|
bool isNarrow,
|
||||||
|
|
@ -829,6 +916,65 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
showDisclaimer = false;
|
showDisclaimer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCompactHeight) {
|
||||||
|
return _buildStopsList(
|
||||||
|
colors,
|
||||||
|
state,
|
||||||
|
true,
|
||||||
|
isVeryCompactHeight,
|
||||||
|
header: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
|
if (mapHeight > 0)
|
||||||
|
SizedBox(
|
||||||
|
height: mapHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
UnifiedMapView(
|
||||||
|
key: const ValueKey('route_planner_map_compact'),
|
||||||
|
center: mapCenter,
|
||||||
|
zoom: 12.0,
|
||||||
|
markers: markers,
|
||||||
|
polylines: polylines,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
child: _buildHudOverlay(
|
||||||
|
colors,
|
||||||
|
state,
|
||||||
|
maxAllowed,
|
||||||
|
isAtLimit,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: _buildRouteInfoInputs(colors, true),
|
||||||
|
),
|
||||||
|
Divider(color: colors.outlineVariant, height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: _buildStopControls(colors, state, isNarrow, true),
|
||||||
|
),
|
||||||
|
if (state.stops.isEmpty)
|
||||||
|
_buildEmptyStopsContent(
|
||||||
|
colors,
|
||||||
|
true,
|
||||||
|
isVeryCompactHeight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
|
|
@ -843,12 +989,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
UnifiedMapView(
|
UnifiedMapView(
|
||||||
key: const ValueKey('route_planner_map_compact'),
|
key: const ValueKey('route_planner_map_compact'),
|
||||||
center: mapCenter,
|
center: mapCenter,
|
||||||
zoom: 12.0,
|
zoom: mapZoom,
|
||||||
markers: markers,
|
markers: markers,
|
||||||
polylines: polylines,
|
polylines: polylines,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: isCompactHeight ? 4 : 8,
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: _buildHudOverlay(
|
child: _buildHudOverlay(
|
||||||
|
|
@ -856,7 +1002,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
state,
|
state,
|
||||||
maxAllowed,
|
maxAllowed,
|
||||||
isAtLimit,
|
isAtLimit,
|
||||||
isCompactHeight,
|
false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showDisclaimer)
|
if (showDisclaimer)
|
||||||
|
|
@ -872,24 +1018,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
|
|
||||||
// Route Info Inputs
|
// Route Info Inputs
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
16,
|
child: _buildRouteInfoInputs(colors, false),
|
||||||
isCompactHeight ? 4 : 12,
|
|
||||||
16,
|
|
||||||
isCompactHeight ? 2 : 4,
|
|
||||||
),
|
|
||||||
child: _buildRouteInfoInputs(colors, isCompactHeight),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
Divider(color: colors.outlineVariant, height: 1),
|
Divider(color: colors.outlineVariant, height: 1),
|
||||||
|
|
||||||
// Stop List Controls
|
// Stop List Controls
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
horizontal: 16,
|
child: _buildStopControls(colors, state, isNarrow, false),
|
||||||
vertical: isCompactHeight ? 4 : 8,
|
|
||||||
),
|
|
||||||
child: _buildStopControls(colors, state, isNarrow, isCompactHeight),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Reorderable Stop List
|
// Reorderable Stop List
|
||||||
|
|
@ -897,7 +1035,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
child: _buildStopsList(
|
child: _buildStopsList(
|
||||||
colors,
|
colors,
|
||||||
state,
|
state,
|
||||||
isCompactHeight,
|
false,
|
||||||
isVeryCompactHeight,
|
isVeryCompactHeight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1072,6 +1210,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: onSurface,
|
color: onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -1163,15 +1302,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isCompact: isCompactHeight,
|
isCompact: isCompactHeight,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isCompactHeight ? 6 : 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 6,
|
||||||
alignment: WrapAlignment.spaceBetween,
|
alignment: WrapAlignment.spaceBetween,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisSize: MainAxisSize.min,
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
key: const ValueKey('route_planner_add_stop_button'),
|
key: const ValueKey('route_planner_add_stop_button'),
|
||||||
|
|
@ -1181,17 +1322,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colors.cyberCyan,
|
backgroundColor: colors.cyberCyan,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
vertical: 8,
|
vertical: isCompactHeight ? 6 : 8,
|
||||||
),
|
),
|
||||||
|
minimumSize: isCompactHeight ? const Size(0, 34) : null,
|
||||||
|
tapTargetSize: isCompactHeight
|
||||||
|
? MaterialTapTargetSize.shrinkWrap
|
||||||
|
: null,
|
||||||
textStyle: const TextStyle(
|
textStyle: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
key: const ValueKey('route_planner_current_gps_button'),
|
key: const ValueKey('route_planner_current_gps_button'),
|
||||||
onPressed: _addCurrentLocationStop,
|
onPressed: _addCurrentLocationStop,
|
||||||
|
|
@ -1206,10 +1350,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
),
|
),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
vertical: 8,
|
vertical: isCompactHeight ? 6 : 8,
|
||||||
),
|
),
|
||||||
|
minimumSize: isCompactHeight ? const Size(0, 34) : null,
|
||||||
|
tapTargetSize: isCompactHeight
|
||||||
|
? MaterialTapTargetSize.shrinkWrap
|
||||||
|
: null,
|
||||||
textStyle: const TextStyle(fontSize: 12),
|
textStyle: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1248,56 +1396,74 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStopsList(
|
Widget _buildEmptyStopsContent(
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
RoutePlannerState state,
|
|
||||||
bool isCompactHeight,
|
bool isCompactHeight,
|
||||||
bool isVeryCompactHeight,
|
bool isVeryCompactHeight,
|
||||||
) {
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!isVeryCompactHeight) ...[
|
||||||
|
Icon(
|
||||||
|
Icons.alt_route,
|
||||||
|
size: isCompactHeight ? 28 : 48,
|
||||||
|
color: onSurfaceVariant.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompactHeight ? 4 : 12),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'No stops added yet.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurfaceVariant,
|
||||||
|
fontSize: isCompactHeight ? 13 : 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
fontSize: isCompactHeight ? 11 : 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStopsList(
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
RoutePlannerState state,
|
||||||
|
bool isCompactHeight,
|
||||||
|
bool isVeryCompactHeight, {
|
||||||
|
Widget? header,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final onSurface = theme.colorScheme.onSurface;
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
if (state.stops.isEmpty) {
|
if (header == null && state.stops.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
child: Padding(
|
child: _buildEmptyStopsContent(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
colors,
|
||||||
child: Column(
|
isCompactHeight,
|
||||||
mainAxisSize: MainAxisSize.min,
|
isVeryCompactHeight,
|
||||||
children: [
|
|
||||||
if (!isVeryCompactHeight) ...[
|
|
||||||
Icon(
|
|
||||||
Icons.alt_route,
|
|
||||||
size: isCompactHeight ? 32 : 48,
|
|
||||||
color: onSurfaceVariant.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
SizedBox(height: isCompactHeight ? 6 : 12),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
'No stops added yet.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: onSurfaceVariant,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: onSurfaceVariant.withValues(alpha: 0.6),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReorderableListView.builder(
|
return ReorderableListView.builder(
|
||||||
|
header: header,
|
||||||
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 4,
|
vertical: 4,
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,7 @@ class _AddressAutocompleteFieldState
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
enabled: widget.enabled && !_isResolving,
|
enabled: widget.enabled && !_isResolving,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
style: TextStyle(color: onSurface, fontSize: 14),
|
style: TextStyle(color: onSurface, fontSize: 14),
|
||||||
onChanged: _onTextChanged,
|
onChanged: _onTextChanged,
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
|
|
|
||||||
|
|
@ -353,8 +353,10 @@ class _ClubDetailScreenState extends ConsumerState<ClubDetailScreen> {
|
||||||
),
|
),
|
||||||
_buildPill(
|
_buildPill(
|
||||||
icon: club.admissionPolicy == ClubAdmissionPolicy.open
|
icon: club.admissionPolicy == ClubAdmissionPolicy.open
|
||||||
? Icons.lock_open_outlined
|
? Icons.group_add_outlined
|
||||||
: Icons.how_to_reg_outlined,
|
: (club.admissionPolicy == ClubAdmissionPolicy.request
|
||||||
|
? Icons.how_to_reg_outlined
|
||||||
|
: Icons.mail_outline),
|
||||||
label: club.admissionPolicy.label,
|
label: club.admissionPolicy.label,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,9 @@ class ClubMembershipPresentation {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated user with an active membership
|
// Root ownership remains authoritative when the member record is missing or stale.
|
||||||
if (member != null && member.status == ClubMemberStatus.active) {
|
if (club.ownerUid == currentUserId || member?.isActive == true) {
|
||||||
final isOwner = member.role == ClubRole.owner || club.ownerUid == currentUserId;
|
final isOwner = member?.role == ClubRole.owner || club.ownerUid == currentUserId;
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
return const ClubMembershipPresentation(
|
return const ClubMembershipPresentation(
|
||||||
state: ClubMembershipPresentationState.activeOwner,
|
state: ClubMembershipPresentationState.activeOwner,
|
||||||
|
|
@ -135,7 +135,7 @@ class ClubMembershipPresentation {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (member.role == ClubRole.admin) {
|
if (member?.role == ClubRole.admin) {
|
||||||
return const ClubMembershipPresentation(
|
return const ClubMembershipPresentation(
|
||||||
state: ClubMembershipPresentationState.activeAdmin,
|
state: ClubMembershipPresentationState.activeAdmin,
|
||||||
statusBadge: 'ADMIN',
|
statusBadge: 'ADMIN',
|
||||||
|
|
|
||||||
|
|
@ -356,11 +356,45 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final member in memberships)
|
LayoutBuilder(
|
||||||
ClubMemberCard(
|
builder: (context, constraints) {
|
||||||
key: ValueKey('my_club_${member.clubId}'),
|
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
|
||||||
member: member,
|
final useGrid = isLandscape || constraints.maxWidth >= 600;
|
||||||
),
|
|
||||||
|
if (useGrid) {
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
mainAxisExtent: 215,
|
||||||
|
),
|
||||||
|
itemCount: memberships.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ClubMemberCard(
|
||||||
|
key: ValueKey('my_club_${memberships[index].clubId}'),
|
||||||
|
member: memberships[index],
|
||||||
|
inGrid: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: memberships.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ClubMemberCard(
|
||||||
|
key: ValueKey('my_club_${memberships[index].clubId}'),
|
||||||
|
member: memberships[index],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -571,9 +605,19 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ClubCard(
|
LayoutBuilder(
|
||||||
club: _searchedClub!,
|
builder: (context, constraints) {
|
||||||
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: isLandscape ? 400 : double.infinity,
|
||||||
|
),
|
||||||
|
child: ClubCard(
|
||||||
|
club: _searchedClub!,
|
||||||
|
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -631,16 +675,46 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return LayoutBuilder(
|
||||||
shrinkWrap: true,
|
builder: (context, constraints) {
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
|
||||||
itemCount: clubs.length,
|
final useGrid = isLandscape || constraints.maxWidth >= 600;
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final club = clubs[index];
|
if (useGrid) {
|
||||||
return ClubCard(
|
return GridView.builder(
|
||||||
key: ValueKey('discover_club_${club.id}'),
|
shrinkWrap: true,
|
||||||
club: club,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
mainAxisExtent: 215,
|
||||||
|
),
|
||||||
|
itemCount: clubs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final club = clubs[index];
|
||||||
|
return ClubCard(
|
||||||
|
key: ValueKey('discover_club_${club.id}'),
|
||||||
|
club: club,
|
||||||
|
inGrid: true,
|
||||||
|
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: clubs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final club = clubs[index];
|
||||||
|
return ClubCard(
|
||||||
|
key: ValueKey('discover_club_${club.id}'),
|
||||||
|
club: club,
|
||||||
|
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -178,12 +178,17 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final mediaSize = MediaQuery.sizeOf(context);
|
||||||
|
final isCompactScreen = mediaSize.height < 500;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(
|
toolbarHeight: isCompactScreen ? 44 : null,
|
||||||
|
title: Text(
|
||||||
'Create Club',
|
'Create Club',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: isCompactScreen ? 16 : null,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -191,332 +196,419 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: ResponsiveLayout(
|
child: ResponsiveLayout(
|
||||||
builder: (context, responsive) {
|
builder: (context, responsive) {
|
||||||
|
final isCompact =
|
||||||
|
responsive.isLandscape || responsive.isShortViewport;
|
||||||
|
final isTwoColumn =
|
||||||
|
responsive.isLandscape && responsive.width >= 600;
|
||||||
|
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
|
||||||
|
final nameField = TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
enabled: !_isSubmitting,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Club Name *',
|
||||||
|
hintText: 'e.g. Ridge Runners MC',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (val) {
|
||||||
|
final clean = val?.trim() ?? '';
|
||||||
|
if (clean.length < 3) {
|
||||||
|
return 'Name must be at least 3 characters';
|
||||||
|
}
|
||||||
|
if (clean.length > 50) {
|
||||||
|
return 'Name cannot exceed 50 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final slugField = SlugInputField(
|
||||||
|
controller: _slugController,
|
||||||
|
enabled: !_isSubmitting,
|
||||||
|
onValidationChanged: (isValid) {
|
||||||
|
setState(() {
|
||||||
|
_isSlugValidAndAvailable = isValid;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final regionSection = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildFieldLabel(
|
||||||
|
'Home base',
|
||||||
|
'Where this club primarily rides or organizes from.',
|
||||||
|
colors,
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 6 : 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _regionController,
|
||||||
|
enabled: !_isSubmitting,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Home region (optional)',
|
||||||
|
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
|
||||||
|
prefixIcon: const Icon(Icons.location_on_outlined),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final focusSection = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildFieldLabel(
|
||||||
|
'Rider community',
|
||||||
|
'Name the motorcycle types or riding style this club is for.',
|
||||||
|
colors,
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 6 : 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _focusController,
|
||||||
|
enabled: !_isSubmitting,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Motorcycle focus (optional)',
|
||||||
|
hintText: 'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
|
||||||
|
prefixIcon: const Icon(Icons.two_wheeler_outlined),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final visibilitySection = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Club Visibility',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 6 : 8),
|
||||||
|
_buildVisibilitySelector(colors, theme),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final admissionSection = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Admission Policy',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 6 : 8),
|
||||||
|
_buildAdmissionSelector(colors, theme),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 700),
|
constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 700),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: Scrollbar(
|
||||||
padding: const EdgeInsets.all(18.0),
|
thumbVisibility: isCompact,
|
||||||
children: [
|
child: ListView(
|
||||||
// Header card explaining Club identity
|
keyboardDismissBehavior:
|
||||||
Container(
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: const EdgeInsets.all(14.0),
|
padding: EdgeInsets.fromLTRB(
|
||||||
decoration: BoxDecoration(
|
isCompact ? 16.0 : 18.0,
|
||||||
color: colors.surfaceContainer,
|
isCompact ? 10.0 : 18.0,
|
||||||
borderRadius: BorderRadius.circular(12),
|
isCompact ? 16.0 : 18.0,
|
||||||
border: Border.all(
|
(isCompact ? 16.0 : 32.0) +
|
||||||
color: colors.outlineVariant,
|
(bottomInset > 0 ? 16.0 : 0.0),
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.shield_outlined,
|
|
||||||
color: colors.cyberCyan,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Found a Motorcycle Club',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontHeadline,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontBody,
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
children: [
|
||||||
|
Column(
|
||||||
// Submission error alert if present
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (_submissionError != null) ...[
|
children: [
|
||||||
|
// Header card explaining Club identity
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: EdgeInsets.all(isCompact ? 10.0 : 14.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.raceRed.withValues(alpha: 0.12),
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: colors.raceRed.withValues(alpha: 0.4),
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.error_outline,
|
Icons.shield_outlined,
|
||||||
color: colors.raceRed,
|
color: colors.cyberCyan,
|
||||||
size: 20,
|
size: isCompact ? 20 : 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
_submissionError!,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontFamily: AppTypography.fontBody,
|
const Text(
|
||||||
fontSize: 12.5,
|
'Found a Motorcycle Club',
|
||||||
color: colors.raceRed,
|
style: TextStyle(
|
||||||
),
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontBody,
|
||||||
|
fontSize: 12,
|
||||||
|
color:
|
||||||
|
theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: isCompact ? 12 : 20),
|
||||||
],
|
|
||||||
|
|
||||||
// Club Name
|
// Submission error alert if present
|
||||||
TextFormField(
|
if (_submissionError != null) ...[
|
||||||
controller: _nameController,
|
Container(
|
||||||
enabled: !_isSubmitting,
|
padding: const EdgeInsets.all(12),
|
||||||
textCapitalization: TextCapitalization.words,
|
decoration: BoxDecoration(
|
||||||
decoration: InputDecoration(
|
color: colors.raceRed.withValues(alpha: 0.12),
|
||||||
labelText: 'Club Name *',
|
borderRadius: BorderRadius.circular(10),
|
||||||
hintText: 'e.g. Ridge Runners MC',
|
border: Border.all(
|
||||||
filled: true,
|
color: colors.raceRed.withValues(alpha: 0.4),
|
||||||
fillColor: colors.surfaceContainer,
|
),
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
enabledBorder: OutlineInputBorder(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(12),
|
Icon(
|
||||||
borderSide: BorderSide(
|
Icons.error_outline,
|
||||||
color: colors.outlineVariant,
|
color: colors.raceRed,
|
||||||
width: 0.8,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
focusedBorder: OutlineInputBorder(
|
Expanded(
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Text(
|
||||||
borderSide: BorderSide(
|
_submissionError!,
|
||||||
color: colors.cyberCyan,
|
style: TextStyle(
|
||||||
width: 1.5,
|
fontFamily: AppTypography.fontBody,
|
||||||
),
|
fontSize: 12.5,
|
||||||
),
|
color: colors.raceRed,
|
||||||
),
|
|
||||||
validator: (val) {
|
|
||||||
final clean = val?.trim() ?? '';
|
|
||||||
if (clean.length < 3) {
|
|
||||||
return 'Name must be at least 3 characters';
|
|
||||||
}
|
|
||||||
if (clean.length > 50) {
|
|
||||||
return 'Name cannot exceed 50 characters';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
|
|
||||||
// Slug / Handle with live debounce & availability checking
|
|
||||||
SlugInputField(
|
|
||||||
controller: _slugController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
onValidationChanged: (isValid) {
|
|
||||||
setState(() {
|
|
||||||
_isSlugValidAndAvailable = isValid;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
|
|
||||||
// Description
|
|
||||||
TextFormField(
|
|
||||||
controller: _descController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
maxLines: 3,
|
|
||||||
maxLength: 500,
|
|
||||||
textCapitalization: TextCapitalization.sentences,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Description (Optional)',
|
|
||||||
hintText:
|
|
||||||
'Share your club mission, riding style, or club guidelines...',
|
|
||||||
filled: true,
|
|
||||||
fillColor: colors.surfaceContainer,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.cyberCyan,
|
|
||||||
width: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Give optional discovery fields enough room for a clear
|
|
||||||
// label and example on narrow phone layouts.
|
|
||||||
_buildFieldLabel(
|
|
||||||
'Home base',
|
|
||||||
'Where this club primarily rides or organizes from.',
|
|
||||||
colors,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: _regionController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Home region (optional)',
|
|
||||||
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
|
|
||||||
prefixIcon: const Icon(Icons.location_on_outlined),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colors.surfaceContainer,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
_buildFieldLabel(
|
|
||||||
'Rider community',
|
|
||||||
'Name the motorcycle types or riding style this club is for.',
|
|
||||||
colors,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: _focusController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Motorcycle focus (optional)',
|
|
||||||
hintText:
|
|
||||||
'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
|
|
||||||
prefixIcon: const Icon(Icons.two_wheeler_outlined),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colors.surfaceContainer,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Visibility Selection
|
|
||||||
Text(
|
|
||||||
'Club Visibility',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontHeadline,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: colors.cyberCyan,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildVisibilitySelector(colors, theme),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Admission Policy Selection
|
|
||||||
Text(
|
|
||||||
'Admission Policy',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontHeadline,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: colors.cyberCyan,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildAdmissionSelector(colors, theme),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// Submit Button
|
|
||||||
SizedBox(
|
|
||||||
height: 50,
|
|
||||||
child: FilledButton(
|
|
||||||
key: const ValueKey('create_club_submit_button'),
|
|
||||||
onPressed:
|
|
||||||
(_isSubmitting || !_isSlugValidAndAvailable)
|
|
||||||
? null
|
|
||||||
: _submitForm,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: colors.cyberCyan,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.5,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Colors.black,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'Create Club',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontHeadline,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 12 : 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Club Name & Slug / Handle
|
||||||
|
if (isTwoColumn)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: nameField),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: slugField),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
nameField,
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
slugField,
|
||||||
|
],
|
||||||
|
SizedBox(height: isCompact ? 12 : 18),
|
||||||
|
|
||||||
|
// Description
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
enabled: !_isSubmitting,
|
||||||
|
maxLines: isCompact ? 2 : 3,
|
||||||
|
maxLength: 500,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
scrollPadding:
|
||||||
|
const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Description (Optional)',
|
||||||
|
hintText:
|
||||||
|
'Share your club mission, riding style, or club guidelines...',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: isCompact ? 10 : 14),
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
// Home base & Rider community
|
||||||
|
if (isTwoColumn)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: regionSection),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: focusSection),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
regionSection,
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
focusSection,
|
||||||
|
],
|
||||||
|
SizedBox(height: isCompact ? 14 : 24),
|
||||||
|
|
||||||
|
// Visibility & Admission Policy Selection
|
||||||
|
if (isTwoColumn)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: visibilitySection),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: admissionSection),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
visibilitySection,
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
admissionSection,
|
||||||
|
],
|
||||||
|
SizedBox(height: isCompact ? 16 : 32),
|
||||||
|
|
||||||
|
// Submit Button
|
||||||
|
SizedBox(
|
||||||
|
height: isCompact ? 46 : 50,
|
||||||
|
child: FilledButton(
|
||||||
|
key: const ValueKey('create_club_submit_button'),
|
||||||
|
onPressed:
|
||||||
|
(_isSubmitting || !_isSlugValidAndAvailable)
|
||||||
|
? null
|
||||||
|
: _submitForm,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: colors.cyberCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Create Club',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isCompact ? 12 : 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -533,12 +625,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
||||||
) {
|
) {
|
||||||
return DropdownButtonFormField<ClubVisibility>(
|
return DropdownButtonFormField<ClubVisibility>(
|
||||||
initialValue: _visibility,
|
initialValue: _visibility,
|
||||||
|
isExpanded: true,
|
||||||
decoration: _choiceDecoration('Who can find this club', colors),
|
decoration: _choiceDecoration('Who can find this club', colors),
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||||
items: ClubVisibility.values
|
items: ClubVisibility.values
|
||||||
.map(
|
.map(
|
||||||
(value) => DropdownMenuItem(value: value, child: Text(value.label)),
|
(value) => DropdownMenuItem(
|
||||||
|
value: value,
|
||||||
|
child: Text(
|
||||||
|
value.label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: _isSubmitting
|
onChanged: _isSubmitting
|
||||||
|
|
@ -555,12 +654,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
||||||
) {
|
) {
|
||||||
return DropdownButtonFormField<ClubAdmissionPolicy>(
|
return DropdownButtonFormField<ClubAdmissionPolicy>(
|
||||||
initialValue: _admissionPolicy,
|
initialValue: _admissionPolicy,
|
||||||
|
isExpanded: true,
|
||||||
decoration: _choiceDecoration('How riders join', colors),
|
decoration: _choiceDecoration('How riders join', colors),
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||||
items: ClubAdmissionPolicy.values
|
items: ClubAdmissionPolicy.values
|
||||||
.map(
|
.map(
|
||||||
(value) => DropdownMenuItem(value: value, child: Text(value.label)),
|
(value) => DropdownMenuItem(
|
||||||
|
value: value,
|
||||||
|
child: Text(
|
||||||
|
value.label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: _isSubmitting
|
onChanged: _isSubmitting
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ class ClubCard extends StatelessWidget {
|
||||||
final Club club;
|
final Club club;
|
||||||
final ClubRole? role;
|
final ClubRole? role;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool inGrid;
|
||||||
|
|
||||||
const ClubCard({
|
const ClubCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.club,
|
required this.club,
|
||||||
this.role,
|
this.role,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.inGrid = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -24,7 +26,7 @@ class ClubCard extends StatelessWidget {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
|
@ -127,7 +129,8 @@ class ClubCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Admission policy badge
|
// Admission policy badge
|
||||||
_buildAdmissionBadge(context, club.admissionPolicy, colors),
|
if (role == null)
|
||||||
|
_buildAdmissionBadge(context, club.admissionPolicy, colors),
|
||||||
|
|
||||||
// Region badge if available
|
// Region badge if available
|
||||||
if (club.homeRegion != null && club.homeRegion!.trim().isNotEmpty)
|
if (club.homeRegion != null && club.homeRegion!.trim().isNotEmpty)
|
||||||
|
|
@ -215,7 +218,7 @@ class ClubCard extends StatelessWidget {
|
||||||
switch (policy) {
|
switch (policy) {
|
||||||
case ClubAdmissionPolicy.open:
|
case ClubAdmissionPolicy.open:
|
||||||
color = colors.launchGreen;
|
color = colors.launchGreen;
|
||||||
icon = Icons.lock_open_outlined;
|
icon = Icons.group_add_outlined;
|
||||||
break;
|
break;
|
||||||
case ClubAdmissionPolicy.request:
|
case ClubAdmissionPolicy.request:
|
||||||
color = colors.cyberCyan;
|
color = colors.cyberCyan;
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import 'club_card.dart';
|
||||||
/// Club document in real time.
|
/// Club document in real time.
|
||||||
class ClubMemberCard extends ConsumerWidget {
|
class ClubMemberCard extends ConsumerWidget {
|
||||||
final ClubMember member;
|
final ClubMember member;
|
||||||
|
final bool inGrid;
|
||||||
|
|
||||||
const ClubMemberCard({
|
const ClubMemberCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.member,
|
required this.member,
|
||||||
|
this.inGrid = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -28,6 +30,7 @@ class ClubMemberCard extends ConsumerWidget {
|
||||||
return ClubCard(
|
return ClubCard(
|
||||||
club: club,
|
club: club,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
|
inGrid: inGrid,
|
||||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -40,7 +43,7 @@ class ClubMemberCard extends ConsumerWidget {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
height: 96,
|
height: 96,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ class _SlugInputFieldState extends ConsumerState<SlugInputField> {
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
textCapitalization: TextCapitalization.none,
|
textCapitalization: TextCapitalization.none,
|
||||||
|
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
|
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
|
||||||
_LowerCaseTextFormatter(),
|
_LowerCaseTextFormatter(),
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
|
final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
|
||||||
final searchQuery = ref.watch(feedSearchQueryProvider);
|
final searchQuery = ref.watch(feedSearchQueryProvider);
|
||||||
final items = ref.watch(socialFeedNotifierProvider);
|
final items = ref.watch(socialFeedNotifierProvider);
|
||||||
|
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||||
|
const buttonHeight = 52.0;
|
||||||
|
const buttonBottom = 12.0;
|
||||||
|
final bottomClearance = buttonHeight + buttonBottom + 16.0 + safeBottom;
|
||||||
|
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
child: ResponsiveLayout(
|
child: ResponsiveLayout(
|
||||||
|
|
@ -83,10 +87,16 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
activeDiscipline,
|
activeDiscipline,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
colors,
|
colors,
|
||||||
|
bottomClearance: bottomClearance,
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
key: const ValueKey('social_feed_list_view'),
|
key: const ValueKey('social_feed_list_view'),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 100),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
16,
|
||||||
|
bottomClearance + 20.0,
|
||||||
|
),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
@ -106,7 +116,7 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 12,
|
bottom: 12 + safeBottom,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 720),
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
|
@ -597,8 +607,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
FeedFilter activeFilter,
|
FeedFilter activeFilter,
|
||||||
RidingDiscipline activeDiscipline,
|
RidingDiscipline activeDiscipline,
|
||||||
String searchQuery,
|
String searchQuery,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors, {
|
||||||
) {
|
double bottomClearance = 88.0,
|
||||||
|
}) {
|
||||||
final hasCustomFilters =
|
final hasCustomFilters =
|
||||||
activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
|
activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
|
||||||
final isLight = Theme.of(context).brightness == Brightness.light;
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||||
|
|
@ -611,51 +622,56 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final isCompact = constraints.maxHeight < 280;
|
||||||
|
final iconSize = isCompact ? 36.0 : 56.0;
|
||||||
|
final verticalGap = isCompact ? 8.0 : 16.0;
|
||||||
|
final textGap = isCompact ? 4.0 : 6.0;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: 80),
|
padding: EdgeInsets.only(bottom: bottomClearance),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minHeight: constraints.maxHeight > 80
|
minHeight: constraints.maxHeight > bottomClearance
|
||||||
? constraints.maxHeight - 80
|
? constraints.maxHeight - bottomClearance
|
||||||
: constraints.maxHeight,
|
: constraints.maxHeight,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.dynamic_feed_outlined,
|
Icons.dynamic_feed_outlined,
|
||||||
size: 56,
|
size: iconSize,
|
||||||
color: secondaryTextColor,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: verticalGap),
|
||||||
Text(
|
Text(
|
||||||
hasCustomFilters
|
hasCustomFilters
|
||||||
? 'No matching ride sessions'
|
? 'No matching ride sessions'
|
||||||
: 'No posts in ${activeFilter.displayName}',
|
: 'No posts in ${activeFilter.displayName}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: isCompact ? 14 : 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: primaryTextColor,
|
color: primaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
SizedBox(height: textGap),
|
||||||
Text(
|
Text(
|
||||||
hasCustomFilters
|
hasCustomFilters
|
||||||
? 'Try relaxing your style filters or search query to discover more community rides.'
|
? 'Try relaxing your style filters or search query to discover more community rides.'
|
||||||
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
|
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: isCompact ? 12 : 13,
|
||||||
color: secondaryTextColor,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: verticalGap),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
|
|
|
||||||
|
|
@ -413,11 +413,13 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
final accentColor =
|
final accentColor =
|
||||||
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
|
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
|
||||||
final buttonFg = AppColors.onBrand;
|
final buttonFg = AppColors.onBrand;
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final isLandscape = size.width > size.height && size.height <= 500;
|
||||||
|
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
maxHeight: size.height * (isLandscape ? 0.98 : 0.9),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -442,14 +444,19 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
children: [
|
children: [
|
||||||
// Modal Header & Handle
|
// Modal Header & Handle
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isLandscape ? 8 : 12,
|
||||||
|
20,
|
||||||
|
isLandscape ? 8 : 12,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Drag handle
|
// Drag handle
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -763,59 +770,22 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (isLandscape) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_buildPublishButton(accentColor, buttonFg),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Publish CTA Button (pinned at modal bottom)
|
// Publish CTA Button (pinned at modal bottom in portrait mode)
|
||||||
Padding(
|
if (!isLandscape)
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
Padding(
|
||||||
child: SizedBox(
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||||
height: 52,
|
child: _buildPublishButton(accentColor, buttonFg),
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -824,6 +794,53 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPublishButton(Color accentColor, Color buttonFg) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 52,
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
foregroundColor: buttonFg,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
onPressed:
|
||||||
|
(_isSubmitting || _selectedRide == null) ? null : _handlePublish,
|
||||||
|
child: _isSubmitting
|
||||||
|
? SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: buttonFg,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.share_rounded, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'PUBLISH TO COMMUNITY FEED',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildReadOnlyTelemetryTile({
|
Widget _buildReadOnlyTelemetryTile({
|
||||||
required Key key,
|
required Key key,
|
||||||
required String label,
|
required String label,
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final insets = MediaQuery.viewInsetsOf(context);
|
||||||
|
final isLandscape = size.width > size.height;
|
||||||
|
final maxHeight = (size.height - insets.bottom) * (isLandscape ? 0.95 : 0.85);
|
||||||
|
final crossAxisCount = size.width >= 720
|
||||||
|
? 4
|
||||||
|
: (size.width >= 480 ? 3 : 2);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||||
|
|
@ -165,7 +171,12 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
isLandscape ? 8 : 12,
|
||||||
|
16,
|
||||||
|
isLandscape ? 8 : 16,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -180,17 +191,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: isLandscape ? 6 : 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24),
|
Icon(Icons.campaign, color: AppColors.cyberCyan, size: isLandscape ? 20 : 24),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'REPORT ROAD INCIDENT',
|
'REPORT ROAD INCIDENT',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: isLandscape ? 14 : 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|
@ -199,23 +210,27 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, color: Colors.white60),
|
icon: const Icon(Icons.close, color: Colors.white60),
|
||||||
|
padding: isLandscape ? EdgeInsets.zero : const EdgeInsets.all(8.0),
|
||||||
|
constraints: isLandscape ? const BoxConstraints() : null,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
if (!isLandscape) ...[
|
||||||
Text(
|
const SizedBox(height: 4),
|
||||||
'1-tap broadcast to all nearby riders & pack members.',
|
Text(
|
||||||
style: TextStyle(
|
'1-tap broadcast to all nearby riders & pack members.',
|
||||||
fontFamily: AppTypography.fontBody,
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontFamily: AppTypography.fontBody,
|
||||||
color: Colors.white70,
|
fontSize: 12,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
if (_isMovingFast) ...[
|
if (_isMovingFast) ...[
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isLandscape ? 6 : 10),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.amber.withValues(alpha: 0.15),
|
color: Colors.amber.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
|
@ -223,7 +238,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18),
|
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 16),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -239,22 +254,23 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 14),
|
SizedBox(height: isLandscape ? 8 : 14),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: _kReportCategories.length,
|
itemCount: _kReportCategories.length,
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: crossAxisCount,
|
||||||
childAspectRatio: 2.2,
|
mainAxisExtent: isLandscape ? 56.0 : 70.0,
|
||||||
crossAxisSpacing: 10,
|
crossAxisSpacing: 8,
|
||||||
mainAxisSpacing: 10,
|
mainAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _kReportCategories[index];
|
final item = _kReportCategories[index];
|
||||||
return _buildGridCard(item);
|
return _buildGridCard(item, isLandscape: isLandscape);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -266,14 +282,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridCard(_ReportItem item) {
|
Widget _buildGridCard(_ReportItem item, {bool isLandscape = false}) {
|
||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: item.color.withValues(alpha: 0.12),
|
backgroundColor: item.color.withValues(alpha: 0.12),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 6 : 10,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
|
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
|
@ -282,14 +301,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(7),
|
padding: EdgeInsets.all(isLandscape ? 5 : 7),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: item.color.withValues(alpha: 0.22),
|
color: item.color.withValues(alpha: 0.22),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(item.icon, color: item.color, size: 22),
|
child: Icon(item.icon, color: item.color, size: isLandscape ? 18 : 22),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: isLandscape ? 6 : 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -299,20 +318,22 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
item.title,
|
item.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 13,
|
fontSize: isLandscape ? 11.5 : 13,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
if (!isLandscape) ...[
|
||||||
Text(
|
const SizedBox(height: 2),
|
||||||
item.subtitle,
|
Text(
|
||||||
style: const TextStyle(fontSize: 10, color: Colors.white70),
|
item.subtitle,
|
||||||
maxLines: 1,
|
style: const TextStyle(fontSize: 10, color: Colors.white70),
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../auth/auth_providers.dart';
|
import '../auth/auth_providers.dart';
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
import 'aws_location_route_provider.dart';
|
import 'aws_location_route_provider.dart';
|
||||||
|
|
@ -7,6 +8,19 @@ import 'route_models.dart';
|
||||||
import 'route_provider.dart';
|
import 'route_provider.dart';
|
||||||
import 'route_service.dart';
|
import 'route_service.dart';
|
||||||
|
|
||||||
|
/// One cached location read per planner visit; never starts GPS or asks permission.
|
||||||
|
final routePlannerLocationProvider = FutureProvider.autoDispose<AppLatLng?>((ref) async {
|
||||||
|
try {
|
||||||
|
final position = await Geolocator.getLastKnownPosition();
|
||||||
|
if (position == null || !RoutePlannerNotifier.isValidCoordinate(position.latitude, position.longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return AppLatLng(position.latitude, position.longitude);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/// Provider for RouteService.
|
/// Provider for RouteService.
|
||||||
final routeServiceProvider = Provider<RouteService>((ref) {
|
final routeServiceProvider = Provider<RouteService>((ref) {
|
||||||
return RouteService();
|
return RouteService();
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||||
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
|
|
||||||
class _FakeAuth implements AuthService {
|
class _FakeAuth implements AuthService {
|
||||||
@override Future<void> ensureBackendIdentity() async {}
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
@ -403,6 +404,106 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Attach Saved Route sheet empty state does not overflow in landscape (800x360) with keyboard inset and portrait (#75)',
|
||||||
|
(tester) async {
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
final fakeAuth = _FakeAuth(user: testUser);
|
||||||
|
|
||||||
|
for (final viewportSize in const [
|
||||||
|
Size(800, 360),
|
||||||
|
Size(640, 320),
|
||||||
|
Size(400, 800),
|
||||||
|
]) {
|
||||||
|
final isLandscape = viewportSize.width > viewportSize.height;
|
||||||
|
tester.view.physicalSize = viewportSize;
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.resetViewInsets();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||||
|
authServiceProvider.overrideWithValue(fakeAuth),
|
||||||
|
currentUserProvider.overrideWithValue(testUser),
|
||||||
|
groupRideStreamProvider('ride_101').overrideWith(
|
||||||
|
(ref) => Stream.value(testRide),
|
||||||
|
),
|
||||||
|
groupRideParticipantsProvider('ride_101').overrideWith(
|
||||||
|
(ref) => Stream.value(testParticipants),
|
||||||
|
),
|
||||||
|
groupRideLivePresenceProvider('ride_101').overrideWith(
|
||||||
|
(ref) => Stream.value(testPresences),
|
||||||
|
),
|
||||||
|
myRiderProfileProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(testProfile),
|
||||||
|
),
|
||||||
|
userRoutesProvider('uid_lead').overrideWith(
|
||||||
|
(ref) => Stream.value(const []),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.darkTheme,
|
||||||
|
home: const GroupRideDetailScreen(rideId: 'ride_101'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final attachBtn =
|
||||||
|
find.byKey(const ValueKey('attach_saved_route_button'));
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
attachBtn,
|
||||||
|
150.0,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(attachBtn);
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
expect(find.text('ATTACH SAVED ROUTE'), findsWidgets);
|
||||||
|
expect(find.text('You have no saved routes yet.'), findsOneWidget);
|
||||||
|
expect(find.text('CREATE A ROUTE'), findsOneWidget);
|
||||||
|
|
||||||
|
final createBtnRect =
|
||||||
|
tester.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
|
||||||
|
expect(
|
||||||
|
createBtnRect.bottom,
|
||||||
|
lessThanOrEqualTo(viewportSize.height),
|
||||||
|
reason: 'CREATE A ROUTE button should fit inside $viewportSize',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLandscape) {
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.ensureVisible(find.text('CREATE A ROUTE'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
final createBtnKeyboardRect = tester
|
||||||
|
.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
|
||||||
|
expect(
|
||||||
|
createBtnKeyboardRect.bottom,
|
||||||
|
lessThanOrEqualTo(viewportSize.height - 180 + 1.0),
|
||||||
|
reason:
|
||||||
|
'CREATE A ROUTE should remain above keyboard at $viewportSize',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss sheet
|
||||||
|
Navigator.of(tester.element(find.text('CREATE A ROUTE'))).pop();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('LiveGroupRideScreen Responsive Matrix', () {
|
group('LiveGroupRideScreen Responsive Matrix', () {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||||
import 'package:motorcycle_dash/features/garage/garage_screen.dart';
|
import 'package:motorcycle_dash/features/garage/garage_screen.dart';
|
||||||
import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart';
|
import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart';
|
||||||
import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart';
|
import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/features/garage/widgets/motorcycle_photo.dart';
|
||||||
|
|
||||||
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
|
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
|
||||||
@override
|
@override
|
||||||
|
|
@ -317,6 +318,109 @@ void main() {
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets('BUG-012: HistoryScreen ride cards show all bottom row telemetry without overflow on 800x360 landscape', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const HistoryScreen()));
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(const ValueKey('history_ride_card_1')), findsOneWidget);
|
||||||
|
expect(find.text('TRIP'), findsOneWidget);
|
||||||
|
expect(find.text('TIME'), findsOneWidget);
|
||||||
|
expect(find.text('TOP SPD'), findsOneWidget);
|
||||||
|
expect(find.text('MAX LEAN'), findsOneWidget);
|
||||||
|
expect(find.text('HARD BRK'), findsOneWidget);
|
||||||
|
expect(find.text('ELEV GAIN'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Garage responsive regression coverage', () {
|
||||||
|
testWidgets('BUG-015: landscape garage card and Add Motorcycle remain reachable', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const GarageScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
final grid = tester.widget<GridView>(find.byType(GridView));
|
||||||
|
final delegate = grid.gridDelegate as SliverGridDelegateWithMaxCrossAxisExtent;
|
||||||
|
expect(delegate.mainAxisExtent, lessThanOrEqualTo(320));
|
||||||
|
expect(tester.getSize(find.byType(MotorcyclePhoto).first).height, lessThanOrEqualTo(120));
|
||||||
|
final addMotorcycle = find.text('Add Motorcycle');
|
||||||
|
await tester.ensureVisible(addMotorcycle);
|
||||||
|
expect(addMotorcycle.hitTestable(), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.text('SPECIFICATIONS'));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(tester.getTopLeft(find.text('SPECIFICATIONS')).dy, lessThan(360));
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final keyboardInset in [0.0, 180.0]) {
|
||||||
|
testWidgets('BUG-016: landscape service dialog remains usable with keyboard inset $keyboardInset', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.viewInsets = FakeViewPadding(bottom: keyboardInset);
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetViewInsets();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.text('Log Service'));
|
||||||
|
await tester.tap(find.text('Log Service'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('Log New Service'), findsOneWidget);
|
||||||
|
final notesLabel = find.text('Notes (e.g. Brand, weight, shop)');
|
||||||
|
await tester.ensureVisible(notesLabel);
|
||||||
|
expect(tester.getBottomRight(notesLabel).dy, lessThan(360 - keyboardInset));
|
||||||
|
expect(find.text('Log New Service').hitTestable(), findsNothing);
|
||||||
|
expect(find.text('Cancel').hitTestable(), findsOneWidget);
|
||||||
|
expect(find.text('Save Record').hitTestable(), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Log New Service'), findsNothing);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('BUG-017: narrow portrait service type dropdown truncates label without overflow', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(360, 640);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.text('Log Service'));
|
||||||
|
await tester.tap(find.text('Log Service'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
final dropdown = tester.widget<DropdownButton<String>>(find.byType(DropdownButton<String>).first);
|
||||||
|
expect(dropdown.isExpanded, isTrue);
|
||||||
|
final label = tester.widget<Text>(find.text('Engine Oil & Filter').first);
|
||||||
|
expect(label.maxLines, 1);
|
||||||
|
expect(label.overflow, TextOverflow.ellipsis);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ void main() {
|
||||||
expect(find.text('@pacific-coast-riders'), findsOneWidget);
|
expect(find.text('@pacific-coast-riders'), findsOneWidget);
|
||||||
expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget);
|
expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget);
|
||||||
expect(find.text('42 members'), findsOneWidget);
|
expect(find.text('42 members'), findsOneWidget);
|
||||||
expect(find.text('Request to Join'), findsOneWidget);
|
expect(find.text('Request to Join'), findsNothing);
|
||||||
expect(find.text('NorCal'), findsOneWidget);
|
expect(find.text('NorCal'), findsOneWidget);
|
||||||
expect(find.text('Sport Touring'), findsOneWidget);
|
expect(find.text('Sport Touring'), findsOneWidget);
|
||||||
expect(find.text('OWNER'), findsOneWidget);
|
expect(find.text('OWNER'), findsOneWidget);
|
||||||
|
|
@ -53,7 +53,7 @@ void main() {
|
||||||
expect(tapped, isTrue);
|
expect(tapped, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders open admission badge correctly', (tester) async {
|
testWidgets('renders open admission badge correctly with group_add_outlined icon', (tester) async {
|
||||||
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
|
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -68,6 +68,26 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Open to Join'), findsOneWidget);
|
expect(find.text('Open to Join'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.group_add_outlined), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.lock_open_outlined), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final role in ClubRole.values) {
|
||||||
|
testWidgets('active ${role.name} has no admission prompt', (tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: ClubCard(club: sampleClub, role: role)),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('Request to Join'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('non-member retains request admission prompt', (tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: ClubCard(club: sampleClub)),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('Request to Join'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {
|
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,53 @@ void main() {
|
||||||
expect(p.primaryAction, isNull);
|
expect(p.primaryAction, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('root owner cannot join even with a missing or stale member record', () {
|
||||||
|
for (final status in [null, ...ClubMemberStatus.values]) {
|
||||||
|
final p = ClubMembershipPresentation.resolve(
|
||||||
|
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
|
||||||
|
member: status == null ? null : ClubMember(
|
||||||
|
uid: 'owner_uid',
|
||||||
|
clubId: baseClub.id,
|
||||||
|
role: ClubRole.member,
|
||||||
|
status: status,
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
),
|
||||||
|
currentUserId: 'owner_uid',
|
||||||
|
isAuthenticated: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(p.state, ClubMembershipPresentationState.activeOwner);
|
||||||
|
expect(p.primaryAction, isNull);
|
||||||
|
expect(p.secondaryAction, isNull);
|
||||||
|
expect(p.canLeave, isFalse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('request admission actions respect active, pending and outsider states', () {
|
||||||
|
for (final role in ClubRole.values) {
|
||||||
|
for (final status in [null, ClubMemberStatus.active, ClubMemberStatus.requested]) {
|
||||||
|
final p = ClubMembershipPresentation.resolve(
|
||||||
|
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
|
||||||
|
member: status == null ? null : ClubMember(
|
||||||
|
uid: 'rider_1',
|
||||||
|
clubId: baseClub.id,
|
||||||
|
role: role,
|
||||||
|
status: status,
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
),
|
||||||
|
currentUserId: 'rider_1',
|
||||||
|
isAuthenticated: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(p.primaryAction, status == null
|
||||||
|
? ClubMembershipAction.requestToJoin
|
||||||
|
: status == ClubMemberStatus.requested
|
||||||
|
? ClubMembershipAction.withdrawRequest
|
||||||
|
: role == ClubRole.owner ? null : ClubMembershipAction.leaveClub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('resolves archived state when club.isArchived is true', () {
|
test('resolves archived state when club.isArchived is true', () {
|
||||||
final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1));
|
final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1));
|
||||||
final p = ClubMembershipPresentation.resolve(
|
final p = ClubMembershipPresentation.resolve(
|
||||||
|
|
|
||||||
|
|
@ -225,5 +225,103 @@ void main() {
|
||||||
expect(find.text('Accept'), findsOneWidget);
|
expect(find.text('Accept'), findsOneWidget);
|
||||||
expect(find.text('Decline'), findsOneWidget);
|
expect(find.text('Decline'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
|
||||||
|
testWidgets('renders responsive layout without overflow on $size (#73)', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(size);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||||
|
final club1 = Club(
|
||||||
|
id: 'club_1',
|
||||||
|
name: 'Bay Area Motos',
|
||||||
|
slug: 'bay-area-motos',
|
||||||
|
description: 'San Francisco & Bay Area motorcycle rides.',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
memberCount: 15,
|
||||||
|
admissionPolicy: ClubAdmissionPolicy.open,
|
||||||
|
createdAt: DateTime(2026, 1, 1),
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
final club2 = Club(
|
||||||
|
id: 'club_2',
|
||||||
|
name: 'NorCal Twisties',
|
||||||
|
slug: 'norcal-twisties',
|
||||||
|
description: 'Carving Northern California canyon roads.',
|
||||||
|
ownerUid: 'bob',
|
||||||
|
memberCount: 24,
|
||||||
|
admissionPolicy: ClubAdmissionPolicy.request,
|
||||||
|
createdAt: DateTime(2026, 1, 1),
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final member1 = ClubMember(
|
||||||
|
uid: 'alice',
|
||||||
|
clubId: 'club_1',
|
||||||
|
status: ClubMemberStatus.active,
|
||||||
|
role: ClubRole.owner,
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
final member2 = ClubMember(
|
||||||
|
uid: 'alice',
|
||||||
|
clubId: 'club_2',
|
||||||
|
status: ClubMemberStatus.active,
|
||||||
|
role: ClubRole.member,
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final fakeService = FakeClubServiceForHub(
|
||||||
|
clubs: {'club_1': club1, 'club_2': club2},
|
||||||
|
handles: {'bay-area-motos': 'club_1', 'norcal-twisties': 'club_2'},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => user),
|
||||||
|
clubServiceProvider.overrideWithValue(fakeService),
|
||||||
|
clubDetailProvider('club_1').overrideWith((ref) => Stream.value(club1)),
|
||||||
|
clubDetailProvider('club_2').overrideWith((ref) => Stream.value(club2)),
|
||||||
|
myClubsProvider.overrideWith((ref) => Stream.value([member1, member2])),
|
||||||
|
myClubInvitesProvider.overrideWith((ref) => Stream.value(const [])),
|
||||||
|
publicClubsProvider.overrideWith((ref) => Future.value([club1, club2])),
|
||||||
|
],
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(size: size),
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const ClubsHubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final isLandscape = size.width > size.height;
|
||||||
|
if (isLandscape) {
|
||||||
|
// Verify GridView is used for memberships in landscape
|
||||||
|
expect(find.byType(GridView), findsOneWidget);
|
||||||
|
} else {
|
||||||
|
expect(find.byType(GridView), findsNothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap Discover Tab
|
||||||
|
await tester.tap(find.text('DISCOVER'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
if (isLandscape) {
|
||||||
|
// Verify GridView is used for discovery in landscape
|
||||||
|
expect(find.byType(GridView), findsOneWidget);
|
||||||
|
} else {
|
||||||
|
expect(find.byType(GridView), findsNothing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1134,6 +1134,222 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Issue #71 Landscape Feed Floating Button Overlap', () {
|
||||||
|
for (final viewport in const [
|
||||||
|
Size(800, 360),
|
||||||
|
Size(640, 360),
|
||||||
|
Size(400, 800),
|
||||||
|
]) {
|
||||||
|
testWidgets(
|
||||||
|
'Empty feed message and action buttons are not covered by share button on ${viewport.width.toInt()}x${viewport.height.toInt()}',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(viewport);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final service = SocialFeedService();
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
socialFeedServiceProvider.overrideWithValue(service),
|
||||||
|
feedSearchQueryProvider.overrideWith((ref) => 'nomatchxyz'),
|
||||||
|
],
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(size: viewport),
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: const [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const SocialFeedScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final shareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
|
||||||
|
final emptyActionFinder = find.byKey(const ValueKey('empty_feed_group_rides_button'));
|
||||||
|
expect(shareBtnFinder, findsOneWidget);
|
||||||
|
expect(emptyActionFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// Scroll until visible if needed in compact landscape
|
||||||
|
final scrollableFinder = find.byType(Scrollable).last;
|
||||||
|
await tester.scrollUntilVisible(emptyActionFinder, 100, scrollable: scrollableFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final shareBtnRect = tester.getRect(shareBtnFinder);
|
||||||
|
final emptyActionRect = tester.getRect(emptyActionFinder);
|
||||||
|
|
||||||
|
expect(emptyActionRect.overlaps(shareBtnRect), isFalse,
|
||||||
|
reason: 'Empty action button $emptyActionRect overlaps share button $shareBtnRect');
|
||||||
|
expect(emptyActionRect.bottom <= shareBtnRect.top, isTrue,
|
||||||
|
reason: 'Empty action button bottom ${emptyActionRect.bottom} should be <= share button top ${shareBtnRect.top}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Populated feed list scrolls so last item is above share button on ${viewport.width.toInt()}x${viewport.height.toInt()}',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(viewport);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final service = SocialFeedService();
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
socialFeedServiceProvider.overrideWithValue(service),
|
||||||
|
],
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(size: viewport),
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: const [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const SocialFeedScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final shareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
|
||||||
|
expect(shareBtnFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// Scroll until the bottom actions of the last post are visible
|
||||||
|
final lastActionFinder = find.byKey(const ValueKey('inspect_telemetry_btn_feed_post_4'));
|
||||||
|
final feedListFinder = find.byKey(const ValueKey('social_feed_list_view'));
|
||||||
|
final feedScrollableFinder = find.descendant(
|
||||||
|
of: feedListFinder,
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
lastActionFinder,
|
||||||
|
100,
|
||||||
|
scrollable: feedScrollableFinder,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(lastActionFinder, findsOneWidget);
|
||||||
|
final lastActionRect = tester.getRect(lastActionFinder);
|
||||||
|
final shareBtnRect = tester.getRect(shareBtnFinder);
|
||||||
|
|
||||||
|
expect(lastActionRect.overlaps(shareBtnRect), isFalse,
|
||||||
|
reason: 'Last item bottom action $lastActionRect should not overlap share button $shareBtnRect');
|
||||||
|
expect(lastActionRect.bottom <= shareBtnRect.top, isTrue,
|
||||||
|
reason: 'Last item bottom action bottom ${lastActionRect.bottom} should be <= share button top ${shareBtnRect.top}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ShareSessionModal Landscape Keyboard & Scroll Layout', () {
|
||||||
|
final testRide = Ride(
|
||||||
|
id: 99,
|
||||||
|
startTime: DateTime(2026, 10, 15, 9, 0),
|
||||||
|
endTime: DateTime(2026, 10, 15, 10, 15),
|
||||||
|
distance: 35.5,
|
||||||
|
distanceMiles: 35.5,
|
||||||
|
movingTime: 3600,
|
||||||
|
stoppedTime: 900,
|
||||||
|
topSpeed: 65.0,
|
||||||
|
topSpeedMph: 65.0,
|
||||||
|
averageSpeed: 35.5,
|
||||||
|
maxLeanLeft: 38.0,
|
||||||
|
maxLeanRight: 42.0,
|
||||||
|
maxAccelG: 0.6,
|
||||||
|
maxBrakingG: 0.7,
|
||||||
|
elevationGain: 1200.0,
|
||||||
|
elevationLoss: 1100.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final viewport in const [
|
||||||
|
Size(800, 360),
|
||||||
|
Size(640, 360),
|
||||||
|
Size(400, 800),
|
||||||
|
]) {
|
||||||
|
final isLandscape = viewport.width > viewport.height;
|
||||||
|
for (final keyboardInset in isLandscape ? [0.0, 180.0] : [0.0]) {
|
||||||
|
testWidgets(
|
||||||
|
'ShareSessionModal on ${viewport.width.toInt()}x${viewport.height.toInt()} with keyboard inset $keyboardInset has scrollable fields and publish button does not cover title',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(viewport);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(
|
||||||
|
size: viewport,
|
||||||
|
viewInsets: EdgeInsets.only(bottom: keyboardInset),
|
||||||
|
),
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: const [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (ctx) => ElevatedButton(
|
||||||
|
key: const ValueKey('open_modal_btn'),
|
||||||
|
onPressed: () => ShareSessionModal.show(ctx, initialRide: testRide),
|
||||||
|
child: const Text('Open Modal'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('open_modal_btn')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final titleFinder = find.byType(TextFormField).first;
|
||||||
|
expect(titleFinder, findsOneWidget);
|
||||||
|
|
||||||
|
final publishBtnFinder = find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED');
|
||||||
|
expect(publishBtnFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// In landscape or when keyboard is open, title rect must not be overlapped by publish button rect
|
||||||
|
final titleRect = tester.getRect(titleFinder);
|
||||||
|
final publishRect = tester.getRect(publishBtnFinder);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
titleRect.overlaps(publishRect),
|
||||||
|
isFalse,
|
||||||
|
reason: 'Title field $titleRect is covered by publish button $publishRect',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify scrolling inside modal allows reaching the publish button and fields
|
||||||
|
final modalScrollable = find.descendant(
|
||||||
|
of: find.byType(ShareSessionModal),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
expect(modalScrollable, findsWidgets);
|
||||||
|
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
publishBtnFinder,
|
||||||
|
100,
|
||||||
|
scrollable: modalScrollable.first,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(publishBtnFinder.hitTestable(), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
group('Sprint 1 Remediation: BUG-003 / Sub-Issue #81 Starting Location & Canyon Removal', () {
|
group('Sprint 1 Remediation: BUG-003 / Sub-Issue #81 Starting Location & Canyon Removal', () {
|
||||||
testWidgets('ShareSessionModal displays STARTING LOCATION, removes Canyon label, and defaults to Morning Ride', (tester) async {
|
testWidgets('ShareSessionModal displays STARTING LOCATION, removes Canyon label, and defaults to Morning Ride', (tester) async {
|
||||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||||
|
|
|
||||||
|
|
@ -229,5 +229,125 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(find.text('Sign In / Register'), findsOneWidget);
|
expect(find.text('Sign In / Register'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'landscape (800x360) with keyboard inset scrolls to lower fields and submit button, and portrait renders cleanly (#77)',
|
||||||
|
(tester) async {
|
||||||
|
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||||
|
final fakeService = FakeCreateClubService();
|
||||||
|
|
||||||
|
// 1. Landscape (800x360) with simulated soft keyboard inset
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||||
|
tester.view.padding = const FakeViewPadding(top: 24);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
addTearDown(tester.view.resetPadding);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => user),
|
||||||
|
clubServiceProvider.overrideWithValue(fakeService),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const CreateClubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Scrollbar should be present for clear scroll affordance in landscape
|
||||||
|
expect(find.byType(Scrollbar), findsOneWidget);
|
||||||
|
|
||||||
|
// Two-column layout in landscape: Club Name and Club Handle share a row
|
||||||
|
final nameFinder = find.widgetWithText(
|
||||||
|
TextFormField,
|
||||||
|
'Club Name *',
|
||||||
|
skipOffstage: false,
|
||||||
|
);
|
||||||
|
final slugFinder = find.widgetWithText(
|
||||||
|
TextFormField,
|
||||||
|
'Club Handle / Slug *',
|
||||||
|
skipOffstage: false,
|
||||||
|
);
|
||||||
|
expect(nameFinder, findsOneWidget);
|
||||||
|
expect(slugFinder, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.ensureVisible(nameFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(nameFinder).dx,
|
||||||
|
lessThan(tester.getTopLeft(slugFinder).dx),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter Club Name & Handle
|
||||||
|
await tester.enterText(nameFinder, 'Canyon Carvers MC');
|
||||||
|
await tester.enterText(slugFinder, 'canyon-carvers-mc');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Lower fields and submit button remain mounted and scrollable into the visible area above keyboard
|
||||||
|
final regionFinder = find.widgetWithText(
|
||||||
|
TextFormField,
|
||||||
|
'Home region (optional)',
|
||||||
|
skipOffstage: false,
|
||||||
|
);
|
||||||
|
final focusFinder = find.widgetWithText(
|
||||||
|
TextFormField,
|
||||||
|
'Motorcycle focus (optional)',
|
||||||
|
skipOffstage: false,
|
||||||
|
);
|
||||||
|
final submitFinder = find.byKey(
|
||||||
|
const ValueKey('create_club_submit_button'),
|
||||||
|
skipOffstage: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(regionFinder, findsOneWidget);
|
||||||
|
expect(focusFinder, findsOneWidget);
|
||||||
|
expect(submitFinder, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.ensureVisible(regionFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(
|
||||||
|
tester.getRect(regionFinder).bottom,
|
||||||
|
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(regionFinder, 'SoCal');
|
||||||
|
await tester.enterText(focusFinder, 'Sport bikes');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Scroll to and tap the primary submit button while keyboard inset is active
|
||||||
|
await tester.ensureVisible(submitFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(
|
||||||
|
tester.getRect(submitFinder).bottom,
|
||||||
|
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(submitFinder);
|
||||||
|
await tester.pump();
|
||||||
|
expect(fakeService.lastCreatedClub, isNotNull);
|
||||||
|
expect(fakeService.lastCreatedClub!.name, equals('Canyon Carvers MC'));
|
||||||
|
expect(fakeService.lastCreatedClub!.homeRegion, equals('SoCal'));
|
||||||
|
|
||||||
|
// 2. Portrait (400x800) with keyboard inset check
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1130,5 +1130,94 @@ void main() {
|
||||||
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
|
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
|
||||||
expect(publishBtn.onPressed, isNull);
|
expect(publishBtn.onPressed, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'renders compact two-column layout in landscape (640x360 and 800x360) with keyboard-safe scrolling and single-column in portrait (#70)',
|
||||||
|
(tester) async {
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
for (final landscapeSize in const [Size(640, 360), Size(800, 360)]) {
|
||||||
|
tester.view.physicalSize = landscapeSize;
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.resetViewInsets();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Two-column layout: 3. MEETING LOCATION is to the right of 1. RIDE DETAILS
|
||||||
|
final detailsTopLeft = tester.getTopLeft(find.text('1. RIDE DETAILS'));
|
||||||
|
final meetingTopLeft =
|
||||||
|
tester.getTopLeft(find.text('3. MEETING LOCATION'));
|
||||||
|
expect(
|
||||||
|
meetingTopLeft.dx,
|
||||||
|
greaterThan(detailsTopLeft.dx + 200),
|
||||||
|
reason: 'Expected two-column layout at $landscapeSize',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compact vertical spacing in landscape so section 2 starts higher up
|
||||||
|
final scheduleTopLeft =
|
||||||
|
tester.getTopLeft(find.text('2. DATE & SCHEDULE'));
|
||||||
|
expect(
|
||||||
|
scheduleTopLeft.dy,
|
||||||
|
lessThan(225.0),
|
||||||
|
reason: 'Expected compact vertical spacing at $landscapeSize',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate keyboard inset in landscape and focus a lower field
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final notesField =
|
||||||
|
find.widgetWithText(TextFormField, 'Meeting Notes (optional)');
|
||||||
|
await tester.ensureVisible(notesField);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(notesField, 'Meet by the north pumps');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Meet by the north pumps'), findsOneWidget);
|
||||||
|
final notesRect = tester.getRect(notesField);
|
||||||
|
expect(
|
||||||
|
notesRect.bottom,
|
||||||
|
lessThanOrEqualTo(landscapeSize.height - 180 + 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
final publishBtn =
|
||||||
|
find.widgetWithText(ElevatedButton, 'PUBLISH GROUP RIDE');
|
||||||
|
await tester.ensureVisible(publishBtn);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(publishBtn, findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portrait check with keyboard inset
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final publishBtnPortrait =
|
||||||
|
find.widgetWithText(ElevatedButton, 'PUBLISH GROUP RIDE');
|
||||||
|
await tester.ensureVisible(publishBtnPortrait);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(publishBtnPortrait, findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
||||||
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
||||||
import 'package:motorcycle_dash/services/location_service.dart';
|
import 'package:motorcycle_dash/services/location_service.dart';
|
||||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||||
|
import 'package:motorcycle_dash/features/social/feed/widgets/quick_squad_feed_button.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -166,5 +168,81 @@ void main() {
|
||||||
final edgeInsets = paddingWidget.padding as EdgeInsets;
|
final edgeInsets = paddingWidget.padding as EdgeInsets;
|
||||||
expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
|
expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007: Short landscape cockpit mount includes incident report and squad buttons with balanced layout', (tester) async {
|
||||||
|
configureViewport(tester, 800, 360);
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byType(QuickSwarmReportButton), findsOneWidget);
|
||||||
|
expect(find.byType(QuickSquadFeedButton), findsOneWidget);
|
||||||
|
expect(find.byType(SpeedReadout), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007 / #54: Start Session button text is fully visible without clipping in minimal landscape (map hidden)', (tester) async {
|
||||||
|
await prefs.setString('dashboardLayout', 'minimal');
|
||||||
|
for (final size in [const Size(640, 360), const Size(800, 360), const Size(840, 390)]) {
|
||||||
|
configureViewport(tester, size.width, size.height);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
final startBtnText = find.text('Start Session');
|
||||||
|
expect(startBtnText, findsOneWidget);
|
||||||
|
|
||||||
|
// Verify button text has positive width and fits cleanly
|
||||||
|
final textRenderBox = tester.renderObject(startBtnText) as RenderBox;
|
||||||
|
expect(textRenderBox.size.width, greaterThan(0));
|
||||||
|
expect(textRenderBox.size.height, greaterThan(0));
|
||||||
|
}
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007 / #54: Start Session button text is fully visible in sport and touring landscape layouts', (tester) async {
|
||||||
|
for (final layout in ['sport', 'touring']) {
|
||||||
|
await prefs.setString('dashboardLayout', layout);
|
||||||
|
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
|
||||||
|
configureViewport(tester, size.width, size.height);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('Start Session'), findsOneWidget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007 / #54: LEAN ANGLE title does not overlap rider graphic across landscape viewports', (tester) async {
|
||||||
|
await prefs.setString('dashboardLayout', 'sport');
|
||||||
|
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
|
||||||
|
configureViewport(tester, size.width, size.height);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final titleFinder = find.text('LEAN ANGLE');
|
||||||
|
final graphicFinder = find.byKey(const ValueKey('lean_angle_rider_graphic'));
|
||||||
|
|
||||||
|
expect(titleFinder, findsOneWidget);
|
||||||
|
expect(graphicFinder, findsOneWidget);
|
||||||
|
|
||||||
|
final titleRect = tester.getRect(titleFinder);
|
||||||
|
final graphicRect = tester.getRect(graphicFinder);
|
||||||
|
|
||||||
|
// Prove no overlap between title rect and rider graphic rect
|
||||||
|
expect(titleRect.overlaps(graphicRect), isFalse, reason: 'Title overlaps rider graphic at ${size.width}x${size.height}');
|
||||||
|
// Prove title is positioned strictly above graphic
|
||||||
|
expect(titleRect.bottom, lessThanOrEqualTo(graphicRect.top), reason: 'Title bottom must be above or at graphic top at ${size.width}x${size.height}');
|
||||||
|
}
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,137 @@ void main() {
|
||||||
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
|
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
|
||||||
expect(find.text('3'), findsOneWidget);
|
expect(find.text('3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Join Group Ride dialog renders without overflow in landscape with keyboard inset and in portrait (#69)',
|
||||||
|
(tester) async {
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
upcomingGroupRidesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value([]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GroupRidesHubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('group_rides_join_appbar_button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final fieldFinder = find.byKey(const ValueKey('join_ride_id_field'));
|
||||||
|
final submitFinder =
|
||||||
|
find.byKey(const ValueKey('join_ride_dialog_submit_button'));
|
||||||
|
|
||||||
|
await tester.ensureVisible(fieldFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(fieldFinder, 'ride_test_123');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('ride_test_123'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.ensureVisible(submitFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitFinder, findsOneWidget);
|
||||||
|
expect(find.text('CANCEL'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Dismiss and verify portrait as well
|
||||||
|
await tester.tap(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('group_rides_join_appbar_button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(fieldFinder, findsOneWidget);
|
||||||
|
expect(submitFinder, findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
|
||||||
|
testWidgets('ride card renders without overflow on $size (#68)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final ride = GroupRide(
|
||||||
|
id: 'ride_1',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
organizerHandle: 'alice_lead',
|
||||||
|
organizerDisplayName: 'Alice Lead',
|
||||||
|
title: 'Sunday Morning Twisties',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
participantCount: 3,
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Dahlonega Chevron',
|
||||||
|
),
|
||||||
|
destination: const GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -83.8,
|
||||||
|
displayName: 'Blood Mountain Summit',
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
upcomingGroupRidesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value([ride]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GroupRidesHubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('group_ride_card_ride_1')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('manage_ride_ride_1')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('open_hub_ride_1')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
group('CreateGroupRideScreen Widget Tests', () {
|
group('CreateGroupRideScreen Widget Tests', () {
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,70 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(find.text('3 RIDERS'), findsOneWidget);
|
expect(find.text('3 RIDERS'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
|
||||||
|
testWidgets('renders landscape controls and panel without overflow on $size (#74)',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(size);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
groupRideStreamProvider('ride_live_test')
|
||||||
|
.overrideWith((ref) => Stream.value(testRide)),
|
||||||
|
groupRideParticipantsProvider('ride_live_test')
|
||||||
|
.overrideWith((ref) => Stream.value(participants)),
|
||||||
|
groupRideLivePresenceProvider('ride_live_test')
|
||||||
|
.overrideWith((ref) => Stream.value(presences)),
|
||||||
|
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||||
|
],
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(size: size),
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: LiveGroupRideScreen(rideId: 'ride_live_test'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Verify key controls are present and reachable
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('live_ride_broadcast_switch')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('live_ride_recenter_button')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('live_ride_roster_button')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status bar items
|
||||||
|
expect(find.text('RIDING'), findsWidgets);
|
||||||
|
expect(find.text('STOPPED'), findsOneWidget);
|
||||||
|
expect(find.text('NEED FUEL'), findsWidgets);
|
||||||
|
expect(find.text('REGROUP'), findsOneWidget);
|
||||||
|
|
||||||
|
final isLandscape = size.width > size.height;
|
||||||
|
if (isLandscape) {
|
||||||
|
// In landscape, verify side-by-side pack telemetry stream panel is present
|
||||||
|
expect(find.text('PACK TELEMETRY STREAM'), findsOneWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||||
|
import 'package:motorcycle_dash/features/map/map_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/database/database.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
|
||||||
|
class _TestHttpOverrides extends HttpOverrides {
|
||||||
|
@override
|
||||||
|
HttpClient createHttpClient(SecurityContext? context) => _MockHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockHttpHeaders extends Fake implements HttpHeaders {
|
||||||
|
@override
|
||||||
|
void add(String name, Object value, {bool preserveHeaderCase = false}) {}
|
||||||
|
@override
|
||||||
|
void set(String name, Object value, {bool preserveHeaderCase = false}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockHttpClient extends Fake implements HttpClient {
|
||||||
|
@override
|
||||||
|
Future<HttpClientRequest> getUrl(Uri url) async => _MockHttpRequest();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HttpClientRequest> openUrl(String method, Uri url) async => _MockHttpRequest();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockHttpRequest extends Fake implements HttpClientRequest {
|
||||||
|
@override
|
||||||
|
final HttpHeaders headers = _MockHttpHeaders();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool followRedirects = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int maxRedirects = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int contentLength = -1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HttpClientResponse> close() async => _MockHttpResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockHttpResponse extends Fake implements HttpClientResponse {
|
||||||
|
@override
|
||||||
|
int get statusCode => 200;
|
||||||
|
@override
|
||||||
|
int get contentLength => _transparent1x1Png.length;
|
||||||
|
@override
|
||||||
|
HttpClientResponseCompressionState get compressionState =>
|
||||||
|
HttpClientResponseCompressionState.notCompressed;
|
||||||
|
@override
|
||||||
|
StreamSubscription<List<int>> listen(
|
||||||
|
void Function(List<int> event)? onData, {
|
||||||
|
Function? onError,
|
||||||
|
void Function()? onDone,
|
||||||
|
bool? cancelOnError,
|
||||||
|
}) {
|
||||||
|
return Stream<List<int>>.value(_transparent1x1Png).listen(
|
||||||
|
onData,
|
||||||
|
onError: onError,
|
||||||
|
onDone: onDone,
|
||||||
|
cancelOnError: cancelOnError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _transparent1x1Png = <int>[
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
|
||||||
|
0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||||
|
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
late AppDatabase db;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
HttpOverrides.global = _TestHttpOverrides();
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
|
||||||
|
// Insert sample ride and telemetry breadcrumbs
|
||||||
|
await db.into(db.rides).insert(
|
||||||
|
RidesCompanion.insert(
|
||||||
|
id: const drift.Value(1),
|
||||||
|
startTime: DateTime(2026, 1, 1, 10, 0),
|
||||||
|
distanceMiles: const drift.Value(15.2),
|
||||||
|
topSpeedMph: const drift.Value(75.0),
|
||||||
|
movingTime: const drift.Value(900),
|
||||||
|
stoppedTime: const drift.Value(60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
await db.into(db.telemetrySamples).insert(
|
||||||
|
TelemetrySamplesCompanion.insert(
|
||||||
|
rideId: 1,
|
||||||
|
timestamp: DateTime(2026, 1, 1, 10, 0, i * 5),
|
||||||
|
latitude: drift.Value(37.3861 + (i * 0.002)),
|
||||||
|
longitude: drift.Value(-122.2641 + (i * 0.002)),
|
||||||
|
speed: drift.Value(35.0 + i),
|
||||||
|
leanAngle: drift.Value(12.0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
HttpOverrides.global = null;
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget buildSubject() {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const MapScreen(rideId: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Portrait: renders both appbar action and floating replay action button', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(360, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildSubject());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('play_replay_appbar_button')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('fire_off_ride_preview_button')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Landscape: removes duplicate floating replay action to keep map unobstructed (#60)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildSubject());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Appbar replay action remains accessible
|
||||||
|
expect(find.byKey(const ValueKey('play_replay_appbar_button')), findsOneWidget);
|
||||||
|
// Floating button is hidden in landscape
|
||||||
|
expect(find.byKey(const ValueKey('fire_off_ride_preview_button')), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,22 @@ void main() {
|
||||||
expect(find.text('0–100 MPH'), findsOneWidget);
|
expect(find.text('0–100 MPH'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-009: Renders cleanly without clipping on 800x360 landscape phone', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||||||
|
expect(find.text('Auto Launch'), findsOneWidget);
|
||||||
|
expect(find.text('Countdown'), findsOneWidget);
|
||||||
|
expect(find.text('0–60 MPH'), findsOneWidget);
|
||||||
|
expect(find.text('0–100 MPH'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
|
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -198,6 +214,21 @@ void main() {
|
||||||
expect(find.text('Tap below to initiate countdown'), findsOneWidget);
|
expect(find.text('Tap below to initiate countdown'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-010: Countdown READY renders without 68px overflow on 800x360 landscape phone', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||||||
|
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 20));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget);
|
||||||
|
expect(find.text('Start Countdown'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('8. READY survives widget rebuild', (tester) async {
|
testWidgets('8. READY survives widget rebuild', (tester) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,18 @@ void main() {
|
||||||
expect(alerts.reports, isEmpty);
|
expect(alerts.reports, isEmpty);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-008: renders cleanly without overflow on 800x360 landscape phone', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await open(tester);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('REPORT ROAD INCIDENT'), findsOneWidget);
|
||||||
|
expect(find.text('Police'), findsOneWidget);
|
||||||
|
expect(find.text('Road Hazard'), findsOneWidget);
|
||||||
|
expect(find.text('Weather Hazard'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,4 +165,63 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-014 (#61): ReplayScreen renders responsive horizontal split in 800x360 landscape without overflow', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ReplayScreen(rideId: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Verifies both map and breakdown table are rendered concurrently in viewport
|
||||||
|
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
||||||
|
expect(find.text('SPEED'), findsOneWidget);
|
||||||
|
expect(find.text('LEAN'), findsOneWidget);
|
||||||
|
expect(find.text('G'), findsOneWidget);
|
||||||
|
expect(find.byType(Slider), findsOneWidget);
|
||||||
|
expect(find.byType(Row), findsWidgets);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-014 (#61): ReplayScreen renders without overflow on 640x320 compact landscape', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(640, 320);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ReplayScreen(rideId: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/features/routes/route_planner_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/map_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/open_street_map_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
|
|
||||||
|
class FakeMapProvider extends Fake implements OpenStreetMapProvider {
|
||||||
|
@override
|
||||||
|
Widget buildMapView({
|
||||||
|
required BuildContext context,
|
||||||
|
required AppLatLng center,
|
||||||
|
required double zoom,
|
||||||
|
double bearing = 0.0,
|
||||||
|
required List<AppMapMarker> markers,
|
||||||
|
required List<AppPolyline> polylines,
|
||||||
|
required bool isFollowMode,
|
||||||
|
required MapConfig config,
|
||||||
|
required ValueChanged<AppLatLng>? onPositionChanged,
|
||||||
|
required VoidCallback? onUserInteraction,
|
||||||
|
}) => const SizedBox.expand();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const riderPosition = AppLatLng(33.749, -84.388);
|
||||||
|
const overview = AppLatLng(39.5, -98.35);
|
||||||
|
|
||||||
|
Future<ProviderContainer> openPlanner(
|
||||||
|
WidgetTester tester,
|
||||||
|
Future<AppLatLng?> location, {
|
||||||
|
bool realMap = false,
|
||||||
|
bool initiallyFollowing = true,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith(
|
||||||
|
(ref) => const AuthUser(uid: 'rider'),
|
||||||
|
),
|
||||||
|
routePlannerLocationProvider.overrideWith((ref) => location),
|
||||||
|
mapFollowStateProvider.overrideWith(
|
||||||
|
(ref) => MapFollowNotifier()..setFollow(initiallyFollowing),
|
||||||
|
),
|
||||||
|
if (!realMap)
|
||||||
|
openStreetMapProviderInstance.overrideWithValue(FakeMapProvider()),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: RoutePlannerScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(RoutePlannerScreen)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UnifiedMapView map(WidgetTester tester) =>
|
||||||
|
tester.widget(find.byType(UnifiedMapView));
|
||||||
|
|
||||||
|
for (final size in [const Size(360, 640), const Size(800, 360)]) {
|
||||||
|
testWidgets(
|
||||||
|
'known rider position centers the planner at $size without adding a stop',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(size);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
final container = await openPlanner(
|
||||||
|
tester,
|
||||||
|
Future.value(riderPosition),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(map(tester).center, riderPosition);
|
||||||
|
expect(map(tester).zoom, 12);
|
||||||
|
expect(container.read(routePlannerNotifierProvider).stops, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('pending or absent location shows an overview without blocking', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final location = Completer<AppLatLng?>();
|
||||||
|
await openPlanner(tester, location.future);
|
||||||
|
|
||||||
|
expect(find.text('ROUTE PLANNER'), findsOneWidget);
|
||||||
|
expect(map(tester).center, overview);
|
||||||
|
expect(map(tester).zoom, 3);
|
||||||
|
|
||||||
|
location.complete(null);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(map(tester).center, overview);
|
||||||
|
expect(map(tester).zoom, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cached position arriving later updates the overview', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final location = Completer<AppLatLng?>();
|
||||||
|
await openPlanner(tester, location.future);
|
||||||
|
expect(map(tester).zoom, 3);
|
||||||
|
|
||||||
|
location.complete(riderPosition);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(map(tester).center, riderPosition);
|
||||||
|
expect(map(tester).zoom, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'previously panned map does not prevent initial cached centering',
|
||||||
|
(tester) async {
|
||||||
|
final location = Completer<AppLatLng?>();
|
||||||
|
await openPlanner(
|
||||||
|
tester,
|
||||||
|
location.future,
|
||||||
|
realMap: true,
|
||||||
|
initiallyFollowing: false,
|
||||||
|
);
|
||||||
|
final controller = tester
|
||||||
|
.widget<FlutterMap>(find.byType(FlutterMap))
|
||||||
|
.mapController!;
|
||||||
|
expect(controller.camera.center.latitude, overview.latitude);
|
||||||
|
|
||||||
|
location.complete(riderPosition);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
controller.camera.center.latitude,
|
||||||
|
closeTo(riderPosition.latitude, 0.00001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
controller.camera.center.longitude,
|
||||||
|
closeTo(riderPosition.longitude, 0.00001),
|
||||||
|
);
|
||||||
|
expect(controller.camera.zoom, 12);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('route stops take priority over a late cached rider position', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final location = Completer<AppLatLng?>();
|
||||||
|
final container = await openPlanner(tester, location.future);
|
||||||
|
container
|
||||||
|
.read(routePlannerNotifierProvider.notifier)
|
||||||
|
.addStop(displayName: 'Route start', latitude: 35.6, longitude: -83.5);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(map(tester).center, const AppLatLng(35.6, -83.5));
|
||||||
|
expect(map(tester).zoom, 12);
|
||||||
|
location.complete(riderPosition);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(map(tester).center, const AppLatLng(35.6, -83.5));
|
||||||
|
expect(map(tester).zoom, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'location source only reads cache and tolerates unavailable GPS',
|
||||||
|
() async {
|
||||||
|
const channel = MethodChannel('flutter.baseflow.com/geolocator');
|
||||||
|
final calls = <String>[];
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
throw PlatformException(code: 'PERMISSION_DENIED');
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(await container.read(routePlannerLocationProvider.future), isNull);
|
||||||
|
expect(calls, ['getLastKnownPosition']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final position in [riderPosition, null, const AppLatLng(0, 0)]) {
|
||||||
|
test(
|
||||||
|
'cached location source validates $position without starting GPS',
|
||||||
|
() async {
|
||||||
|
const channel = MethodChannel('flutter.baseflow.com/geolocator');
|
||||||
|
final calls = <String>[];
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
return position == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
'latitude': position.latitude,
|
||||||
|
'longitude': position.longitude,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await container.read(routePlannerLocationProvider.future),
|
||||||
|
position == riderPosition ? riderPosition : null,
|
||||||
|
);
|
||||||
|
expect(calls, ['getLastKnownPosition']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -225,4 +225,77 @@ void main() {
|
||||||
// No RenderFlex overflow
|
// No RenderFlex overflow
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'RoutePlannerScreen landscape (800x360) with status bar and keyboard inset scrolls cleanly and portrait renders cleanly (#76)',
|
||||||
|
(tester) async {
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetPadding);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
tester.view.padding = const FakeViewPadding(top: 24);
|
||||||
|
tester.view.resetViewInsets();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => const AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
isAnonymous: false,
|
||||||
|
)),
|
||||||
|
isProSubscriberProvider.overrideWith((ref) => false),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: RoutePlannerScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// No 2.5px overflow on landscape with status bar padding
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Simulate keyboard opening in landscape (260.5px inset)
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 200);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final titleField = find.byType(TextField).first;
|
||||||
|
await tester.ensureVisible(titleField);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(titleField, 'Cherohala Skyway');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Cherohala Skyway'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Scroll down to ADD STOP button and open Add Stop dialog with keyboard inset
|
||||||
|
final addStopBtn =
|
||||||
|
find.byKey(const ValueKey('route_planner_add_stop_button'));
|
||||||
|
await tester.ensureVisible(addStopBtn);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.tap(addStopBtn);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('Add Route Stop'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.ensureVisible(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Portrait check with keyboard inset
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.padding = const FakeViewPadding(top: 24);
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('SAVE'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue