Temporary setup of alternative to advertisements

This commit is contained in:
Courtney Arnold 2026-09-19 20:45:59 -05:00
parent ad2d932842
commit 57a9793a40
14 changed files with 653 additions and 56 deletions

14
lib/app_navigator.dart Normal file
View file

@ -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<NavigatorState>();

View file

@ -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<NavigatorState>();
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,

View file

@ -127,11 +127,21 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
);
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;
}

View file

@ -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;

View file

@ -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<void> initialize() async {
if (!adsEnabled) return;
final alreadyInitialized = _sdkInitialized;
await _ensureSdkInitialized();
if (!alreadyInitialized) unawaited(_preload());
@ -161,6 +172,16 @@ class AdService {
Future<AdGateResult> 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<void> 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<bool> 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<void> 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<bool> maybeShowFuelSaveAd() async {
if (!adsEnabled) {
await showAdPlaceholder(mustConfirm: false);
return true;
}
final ad = _preloadedFuelSaveAd;
_preloadedFuelSaveAd = null;
final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial');

View file

@ -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<void> _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<bool> 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;
}

View file

@ -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<void> showAdPlaceholder({required bool mustConfirm}) async {
final navigator = navigatorKey.currentState;
if (navigator == null) return;
await navigator.push(
PageRouteBuilder<void>(
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<AdPlaceholderScreen> createState() => _AdPlaceholderScreenState();
}
class _AdPlaceholderScreenState extends State<AdPlaceholderScreen> {
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),
),
],
),
),
),
),
);
}
}

View file

@ -28,6 +28,34 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
final keepMaxQuality = context.read<AppState>().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<void> 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<void> 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<void> 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<void> 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