import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.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 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 targetKeys; /// Gets the app into the right state to show this step (switch tabs, /// push a route, ...). Returns the route it just pushed, if any — so /// [_MainShellState._waitForTargets] can wait for that specific route's /// transition to settle before spotlighting something on it — or null /// for a step (like every tab switch) that didn't push one. final ModalRoute? Function() activate; const _OnboardingStep({ required this.title, required this.description, required this.targetKeys, required this.activate, }); } class _MainShellState extends State { 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 — up to 12.5¢/gal " 'for highway use, up to 29.5¢/gal non-highway.\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: () => null, ), _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); return null; }, ), _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); return null; }, ), _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); return null; }, ), _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); return null; }, ), _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; final route = MaterialPageRoute(builder: (_) => const DataSettingsScreen()); // 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, then for `route` itself — returned below — to finish its // transition) is what actually waits for this screen to be ready. unawaited(Navigator.of(context).push(route)); return route; }, ), ]; @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(); 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 _waitForTargets(List keys, ModalRoute? pushedRoute) 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)); } if (!mounted) return; // The "Back Up to the Cloud" step's activate() pushes a route and // returns it as [pushedRoute] — wait for that push transition to fully // settle before capturing its target's position, or the spotlight ends // up shifted by however far the slide-in hadn't yet finished. A no-op // for every other step, which only switches tabs and so has no route // to pass here. await waitForRouteTransition(pushedRoute?.animation); if (!mounted) return; // The route's own AnimationController reports AnimationStatus.completed // at this point, but empirically the render tree's transforms (from // FadeForwardsPageTransitionsBuilder's SlideTransition, the actual // Android default as of Flutter 3.44) still reflect a mid-transition // position for a few more frames after that — confirmed by walking the // RenderObject ancestor chain and finding an active // RenderFractionalTranslation still present, with a position matching // the transition's *starting* offset rather than its resting // Offset.zero. These extra frames give it time to actually settle // before the spotlight measures anything. SchedulerBinding.endOfFrame // (rather than a bare Future.delayed) so this genuinely waits on real // frames — in a widget test, tester.pumpAndSettle() only keeps pumping // while something has an actual frame scheduled, which a bare delay // timer doesn't count as once the route's own transition has already // finished. for (var i = 0; i < 10; i++) { if (!mounted) return; await SchedulerBinding.instance.endOfFrame; } } Future _showTourStep() async { final step = _tourSteps[_tourStepIndex]; final pushedRoute = step.activate(); await _waitForTargets(step.targetKeys, pushedRoute); 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().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', ), ], ), ); } }