Tour
This commit is contained in:
parent
956231048e
commit
606106e5ec
10 changed files with 632 additions and 1 deletions
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/cloud/cloud_storage_provider.dart';
|
import '../services/cloud/cloud_storage_provider.dart';
|
||||||
|
import '../services/onboarding_keys.dart';
|
||||||
import 'cloud_folder_browser_screen.dart';
|
import 'cloud_folder_browser_screen.dart';
|
||||||
|
|
||||||
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
|
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
|
||||||
|
|
@ -217,6 +218,7 @@ class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
|
key: OnboardingKeys.cloudStorageCard,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
|
import '../services/onboarding_keys.dart';
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
import 'vehicle_detail_screen.dart';
|
import 'vehicle_detail_screen.dart';
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
actionsPadding: const EdgeInsets.only(right: 20),
|
actionsPadding: const EdgeInsets.only(right: 20),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
key: OnboardingKeys.vehiclesAddButton,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
tooltip: 'Add Vehicle',
|
tooltip: 'Add Vehicle',
|
||||||
onPressed: () => Navigator.of(context).push(
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
|
|
||||||
|
|
@ -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 'home_screen.dart';
|
||||||
import 'receipts_screen.dart';
|
import 'receipts_screen.dart';
|
||||||
import 'report_screen.dart';
|
import 'report_screen.dart';
|
||||||
|
|
@ -20,6 +27,13 @@ import 'settings_screen.dart';
|
||||||
/// fields — alive when [PageView] builds/tears down pages outside its
|
/// fields — alive when [PageView] builds/tears down pages outside its
|
||||||
/// cache extent, the same guarantee the previous [IndexedStack]-based
|
/// cache extent, the same guarantee the previous [IndexedStack]-based
|
||||||
/// version gave "for free".
|
/// 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 {
|
class MainShell extends StatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
|
|
||||||
|
|
@ -27,10 +41,31 @@ class MainShell extends StatefulWidget {
|
||||||
State<MainShell> createState() => _MainShellState();
|
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> {
|
class _MainShellState extends State<MainShell> {
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
final _pageController = PageController();
|
final _pageController = PageController();
|
||||||
|
|
||||||
|
OverlayEntry? _tourEntry;
|
||||||
|
int _tourStepIndex = 0;
|
||||||
|
bool _tourPushedDataScreen = false;
|
||||||
|
|
||||||
static const _tabs = [
|
static const _tabs = [
|
||||||
ReceiptsScreen(),
|
ReceiptsScreen(),
|
||||||
HomeScreen(),
|
HomeScreen(),
|
||||||
|
|
@ -38,8 +73,59 @@ class _MainShellState extends State<MainShell> {
|
||||||
SettingsScreen(),
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tourEntry?.remove();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../models/fuel_entry.dart';
|
import '../models/fuel_entry.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
|
import '../services/onboarding_keys.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/hero_banner.dart';
|
import '../widgets/hero_banner.dart';
|
||||||
import '../widgets/receipt_capture.dart';
|
import '../widgets/receipt_capture.dart';
|
||||||
|
|
@ -136,6 +137,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
||||||
actionsPadding: const EdgeInsets.only(right: 20),
|
actionsPadding: const EdgeInsets.only(right: 20),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
key: OnboardingKeys.receiptsAddButton,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
tooltip: 'Log Fuel Receipt',
|
tooltip: 'Log Fuel Receipt',
|
||||||
onPressed: () => _addReceipt(context),
|
onPressed: () => _addReceipt(context),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../services/app_state.dart';
|
||||||
import '../services/fuel_report.dart';
|
import '../services/fuel_report.dart';
|
||||||
import '../services/fuel_report_images.dart';
|
import '../services/fuel_report_images.dart';
|
||||||
import '../services/fuel_report_pdf.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
|
/// 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
|
/// 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),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
key: OnboardingKeys.reportDateRange,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||||
const _prefsKeyThemeMode = 'theme_mode';
|
const _prefsKeyThemeMode = 'theme_mode';
|
||||||
|
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
|
||||||
|
|
||||||
const defaultStaleLockMinutes = 10;
|
const defaultStaleLockMinutes = 10;
|
||||||
const minStaleLockMinutes = 1;
|
const minStaleLockMinutes = 1;
|
||||||
|
|
@ -89,6 +90,17 @@ class AppState extends ChangeNotifier {
|
||||||
|
|
||||||
ThemeMode themeMode = ThemeMode.system;
|
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
|
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||||
/// of spinning forever — an unhandled exception here previously left
|
/// of spinning forever — an unhandled exception here previously left
|
||||||
/// `isLoading` stuck at true with no feedback at all.
|
/// `isLoading` stuck at true with no feedback at all.
|
||||||
|
|
@ -117,6 +129,7 @@ class AppState extends ChangeNotifier {
|
||||||
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
|
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
|
||||||
orElse: () => ThemeMode.system,
|
orElse: () => ThemeMode.system,
|
||||||
);
|
);
|
||||||
|
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
initError = e;
|
initError = e;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
@ -304,6 +317,15 @@ class AppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
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
|
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||||
/// Settings "Advanced" section, which restricts the picker to that range
|
/// Settings "Advanced" section, which restricts the picker to that range
|
||||||
/// anyway; this is a defensive backstop for any other caller.
|
/// anyway; this is a defensive backstop for any other caller.
|
||||||
|
|
|
||||||
15
lib/services/onboarding_keys.dart
Normal file
15
lib/services/onboarding_keys.dart
Normal 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');
|
||||||
|
}
|
||||||
151
lib/widgets/onboarding_tour_overlay.dart
Normal file
151
lib/widgets/onboarding_tour_overlay.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
|
||||||
import 'package:fuel_tax_tracker/screens/main_shell.dart';
|
import 'package:fuel_tax_tracker/screens/main_shell.dart';
|
||||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||||
|
|
||||||
|
|
@ -102,4 +104,148 @@ void main() {
|
||||||
final selectedLabel = tester.widget<Text>(find.text('Last 3 Months'));
|
final selectedLabel = tester.widget<Text>(find.text('Last 3 Months'));
|
||||||
expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range');
|
expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('first-launch onboarding tour', () {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<AppState> pumpShellWithFreshTour(WidgetTester tester) async {
|
||||||
|
// AppState.hasSeenOnboardingTour defaults to true precisely so that
|
||||||
|
// ad-hoc AppState() construction elsewhere (every other test in this
|
||||||
|
// file included) doesn't trigger the tour — these tests explicitly
|
||||||
|
// flip it to false to reproduce a real fresh install's state.
|
||||||
|
final appState = AppState()
|
||||||
|
..isLoading = false
|
||||||
|
..hasSeenOnboardingTour = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ChangeNotifierProvider.value(
|
||||||
|
value: appState,
|
||||||
|
child: const MaterialApp(home: MainShell()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return appState;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('shows step 1 (Receipts) automatically on first launch', (tester) async {
|
||||||
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
expect(find.text('Add Receipts Here'), findsOneWidget);
|
||||||
|
expect(find.text('Step 1 of 4'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not show the tour once hasSeenOnboardingTour is true', (tester) async {
|
||||||
|
final appState = AppState()
|
||||||
|
..isLoading = false
|
||||||
|
..hasSeenOnboardingTour = true;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ChangeNotifierProvider.value(
|
||||||
|
value: appState,
|
||||||
|
child: const MaterialApp(home: MainShell()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add Receipts Here'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the dimmed barrier does not dismiss the tour or reach the highlighted button',
|
||||||
|
(tester) async {
|
||||||
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
// Top-left corner, away from the card and from the highlighted "+"
|
||||||
|
// (which sits top-right in the AppBar).
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add Receipts Here'), findsOneWidget, reason: 'tour still showing');
|
||||||
|
expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsNothing,
|
||||||
|
reason: "the receipt '+' — which opens vehicle creation when there are no "
|
||||||
|
"vehicles yet — was not actually reached");
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Next advances through all four steps, switching tabs and pushing Settings > Data',
|
||||||
|
(tester) async {
|
||||||
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
expect(find.text('Add Receipts Here'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Add Vehicles Here'), findsOneWidget);
|
||||||
|
expect(find.text('Step 2 of 4'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Choose a Report Date Range'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Back Up to the Cloud'), findsOneWidget);
|
||||||
|
expect(find.text('Step 4 of 4'), findsOneWidget);
|
||||||
|
expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'tour pushed Settings > Data');
|
||||||
|
expect(find.text('Cloud Storage'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget,
|
||||||
|
reason: 'last step reads "Got It", not "Next"');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'finishing on the last step pops the tour-pushed Data screen and marks the tour seen',
|
||||||
|
(tester) async {
|
||||||
|
final appState = await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
expect(find.byType(DataSettingsScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Got It'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(DataSettingsScreen), findsNothing, reason: 'tour-pushed screen popped');
|
||||||
|
expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'overlay gone');
|
||||||
|
expect(appState.hasSeenOnboardingTour, isTrue);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getBool('has_seen_onboarding_tour'), isTrue,
|
||||||
|
reason: 'persisted so the tour never shows again on this device');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Skip Tour on the very first step ends the tour immediately and marks it seen',
|
||||||
|
(tester) async {
|
||||||
|
final appState = await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, 'Skip Tour'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add Receipts Here'), findsNothing);
|
||||||
|
expect(appState.hasSeenOnboardingTour, isTrue);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget,
|
||||||
|
reason: 'stayed on the tab it started on');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Skip Tour from the last step still pops the tour-pushed Data screen',
|
||||||
|
(tester) async {
|
||||||
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
expect(find.byType(DataSettingsScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, 'Skip Tour'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(DataSettingsScreen), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
test/onboarding_tour_overlay_test.dart
Normal file
132
test/onboarding_tour_overlay_test.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fuel_tax_tracker/widgets/onboarding_tour_overlay.dart';
|
||||||
|
|
||||||
|
/// Unit-level coverage of the tour's presentational overlay widget,
|
||||||
|
/// independent of MainShell's step-sequencing/navigation logic (covered
|
||||||
|
/// in main_shell_test.dart's onboarding-tour group instead).
|
||||||
|
void main() {
|
||||||
|
Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||||
|
|
||||||
|
testWidgets('shows the title, description, and step count', (tester) async {
|
||||||
|
final targetKey = GlobalKey();
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKey: targetKey,
|
||||||
|
title: 'Example Title',
|
||||||
|
description: 'Example description text.',
|
||||||
|
stepNumber: 2,
|
||||||
|
totalSteps: 4,
|
||||||
|
onNext: () {},
|
||||||
|
onSkip: () {},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('Example Title'), findsOneWidget);
|
||||||
|
expect(find.text('Example description text.'), findsOneWidget);
|
||||||
|
expect(find.text('Step 2 of 4'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows "Next" before the last step and "Got It" on the last step', (tester) async {
|
||||||
|
final targetKey = GlobalKey();
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKey: targetKey,
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 1,
|
||||||
|
totalSteps: 3,
|
||||||
|
onNext: () {},
|
||||||
|
onSkip: () {},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Next'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Got It'), findsNothing);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKey: targetKey,
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 3,
|
||||||
|
totalSteps: 3,
|
||||||
|
onNext: () {},
|
||||||
|
onSkip: () {},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Next'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('invokes onNext/onSkip when their buttons are tapped', (tester) async {
|
||||||
|
var nextTapped = false;
|
||||||
|
var skipTapped = false;
|
||||||
|
final targetKey = GlobalKey();
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKey: targetKey,
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 1,
|
||||||
|
totalSteps: 2,
|
||||||
|
onNext: () => nextTapped = true,
|
||||||
|
onSkip: () => skipTapped = true,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, 'Skip Tour'));
|
||||||
|
expect(skipTapped, isTrue);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
expect(nextTapped, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'renders without crashing when the target key has no RenderObject yet — falls back to a '
|
||||||
|
'centered card', (tester) async {
|
||||||
|
final targetKey = GlobalKey(); // never attached to anything
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKey: targetKey,
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 1,
|
||||||
|
totalSteps: 1,
|
||||||
|
onNext: () {},
|
||||||
|
onSkip: () {},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('T'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the dimmed barrier does not invoke onNext or onSkip', (tester) async {
|
||||||
|
var nextTapped = false;
|
||||||
|
var skipTapped = false;
|
||||||
|
final targetKey = GlobalKey();
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKey: targetKey,
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 1,
|
||||||
|
totalSteps: 1,
|
||||||
|
onNext: () => nextTapped = true,
|
||||||
|
onSkip: () => skipTapped = true,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Top-left corner — away from the card, which sits mid/lower-screen.
|
||||||
|
await tester.tapAt(const Offset(5, 5));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(nextTapped, isFalse);
|
||||||
|
expect(skipTapped, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue