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 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 { 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(); 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 _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 _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().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', ), ], ), ); } }