This commit is contained in:
Courtney Arnold 2026-08-15 19:23:03 -05:00
parent 956231048e
commit 606106e5ec
10 changed files with 632 additions and 1 deletions

View file

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
import '../services/onboarding_keys.dart';
import 'cloud_folder_browser_screen.dart';
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
@ -217,6 +218,7 @@ class _DataSettingsScreenState extends State<DataSettingsScreen> {
padding: const EdgeInsets.all(16),
children: [
Card(
key: OnboardingKeys.cloudStorageCard,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(

View file

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../services/onboarding_keys.dart';
import 'add_edit_vehicle_screen.dart';
import 'vehicle_detail_screen.dart';
@ -22,6 +23,7 @@ class HomeScreen extends StatelessWidget {
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
key: OnboardingKeys.vehiclesAddButton,
icon: const Icon(Icons.add),
tooltip: 'Add Vehicle',
onPressed: () => Navigator.of(context).push(

View file

@ -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(

View file

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../services/onboarding_keys.dart';
import '../theme/app_theme.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
@ -136,6 +137,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
key: OnboardingKeys.receiptsAddButton,
icon: const Icon(Icons.add),
tooltip: 'Log Fuel Receipt',
onPressed: () => _addReceipt(context),

View file

@ -10,6 +10,7 @@ import '../services/app_state.dart';
import '../services/fuel_report.dart';
import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.dart';
import '../services/onboarding_keys.dart';
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
/// file for the user — it's a state form, submitted to the state — so the
@ -165,6 +166,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
padding: const EdgeInsets.all(16),
children: [
Row(
key: OnboardingKeys.reportDateRange,
children: [
Expanded(
child: OutlinedButton(

View file

@ -24,6 +24,7 @@ const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const _prefsKeyThemeMode = 'theme_mode';
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
@ -89,6 +90,17 @@ class AppState extends ChangeNotifier {
ThemeMode themeMode = ThemeMode.system;
/// Whether the first-launch guided tour ([OnboardingTour]) has already
/// been shown. Defaults to `true` here (not `false`) specifically so
/// that widget tests constructing `AppState()` directly and skipping
/// [init] — the established pattern throughout this app's test suite,
/// to avoid init()'s database/platform-channel dependencies — don't get
/// an unexpected full-screen tour overlay blocking every tap. [init]
/// overwrites this from the persisted value (defaulting to `false`
/// there) for real app startups, where a missing pref genuinely means
/// "never shown before."
bool hasSeenOnboardingTour = true;
/// Set if [init] fails. The UI shows this (with a retry option) instead
/// of spinning forever — an unhandled exception here previously left
/// `isLoading` stuck at true with no feedback at all.
@ -117,6 +129,7 @@ class AppState extends ChangeNotifier {
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
orElse: () => ThemeMode.system,
);
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
} catch (e) {
initError = e;
isLoading = false;
@ -304,6 +317,15 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
/// Called once the first-launch guided tour finishes or is skipped, so
/// it never shows again on this device.
Future<void> markOnboardingTourSeen() async {
hasSeenOnboardingTour = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyHasSeenOnboardingTour, true);
notifyListeners();
}
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
/// Settings "Advanced" section, which restricts the picker to that range
/// anyway; this is a defensive backstop for any other caller.

View file

@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
/// One [GlobalKey] per on-screen element the first-launch guided tour
/// (`lib/widgets/onboarding_tour.dart`, orchestrated from
/// `lib/screens/main_shell.dart`) highlights. Each screen that owns one of
/// these elements assigns the matching key to the actual widget, so the
/// tour can find its on-screen position (via
/// `key.currentContext?.findRenderObject()`) without those screens knowing
/// anything about the tour itself.
class OnboardingKeys {
static final receiptsAddButton = GlobalKey(debugLabel: 'onboarding-receipts-add');
static final vehiclesAddButton = GlobalKey(debugLabel: 'onboarding-vehicles-add');
static final reportDateRange = GlobalKey(debugLabel: 'onboarding-report-date-range');
static final cloudStorageCard = GlobalKey(debugLabel: 'onboarding-cloud-storage-card');
}

View file

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
/// The full-screen "spotlight" shown by one step of the first-launch
/// guided tour: a dimmed barrier with a cut-out hole around whatever
/// [targetKey] currently points to (or, if that key isn't laid out yet or
/// doesn't resolve, just a plain dimmed barrier with the card centered),
/// plus a card explaining that element with Next/Skip controls.
///
/// This widget is purely presentational — it doesn't know about tabs,
/// routes, or persistence. `MainShell` is responsible for sequencing
/// steps, navigating to the right screen/route before each one, and
/// recording that the tour's been seen once it ends.
class OnboardingTourOverlay extends StatelessWidget {
final GlobalKey targetKey;
final String title;
final String description;
final int stepNumber;
final int totalSteps;
final VoidCallback onNext;
final VoidCallback onSkip;
const OnboardingTourOverlay({
super.key,
required this.targetKey,
required this.title,
required this.description,
required this.stepNumber,
required this.totalSteps,
required this.onNext,
required this.onSkip,
});
Rect? _targetRect() {
final renderObject = targetKey.currentContext?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
}
@override
Widget build(BuildContext context) {
final rect = _targetRect()?.inflate(6);
final media = MediaQuery.of(context);
final isLastStep = stepNumber >= totalSteps;
final colorScheme = Theme.of(context).colorScheme;
// Prefer placing the card below the highlighted element; only flip
// above it if there's not enough room below on this screen.
final cardBelow = rect == null || (rect.bottom + 260) < media.size.height;
final cardTop = cardBelow ? (rect?.bottom ?? media.size.height * 0.38) + 16 : null;
final cardBottom = !cardBelow ? media.size.height - rect.top + 16 : null;
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// Swallows every tap on the barrier (and the spotlight hole
// itself) so the highlighted element can't be accidentally
// triggered mid-tour — Next/Skip on the card are the only way
// to advance.
onTap: () {},
child: CustomPaint(
painter: _SpotlightPainter(
targetRect: rect,
color: Colors.black.withValues(alpha: 0.75),
),
),
),
),
if (rect != null)
Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: colorScheme.primary, width: 2.5),
borderRadius: BorderRadius.circular(12),
),
),
),
),
Positioned(
left: 20,
right: 20,
top: cardTop,
bottom: cardBottom,
child: SafeArea(
child: Card(
elevation: 8,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Step $stepNumber of $totalSteps',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(description, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(onPressed: onSkip, child: const Text('Skip Tour')),
FilledButton(onPressed: onNext, child: Text(isLastStep ? 'Got It' : 'Next')),
],
),
],
),
),
),
),
),
],
);
}
}
class _SpotlightPainter extends CustomPainter {
final Rect? targetRect;
final Color color;
const _SpotlightPainter({required this.targetRect, required this.color});
@override
void paint(Canvas canvas, Size size) {
final barrierPaint = Paint()..color = color;
final fullPath = Path()..addRect(Offset.zero & size);
final rect = targetRect;
if (rect == null) {
canvas.drawPath(fullPath, barrierPaint);
return;
}
final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
canvas.drawPath(Path.combine(PathOperation.difference, fullPath, holePath), barrierPaint);
}
@override
bool shouldRepaint(covariant _SpotlightPainter oldDelegate) =>
oldDelegate.targetRect != targetRect || oldDelegate.color != color;
}