diff --git a/lib/screens/data_settings_screen.dart b/lib/screens/data_settings_screen.dart index 2654640..f0a06d2 100644 --- a/lib/screens/data_settings_screen.dart +++ b/lib/screens/data_settings_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../services/app_state.dart'; import '../services/cloud/cloud_storage_provider.dart'; +import '../services/onboarding_keys.dart'; import 'cloud_folder_browser_screen.dart'; /// "Data" settings submenu, reached from [SettingsScreen]: cloud storage @@ -217,6 +218,7 @@ class _DataSettingsScreenState extends State { padding: const EdgeInsets.all(16), children: [ Card( + key: OnboardingKeys.cloudStorageCard, child: Padding( padding: const EdgeInsets.all(16), child: Column( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 39302c3..983a34e 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; import 'add_edit_vehicle_screen.dart'; import 'vehicle_detail_screen.dart'; @@ -22,6 +23,7 @@ class HomeScreen extends StatelessWidget { actionsPadding: const EdgeInsets.only(right: 20), actions: [ IconButton( + key: OnboardingKeys.vehiclesAddButton, icon: const Icon(Icons.add), tooltip: 'Add Vehicle', onPressed: () => Navigator.of(context).push( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index f747479..0378deb 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,5 +1,12 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; +import '../widgets/onboarding_tour_overlay.dart'; +import 'data_settings_screen.dart'; import 'home_screen.dart'; import 'receipts_screen.dart'; import 'report_screen.dart'; @@ -20,6 +27,13 @@ import 'settings_screen.dart'; /// fields — alive when [PageView] builds/tears down pages outside its /// cache extent, the same guarantee the previous [IndexedStack]-based /// version gave "for free". +/// +/// This is also where the first-launch guided tour is orchestrated (see +/// [_startOnboardingTourIfNeeded] and friends below) — it's the natural +/// home for that since it's the only widget that owns both the +/// [PageController] (to switch tabs) and a [Navigator] ancestor (to push +/// the one route the tour needs, Settings > Data) that everything else in +/// the tour can share. class MainShell extends StatefulWidget { const MainShell({super.key}); @@ -27,10 +41,31 @@ class MainShell extends StatefulWidget { State 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 { int _index = 0; final _pageController = PageController(); + OverlayEntry? _tourEntry; + int _tourStepIndex = 0; + bool _tourPushedDataScreen = false; + static const _tabs = [ ReceiptsScreen(), HomeScreen(), @@ -38,8 +73,59 @@ class _MainShellState extends State { SettingsScreen(), ]; + late final List<_OnboardingStep> _tourSteps = [ + _OnboardingStep( + title: 'Add Receipts Here', + description: 'Tap the + button to log a fuel receipt. Snap a photo of it and the app ' + 'reads the date, gallons, and price for you automatically.', + targetKey: OnboardingKeys.receiptsAddButton, + activate: () => _jumpToTab(0), + ), + _OnboardingStep( + title: 'Add Vehicles Here', + description: 'Add a vehicle here first — every receipt gets linked to one of your ' + 'vehicles, so this is where a new vehicle needs to be added before you can log a ' + "receipt for it.", + targetKey: OnboardingKeys.vehiclesAddButton, + activate: () => _jumpToTab(1), + ), + _OnboardingStep( + title: 'Choose a Report Date Range', + description: 'Pick a start and end date to set the timeframe your report covers. Once ' + "you're ready to file, scroll down on this page for a link to Missouri's official " + 'Motor Fuel Refund Claim form — this report is what you\'ll use to fill it out.', + targetKey: OnboardingKeys.reportDateRange, + activate: () => _jumpToTab(2), + ), + _OnboardingStep( + title: 'Back Up to the Cloud', + description: 'Connect a cloud storage account here to back up every vehicle, receipt, ' + "and photo you log — so nothing is lost if this device is lost, damaged, or " + 'replaced.', + targetKey: OnboardingKeys.cloudStorageCard, + activate: () { + _jumpToTab(3); + _tourPushedDataScreen = true; + // Not awaited: Navigator.push's returned future only completes on + // pop, not on the pushed route finishing its build — the polling + // wait in _showTourStep (for the target key's RenderObject to + // exist) is what actually waits for this screen to be ready. + unawaited(Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DataSettingsScreen()), + )); + }, + ), + ]; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _startOnboardingTourIfNeeded()); + } + @override void dispose() { + _tourEntry?.remove(); _pageController.dispose(); super.dispose(); } @@ -52,6 +138,77 @@ class _MainShellState extends State { ); } + /// 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(); + 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 _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 _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().markOnboardingTourSeen()); + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart index d2a866f..48b5c7b 100644 --- a/lib/screens/receipts_screen.dart +++ b/lib/screens/receipts_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; import '../theme/app_theme.dart'; import '../widgets/hero_banner.dart'; import '../widgets/receipt_capture.dart'; @@ -136,6 +137,7 @@ class _ReceiptsScreenState extends State with AutomaticKeepAlive actionsPadding: const EdgeInsets.only(right: 20), actions: [ IconButton( + key: OnboardingKeys.receiptsAddButton, icon: const Icon(Icons.add), tooltip: 'Log Fuel Receipt', onPressed: () => _addReceipt(context), diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart index 743fed7..e98044e 100644 --- a/lib/screens/report_screen.dart +++ b/lib/screens/report_screen.dart @@ -10,6 +10,7 @@ import '../services/app_state.dart'; import '../services/fuel_report.dart'; import '../services/fuel_report_images.dart'; import '../services/fuel_report_pdf.dart'; +import '../services/onboarding_keys.dart'; /// Missouri's Motor Fuel Refund Claim form isn't something this app can /// file for the user — it's a state form, submitted to the state — so the @@ -165,6 +166,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie padding: const EdgeInsets.all(16), children: [ Row( + key: OnboardingKeys.reportDateRange, children: [ Expanded( child: OutlinedButton( diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index c318276..bd6ff94 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -24,6 +24,7 @@ const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally'; const _prefsKeyStaleLockMinutes = 'stale_lock_minutes'; const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos'; const _prefsKeyThemeMode = 'theme_mode'; +const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour'; const defaultStaleLockMinutes = 10; const minStaleLockMinutes = 1; @@ -89,6 +90,17 @@ class AppState extends ChangeNotifier { ThemeMode themeMode = ThemeMode.system; + /// Whether the first-launch guided tour ([OnboardingTour]) has already + /// been shown. Defaults to `true` here (not `false`) specifically so + /// that widget tests constructing `AppState()` directly and skipping + /// [init] — the established pattern throughout this app's test suite, + /// to avoid init()'s database/platform-channel dependencies — don't get + /// an unexpected full-screen tour overlay blocking every tap. [init] + /// overwrites this from the persisted value (defaulting to `false` + /// there) for real app startups, where a missing pref genuinely means + /// "never shown before." + bool hasSeenOnboardingTour = true; + /// Set if [init] fails. The UI shows this (with a retry option) instead /// of spinning forever — an unhandled exception here previously left /// `isLoading` stuck at true with no feedback at all. @@ -117,6 +129,7 @@ class AppState extends ChangeNotifier { (mode) => mode.name == prefs.getString(_prefsKeyThemeMode), orElse: () => ThemeMode.system, ); + hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false; } catch (e) { initError = e; isLoading = false; @@ -304,6 +317,15 @@ class AppState extends ChangeNotifier { notifyListeners(); } + /// Called once the first-launch guided tour finishes or is skipped, so + /// it never shows again on this device. + Future markOnboardingTourSeen() async { + hasSeenOnboardingTour = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyHasSeenOnboardingTour, true); + notifyListeners(); + } + /// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the /// Settings "Advanced" section, which restricts the picker to that range /// anyway; this is a defensive backstop for any other caller. diff --git a/lib/services/onboarding_keys.dart b/lib/services/onboarding_keys.dart new file mode 100644 index 0000000..d7c23a3 --- /dev/null +++ b/lib/services/onboarding_keys.dart @@ -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'); +} diff --git a/lib/widgets/onboarding_tour_overlay.dart b/lib/widgets/onboarding_tour_overlay.dart new file mode 100644 index 0000000..efee9bd --- /dev/null +++ b/lib/widgets/onboarding_tour_overlay.dart @@ -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; +} diff --git a/test/main_shell_test.dart b/test/main_shell_test.dart index 8edceae..15dd999 100644 --- a/test/main_shell_test.dart +++ b/test/main_shell_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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/services/app_state.dart'; @@ -102,4 +104,148 @@ void main() { final selectedLabel = tester.widget(find.text('Last 3 Months')); expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range'); }); + + group('first-launch onboarding tour', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future 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); + }); + }); } diff --git a/test/onboarding_tour_overlay_test.dart b/test/onboarding_tour_overlay_test.dart new file mode 100644 index 0000000..a51a917 --- /dev/null +++ b/test/onboarding_tour_overlay_test.dart @@ -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); + }); +}