From 57a9793a405a397d8467cf1022f8551899b176cb Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Sat, 19 Sep 2026 20:45:59 -0500 Subject: [PATCH] Temporary setup of alternative to advertisements --- android/app/src/main/AndroidManifest.xml | 2 +- ios/Runner/Info.plist | 2 +- lib/app_navigator.dart | 14 ++ lib/main.dart | 9 +- lib/screens/confirm_fuel_entry_screen.dart | 14 +- lib/services/ad_config.dart | 17 ++ lib/services/ad_service.dart | 33 ++++ lib/services/app_state.dart | 48 +++--- lib/widgets/ad_placeholder_screen.dart | 156 +++++++++++++++++++ lib/widgets/receipt_capture.dart | 82 +++++++++- pubspec.yaml | 2 +- test/ad_placeholder_screen_test.dart | 85 ++++++++++ test/fuel_save_ad_gate_test.dart | 173 +++++++++++++++++++++ test/receipts_screen_test.dart | 72 ++++++--- 14 files changed, 653 insertions(+), 56 deletions(-) create mode 100644 lib/app_navigator.dart create mode 100644 lib/services/ad_config.dart create mode 100644 lib/widgets/ad_placeholder_screen.dart create mode 100644 test/ad_placeholder_screen_test.dart create mode 100644 test/fuel_save_ad_gate_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index adcae23..e65e775 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ NSPhotoLibraryUsageDescription Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt. CFBundleDisplayName - Fuel Tax Tracker + Receipt Tracker CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/lib/app_navigator.dart b/lib/app_navigator.dart new file mode 100644 index 0000000..ebba6fb --- /dev/null +++ b/lib/app_navigator.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// A stable handle on the app's navigator, for the few places that need to +/// put something on screen from outside a widget's own build method: +/// +/// - [AppState.onAdWatched]'s "remove ads for a year" upsell, which fires +/// from deep inside a background sync rather than from a widget. +/// - `showAdPlaceholder`, called by [AdService], which owns no +/// [BuildContext] of its own (real ads are native overlays that never +/// needed one). +/// +/// Lives here rather than in `main.dart` so services and widgets can reach +/// it without importing the app's entry point. +final navigatorKey = GlobalKey(); diff --git a/lib/main.dart b/lib/main.dart index dbdc7ac..c648e5c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'app_navigator.dart'; import 'screens/main_shell.dart'; import 'screens/user_agreement_screen.dart'; import 'services/app_state.dart'; @@ -11,12 +12,6 @@ void main() { runApp(const FuelTaxTrackerApp()); } -/// A stable handle on the navigator so [AppState.onAdWatched] can show the -/// "remove ads" dialog from wherever the app happens to be — it fires from -/// deep inside a background sync, not from a widget's own build method, so -/// there's no local [BuildContext] to reach for at that point. -final navigatorKey = GlobalKey(); - class FuelTaxTrackerApp extends StatelessWidget { const FuelTaxTrackerApp({super.key}); @@ -32,7 +27,7 @@ class FuelTaxTrackerApp extends StatelessWidget { }; return MaterialApp( navigatorKey: navigatorKey, - title: 'Fuel Tax Tracker', + title: 'Receipt Tracker', theme: AppTheme.light, darkTheme: AppTheme.dark, themeMode: appState.themeMode, diff --git a/lib/screens/confirm_fuel_entry_screen.dart b/lib/screens/confirm_fuel_entry_screen.dart index eb10bf4..b298be5 100644 --- a/lib/screens/confirm_fuel_entry_screen.dart +++ b/lib/screens/confirm_fuel_entry_screen.dart @@ -127,11 +127,21 @@ class _ConfirmFuelEntryScreenState extends State { ); if (!mounted) return; + // The entry is already committed by this point, so the ad isn't + // gating the save — it just fills the moment between saving and + // landing back on the list. Awaited rather than fired off, so the + // two don't race and the ad can't end up drawn over the Receipts + // tab after this screen has gone. + final adShown = await appState.maybeShowFuelSaveAd(); + if (!mounted) return; + // Ask while this screen (and its context) is still fully alive, // before popping — simpler than trying to show a dialog against a - // context whose widget is mid-removal. + // context whose widget is mid-removal. Held back when an ad just + // ran: one full-screen surface per save, and the reminder comes + // back around on the next save anyway. var wantsBackupSetup = false; - if (!appState.hasCloudBackupConfigured) { + if (!adShown && !appState.hasCloudBackupConfigured) { wantsBackupSetup = await showBackupReminderDialog(context); if (!mounted) return; } diff --git a/lib/services/ad_config.dart b/lib/services/ad_config.dart new file mode 100644 index 0000000..c22c83e --- /dev/null +++ b/lib/services/ad_config.dart @@ -0,0 +1,17 @@ +/// The single switch that turns real AdMob ads on. +/// +/// While this is `false`, the app behaves in every observable way as though +/// ads were live — each placement is still *decided* on exactly the same +/// schedule (new-user grace periods, earned credits, the ad-free purchase, +/// the sync gate blocking uploads) — but where a real ad would appear, it +/// shows `AdPlaceholderScreen` instead, and the Google Mobile Ads SDK is +/// never initialized at all. No ad requests go out, so none of them can +/// come back "Account not approved yet" or no-fill. +/// +/// That means the pacing and gating can be exercised and tuned for real +/// before AdMob approval lands. +/// +/// Flipping this to `true` is the only change needed to go live: every +/// placement picks up its real ad unit from [AdService] and the placeholder +/// stops being reachable. Nothing else in the app branches on it. +const bool adsEnabled = false; diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart index ee2a6cf..b56685d 100644 --- a/lib/services/ad_service.dart +++ b/lib/services/ad_service.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; +import '../widgets/ad_placeholder_screen.dart'; +import 'ad_config.dart'; + /// This app has three ad placements: /// /// 1. A rewarded ad gating the *upload* phase of a cloud sync (see @@ -42,6 +45,13 @@ import 'package:google_mobile_ads/google_mobile_ads.dart'; /// post-task transitions, which is exactly what plain interstitials are /// for. Neither ever blocks or gates anything, unlike placement 1. /// +/// All three are gated behind [adsEnabled] (lib/services/ad_config.dart). +/// While that's false, every method here short-circuits to +/// [showAdPlaceholder] and the Mobile Ads SDK is never touched — no +/// requests, no failures — while the *decisions* about when each placement +/// fires stay exactly as they'd be with real ads, including the sync gate +/// still withholding uploads until the user acts. +/// /// This class only owns ad-format *mechanics* — load, preload, show — for /// all three. Deciding *when* each is actually due (grace periods for a /// new user, earned watch-credits, the ad-free purchase) is [AppState]'s @@ -115,6 +125,7 @@ class AdService { /// than unconditionally at app startup, so a user who never connects /// cloud sync never triggers any ad-related network activity at all. Future initialize() async { + if (!adsEnabled) return; final alreadyInitialized = _sdkInitialized; await _ensureSdkInitialized(); if (!alreadyInitialized) unawaited(_preload()); @@ -161,6 +172,16 @@ class AdService { Future showGateAd() async { if (_gateOpen) return AdGateResult.alreadyOpen; + // Stand-in for the rewarded ad: the upload still waits on the user + // actively dismissing something, and still opens the same + // [gateValidity] window afterward, so the gate's behavior is unchanged + // apart from what's on screen. See lib/services/ad_config.dart. + if (!adsEnabled) { + await showAdPlaceholder(mustConfirm: true); + _lastShownAt = DateTime.now(); + return AdGateResult.justShown; + } + if (_preloadedAd == null) { await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {}); } @@ -205,6 +226,7 @@ class AdService { /// ad is ready by the time the user shares/prints without delaying the /// export itself. Future preloadReportAd() async { + if (!adsEnabled) return; await _ensureSdkInitialized(); unawaited(_preloadPlainInterstitial( adUnitId: _reportInterstitialAdUnitId, @@ -224,6 +246,11 @@ class AdService { /// at all). Returns whether an ad actually showed, so the caller knows /// whether to treat its credit as earned. Future maybeShowReportAd() async { + if (!adsEnabled) { + await showAdPlaceholder(mustConfirm: false); + return true; + } + final ad = _preloadedReportAd; _preloadedReportAd = null; final shown = await _showPlainInterstitial(ad, logLabel: 'Report interstitial'); @@ -238,6 +265,7 @@ class AdService { /// Same shape as [preloadReportAd], for the fuel-save interstitial — /// [AppState] calls this once the confirm-entry screen is first built. Future preloadFuelSaveAd() async { + if (!adsEnabled) return; await _ensureSdkInitialized(); unawaited(_preloadPlainInterstitial( adUnitId: _fuelSaveInterstitialAdUnitId, @@ -249,6 +277,11 @@ class AdService { /// Same shape and caveats as [maybeShowReportAd], for the fuel-save /// interstitial. Future maybeShowFuelSaveAd() async { + if (!adsEnabled) { + await showAdPlaceholder(mustConfirm: false); + return true; + } + final ad = _preloadedFuelSaveAd; _preloadedFuelSaveAd = null; final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial'); diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index a0f4797..98c622d 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -157,7 +157,7 @@ class AppState extends ChangeNotifier { /// The earliest-known moment this app was ever used — null until [init] /// loads it. Drives the new-user ad grace period (see - /// [_maybeShowFuelSaveAd]/[maybeShowReportAd]); see + /// [maybeShowFuelSaveAd]/[maybeShowReportAd]); see /// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced /// rather than a local-only preference. DateTime? firstUsedAt; @@ -247,34 +247,47 @@ class AppState extends ChangeNotifier { return firstUsed == null || DateTime.now().difference(firstUsed) < _newUserAdGracePeriod; } - /// Best-effort ad shown after a fuel entry is saved — see - /// [AdService.maybeShowFuelSaveAd]. A no-op for a user with an active ad - /// -free purchase, during the new-user grace period or the first - /// [_fuelSaveAdGraceSaves] saves, while a previously-watched fuel-save - /// ad's credit is still active, or — deliberately — whenever cloud - /// backup isn't configured: [showBackupReminderDialog] already claims - /// that same save's attention in that case (see its call site in - /// `ConfirmFuelEntryScreen._save`), and letting both compete for the - /// same moment is exactly the stacked-full-screen-surfaces problem this - /// scoping avoids. Skipping this way costs nothing — the credit/grace - /// state simply isn't touched, so the check is just as "due" next save. - Future _maybeShowFuelSaveAd() async { - if (adsCurrentlyDisabled || !hasCloudBackupConfigured) return; - if (_inNewUserGracePeriod) return; - if (fuelEntries.length <= _fuelSaveAdGraceSaves) return; + /// Ad shown on saving a fuel entry — see [AdService.maybeShowFuelSaveAd]. + /// + /// Called by `ConfirmFuelEntryScreen._save` *after* [addFuelEntry] has + /// already committed the entry, and awaited before that screen pops. So + /// it reads to the user as "tap Save, see the ad, land back on the + /// list", while the entry itself is never actually waiting on the ad — + /// which matters both for their data (nothing is lost if the app dies + /// mid-ad) and for AdMob's rule that a plain interstitial must never + /// gate access to app functionality. That's the same rule that forced + /// the cloud-sync gate to a *rewarded* ad; the difference here is that + /// nothing is being withheld pending the ad. + /// + /// A no-op for a user with an active ad-free purchase, during the + /// new-user grace period or the first [_fuelSaveAdGraceSaves] saves, or + /// while a previously-watched fuel-save ad's credit is still active. + /// + /// Returns whether an ad actually showed. The caller uses that to hold + /// back [showBackupReminderDialog] for this one save: only ever one + /// full-screen surface per save (the "post-save arbitration" rule), but + /// the ad takes precedence rather than being suppressed outright — the + /// reminder recurs on the next save anyway, whereas suppressing meant a + /// user who never configured backup never saw this placement at all. + Future maybeShowFuelSaveAd() async { + if (adsCurrentlyDisabled) return false; + if (_inNewUserGracePeriod) return false; + if (fuelEntries.length <= _fuelSaveAdGraceSaves) return false; final lastShown = _lastFuelSaveAdShownAt; if (lastShown != null && DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration && _fuelSavesSinceAd < _fuelSaveAdCreditSaves) { _fuelSavesSinceAd++; - return; + return false; } if (await adService.maybeShowFuelSaveAd()) { _lastFuelSaveAdShownAt = DateTime.now(); _fuelSavesSinceAd = 0; + return true; } + return false; } /// The store's own formatted, localized price for the ad-free-year @@ -703,7 +716,6 @@ class AppState extends ChangeNotifier { ); await database.saveFuelEntry(entry); await _persist(); - unawaited(_maybeShowFuelSaveAd()); return entry; } diff --git a/lib/widgets/ad_placeholder_screen.dart b/lib/widgets/ad_placeholder_screen.dart new file mode 100644 index 0000000..83c5251 --- /dev/null +++ b/lib/widgets/ad_placeholder_screen.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../app_navigator.dart'; + +/// How long a non-gating placeholder stays up before closing itself — +/// short enough not to be a nuisance while testing, long enough to be +/// clearly noticed rather than flickering past. +const _autoDismissAfter = Duration(seconds: 4); + +/// Stands in for a real ad while `adsEnabled` is false — see +/// lib/services/ad_config.dart for why that exists. +/// +/// [mustConfirm] mirrors the difference between this app's two kinds of ad: +/// - `false` (the report-export and fuel-save interstitials): closes +/// itself after [_autoDismissAfter], the same "you didn't have to do +/// anything" feel as a plain interstitial. +/// - `true` (the cloud-sync gate): waits for the user to tap Continue, +/// standing in for a rewarded ad's watch-it-through requirement, since +/// the upload genuinely shouldn't proceed until they've acted. +/// +/// Returns once the placeholder has been dismissed, so callers can treat it +/// exactly like awaiting a real ad's show-and-dismiss cycle. A no-op (and +/// an immediate return) if the navigator isn't mounted yet. +Future showAdPlaceholder({required bool mustConfirm}) async { + final navigator = navigatorKey.currentState; + if (navigator == null) return; + + await navigator.push( + PageRouteBuilder( + opaque: true, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (_, _, _) => AdPlaceholderScreen(mustConfirm: mustConfirm), + ), + ); +} + +class AdPlaceholderScreen extends StatefulWidget { + /// See [showAdPlaceholder] — true for the sync gate, false otherwise. + final bool mustConfirm; + + const AdPlaceholderScreen({super.key, required this.mustConfirm}); + + @override + State createState() => _AdPlaceholderScreenState(); +} + +class _AdPlaceholderScreenState extends State { + Timer? _ticker; + late int _secondsLeft = _autoDismissAfter.inSeconds; + + @override + void initState() { + super.initState(); + if (widget.mustConfirm) return; + _ticker = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() => _secondsLeft--); + if (_secondsLeft <= 0) _dismiss(); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + void _dismiss() { + _ticker?.cancel(); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return PopScope( + // A real ad can't be dismissed with the system back gesture either; + // letting it through here would let the sync gate be bypassed. + canPop: false, + child: Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Align( + alignment: Alignment.topCenter, + child: Text( + 'ADVERTISEMENT', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 1.6, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/icon/hero_icon.png', + height: 96, + fit: BoxFit.contain, + ), + const SizedBox(height: 28), + Text( + 'An advertisement will appear here', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + Text( + 'Ads are not running yet. This placeholder stands in ' + 'for one so the rest of the app behaves exactly as it ' + 'will once they are.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + ), + if (widget.mustConfirm) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _dismiss, + child: const Text('Continue'), + ), + ) + else + Text( + 'Closing in $_secondsLeft…', + style: theme.textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/receipt_capture.dart b/lib/widgets/receipt_capture.dart index 3eaef49..3739d1d 100644 --- a/lib/widgets/receipt_capture.dart +++ b/lib/widgets/receipt_capture.dart @@ -28,6 +28,34 @@ Future captureReceiptForVehicle(BuildContext context, String vehicleId) as final keepMaxQuality = context.read().keepMaxQualityReceiptPhotos; final picker = ImagePicker(); + + // Raised *before* the camera/gallery opens rather than after it returns. + // [ImagePicker.pickImage] doesn't complete when the camera closes — it + // also downscales the captured photo (several MB straight off the + // sensor), which takes seconds on a busy device. With the progress + // shown only afterward, that whole stretch left the user staring at an + // unchanged Receipts list with no sign anything was happening, which + // reads as "the capture did nothing" — and tapping away during it + // genuinely does abandon the capture. + var progressShowing = false; + void showProgress() { + if (progressShowing || !context.mounted) return; + progressShowing = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _ReceiptProgressDialog(), + ); + } + + void hideProgress() { + if (!progressShowing || !context.mounted) return; + progressShowing = false; + Navigator.of(context).pop(); + } + + showProgress(); + XFile? photo; try { photo = await picker.pickImage( @@ -37,6 +65,7 @@ Future captureReceiptForVehicle(BuildContext context, String vehicleId) as maxHeight: keepMaxQuality ? null : receiptImageMaxDimension, ); } catch (e) { + hideProgress(); if (context.mounted) { final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library'; ScaffoldMessenger.of(context).showSnackBar( @@ -45,16 +74,16 @@ Future captureReceiptForVehicle(BuildContext context, String vehicleId) as } return; } - if (photo == null) return; + // Cancelled (backed out of the camera, or didn't confirm the shot) — + // nothing to say about that, it was deliberate. + if (photo == null) { + hideProgress(); + return; + } final imageFile = File(photo.path); if (!context.mounted) return; - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const Center(child: CircularProgressIndicator()), - ); final ocrService = OcrService(); String recognizedText = ''; @@ -69,7 +98,7 @@ Future captureReceiptForVehicle(BuildContext context, String vehicleId) as final parsed = ReceiptParser.parse(recognizedText); if (!context.mounted) return; - Navigator.of(context).pop(); // close the OCR loading spinner + hideProgress(); final state = parsed.state; if (state != null && state != 'MO') { @@ -90,6 +119,45 @@ Future captureReceiptForVehicle(BuildContext context, String vehicleId) as } } +/// Shown from the moment the camera/gallery is opened until the parsed +/// receipt is ready to review. Deliberately says what's happening rather +/// than showing a bare spinner: the wait here is mostly downscaling and +/// reading the photo, which is long enough on a slow device that an +/// unexplained spinner invites tapping away mid-capture. +class _ReceiptProgressDialog extends StatelessWidget { + const _ReceiptProgressDialog(); + + @override + Widget build(BuildContext context) { + return PopScope( + // Backing out here wouldn't cancel the underlying capture, it would + // just hide it and strand the user. + canPop: false, + child: Dialog( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text('Reading your receipt…', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 6), + Text( + 'This can take a few seconds.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + /// Missouri's fuel tax refund only applies to fuel bought in Missouri — /// warn if the receipt's address is somewhere else, and let the user /// decide whether to log it anyway (e.g. it might still be worth tracking diff --git a/pubspec.yaml b/pubspec.yaml index e5cc35d..8c10830 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+4 +version: 1.0.0+5 environment: sdk: ^3.12.2 diff --git a/test/ad_placeholder_screen_test.dart b/test/ad_placeholder_screen_test.dart new file mode 100644 index 0000000..03980b5 --- /dev/null +++ b/test/ad_placeholder_screen_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/widgets/ad_placeholder_screen.dart'; + +void main() { + /// Pushes the placeholder onto a real navigator so its self-dismissal + /// (which pops its own route) has something to pop. + /// + /// Deliberately avoids [WidgetTester.pumpAndSettle] while the + /// auto-dismiss ticker is alive — that timer reschedules a frame every + /// second, so settling would spin until it timed out rather than + /// returning. + Future pushPlaceholder(WidgetTester tester, {required bool mustConfirm}) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AdPlaceholderScreen(mustConfirm: mustConfirm), + ), + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + group('auto-dismissing placeholder (report + fuel-save interstitials)', () { + testWidgets('shows the logo and the future-ad message, with no Continue button', + (tester) async { + await pushPlaceholder(tester, mustConfirm: false); + + expect(find.text('ADVERTISEMENT'), findsOneWidget); + expect(find.byType(Image), findsOneWidget, reason: 'the app logo'); + expect(find.text('An advertisement will appear here'), findsOneWidget); + expect(find.textContaining('Ads are not running yet'), findsOneWidget); + expect(find.text('Continue'), findsNothing, + reason: 'only the sync gate waits on the user'); + + // Let it close so the ticker is cancelled before the test ends. + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + }); + + testWidgets('counts down and closes itself after four seconds', (tester) async { + await pushPlaceholder(tester, mustConfirm: false); + + expect(find.textContaining('Closing in'), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(find.text('ADVERTISEMENT'), findsOneWidget, + reason: 'still up three seconds in'); + + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text('ADVERTISEMENT'), findsNothing, reason: 'closed itself'); + expect(find.text('open'), findsOneWidget, reason: 'back on the page underneath'); + }); + }); + + group('confirming placeholder (the cloud-sync gate)', () { + testWidgets('waits for Continue instead of closing on its own', (tester) async { + await pushPlaceholder(tester, mustConfirm: true); + + expect(find.text('Continue'), findsOneWidget); + expect(find.textContaining('Closing in'), findsNothing, + reason: 'nothing counts down — the upload waits on the user'); + + await tester.pump(const Duration(seconds: 10)); + expect(find.text('ADVERTISEMENT'), findsOneWidget, + reason: 'still up well past the auto-dismiss window'); + + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('ADVERTISEMENT'), findsNothing); + expect(find.text('open'), findsOneWidget); + }); + }); +} diff --git a/test/fuel_save_ad_gate_test.dart b/test/fuel_save_ad_gate_test.dart new file mode 100644 index 0000000..2c33617 --- /dev/null +++ b/test/fuel_save_ad_gate_test.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/app_navigator.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers [AppState.maybeShowFuelSaveAd]'s "when" decision — the grace +/// periods and watch-credits that sit in front of the fuel-save ad — by +/// calling it directly against a hand-built [AppState] rather than through +/// a real save, which would need an initialized database. +/// +/// With `adsEnabled` false (lib/services/ad_config.dart), the underlying +/// [AdService.maybeShowFuelSaveAd] always resolves to showing the +/// placeholder, so what's under test here is purely the gating above it: +/// if a test sees no placeholder, a gate stopped it. +/// +/// These push against the real [navigatorKey], since that's what +/// `showAdPlaceholder` pushes onto — an app that doesn't wire it up would +/// make the placeholder silently do nothing, which is itself one of the +/// failure modes worth pinning down. +void main() { + /// Mirrors a well-used install: past the new-user window and well past + /// the first few saves, with no ad-free purchase. + AppState buildEstablishedUser({int entries = 9}) { + return AppState() + ..firstUsedAt = DateTime.now().subtract(const Duration(days: 3)) + ..adFreeUntil = null + ..fuelEntries = List.generate( + entries, + (i) => FuelEntry( + id: 'e$i', + vehicleId: 'v1', + date: DateTime.now(), + gallons: 13.405, + pricePerGallon: 3.899, + totalCost: 52.27, + updatedAt: DateTime.now(), + ), + ); + } + + Future pumpHost(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Text('receipts')), + ), + ); + } + + /// Runs one save's worth of the gate to completion and reports what it + /// decided, running [whileShowing] at the point a placeholder would be + /// on screen. + /// + /// The gate's future is deliberately *not* awaited up front: when a + /// placeholder does show, that future only completes once the + /// placeholder pops itself, and it's [WidgetTester.pump] that advances + /// the timer doing the popping — awaiting first deadlocks the two + /// against each other. + Future runGate( + WidgetTester tester, + AppState appState, { + void Function()? whileShowing, + }) async { + bool? result; + unawaited(appState.maybeShowFuelSaveAd().then((value) => result = value)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + whileShowing?.call(); + + // Enough for the placeholder's four-second auto-dismiss, harmless + // when nothing came up. + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + + expect(result, isNotNull, reason: 'the gate should have settled by now'); + return result!; + } + + testWidgets('an established user gets the ad on save', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser(); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget), + ); + + expect(shown, isTrue, reason: 'the caller needs this to hold back the backup reminder'); + expect(find.text('receipts'), findsOneWidget, reason: 'placeholder closed itself'); + }); + + testWidgets('cloud backup being unconfigured does not suppress it', (tester) async { + // The regression this is really here for: the gate used to bail out + // whenever backup wasn't set up, which meant a user who never + // configured backup never saw this placement at all. + await pumpHost(tester); + final appState = buildEstablishedUser(); + expect(appState.hasCloudBackupConfigured, isFalse, reason: 'no cloudSync wired up'); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget), + ); + + expect(shown, isTrue); + }); + + testWidgets('no ad during the first five minutes of app usage', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser() + ..firstUsedAt = DateTime.now().subtract(const Duration(minutes: 2)); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing), + ); + + expect(shown, isFalse); + }); + + testWidgets('no ad for the first four saves', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser(entries: 4); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing), + ); + + expect(shown, isFalse); + }); + + testWidgets('an active ad-free purchase suppresses it', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser() + ..adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 300)); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing), + ); + + expect(shown, isFalse); + }); + + testWidgets('the credit bought by one ad covers the next two saves', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser(); + + expect(await runGate(tester, appState), isTrue, reason: 'due'); + expect(await runGate(tester, appState), isFalse, reason: 'credited save 1'); + expect(await runGate(tester, appState), isFalse, reason: 'credited save 2'); + + final fourth = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget), + ); + + expect(fourth, isTrue, reason: 'credit spent — due again'); + }); +} diff --git a/test/receipts_screen_test.dart b/test/receipts_screen_test.dart index 2fb2b8f..644bafe 100644 --- a/test/receipts_screen_test.dart +++ b/test/receipts_screen_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:fuel_tax_tracker/models/fuel_entry.dart'; @@ -215,37 +216,59 @@ void main() { testWidgets('date range selector filters totals and the list, and recolors the active label', (tester) async { - // Aug 5 2026: within Current Month, Last 3 Months, and Year to Date. + // These dates are derived from "now" rather than written out, because + // the screen filters against DateTime.now() (see _DateRange.startDate). + // Fixed dates quietly change which bucket they land in as the calendar + // moves — an earlier version of this test hardcoded August dates and + // started failing the moment September arrived. + final now = DateTime.now(); + final format = DateFormat.yMMMd(); + + // The 1st of this month: always inside all three ranges. + final recentDate = DateTime(now.year, now.month, 1); + // The 1st of last month: always inside Last 3 Months, never inside + // Current Month. Also inside Year to Date except in January, when + // "last month" lands in the previous year. + final midDate = DateTime(now.year, now.month - 1, 1); + final midInYearToDate = midDate.year == now.year; + // Jan 1 this year: always inside Year to Date. It only falls *outside* + // Last 3 Months from April onward — before that, Last 3 Months already + // reaches back to Jan 1 or earlier, so no date at all can be "Year to + // Date but not Last 3 Months" and that distinction isn't testable. + final oldDate = DateTime(now.year, 1, 1); + final oldOutsideLast3Months = oldDate.isBefore(DateTime(now.year, now.month - 2, 1)); + // In January the 1st of this month *is* Jan 1, so these two would + // render the same date text and findsOneWidget would double-count. + final oldIsDistinctFromRecent = oldDate != recentDate; + final recentEntry = FuelEntry( id: 'recent', vehicleId: 'v1', - date: DateTime(2026, 8, 5), + date: recentDate, gallons: 10, pricePerGallon: 3, totalCost: 30, - updatedAt: DateTime.utc(2026, 8, 5), + updatedAt: recentDate.toUtc(), receiptDriveFileId: 'r1', ); - // Jul 1 2026: within Last 3 Months and Year to Date, not Current Month. final midEntry = FuelEntry( id: 'mid', vehicleId: 'v1', - date: DateTime(2026, 7, 1), + date: midDate, gallons: 20, pricePerGallon: 3, totalCost: 60, - updatedAt: DateTime.utc(2026, 7, 1), + updatedAt: midDate.toUtc(), receiptDriveFileId: 'r2', ); - // Mar 1 2026: within Year to Date only. final oldEntry = FuelEntry( id: 'old', vehicleId: 'v1', - date: DateTime(2026, 3, 1), + date: oldDate, gallons: 40, pricePerGallon: 3, totalCost: 120, - updatedAt: DateTime.utc(2026, 3, 1), + updatedAt: oldDate.toUtc(), receiptDriveFileId: 'r3', ); final appState = AppState() @@ -264,27 +287,38 @@ void main() { .widgetList(find.text(label)) .any((t) => t.style?.fontWeight == FontWeight.w800); - // Defaults to Year to Date: all three visible. - expect(find.text('Aug 5, 2026'), findsOneWidget); - expect(find.text('Jul 1, 2026'), findsOneWidget); - expect(find.text('Mar 1, 2026'), findsOneWidget); + // Defaults to Year to Date. + expect(find.text(format.format(recentDate)), findsOneWidget); + if (midInYearToDate) { + expect(find.text(format.format(midDate)), findsOneWidget); + } + if (oldIsDistinctFromRecent) { + expect(find.text(format.format(oldDate)), findsOneWidget); + } expect(isBold('Year to Date'), isTrue, reason: 'selected by default'); await tester.tap(find.text('Last 3 Months')); await tester.pumpAndSettle(); - expect(find.text('Aug 5, 2026'), findsOneWidget); - expect(find.text('Jul 1, 2026'), findsOneWidget); - expect(find.text('Mar 1, 2026'), findsNothing, reason: 'older than 3 months back'); + expect(find.text(format.format(recentDate)), findsOneWidget); + expect(find.text(format.format(midDate)), findsOneWidget, + reason: 'last month is always within the last three'); + if (oldOutsideLast3Months) { + expect(find.text(format.format(oldDate)), findsNothing, + reason: 'older than three months back'); + } expect(isBold('Last 3 Months'), isTrue); expect(isBold('Year to Date'), isFalse, reason: 'no longer selected'); await tester.tap(find.text('Current Month')); await tester.pumpAndSettle(); - expect(find.text('Aug 5, 2026'), findsOneWidget); - expect(find.text('Jul 1, 2026'), findsNothing, reason: 'not in the current month'); - expect(find.text('Mar 1, 2026'), findsNothing); + expect(find.text(format.format(recentDate)), findsOneWidget); + expect(find.text(format.format(midDate)), findsNothing, + reason: 'not in the current month'); + if (oldIsDistinctFromRecent) { + expect(find.text(format.format(oldDate)), findsNothing); + } }); testWidgets(