MO-Fuel-Tax-Back/lib/screens/main_shell.dart
2026-08-15 19:23:03 -05:00

248 lines
8.4 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 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.
class _OnboardingStep {
final String title;
final String description;
final GlobalKey targetKey;
final void Function() activate;
const _OnboardingStep({
required this.title,
required this.description,
required this.targetKey,
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: '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,
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,
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,
activate: () => _jumpToTab(2),
),
_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,
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
// 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 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 {
for (var attempt = 0; attempt < 30; attempt++) {
if (!mounted) return;
if (key.currentContext?.findRenderObject() case RenderBox box when box.hasSize) return;
await Future.delayed(const Duration(milliseconds: 16));
}
}
Future<void> _showTourStep() async {
final step = _tourSteps[_tourStepIndex];
step.activate();
await _waitForTarget(step.targetKey);
if (!mounted) return;
_tourEntry?.remove();
_tourEntry = OverlayEntry(
builder: (_) => OnboardingTourOverlay(
targetKey: step.targetKey,
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;
}
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: const [
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Receipts',
),
NavigationDestination(
icon: Icon(Icons.directions_car_outlined),
selectedIcon: Icon(Icons.directions_car),
label: 'Vehicles',
),
NavigationDestination(
icon: Icon(Icons.summarize_outlined),
selectedIcon: Icon(Icons.summarize),
label: 'Reports',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}