Finished updating the tour
This commit is contained in:
parent
606106e5ec
commit
2fcc6dcab7
7 changed files with 288 additions and 83 deletions
|
|
@ -41,19 +41,22 @@ class MainShell extends StatefulWidget {
|
|||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
/// One step of the first-launch tour: which element to highlight, what to
|
||||
/// say about it, and how to get the app into the right state to show it
|
||||
/// (switch tabs, push a route, ...) before it's shown.
|
||||
/// One step of the first-launch tour: which element(s) to highlight
|
||||
/// together, what to say about them, and how to get the app into the
|
||||
/// right state to show it (switch tabs, push a route, ...) before it's
|
||||
/// shown. Most steps highlight a single element, but [targetKeys] can hold
|
||||
/// more than one — e.g. the first step highlights both the "+" button and
|
||||
/// the Receipts tab it lives on, together.
|
||||
class _OnboardingStep {
|
||||
final String title;
|
||||
final String description;
|
||||
final GlobalKey targetKey;
|
||||
final List<GlobalKey> targetKeys;
|
||||
final void Function() activate;
|
||||
|
||||
const _OnboardingStep({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.targetKey,
|
||||
required this.targetKeys,
|
||||
required this.activate,
|
||||
});
|
||||
}
|
||||
|
|
@ -74,37 +77,71 @@ class _MainShellState extends State<MainShell> {
|
|||
];
|
||||
|
||||
late final List<_OnboardingStep> _tourSteps = [
|
||||
_OnboardingStep(
|
||||
title: 'What This App Does',
|
||||
description: 'This app helps you collect and organize your Missouri fuel purchase '
|
||||
"receipts so you can claim Missouri's Motor Fuel Tax Refund — currently 12.5¢ back "
|
||||
'per gallon.\n\n'
|
||||
'Disclaimer: this refund is available for as long as Missouri lawmakers continue to '
|
||||
'offer it, and state law could change or end the program at any time. Always confirm '
|
||||
'current eligibility and rates with the Missouri Department of Revenue before filing.',
|
||||
// No specific element to highlight for this intro step — just a
|
||||
// centered card over a dimmed screen, wherever the user happens to
|
||||
// be when the tour starts (normally the Receipts tab, since that's
|
||||
// MainShell's default).
|
||||
targetKeys: const [],
|
||||
activate: () {},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Add Receipts Here',
|
||||
description: 'Tap the + button to log a fuel receipt. Snap a photo of it and the app '
|
||||
'reads the date, gallons, and price for you automatically.',
|
||||
targetKey: OnboardingKeys.receiptsAddButton,
|
||||
description: "This is the Receipts tab — tap the + button here to log a fuel receipt. "
|
||||
'Snap a photo of it and the app reads the date, gallons, and price for you '
|
||||
'automatically.',
|
||||
targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination],
|
||||
activate: () => _jumpToTab(0),
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Add Vehicles Here',
|
||||
description: 'Add a vehicle here first — every receipt gets linked to one of your '
|
||||
'vehicles, so this is where a new vehicle needs to be added before you can log a '
|
||||
"receipt for it.",
|
||||
targetKey: OnboardingKeys.vehiclesAddButton,
|
||||
description: 'This is the Vehicles tab — tap the + button here to add a vehicle. Every '
|
||||
'receipt gets linked to one of your vehicles, so add one here first before logging '
|
||||
'a receipt for it.',
|
||||
targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination],
|
||||
activate: () => _jumpToTab(1),
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Choose a Report Date Range',
|
||||
description: 'Pick a start and end date to set the timeframe your report covers. Once '
|
||||
"you're ready to file, scroll down on this page for a link to Missouri's official "
|
||||
'Motor Fuel Refund Claim form — this report is what you\'ll use to fill it out.',
|
||||
targetKey: OnboardingKeys.reportDateRange,
|
||||
description: "This is the Reports tab. Pick a start and end date here to set the "
|
||||
"timeframe your report covers, then use Share or Print once it's ready. When you're "
|
||||
"ready to file, tap the link for Missouri's official Motor Fuel Refund Claim form.",
|
||||
// Ordered to match their actual top-to-bottom position on the page
|
||||
// (date range, then the share/print row, then the refund-form link
|
||||
// below it) — _waitForTargets scrolls each into view in this same
|
||||
// order, so the last one (closest to the bottom) determines the
|
||||
// final scroll position without undoing visibility of the one right
|
||||
// above it. reportsNavDestination isn't inside that scrollable at
|
||||
// all, so where it falls in the list doesn't matter.
|
||||
targetKeys: [
|
||||
OnboardingKeys.reportDateRange,
|
||||
OnboardingKeys.reportsNavDestination,
|
||||
OnboardingKeys.reportShareAndPrintRow,
|
||||
OnboardingKeys.refundFormLink,
|
||||
],
|
||||
activate: () => _jumpToTab(2),
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Find Data Settings',
|
||||
description: 'This is the Settings tab — tap Data here to manage where your vehicles, '
|
||||
'receipts, and photos get backed up.',
|
||||
targetKeys: [OnboardingKeys.settingsNavDestination, OnboardingKeys.settingsDataMenuEntry],
|
||||
activate: () => _jumpToTab(3),
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Back Up to the Cloud',
|
||||
description: 'Connect a cloud storage account here to back up every vehicle, receipt, '
|
||||
"and photo you log — so nothing is lost if this device is lost, damaged, or "
|
||||
'replaced.',
|
||||
targetKey: OnboardingKeys.cloudStorageCard,
|
||||
targetKeys: [OnboardingKeys.cloudStorageCard],
|
||||
activate: () {
|
||||
_jumpToTab(3);
|
||||
_tourPushedDataScreen = true;
|
||||
// Not awaited: Navigator.push's returned future only completes on
|
||||
// pop, not on the pushed route finishing its build — the polling
|
||||
|
|
@ -154,16 +191,42 @@ class _MainShellState extends State<MainShell> {
|
|||
_showTourStep();
|
||||
}
|
||||
|
||||
/// Polls for [key] to have a laid-out [RenderBox] — needed because the
|
||||
/// step's [_OnboardingStep.activate] (a tab switch, or a route push)
|
||||
/// only *starts* getting the target on screen; it doesn't block until
|
||||
/// that frame has actually built. Bounded so a step whose target never
|
||||
/// appears (shouldn't normally happen) doesn't hang the tour forever —
|
||||
/// it just falls back to a spotlight-less, centered card for that step.
|
||||
Future<void> _waitForTarget(GlobalKey key) async {
|
||||
/// Polls until every key in [keys] has a laid-out [RenderBox] — needed
|
||||
/// because the step's [_OnboardingStep.activate] (a tab switch, or a
|
||||
/// route push) only *starts* getting the target(s) on screen; it
|
||||
/// doesn't block until that frame has actually built. Bounded so a step
|
||||
/// whose target never appears (e.g. the Reports step's Share/Print row,
|
||||
/// which only renders once a report actually exists) doesn't hang the
|
||||
/// tour forever — it just falls back to a spotlight-less, centered card
|
||||
/// for that step (or, if only some of [keys] resolve in time,
|
||||
/// [OnboardingTourOverlay] highlights whichever ones did).
|
||||
///
|
||||
/// Once resolved, each target is also scrolled into view (a no-op for
|
||||
/// anything not inside a scrollable, like an AppBar action or a
|
||||
/// bottom-nav destination) — otherwise an element further down a long
|
||||
/// page (like the Reports tab's refund-form link) could still be
|
||||
/// off-screen despite having a real, laid-out RenderBox, and would get
|
||||
/// "highlighted" somewhere the user can't actually see.
|
||||
Future<void> _waitForTargets(List<GlobalKey> keys) async {
|
||||
for (var attempt = 0; attempt < 30; attempt++) {
|
||||
if (!mounted) return;
|
||||
if (key.currentContext?.findRenderObject() case RenderBox box when box.hasSize) return;
|
||||
final allResolved = keys.every((key) {
|
||||
final renderObject = key.currentContext?.findRenderObject();
|
||||
return renderObject is RenderBox && renderObject.hasSize;
|
||||
});
|
||||
if (allResolved) break;
|
||||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
for (final key in keys) {
|
||||
key.currentContext?.findRenderObject()?.showOnScreen(duration: Duration.zero);
|
||||
}
|
||||
// A couple of extra frames for that (instant, but still frame-driven)
|
||||
// scroll to actually apply before the overlay captures each target's
|
||||
// on-screen position.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
if (!mounted) return;
|
||||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
}
|
||||
|
|
@ -171,13 +234,13 @@ class _MainShellState extends State<MainShell> {
|
|||
Future<void> _showTourStep() async {
|
||||
final step = _tourSteps[_tourStepIndex];
|
||||
step.activate();
|
||||
await _waitForTarget(step.targetKey);
|
||||
await _waitForTargets(step.targetKeys);
|
||||
if (!mounted) return;
|
||||
|
||||
_tourEntry?.remove();
|
||||
_tourEntry = OverlayEntry(
|
||||
builder: (_) => OnboardingTourOverlay(
|
||||
targetKey: step.targetKey,
|
||||
targetKeys: step.targetKeys,
|
||||
title: step.title,
|
||||
description: step.description,
|
||||
stepNumber: _tourStepIndex + 1,
|
||||
|
|
@ -220,25 +283,29 @@ class _MainShellState extends State<MainShell> {
|
|||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: _goToTab,
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
key: OnboardingKeys.receiptsNavDestination,
|
||||
icon: const Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: const Icon(Icons.receipt_long),
|
||||
label: 'Receipts',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.directions_car_outlined),
|
||||
selectedIcon: Icon(Icons.directions_car),
|
||||
key: OnboardingKeys.vehiclesNavDestination,
|
||||
icon: const Icon(Icons.directions_car_outlined),
|
||||
selectedIcon: const Icon(Icons.directions_car),
|
||||
label: 'Vehicles',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.summarize_outlined),
|
||||
selectedIcon: Icon(Icons.summarize),
|
||||
key: OnboardingKeys.reportsNavDestination,
|
||||
icon: const Icon(Icons.summarize_outlined),
|
||||
selectedIcon: const Icon(Icons.summarize),
|
||||
label: 'Reports',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
key: OnboardingKeys.settingsNavDestination,
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
selectedIcon: const Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
key: OnboardingKeys.reportShareAndPrintRow,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
|
|
@ -295,6 +296,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
key: OnboardingKeys.refundFormLink,
|
||||
onTap: _openRefundFormLink,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'data_settings_screen.dart';
|
||||
import 'ui_settings_screen.dart';
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
key: OnboardingKeys.settingsDataMenuEntry,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.storage_outlined),
|
||||
title: const Text('Data'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue