Finished updating the tour
This commit is contained in:
parent
606106e5ec
commit
2fcc6dcab7
7 changed files with 288 additions and 83 deletions
|
|
@ -41,19 +41,22 @@ 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
|
/// One step of the first-launch tour: which element(s) to highlight
|
||||||
/// say about it, and how to get the app into the right state to show it
|
/// together, what to say about them, and how to get the app into the
|
||||||
/// (switch tabs, push a route, ...) before it's shown.
|
/// 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 {
|
class _OnboardingStep {
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
final GlobalKey targetKey;
|
final List<GlobalKey> targetKeys;
|
||||||
final void Function() activate;
|
final void Function() activate;
|
||||||
|
|
||||||
const _OnboardingStep({
|
const _OnboardingStep({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.targetKey,
|
required this.targetKeys,
|
||||||
required this.activate,
|
required this.activate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -74,37 +77,71 @@ class _MainShellState extends State<MainShell> {
|
||||||
];
|
];
|
||||||
|
|
||||||
late final List<_OnboardingStep> _tourSteps = [
|
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 — currently 12.5¢ back "
|
||||||
|
'per gallon.\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: () {},
|
||||||
|
),
|
||||||
_OnboardingStep(
|
_OnboardingStep(
|
||||||
title: 'Add Receipts Here',
|
title: 'Add Receipts Here',
|
||||||
description: 'Tap the + button to log a fuel receipt. Snap a photo of it and the app '
|
description: "This is the Receipts tab — tap the + button here to log a fuel receipt. "
|
||||||
'reads the date, gallons, and price for you automatically.',
|
'Snap a photo of it and the app reads the date, gallons, and price for you '
|
||||||
targetKey: OnboardingKeys.receiptsAddButton,
|
'automatically.',
|
||||||
|
targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination],
|
||||||
activate: () => _jumpToTab(0),
|
activate: () => _jumpToTab(0),
|
||||||
),
|
),
|
||||||
_OnboardingStep(
|
_OnboardingStep(
|
||||||
title: 'Add Vehicles Here',
|
title: 'Add Vehicles Here',
|
||||||
description: 'Add a vehicle here first — every receipt gets linked to one of your '
|
description: 'This is the Vehicles tab — tap the + button here to add a vehicle. Every '
|
||||||
'vehicles, so this is where a new vehicle needs to be added before you can log a '
|
'receipt gets linked to one of your vehicles, so add one here first before logging '
|
||||||
"receipt for it.",
|
'a receipt for it.',
|
||||||
targetKey: OnboardingKeys.vehiclesAddButton,
|
targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination],
|
||||||
activate: () => _jumpToTab(1),
|
activate: () => _jumpToTab(1),
|
||||||
),
|
),
|
||||||
_OnboardingStep(
|
_OnboardingStep(
|
||||||
title: 'Choose a Report Date Range',
|
title: 'Choose a Report Date Range',
|
||||||
description: 'Pick a start and end date to set the timeframe your report covers. Once '
|
description: "This is the Reports tab. Pick a start and end date here to set the "
|
||||||
"you're ready to file, scroll down on this page for a link to Missouri's official "
|
"timeframe your report covers, then use Share or Print once it's ready. When you're "
|
||||||
'Motor Fuel Refund Claim form — this report is what you\'ll use to fill it out.',
|
"ready to file, tap the link for Missouri's official Motor Fuel Refund Claim form.",
|
||||||
targetKey: OnboardingKeys.reportDateRange,
|
// 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),
|
activate: () => _jumpToTab(2),
|
||||||
),
|
),
|
||||||
|
_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),
|
||||||
|
),
|
||||||
_OnboardingStep(
|
_OnboardingStep(
|
||||||
title: 'Back Up to the Cloud',
|
title: 'Back Up to the Cloud',
|
||||||
description: 'Connect a cloud storage account here to back up every vehicle, receipt, '
|
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 "
|
"and photo you log — so nothing is lost if this device is lost, damaged, or "
|
||||||
'replaced.',
|
'replaced.',
|
||||||
targetKey: OnboardingKeys.cloudStorageCard,
|
targetKeys: [OnboardingKeys.cloudStorageCard],
|
||||||
activate: () {
|
activate: () {
|
||||||
_jumpToTab(3);
|
|
||||||
_tourPushedDataScreen = true;
|
_tourPushedDataScreen = true;
|
||||||
// Not awaited: Navigator.push's returned future only completes on
|
// Not awaited: Navigator.push's returned future only completes on
|
||||||
// pop, not on the pushed route finishing its build — the polling
|
// pop, not on the pushed route finishing its build — the polling
|
||||||
|
|
@ -154,16 +191,42 @@ class _MainShellState extends State<MainShell> {
|
||||||
_showTourStep();
|
_showTourStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls for [key] to have a laid-out [RenderBox] — needed because the
|
/// Polls until every key in [keys] has a laid-out [RenderBox] — needed
|
||||||
/// step's [_OnboardingStep.activate] (a tab switch, or a route push)
|
/// because the step's [_OnboardingStep.activate] (a tab switch, or a
|
||||||
/// only *starts* getting the target on screen; it doesn't block until
|
/// route push) only *starts* getting the target(s) on screen; it
|
||||||
/// that frame has actually built. Bounded so a step whose target never
|
/// doesn't block until that frame has actually built. Bounded so a step
|
||||||
/// appears (shouldn't normally happen) doesn't hang the tour forever —
|
/// whose target never appears (e.g. the Reports step's Share/Print row,
|
||||||
/// it just falls back to a spotlight-less, centered card for that step.
|
/// which only renders once a report actually exists) doesn't hang the
|
||||||
Future<void> _waitForTarget(GlobalKey key) async {
|
/// 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<void> _waitForTargets(List<GlobalKey> keys) async {
|
||||||
for (var attempt = 0; attempt < 30; attempt++) {
|
for (var attempt = 0; attempt < 30; attempt++) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (key.currentContext?.findRenderObject() case RenderBox box when box.hasSize) 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));
|
await Future.delayed(const Duration(milliseconds: 16));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,13 +234,13 @@ class _MainShellState extends State<MainShell> {
|
||||||
Future<void> _showTourStep() async {
|
Future<void> _showTourStep() async {
|
||||||
final step = _tourSteps[_tourStepIndex];
|
final step = _tourSteps[_tourStepIndex];
|
||||||
step.activate();
|
step.activate();
|
||||||
await _waitForTarget(step.targetKey);
|
await _waitForTargets(step.targetKeys);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
_tourEntry?.remove();
|
_tourEntry?.remove();
|
||||||
_tourEntry = OverlayEntry(
|
_tourEntry = OverlayEntry(
|
||||||
builder: (_) => OnboardingTourOverlay(
|
builder: (_) => OnboardingTourOverlay(
|
||||||
targetKey: step.targetKey,
|
targetKeys: step.targetKeys,
|
||||||
title: step.title,
|
title: step.title,
|
||||||
description: step.description,
|
description: step.description,
|
||||||
stepNumber: _tourStepIndex + 1,
|
stepNumber: _tourStepIndex + 1,
|
||||||
|
|
@ -220,25 +283,29 @@ class _MainShellState extends State<MainShell> {
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _index,
|
selectedIndex: _index,
|
||||||
onDestinationSelected: _goToTab,
|
onDestinationSelected: _goToTab,
|
||||||
destinations: const [
|
destinations: [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.receipt_long_outlined),
|
key: OnboardingKeys.receiptsNavDestination,
|
||||||
selectedIcon: Icon(Icons.receipt_long),
|
icon: const Icon(Icons.receipt_long_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.receipt_long),
|
||||||
label: 'Receipts',
|
label: 'Receipts',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.directions_car_outlined),
|
key: OnboardingKeys.vehiclesNavDestination,
|
||||||
selectedIcon: Icon(Icons.directions_car),
|
icon: const Icon(Icons.directions_car_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.directions_car),
|
||||||
label: 'Vehicles',
|
label: 'Vehicles',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.summarize_outlined),
|
key: OnboardingKeys.reportsNavDestination,
|
||||||
selectedIcon: Icon(Icons.summarize),
|
icon: const Icon(Icons.summarize_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.summarize),
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.settings_outlined),
|
key: OnboardingKeys.settingsNavDestination,
|
||||||
selectedIcon: Icon(Icons.settings),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
|
key: OnboardingKeys.reportShareAndPrintRow,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
|
|
@ -295,6 +296,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
InkWell(
|
InkWell(
|
||||||
|
key: OnboardingKeys.refundFormLink,
|
||||||
onTap: _openRefundFormLink,
|
onTap: _openRefundFormLink,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
|
import '../services/onboarding_keys.dart';
|
||||||
import 'data_settings_screen.dart';
|
import 'data_settings_screen.dart';
|
||||||
import 'ui_settings_screen.dart';
|
import 'ui_settings_screen.dart';
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ class SettingsScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
|
key: OnboardingKeys.settingsDataMenuEntry,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.storage_outlined),
|
leading: const Icon(Icons.storage_outlined),
|
||||||
title: const Text('Data'),
|
title: const Text('Data'),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ import 'package:flutter/material.dart';
|
||||||
/// anything about the tour itself.
|
/// anything about the tour itself.
|
||||||
class OnboardingKeys {
|
class OnboardingKeys {
|
||||||
static final receiptsAddButton = GlobalKey(debugLabel: 'onboarding-receipts-add');
|
static final receiptsAddButton = GlobalKey(debugLabel: 'onboarding-receipts-add');
|
||||||
|
static final receiptsNavDestination = GlobalKey(debugLabel: 'onboarding-receipts-nav-destination');
|
||||||
static final vehiclesAddButton = GlobalKey(debugLabel: 'onboarding-vehicles-add');
|
static final vehiclesAddButton = GlobalKey(debugLabel: 'onboarding-vehicles-add');
|
||||||
|
static final vehiclesNavDestination = GlobalKey(debugLabel: 'onboarding-vehicles-nav-destination');
|
||||||
static final reportDateRange = GlobalKey(debugLabel: 'onboarding-report-date-range');
|
static final reportDateRange = GlobalKey(debugLabel: 'onboarding-report-date-range');
|
||||||
|
static final reportsNavDestination = GlobalKey(debugLabel: 'onboarding-reports-nav-destination');
|
||||||
|
static final reportShareAndPrintRow = GlobalKey(debugLabel: 'onboarding-report-share-print-row');
|
||||||
|
static final refundFormLink = GlobalKey(debugLabel: 'onboarding-refund-form-link');
|
||||||
|
static final settingsNavDestination = GlobalKey(debugLabel: 'onboarding-settings-nav-destination');
|
||||||
|
static final settingsDataMenuEntry = GlobalKey(debugLabel: 'onboarding-settings-data-menu-entry');
|
||||||
static final cloudStorageCard = GlobalKey(debugLabel: 'onboarding-cloud-storage-card');
|
static final cloudStorageCard = GlobalKey(debugLabel: 'onboarding-cloud-storage-card');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// The full-screen "spotlight" shown by one step of the first-launch
|
/// The full-screen "spotlight" shown by one step of the first-launch
|
||||||
/// guided tour: a dimmed barrier with a cut-out hole around whatever
|
/// guided tour: a dimmed barrier with a cut-out hole around each of
|
||||||
/// [targetKey] currently points to (or, if that key isn't laid out yet or
|
/// [targetKeys]' current on-screen positions (or, for any key that isn't
|
||||||
/// doesn't resolve, just a plain dimmed barrier with the card centered),
|
/// laid out yet / doesn't resolve, no hole for it — falling back to a
|
||||||
/// plus a card explaining that element with Next/Skip controls.
|
/// plain dimmed barrier with the card centered if none resolve), plus a
|
||||||
|
/// card explaining the step with Next/Skip controls. 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.
|
||||||
///
|
///
|
||||||
/// This widget is purely presentational — it doesn't know about tabs,
|
/// This widget is purely presentational — it doesn't know about tabs,
|
||||||
/// routes, or persistence. `MainShell` is responsible for sequencing
|
/// routes, or persistence. `MainShell` is responsible for sequencing
|
||||||
/// steps, navigating to the right screen/route before each one, and
|
/// steps, navigating to the right screen/route before each one, and
|
||||||
/// recording that the tour's been seen once it ends.
|
/// recording that the tour's been seen once it ends.
|
||||||
class OnboardingTourOverlay extends StatelessWidget {
|
class OnboardingTourOverlay extends StatelessWidget {
|
||||||
final GlobalKey targetKey;
|
final List<GlobalKey> targetKeys;
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
final int stepNumber;
|
final int stepNumber;
|
||||||
|
|
@ -21,7 +26,7 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
|
|
||||||
const OnboardingTourOverlay({
|
const OnboardingTourOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.targetKey,
|
required this.targetKeys,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.stepNumber,
|
required this.stepNumber,
|
||||||
|
|
@ -30,44 +35,69 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
required this.onSkip,
|
required this.onSkip,
|
||||||
});
|
});
|
||||||
|
|
||||||
Rect? _targetRect() {
|
List<Rect> _targetRects() {
|
||||||
final renderObject = targetKey.currentContext?.findRenderObject();
|
final rects = <Rect>[];
|
||||||
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
|
for (final key in targetKeys) {
|
||||||
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
final renderObject = key.currentContext?.findRenderObject();
|
||||||
|
if (renderObject is RenderBox && renderObject.hasSize) {
|
||||||
|
rects.add((renderObject.localToGlobal(Offset.zero) & renderObject.size).inflate(6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final rect = _targetRect()?.inflate(6);
|
final rects = _targetRects();
|
||||||
final media = MediaQuery.of(context);
|
final media = MediaQuery.of(context);
|
||||||
final isLastStep = stepNumber >= totalSteps;
|
final isLastStep = stepNumber >= totalSteps;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Prefer placing the card below the highlighted element; only flip
|
final screenMid = media.size.height / 2;
|
||||||
// above it if there's not enough room below on this screen.
|
final topGroup = rects.where((r) => r.center.dy < screenMid);
|
||||||
final cardBelow = rect == null || (rect.bottom + 260) < media.size.height;
|
final bottomGroup = rects.where((r) => r.center.dy >= screenMid);
|
||||||
final cardTop = cardBelow ? (rect?.bottom ?? media.size.height * 0.38) + 16 : null;
|
|
||||||
final cardBottom = !cardBelow ? media.size.height - rect.top + 16 : null;
|
double? cardTop;
|
||||||
|
double? cardBottom;
|
||||||
|
if (topGroup.isNotEmpty && bottomGroup.isNotEmpty) {
|
||||||
|
// Highlighted elements exist both above and below the screen's
|
||||||
|
// midpoint (e.g. an AppBar action + a bottom-nav destination) — the
|
||||||
|
// union of every rect would span almost the full screen height in
|
||||||
|
// this case, which the plain below/above logic below handles badly.
|
||||||
|
// Anchor the card just under the topmost group instead, into the
|
||||||
|
// open band between the two.
|
||||||
|
cardTop = topGroup.map((r) => r.bottom).reduce((a, b) => a > b ? a : b) + 16;
|
||||||
|
} else {
|
||||||
|
final unionRect = rects.isEmpty ? null : rects.reduce((a, b) => a.expandToInclude(b));
|
||||||
|
// Prefer placing the card below the highlighted element(s); only
|
||||||
|
// flip above if there's not enough room below on this screen.
|
||||||
|
final cardBelow = unionRect == null || (unionRect.bottom + 260) < media.size.height;
|
||||||
|
if (cardBelow) {
|
||||||
|
cardTop = (unionRect?.bottom ?? media.size.height * 0.38) + 16;
|
||||||
|
} else {
|
||||||
|
cardBottom = media.size.height - unionRect.top + 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
// Swallows every tap on the barrier (and the spotlight hole
|
// Swallows every tap on the barrier (and the spotlight holes
|
||||||
// itself) so the highlighted element can't be accidentally
|
// themselves) so a highlighted element can't be accidentally
|
||||||
// triggered mid-tour — Next/Skip on the card are the only way
|
// triggered mid-tour — Next/Skip on the card are the only way
|
||||||
// to advance.
|
// to advance.
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _SpotlightPainter(
|
painter: _SpotlightPainter(
|
||||||
targetRect: rect,
|
targetRects: rects,
|
||||||
color: Colors.black.withValues(alpha: 0.75),
|
color: Colors.black.withValues(alpha: 0.75),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (rect != null)
|
for (final rect in rects)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
|
|
@ -127,25 +157,23 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SpotlightPainter extends CustomPainter {
|
class _SpotlightPainter extends CustomPainter {
|
||||||
final Rect? targetRect;
|
final List<Rect> targetRects;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
const _SpotlightPainter({required this.targetRect, required this.color});
|
const _SpotlightPainter({required this.targetRects, required this.color});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final barrierPaint = Paint()..color = color;
|
final barrierPaint = Paint()..color = color;
|
||||||
final fullPath = Path()..addRect(Offset.zero & size);
|
var path = Path()..addRect(Offset.zero & size);
|
||||||
final rect = targetRect;
|
for (final rect in targetRects) {
|
||||||
if (rect == null) {
|
final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
|
||||||
canvas.drawPath(fullPath, barrierPaint);
|
path = Path.combine(PathOperation.difference, path, holePath);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
|
canvas.drawPath(path, barrierPaint);
|
||||||
canvas.drawPath(Path.combine(PathOperation.difference, fullPath, holePath), barrierPaint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _SpotlightPainter oldDelegate) =>
|
bool shouldRepaint(covariant _SpotlightPainter oldDelegate) =>
|
||||||
oldDelegate.targetRect != targetRect || oldDelegate.color != color;
|
!listEquals(oldDelegate.targetRects, targetRects) || oldDelegate.color != color;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,11 +129,17 @@ void main() {
|
||||||
return appState;
|
return appState;
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('shows step 1 (Receipts) automatically on first launch', (tester) async {
|
testWidgets('shows the "What This App Does" intro automatically on first launch, with the '
|
||||||
|
'refund-rate and lawmakers disclaimer', (tester) async {
|
||||||
await pumpShellWithFreshTour(tester);
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
expect(find.text('Add Receipts Here'), findsOneWidget);
|
expect(find.text('What This App Does'), findsOneWidget);
|
||||||
expect(find.text('Step 1 of 4'), findsOneWidget);
|
expect(find.text('Step 1 of 6'), findsOneWidget);
|
||||||
|
expect(find.textContaining('12.5¢'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Disclaimer'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Missouri lawmakers'), findsOneWidget);
|
||||||
|
// No specific element to spotlight for the intro — stays on
|
||||||
|
// whichever tab MainShell defaults to.
|
||||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -150,13 +156,19 @@ void main() {
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Add Receipts Here'), findsNothing);
|
expect(find.text('What This App Does'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping the dimmed barrier does not dismiss the tour or reach the highlighted button',
|
testWidgets('tapping the dimmed barrier does not dismiss the tour or reach the highlighted button',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpShellWithFreshTour(tester);
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
// Advance to the Receipts step, which is the one with an actual
|
||||||
|
// highlighted, tappable element (the "+") behind the barrier.
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Add Receipts Here'), findsOneWidget);
|
||||||
|
|
||||||
// Top-left corner, away from the card and from the highlighted "+"
|
// Top-left corner, away from the card and from the highlighted "+"
|
||||||
// (which sits top-right in the AppBar).
|
// (which sits top-right in the AppBar).
|
||||||
await tester.tapAt(const Offset(10, 10));
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
|
@ -168,28 +180,44 @@ void main() {
|
||||||
"vehicles yet — was not actually reached");
|
"vehicles yet — was not actually reached");
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Next advances through all four steps, switching tabs and pushing Settings > Data',
|
testWidgets(
|
||||||
|
'Next advances through all six steps, switching tabs, then pushing Settings > Data',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpShellWithFreshTour(tester);
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
|
expect(find.text('What This App Does'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Add Receipts Here'), findsOneWidget);
|
expect(find.text('Add Receipts Here'), findsOneWidget);
|
||||||
|
expect(find.text('Step 2 of 6'), findsOneWidget);
|
||||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Add Vehicles Here'), findsOneWidget);
|
expect(find.text('Add Vehicles Here'), findsOneWidget);
|
||||||
expect(find.text('Step 2 of 4'), findsOneWidget);
|
expect(find.text('Step 3 of 6'), findsOneWidget);
|
||||||
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
|
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Choose a Report Date Range'), findsOneWidget);
|
expect(find.text('Choose a Report Date Range'), findsOneWidget);
|
||||||
|
expect(find.text('Step 4 of 6'), findsOneWidget);
|
||||||
expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget);
|
expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Find Data Settings'), findsOneWidget);
|
||||||
|
expect(find.text('Step 5 of 6'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Settings'), findsOneWidget);
|
||||||
|
expect(find.byType(DataSettingsScreen), findsNothing,
|
||||||
|
reason: 'still on the Settings landing page, not pushed into Data yet');
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Back Up to the Cloud'), findsOneWidget);
|
expect(find.text('Back Up to the Cloud'), findsOneWidget);
|
||||||
expect(find.text('Step 4 of 4'), findsOneWidget);
|
expect(find.text('Step 6 of 6'), findsOneWidget);
|
||||||
expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'tour pushed Settings > Data');
|
expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'tour pushed Settings > Data');
|
||||||
expect(find.text('Cloud Storage'), findsOneWidget);
|
expect(find.text('Cloud Storage'), findsOneWidget);
|
||||||
expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget,
|
expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget,
|
||||||
|
|
@ -201,7 +229,7 @@ void main() {
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final appState = await pumpShellWithFreshTour(tester);
|
final appState = await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
for (var i = 0; i < 3; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +254,7 @@ void main() {
|
||||||
await tester.tap(find.widgetWithText(TextButton, 'Skip Tour'));
|
await tester.tap(find.widgetWithText(TextButton, 'Skip Tour'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Add Receipts Here'), findsNothing);
|
expect(find.text('What This App Does'), findsNothing);
|
||||||
expect(appState.hasSeenOnboardingTour, isTrue);
|
expect(appState.hasSeenOnboardingTour, isTrue);
|
||||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget,
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget,
|
||||||
reason: 'stayed on the tab it started on');
|
reason: 'stayed on the tab it started on');
|
||||||
|
|
@ -236,7 +264,7 @@ void main() {
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpShellWithFreshTour(tester);
|
await pumpShellWithFreshTour(tester);
|
||||||
|
|
||||||
for (var i = 0; i < 3; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(
|
await tester.pumpWidget(wrap(
|
||||||
OnboardingTourOverlay(
|
OnboardingTourOverlay(
|
||||||
targetKey: targetKey,
|
targetKeys: [targetKey],
|
||||||
title: 'Example Title',
|
title: 'Example Title',
|
||||||
description: 'Example description text.',
|
description: 'Example description text.',
|
||||||
stepNumber: 2,
|
stepNumber: 2,
|
||||||
|
|
@ -34,7 +34,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(
|
await tester.pumpWidget(wrap(
|
||||||
OnboardingTourOverlay(
|
OnboardingTourOverlay(
|
||||||
targetKey: targetKey,
|
targetKeys: [targetKey],
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'D',
|
description: 'D',
|
||||||
stepNumber: 1,
|
stepNumber: 1,
|
||||||
|
|
@ -48,7 +48,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(
|
await tester.pumpWidget(wrap(
|
||||||
OnboardingTourOverlay(
|
OnboardingTourOverlay(
|
||||||
targetKey: targetKey,
|
targetKeys: [targetKey],
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'D',
|
description: 'D',
|
||||||
stepNumber: 3,
|
stepNumber: 3,
|
||||||
|
|
@ -68,7 +68,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(
|
await tester.pumpWidget(wrap(
|
||||||
OnboardingTourOverlay(
|
OnboardingTourOverlay(
|
||||||
targetKey: targetKey,
|
targetKeys: [targetKey],
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'D',
|
description: 'D',
|
||||||
stepNumber: 1,
|
stepNumber: 1,
|
||||||
|
|
@ -92,7 +92,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(
|
await tester.pumpWidget(wrap(
|
||||||
OnboardingTourOverlay(
|
OnboardingTourOverlay(
|
||||||
targetKey: targetKey,
|
targetKeys: [targetKey],
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'D',
|
description: 'D',
|
||||||
stepNumber: 1,
|
stepNumber: 1,
|
||||||
|
|
@ -112,7 +112,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(
|
await tester.pumpWidget(wrap(
|
||||||
OnboardingTourOverlay(
|
OnboardingTourOverlay(
|
||||||
targetKey: targetKey,
|
targetKeys: [targetKey],
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'D',
|
description: 'D',
|
||||||
stepNumber: 1,
|
stepNumber: 1,
|
||||||
|
|
@ -129,4 +129,75 @@ void main() {
|
||||||
expect(nextTapped, isFalse);
|
expect(nextTapped, isFalse);
|
||||||
expect(skipTapped, isFalse);
|
expect(skipTapped, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('highlights every key in targetKeys, not just the first', (tester) async {
|
||||||
|
final topKey = GlobalKey();
|
||||||
|
final bottomKey = GlobalKey();
|
||||||
|
|
||||||
|
Widget buildTree({required bool showOverlay}) => MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(top: 0, left: 0, child: SizedBox(key: topKey, width: 40, height: 40)),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0, left: 0, child: SizedBox(key: bottomKey, width: 40, height: 40)),
|
||||||
|
if (showOverlay)
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKeys: [topKey, bottomKey],
|
||||||
|
title: 'Both Highlighted',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 1,
|
||||||
|
totalSteps: 1,
|
||||||
|
onNext: () {},
|
||||||
|
onSkip: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two frames, matching how MainShell actually uses this: the target
|
||||||
|
// keys' RenderBoxes need to already exist and be laid out (from a
|
||||||
|
// prior frame) before the overlay's own build() can find their real
|
||||||
|
// position — querying them in the very same frame they're first
|
||||||
|
// mounted finds nothing yet (layout hasn't run for that frame at
|
||||||
|
// build time).
|
||||||
|
await tester.pumpWidget(buildTree(showOverlay: false));
|
||||||
|
await tester.pumpWidget(buildTree(showOverlay: true));
|
||||||
|
|
||||||
|
// One highlight border per resolved key.
|
||||||
|
expect(find.byType(DecoratedBox), findsNWidgets(2));
|
||||||
|
expect(find.text('Both Highlighted'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('still renders correctly when only some of targetKeys resolve', (tester) async {
|
||||||
|
final resolvedKey = GlobalKey();
|
||||||
|
final unresolvedKey = GlobalKey(); // never attached to anything
|
||||||
|
|
||||||
|
Widget buildTree({required bool showOverlay}) => MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
SizedBox(key: resolvedKey, width: 40, height: 40),
|
||||||
|
if (showOverlay)
|
||||||
|
OnboardingTourOverlay(
|
||||||
|
targetKeys: [resolvedKey, unresolvedKey],
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
stepNumber: 1,
|
||||||
|
totalSteps: 1,
|
||||||
|
onNext: () {},
|
||||||
|
onSkip: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTree(showOverlay: false));
|
||||||
|
await tester.pumpWidget(buildTree(showOverlay: true));
|
||||||
|
|
||||||
|
expect(find.byType(DecoratedBox), findsNWidgets(1), reason: 'only the resolved key gets a border');
|
||||||
|
expect(find.text('T'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue