From 2fcc6dcab72a7dfb388d3f3568e41f40bb733ca9 Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Sat, 15 Aug 2026 20:07:16 -0500 Subject: [PATCH] Finished updating the tour --- lib/screens/main_shell.dart | 141 +++++++++++++++++------ lib/screens/report_screen.dart | 2 + lib/screens/settings_screen.dart | 2 + lib/services/onboarding_keys.dart | 7 ++ lib/widgets/onboarding_tour_overlay.dart | 88 +++++++++----- test/main_shell_test.dart | 48 ++++++-- test/onboarding_tour_overlay_test.dart | 83 ++++++++++++- 7 files changed, 288 insertions(+), 83 deletions(-) diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 0378deb..be381c7 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -41,19 +41,22 @@ class MainShell extends StatefulWidget { State createState() => _MainShellState(); } -/// One step of the first-launch tour: which element to highlight, what to -/// say about it, and how to get the app into the right state to show it -/// (switch tabs, push a route, ...) before it's shown. +/// One step of the first-launch tour: which element(s) to highlight +/// together, what to say about them, and how to get the app into the +/// 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 { final String title; final String description; - final GlobalKey targetKey; + final List targetKeys; final void Function() activate; const _OnboardingStep({ required this.title, required this.description, - required this.targetKey, + required this.targetKeys, required this.activate, }); } @@ -74,37 +77,71 @@ class _MainShellState extends State { ]; 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( 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, + description: "This is the Receipts tab — tap the + button here to log a fuel receipt. " + 'Snap a photo of it and the app reads the date, gallons, and price for you ' + 'automatically.', + targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination], 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, + description: 'This is the Vehicles tab — tap the + button here to add a vehicle. Every ' + 'receipt gets linked to one of your vehicles, so add one here first before logging ' + 'a receipt for it.', + targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination], 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, + description: "This is the Reports tab. Pick a start and end date here to set the " + "timeframe your report covers, then use Share or Print once it's ready. When you're " + "ready to file, tap the link for Missouri's official Motor Fuel Refund Claim form.", + // 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), ), + _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( 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, + targetKeys: [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 @@ -154,16 +191,42 @@ class _MainShellState extends State { _showTourStep(); } - /// Polls for [key] to have a laid-out [RenderBox] — needed because the - /// step's [_OnboardingStep.activate] (a tab switch, or a route push) - /// only *starts* getting the target on screen; it doesn't block until - /// that frame has actually built. Bounded so a step whose target never - /// appears (shouldn't normally happen) doesn't hang the tour forever — - /// it just falls back to a spotlight-less, centered card for that step. - Future _waitForTarget(GlobalKey key) async { + /// Polls until every key in [keys] has a laid-out [RenderBox] — needed + /// because the step's [_OnboardingStep.activate] (a tab switch, or a + /// route push) only *starts* getting the target(s) on screen; it + /// doesn't block until that frame has actually built. Bounded so a step + /// whose target never appears (e.g. the Reports step's Share/Print row, + /// which only renders once a report actually exists) doesn't hang the + /// 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 _waitForTargets(List keys) async { for (var attempt = 0; attempt < 30; attempt++) { 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)); } } @@ -171,13 +234,13 @@ class _MainShellState extends State { Future _showTourStep() async { final step = _tourSteps[_tourStepIndex]; step.activate(); - await _waitForTarget(step.targetKey); + await _waitForTargets(step.targetKeys); if (!mounted) return; _tourEntry?.remove(); _tourEntry = OverlayEntry( builder: (_) => OnboardingTourOverlay( - targetKey: step.targetKey, + targetKeys: step.targetKeys, title: step.title, description: step.description, stepNumber: _tourStepIndex + 1, @@ -220,25 +283,29 @@ class _MainShellState extends State { bottomNavigationBar: NavigationBar( selectedIndex: _index, onDestinationSelected: _goToTab, - destinations: const [ + destinations: [ NavigationDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), + key: OnboardingKeys.receiptsNavDestination, + icon: const Icon(Icons.receipt_long_outlined), + selectedIcon: const Icon(Icons.receipt_long), label: 'Receipts', ), NavigationDestination( - icon: Icon(Icons.directions_car_outlined), - selectedIcon: Icon(Icons.directions_car), + key: OnboardingKeys.vehiclesNavDestination, + icon: const Icon(Icons.directions_car_outlined), + selectedIcon: const Icon(Icons.directions_car), label: 'Vehicles', ), NavigationDestination( - icon: Icon(Icons.summarize_outlined), - selectedIcon: Icon(Icons.summarize), + key: OnboardingKeys.reportsNavDestination, + icon: const Icon(Icons.summarize_outlined), + selectedIcon: const Icon(Icons.summarize), label: 'Reports', ), NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), + key: OnboardingKeys.settingsNavDestination, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), label: 'Settings', ), ], diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart index e98044e..2cb4a32 100644 --- a/lib/screens/report_screen.dart +++ b/lib/screens/report_screen.dart @@ -258,6 +258,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie ), const SizedBox(height: 16), Row( + key: OnboardingKeys.reportShareAndPrintRow, children: [ Expanded( child: FilledButton.icon( @@ -295,6 +296,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie ), const SizedBox(height: 12), InkWell( + key: OnboardingKeys.refundFormLink, onTap: _openRefundFormLink, child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d694928..6b86714 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; import 'data_settings_screen.dart'; import 'ui_settings_screen.dart'; @@ -33,6 +34,7 @@ class SettingsScreen extends StatelessWidget { ), const SizedBox(height: 16), Card( + key: OnboardingKeys.settingsDataMenuEntry, child: ListTile( leading: const Icon(Icons.storage_outlined), title: const Text('Data'), diff --git a/lib/services/onboarding_keys.dart b/lib/services/onboarding_keys.dart index d7c23a3..ce8c50e 100644 --- a/lib/services/onboarding_keys.dart +++ b/lib/services/onboarding_keys.dart @@ -9,7 +9,14 @@ import 'package:flutter/material.dart'; /// anything about the tour itself. class OnboardingKeys { 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 vehiclesNavDestination = GlobalKey(debugLabel: 'onboarding-vehicles-nav-destination'); 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'); } diff --git a/lib/widgets/onboarding_tour_overlay.dart b/lib/widgets/onboarding_tour_overlay.dart index efee9bd..2af2bd7 100644 --- a/lib/widgets/onboarding_tour_overlay.dart +++ b/lib/widgets/onboarding_tour_overlay.dart @@ -1,17 +1,22 @@ +import 'package:flutter/foundation.dart'; 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. +/// guided tour: a dimmed barrier with a cut-out hole around each of +/// [targetKeys]' current on-screen positions (or, for any key that isn't +/// laid out yet / doesn't resolve, no hole for it — falling back to a +/// 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, /// 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 List targetKeys; final String title; final String description; final int stepNumber; @@ -21,7 +26,7 @@ class OnboardingTourOverlay extends StatelessWidget { const OnboardingTourOverlay({ super.key, - required this.targetKey, + required this.targetKeys, required this.title, required this.description, required this.stepNumber, @@ -30,44 +35,69 @@ class OnboardingTourOverlay extends StatelessWidget { 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; + List _targetRects() { + final rects = []; + for (final key in targetKeys) { + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is RenderBox && renderObject.hasSize) { + rects.add((renderObject.localToGlobal(Offset.zero) & renderObject.size).inflate(6)); + } + } + return rects; } @override Widget build(BuildContext context) { - final rect = _targetRect()?.inflate(6); + final rects = _targetRects(); 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; + final screenMid = media.size.height / 2; + final topGroup = rects.where((r) => r.center.dy < screenMid); + final bottomGroup = rects.where((r) => r.center.dy >= screenMid); + + 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( 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 + // Swallows every tap on the barrier (and the spotlight holes + // themselves) so a 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, + targetRects: rects, color: Colors.black.withValues(alpha: 0.75), ), ), ), ), - if (rect != null) + for (final rect in rects) Positioned( left: rect.left, top: rect.top, @@ -127,25 +157,23 @@ class OnboardingTourOverlay extends StatelessWidget { } class _SpotlightPainter extends CustomPainter { - final Rect? targetRect; + final List targetRects; final Color color; - const _SpotlightPainter({required this.targetRect, required this.color}); + const _SpotlightPainter({required this.targetRects, 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; + var path = Path()..addRect(Offset.zero & size); + for (final rect in targetRects) { + final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12))); + path = Path.combine(PathOperation.difference, path, holePath); } - final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12))); - canvas.drawPath(Path.combine(PathOperation.difference, fullPath, holePath), barrierPaint); + canvas.drawPath(path, barrierPaint); } @override bool shouldRepaint(covariant _SpotlightPainter oldDelegate) => - oldDelegate.targetRect != targetRect || oldDelegate.color != color; + !listEquals(oldDelegate.targetRects, targetRects) || oldDelegate.color != color; } diff --git a/test/main_shell_test.dart b/test/main_shell_test.dart index 15dd999..6ef3b15 100644 --- a/test/main_shell_test.dart +++ b/test/main_shell_test.dart @@ -129,11 +129,17 @@ void main() { 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); - expect(find.text('Add Receipts Here'), findsOneWidget); - expect(find.text('Step 1 of 4'), findsOneWidget); + expect(find.text('What This App Does'), 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); }); @@ -150,13 +156,19 @@ void main() { ); 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', (tester) async { 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 "+" // (which sits top-right in the AppBar). await tester.tapAt(const Offset(10, 10)); @@ -168,28 +180,44 @@ void main() { "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 { 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('Step 2 of 6'), 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.text('Step 3 of 6'), 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.text('Step 4 of 6'), 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.pumpAndSettle(); 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.text('Cloud Storage'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget, @@ -201,7 +229,7 @@ void main() { (tester) async { 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.pumpAndSettle(); } @@ -226,7 +254,7 @@ void main() { await tester.tap(find.widgetWithText(TextButton, 'Skip Tour')); await tester.pumpAndSettle(); - expect(find.text('Add Receipts Here'), findsNothing); + expect(find.text('What This App Does'), findsNothing); expect(appState.hasSeenOnboardingTour, isTrue); expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget, reason: 'stayed on the tab it started on'); @@ -236,7 +264,7 @@ void main() { (tester) async { 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.pumpAndSettle(); } diff --git a/test/onboarding_tour_overlay_test.dart b/test/onboarding_tour_overlay_test.dart index a51a917..b33298e 100644 --- a/test/onboarding_tour_overlay_test.dart +++ b/test/onboarding_tour_overlay_test.dart @@ -14,7 +14,7 @@ void main() { await tester.pumpWidget(wrap( OnboardingTourOverlay( - targetKey: targetKey, + targetKeys: [targetKey], title: 'Example Title', description: 'Example description text.', stepNumber: 2, @@ -34,7 +34,7 @@ void main() { await tester.pumpWidget(wrap( OnboardingTourOverlay( - targetKey: targetKey, + targetKeys: [targetKey], title: 'T', description: 'D', stepNumber: 1, @@ -48,7 +48,7 @@ void main() { await tester.pumpWidget(wrap( OnboardingTourOverlay( - targetKey: targetKey, + targetKeys: [targetKey], title: 'T', description: 'D', stepNumber: 3, @@ -68,7 +68,7 @@ void main() { await tester.pumpWidget(wrap( OnboardingTourOverlay( - targetKey: targetKey, + targetKeys: [targetKey], title: 'T', description: 'D', stepNumber: 1, @@ -92,7 +92,7 @@ void main() { await tester.pumpWidget(wrap( OnboardingTourOverlay( - targetKey: targetKey, + targetKeys: [targetKey], title: 'T', description: 'D', stepNumber: 1, @@ -112,7 +112,7 @@ void main() { await tester.pumpWidget(wrap( OnboardingTourOverlay( - targetKey: targetKey, + targetKeys: [targetKey], title: 'T', description: 'D', stepNumber: 1, @@ -129,4 +129,75 @@ void main() { expect(nextTapped, 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); + }); }