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(