320 lines
12 KiB
Dart
320 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../services/app_state.dart';
|
|
import '../services/onboarding_keys.dart';
|
|
import '../widgets/onboarding_tour_overlay.dart';
|
|
import 'data_settings_screen.dart';
|
|
import 'home_screen.dart';
|
|
import 'receipts_screen.dart';
|
|
import 'report_screen.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
/// App root once loaded: a persistent bottom nav bar over the four main
|
|
/// sections — Receipts (all vehicles), Vehicles (the vehicle list),
|
|
/// Reports, and Settings. Each tab keeps its own [Scaffold]/[AppBar]; this
|
|
/// shell only owns the [PageView]/[NavigationBar] and which tab is
|
|
/// selected.
|
|
///
|
|
/// A right-to-left swipe on the body advances to the next tab (and
|
|
/// left-to-right goes back) with the same sliding animation tapping a
|
|
/// [NavigationDestination] uses — both go through [_goToTab], which is the
|
|
/// only thing that moves [_pageController]. [AutomaticKeepAliveClientMixin]
|
|
/// on the three stateful tab screens (Receipts/Report/Settings) is what
|
|
/// keeps each tab's state — scroll position, toggles, in-progress form
|
|
/// fields — alive when [PageView] builds/tears down pages outside its
|
|
/// cache extent, the same guarantee the previous [IndexedStack]-based
|
|
/// version gave "for free".
|
|
///
|
|
/// This is also where the first-launch guided tour is orchestrated (see
|
|
/// [_startOnboardingTourIfNeeded] and friends below) — it's the natural
|
|
/// home for that since it's the only widget that owns both the
|
|
/// [PageController] (to switch tabs) and a [Navigator] ancestor (to push
|
|
/// the one route the tour needs, Settings > Data) that everything else in
|
|
/// the tour can share.
|
|
class MainShell extends StatefulWidget {
|
|
const MainShell({super.key});
|
|
|
|
@override
|
|
State<MainShell> createState() => _MainShellState();
|
|
}
|
|
|
|
/// 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 List<GlobalKey> targetKeys;
|
|
final void Function() activate;
|
|
|
|
const _OnboardingStep({
|
|
required this.title,
|
|
required this.description,
|
|
required this.targetKeys,
|
|
required this.activate,
|
|
});
|
|
}
|
|
|
|
class _MainShellState extends State<MainShell> {
|
|
int _index = 0;
|
|
final _pageController = PageController();
|
|
|
|
OverlayEntry? _tourEntry;
|
|
int _tourStepIndex = 0;
|
|
bool _tourPushedDataScreen = false;
|
|
|
|
static const _tabs = [
|
|
ReceiptsScreen(),
|
|
HomeScreen(),
|
|
ReportScreen(),
|
|
SettingsScreen(),
|
|
];
|
|
|
|
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: "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: '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: "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.',
|
|
targetKeys: [OnboardingKeys.cloudStorageCard],
|
|
activate: () {
|
|
_tourPushedDataScreen = true;
|
|
// Not awaited: Navigator.push's returned future only completes on
|
|
// pop, not on the pushed route finishing its build — the polling
|
|
// wait in _showTourStep (for the target key's RenderObject to
|
|
// exist) is what actually waits for this screen to be ready.
|
|
unawaited(Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
|
|
));
|
|
},
|
|
),
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _startOnboardingTourIfNeeded());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tourEntry?.remove();
|
|
_pageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _goToTab(int index) {
|
|
_pageController.animateToPage(
|
|
index,
|
|
duration: const Duration(milliseconds: 280),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
|
|
/// Instant (non-animated) tab switch used by the tour — the tour's own
|
|
/// step transitions already animate via the overlay fading between
|
|
/// steps, so an additionally-animated page slide underneath would just
|
|
/// make each step feel slower without adding anything.
|
|
void _jumpToTab(int index) {
|
|
_pageController.jumpToPage(index);
|
|
setState(() => _index = index);
|
|
}
|
|
|
|
void _startOnboardingTourIfNeeded() {
|
|
final appState = context.read<AppState>();
|
|
if (appState.hasSeenOnboardingTour) return;
|
|
_tourStepIndex = 0;
|
|
_showTourStep();
|
|
}
|
|
|
|
/// 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;
|
|
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));
|
|
}
|
|
}
|
|
|
|
Future<void> _showTourStep() async {
|
|
final step = _tourSteps[_tourStepIndex];
|
|
step.activate();
|
|
await _waitForTargets(step.targetKeys);
|
|
if (!mounted) return;
|
|
|
|
_tourEntry?.remove();
|
|
_tourEntry = OverlayEntry(
|
|
builder: (_) => OnboardingTourOverlay(
|
|
targetKeys: step.targetKeys,
|
|
title: step.title,
|
|
description: step.description,
|
|
stepNumber: _tourStepIndex + 1,
|
|
totalSteps: _tourSteps.length,
|
|
onNext: _tourNext,
|
|
onSkip: _finishTour,
|
|
),
|
|
);
|
|
Overlay.of(context, rootOverlay: true).insert(_tourEntry!);
|
|
}
|
|
|
|
void _tourNext() {
|
|
if (_tourStepIndex >= _tourSteps.length - 1) {
|
|
_finishTour();
|
|
return;
|
|
}
|
|
_tourStepIndex++;
|
|
_showTourStep();
|
|
}
|
|
|
|
void _finishTour() {
|
|
_tourEntry?.remove();
|
|
_tourEntry = null;
|
|
if (_tourPushedDataScreen) {
|
|
final navigator = Navigator.of(context);
|
|
if (navigator.canPop()) navigator.pop();
|
|
_tourPushedDataScreen = false;
|
|
}
|
|
// Whether the tour finished naturally (on the Settings tab, since the
|
|
// last step lives there) or was skipped mid-way through (on whichever
|
|
// tab that step happened to be on), always land back on Receipts —
|
|
// that's the tab a first-time user should actually start using.
|
|
_jumpToTab(0);
|
|
unawaited(context.read<AppState>().markOnboardingTourSeen());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: PageView(
|
|
controller: _pageController,
|
|
onPageChanged: (index) => setState(() => _index = index),
|
|
children: _tabs,
|
|
),
|
|
bottomNavigationBar: NavigationBar(
|
|
selectedIndex: _index,
|
|
onDestinationSelected: _goToTab,
|
|
destinations: [
|
|
NavigationDestination(
|
|
key: OnboardingKeys.receiptsNavDestination,
|
|
icon: const Icon(Icons.receipt_long_outlined),
|
|
selectedIcon: const Icon(Icons.receipt_long),
|
|
label: 'Receipts',
|
|
),
|
|
NavigationDestination(
|
|
key: OnboardingKeys.vehiclesNavDestination,
|
|
icon: const Icon(Icons.directions_car_outlined),
|
|
selectedIcon: const Icon(Icons.directions_car),
|
|
label: 'Vehicles',
|
|
),
|
|
NavigationDestination(
|
|
key: OnboardingKeys.reportsNavDestination,
|
|
icon: const Icon(Icons.summarize_outlined),
|
|
selectedIcon: const Icon(Icons.summarize),
|
|
label: 'Reports',
|
|
),
|
|
NavigationDestination(
|
|
key: OnboardingKeys.settingsNavDestination,
|
|
icon: const Icon(Icons.settings_outlined),
|
|
selectedIcon: const Icon(Icons.settings),
|
|
label: 'Settings',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|