Tour
This commit is contained in:
parent
956231048e
commit
606106e5ec
10 changed files with 632 additions and 1 deletions
|
|
@ -1,5 +1,12 @@
|
|||
import 'package:flutter/material.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';
|
||||
|
|
@ -20,6 +27,13 @@ import 'settings_screen.dart';
|
|||
/// 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});
|
||||
|
||||
|
|
@ -27,10 +41,31 @@ 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.
|
||||
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(),
|
||||
|
|
@ -38,8 +73,59 @@ class _MainShellState extends State<MainShell> {
|
|||
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();
|
||||
}
|
||||
|
|
@ -52,6 +138,77 @@ class _MainShellState extends State<MainShell> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue