diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 41c4c13..adcae23 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -48,12 +48,9 @@
-
+
diff --git a/lib/screens/confirm_fuel_entry_screen.dart b/lib/screens/confirm_fuel_entry_screen.dart
index d421438..eb10bf4 100644
--- a/lib/screens/confirm_fuel_entry_screen.dart
+++ b/lib/screens/confirm_fuel_entry_screen.dart
@@ -54,6 +54,8 @@ class _ConfirmFuelEntryScreenState extends State {
_totalController = TextEditingController(
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
);
+ // So an ad is ready by the time _save() finishes without delaying it.
+ context.read().preloadFuelSaveAd();
}
@override
diff --git a/lib/screens/data_settings_screen.dart b/lib/screens/data_settings_screen.dart
index f0a06d2..fcc5c13 100644
--- a/lib/screens/data_settings_screen.dart
+++ b/lib/screens/data_settings_screen.dart
@@ -92,6 +92,25 @@ class _DataSettingsScreenState extends State {
}
}
+ /// The resting (non-syncing) Sync Now icon — badged with the same
+ /// play-triangle [CloudBackupActionButton] uses, for anyone who isn't on
+ /// an active ad-free purchase, since tapping this can trigger the
+ /// rewarded ad gate (see [AppState.syncNow]/`AdService.showGateAd`). Not
+ /// a live prediction of whether *this* tap specifically will show one —
+ /// same general "this leads to an ad-supported feature" disclosure the
+ /// icon-badge uses elsewhere, not an attempt to account for the open
+ /// gate window or the free-first-sync case.
+ Widget _syncNowIcon(BuildContext context, AppState appState) {
+ const icon = Icon(Icons.sync);
+ if (appState.adsCurrentlyDisabled) return icon;
+ final colors = Theme.of(context).colorScheme;
+ return Badge(
+ backgroundColor: colors.tertiary,
+ label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary),
+ child: icon,
+ );
+ }
+
/// Shared warning-dialog shell for both purge actions — [message] is the
/// caller's job to make specific and unambiguous, since this is the only
/// thing standing between the user and an unrecoverable delete.
@@ -283,7 +302,7 @@ class _DataSettingsScreenState extends State {
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
- : const Icon(Icons.sync),
+ : _syncNowIcon(context, appState),
label: const Text('Sync Now'),
),
TextButton.icon(
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 0b25f44..5641314 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../services/onboarding_keys.dart';
+import '../widgets/cloud_backup_action_button.dart';
import 'add_edit_vehicle_screen.dart';
import 'faq_screen.dart';
import 'vehicle_detail_screen.dart';
@@ -31,6 +32,7 @@ class HomeScreen extends StatelessWidget {
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
),
+ const CloudBackupActionButton(),
const FaqActionButton(),
],
),
diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart
index 3560985..887ef8e 100644
--- a/lib/screens/receipts_screen.dart
+++ b/lib/screens/receipts_screen.dart
@@ -8,6 +8,7 @@ import '../services/app_state.dart';
import '../services/estimated_refund.dart';
import '../services/onboarding_keys.dart';
import '../theme/app_theme.dart';
+import '../widgets/cloud_backup_action_button.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import 'add_edit_vehicle_screen.dart';
@@ -144,6 +145,7 @@ class _ReceiptsScreenState extends State with AutomaticKeepAlive
tooltip: 'Log Fuel Receipt',
onPressed: () => _addReceipt(context),
),
+ const CloudBackupActionButton(),
const FaqActionButton(),
],
),
diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart
index 2e3157b..49d2af6 100644
--- a/lib/screens/report_screen.dart
+++ b/lib/screens/report_screen.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
@@ -12,6 +13,7 @@ import '../services/fuel_report.dart';
import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.dart';
import '../services/onboarding_keys.dart';
+import '../widgets/cloud_backup_action_button.dart';
import 'faq_screen.dart';
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
@@ -59,6 +61,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie
final (start, end) = defaultReportDateRange(DateTime.now());
_startDate = start;
_endDate = end;
+ context.read().preloadReportAd();
}
Future _pickDate({required bool isStart}) async {
@@ -118,6 +121,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie
try {
final bytes = await _buildPdfBytes(report);
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
+ if (mounted) unawaited(context.read().maybeShowReportAd());
} finally {
if (mounted) setState(() => _busy = false);
}
@@ -128,6 +132,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie
try {
final bytes = await _buildPdfBytes(report);
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
+ if (mounted) unawaited(context.read().maybeShowReportAd());
} finally {
if (mounted) setState(() => _busy = false);
}
@@ -194,7 +199,7 @@ class _ReportScreenState extends State with AutomaticKeepAliveClie
return Scaffold(
appBar: AppBar(
title: const Text('Fuel Report'),
- actions: const [FaqActionButton()],
+ actions: const [CloudBackupActionButton(), FaqActionButton()],
),
body: ListView(
padding: const EdgeInsets.all(16),
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 120945c..fbe258d 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/onboarding_keys.dart';
+import '../widgets/cloud_backup_action_button.dart';
import 'data_settings_screen.dart';
import 'faq_screen.dart';
import 'ui_settings_screen.dart';
@@ -22,7 +23,7 @@ class SettingsScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
- actions: const [FaqActionButton()],
+ actions: const [CloudBackupActionButton(), FaqActionButton()],
),
body: ListView(
padding: const EdgeInsets.all(16),
diff --git a/lib/screens/user_agreement_screen.dart b/lib/screens/user_agreement_screen.dart
index 1f1fb2b..6022ee9 100644
--- a/lib/screens/user_agreement_screen.dart
+++ b/lib/screens/user_agreement_screen.dart
@@ -61,7 +61,12 @@ class UserAgreementScreen extends StatelessWidget {
'this device. If you connect a cloud storage account (Google Drive, '
'Dropbox, OneDrive, or your own WebDAV server) in Settings, a copy is '
'also kept there — in storage you control, not on any server we run. '
- "We don't operate a backend, and we don't have a copy of your data.",
+ "We don't operate a backend, and we don't have a copy of your data.\n\n"
+ "This is also how sharing works: if the folder you connect to is one "
+ "you share with someone else, anyone else who connects the app to "
+ "that same folder sees and can edit the same vehicles, fuel entries, "
+ "and receipt photos you do. Data in a shared folder isn't private to "
+ "just you.",
),
_Section(
title: 'Privacy',
diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart
index b5fe43a..ee2a6cf 100644
--- a/lib/services/ad_service.dart
+++ b/lib/services/ad_service.dart
@@ -1,25 +1,67 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
-/// The single ad placement this app has: one interstitial, gating the
-/// *upload* phase of a cloud sync (see [CloudSyncService.syncNow]'s
-/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not pulling/
-/// merging remote changes down, and never anywhere else in the app.
+/// This app has three ad placements:
///
-/// Showing an ad opens a gate that stays open for [gateValidity]; a sync
-/// attempted after that window closes has to show (and have the user sit
-/// through the start of) another one before it's allowed to push data to
-/// the cloud. Selecting/connecting a cloud provider is never gated — only
-/// the upload side of a sync is, via [showGateAd].
+/// 1. A rewarded ad gating the *upload* phase of a cloud sync (see
+/// [CloudSyncService.syncNow]'s `beforeUpload` hook and
+/// [AppState.syncNow]) — deliberately not pulling/merging remote
+/// changes down. Rewarded, not a plain interstitial, because AdMob's
+/// interstitial policy requires those to sit at natural transition
+/// points and never gate access to app functionality — conditioning an
+/// in-app benefit on watching an ad through to completion is what the
+/// reward formats exist for. See
+/// https://support.google.com/admob/answer/6201362 and
+/// https://support.google.com/admob/answer/6128543. Plain Rewarded,
+/// not Rewarded Interstitial: the app already has its own explicit
+/// opt-in trigger for this (the Sync Now button / the "not synced"
+/// icon, both direct user taps that call [AppState.syncNow]), so
+/// Rewarded Interstitial's main advantage — being safe to show without
+/// one — doesn't buy anything extra here, and plain Rewarded gives
+/// full control over the pre-ad copy instead of Google's generic
+/// built-in opt-in screen. Showing an ad opens a gate that stays open
+/// for [gateValidity]; a sync attempted after that window closes has
+/// to show (and have the user sit through) another one before it's
+/// allowed to push data to the cloud. Selecting/connecting a cloud
+/// provider is never gated — only the upload side of a sync is, via
+/// [showGateAd]. The very first sync a cloud folder ever sees skips
+/// this entirely — see [CloudSyncService.syncNow]'s `everSyncedBefore`
+/// parameter.
+/// 2. A plain interstitial shown after a report export/share/print
+/// completes (see [AppState.maybeShowReportAd], called from
+/// `ReportScreen._share`/`_print`).
+/// 3. A plain interstitial shown after a fuel entry is saved (see
+/// [AppState.addFuelEntry]'s call to its own fuel-save-ad decision
+/// logic).
///
-/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) and
-/// [_interstitialAdUnitId] below are both the real ones from your AdMob
-/// console. ios/Runner/Info.plist's `GADApplicationIdentifier` is still
-/// Google's test iOS App ID, though — an AdMob App ID is registered
-/// per-platform, so it needs its own real iOS App ID (and a real iOS
-/// interstitial ad unit ID here) from the AdMob console if this app ever
-/// ships on iOS.
+/// Placements 2 and 3 are plain, not rewarded, because nothing is being
+/// unlocked — the save/export already happened by the time either fires,
+/// so there's no benefit to condition on watching it; they're just natural
+/// post-task transitions, which is exactly what plain interstitials are
+/// for. Neither ever blocks or gates anything, unlike placement 1.
+///
+/// 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
+/// job, not this class's: those decisions need domain state (how long
+/// they've used the app, how many fuel entries they've saved) this class
+/// has no business knowing about. So every `maybeShowX` method here is
+/// unconditional other than "is something preloaded" — callers are
+/// expected to have already decided the ad should show before calling.
+///
+/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml),
+/// [_rewardedAdUnitId], [_reportInterstitialAdUnitId], and
+/// [_fuelSaveInterstitialAdUnitId] below are all real ones from your AdMob
+/// console — ad unit IDs are tied to the format they were created as, so
+/// each placement needs its own unit created under the matching format
+/// (Rewarded / Interstitial / Interstitial) in the console, they can't
+/// share one. ios/Runner/Info.plist's `GADApplicationIdentifier`
+/// is still Google's test iOS App ID, though — an AdMob App ID is
+/// registered per-platform, so it needs its own real iOS App ID (and real
+/// iOS ad unit IDs here) from the AdMob console if this app ever ships on
+/// iOS.
/// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the
/// caller may proceed" — they're kept distinct only so [AppState] can tell
/// whether *this* call is the one that actually put a user in front of an
@@ -28,46 +70,70 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
enum AdGateResult { alreadyOpen, justShown, blocked }
class AdService {
- static const _interstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
+ /// The real *Rewarded* ad unit for the sync gate — distinct from
+ /// `9482586380` below, which is a plain Interstitial unit and wrong
+ /// format for [RewardedAd.load].
+ static const _rewardedAdUnitId = 'ca-app-pub-9212406812117696/8520582833';
+
+ /// The one real Interstitial ad unit created under this app so far —
+ /// shared by both plain-interstitial placements below. Perfectly valid
+ /// for one ad unit to back multiple `InterstitialAd.load` call sites in
+ /// the same app; the only downside is AdMob's dashboard won't be able to
+ /// break impressions/revenue out by placement. Create a second
+ /// Interstitial unit and give [_fuelSaveInterstitialAdUnitId] its own ID
+ /// later if that per-placement visibility ends up mattering.
+ static const _reportInterstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
+
+ static const _fuelSaveInterstitialAdUnitId = _reportInterstitialAdUnitId;
/// How long a successfully-shown ad keeps the upload gate open before the
/// next sync attempt has to show another one.
static const gateValidity = Duration(minutes: 5);
+ /// Safety net against a stuck SDK callback after [_preloadedAd] is shown
+ /// — not sized to normal watch time, which this deliberately doesn't
+ /// bound. Generous rather than tight: firing early would wrongly block a
+ /// user who's still legitimately watching.
+ static const _earnedRewardSafetyTimeout = Duration(minutes: 5);
+
bool _sdkInitialized = false;
DateTime? _lastShownAt;
- InterstitialAd? _preloadedAd;
+ RewardedAd? _preloadedAd;
Completer? _loadCompleter;
+ InterstitialAd? _preloadedReportAd;
+ InterstitialAd? _preloadedFuelSaveAd;
+
bool get _gateOpen {
final lastShownAt = _lastShownAt;
return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity;
}
- /// Starts the Mobile Ads SDK and begins preloading an interstitial.
+ /// Starts the Mobile Ads SDK and begins preloading a rewarded ad.
/// Idempotent (a no-op after the first call) and safe to call
/// speculatively — [AppState] calls this from [AppState.syncNow] rather
/// 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 (_sdkInitialized) return;
- _sdkInitialized = true;
- await MobileAds.instance.initialize();
- unawaited(_preload());
+ final alreadyInitialized = _sdkInitialized;
+ await _ensureSdkInitialized();
+ if (!alreadyInitialized) unawaited(_preload());
}
Future _preload() {
final completer = Completer();
_loadCompleter = completer;
- InterstitialAd.load(
- adUnitId: _interstitialAdUnitId,
+ RewardedAd.load(
+ adUnitId: _rewardedAdUnitId,
request: const AdRequest(),
- adLoadCallback: InterstitialAdLoadCallback(
+ rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: (ad) {
_preloadedAd = ad;
if (!completer.isCompleted) completer.complete();
},
onAdFailedToLoad: (error) {
+ debugPrint(
+ '[AdService] Rewarded ad failed to load: ${error.code} ${error.domain} ${error.message}');
if (!completer.isCompleted) completer.complete();
},
),
@@ -77,15 +143,18 @@ class AdService {
/// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an
/// ad was already shown within [gateValidity]; otherwise shows the
- /// preloaded interstitial and returns [AdGateResult.justShown] once it
- /// actually starts displaying (the only "watched it" signal a plain
- /// interstitial — as opposed to a rewarded ad — can give us), or
- /// [AdGateResult.blocked] if none was available in time or it failed to
- /// show. A [AdGateResult.blocked] result means the caller must not
+ /// preloaded rewarded ad and returns [AdGateResult.justShown]
+ /// once the user actually earns the reward (i.e. watches it through, not
+ /// just that it opened), or [AdGateResult.blocked] if none was available
+ /// in time, it failed to show, or the user dismissed it before earning
+ /// the reward. A [AdGateResult.blocked] result means the caller must not
/// proceed with uploading.
///
- /// Bounded throughout so a slow ad load or a stuck ad SDK callback can
- /// never hang a sync indefinitely. Always lines up the next interstitial
+ /// The load step is bounded to a few seconds so a slow ad load can never
+ /// hang a sync indefinitely; the watch step isn't, since the reward is
+ /// legitimately expected to take as long as the user spends on the ad —
+ /// [_earnedRewardSafetyTimeout] only guards against a genuinely stuck SDK
+ /// callback, not normal watch time. Always lines up the next ad
/// afterward, whether this attempt succeeded or not, so the next call —
/// whether that's because this one failed or because [gateValidity]
/// elapsed — has the best chance of a preloaded ad ready to go.
@@ -103,22 +172,150 @@ class AdService {
return AdGateResult.blocked;
}
+ final earnedReward = Completer();
+ ad.fullScreenContentCallback = FullScreenContentCallback(
+ onAdDismissedFullScreenContent: (ad) {
+ ad.dispose();
+ if (!earnedReward.isCompleted) earnedReward.complete(false);
+ },
+ onAdFailedToShowFullScreenContent: (ad, error) {
+ debugPrint(
+ '[AdService] Rewarded ad failed to show: ${error.code} ${error.domain} ${error.message}');
+ ad.dispose();
+ if (!earnedReward.isCompleted) earnedReward.complete(false);
+ },
+ );
+
+ await ad.show(
+ onUserEarnedReward: (ad, reward) {
+ _lastShownAt = DateTime.now();
+ if (!earnedReward.isCompleted) earnedReward.complete(true);
+ },
+ );
+ final earned =
+ await earnedReward.future.timeout(_earnedRewardSafetyTimeout, onTimeout: () => false);
+ unawaited(_preload());
+ return earned ? AdGateResult.justShown : AdGateResult.blocked;
+ }
+
+ /// Starts the Mobile Ads SDK (if [initialize] hasn't already) and begins
+ /// preloading the report-export interstitial. Idempotent and safe to call
+ /// speculatively — [AppState] calls this when the Reports tab is first
+ /// built, independently of whether cloud sync is ever configured, so the
+ /// ad is ready by the time the user shares/prints without delaying the
+ /// export itself.
+ Future preloadReportAd() async {
+ await _ensureSdkInitialized();
+ unawaited(_preloadPlainInterstitial(
+ adUnitId: _reportInterstitialAdUnitId,
+ logLabel: 'Report interstitial',
+ onLoaded: (ad) => _preloadedReportAd = ad,
+ ));
+ }
+
+ /// Best-effort interstitial shown after a report export/share/print
+ /// completes. Unlike [showGateAd], this never blocks or gates anything —
+ /// the export has already happened by the time this is called. Whether
+ /// this is actually due (grace periods, earned credit) is entirely
+ /// [AppState]'s call, made before this is ever invoked — this method
+ /// itself is unconditional: shows whatever's preloaded, or does nothing
+ /// if nothing was ready in time (this doesn't wait — showing it late,
+ /// after the user's already moved on, would be worse than not showing it
+ /// at all). Returns whether an ad actually showed, so the caller knows
+ /// whether to treat its credit as earned.
+ Future maybeShowReportAd() async {
+ final ad = _preloadedReportAd;
+ _preloadedReportAd = null;
+ final shown = await _showPlainInterstitial(ad, logLabel: 'Report interstitial');
+ unawaited(_preloadPlainInterstitial(
+ adUnitId: _reportInterstitialAdUnitId,
+ logLabel: 'Report interstitial',
+ onLoaded: (ad) => _preloadedReportAd = ad,
+ ));
+ return shown;
+ }
+
+ /// Same shape as [preloadReportAd], for the fuel-save interstitial —
+ /// [AppState] calls this once the confirm-entry screen is first built.
+ Future preloadFuelSaveAd() async {
+ await _ensureSdkInitialized();
+ unawaited(_preloadPlainInterstitial(
+ adUnitId: _fuelSaveInterstitialAdUnitId,
+ logLabel: 'Fuel-save interstitial',
+ onLoaded: (ad) => _preloadedFuelSaveAd = ad,
+ ));
+ }
+
+ /// Same shape and caveats as [maybeShowReportAd], for the fuel-save
+ /// interstitial.
+ Future maybeShowFuelSaveAd() async {
+ final ad = _preloadedFuelSaveAd;
+ _preloadedFuelSaveAd = null;
+ final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial');
+ unawaited(_preloadPlainInterstitial(
+ adUnitId: _fuelSaveInterstitialAdUnitId,
+ logLabel: 'Fuel-save interstitial',
+ onLoaded: (ad) => _preloadedFuelSaveAd = ad,
+ ));
+ return shown;
+ }
+
+ Future _ensureSdkInitialized() async {
+ if (_sdkInitialized) return;
+ _sdkInitialized = true;
+ await MobileAds.instance.initialize();
+ }
+
+ Future _preloadPlainInterstitial({
+ required String adUnitId,
+ required String logLabel,
+ required void Function(InterstitialAd ad) onLoaded,
+ }) {
+ final completer = Completer();
+ InterstitialAd.load(
+ adUnitId: adUnitId,
+ request: const AdRequest(),
+ adLoadCallback: InterstitialAdLoadCallback(
+ onAdLoaded: (ad) {
+ onLoaded(ad);
+ if (!completer.isCompleted) completer.complete();
+ },
+ onAdFailedToLoad: (error) {
+ debugPrint(
+ '[AdService] $logLabel failed to load: ${error.code} ${error.domain} ${error.message}');
+ if (!completer.isCompleted) completer.complete();
+ },
+ ),
+ );
+ return completer.future;
+ }
+
+ /// Shows [ad] if non-null and reports back whether it actually displayed
+ /// (as opposed to merely being asked to) — the same "watched it" signal
+ /// [showGateAd] gets from a rewarded ad's earn callback, just sourced
+ /// from [FullScreenContentCallback.onAdShowedFullScreenContent] since a
+ /// plain interstitial has no reward callback to key off instead. Bounded
+ /// the same way [showGateAd]'s load step is: a plain interstitial has no
+ /// legitimate long "watch" phase the way a rewarded ad does, so 8 seconds
+ /// is generous rather than a real constraint.
+ Future _showPlainInterstitial(InterstitialAd? ad, {required String logLabel}) async {
+ if (ad == null) return false;
+
final showed = Completer();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdShowedFullScreenContent: (ad) {
- _lastShownAt = DateTime.now();
if (!showed.isCompleted) showed.complete(true);
},
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
onAdFailedToShowFullScreenContent: (ad, error) {
+ debugPrint(
+ '[AdService] $logLabel failed to show: ${error.code} ${error.domain} ${error.message}');
ad.dispose();
if (!showed.isCompleted) showed.complete(false);
},
);
await ad.show();
- final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
- unawaited(_preload());
- return opened ? AdGateResult.justShown : AdGateResult.blocked;
+ return showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
}
}
diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart
index 154ed2a..a0f4797 100644
--- a/lib/services/app_state.dart
+++ b/lib/services/app_state.dart
@@ -155,6 +155,22 @@ class AppState extends ChangeNotifier {
/// for sync failures.
String? purchaseError;
+ /// 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
+ /// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced
+ /// rather than a local-only preference.
+ DateTime? firstUsedAt;
+
+ /// True if there's local data not yet pushed to the cloud — either
+ /// nothing has synced yet, or something changed since the last
+ /// successful push. Refreshed alongside [vehicles]/[fuelEntries] in
+ /// [_refreshFromDatabase], so it's accurate after every local mutation
+ /// and every sync attempt. Drives the "not backed up" icon (see
+ /// `CloudBackupActionButton`) once backup *is* configured — before that,
+ /// [hasCloudBackupConfigured] alone already covers it.
+ bool hasUnsyncedChanges = false;
+
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
String? get cloudAccountLabel => activeProvider?.accountLabel;
@@ -171,6 +187,96 @@ class AppState extends ChangeNotifier {
return until != null && DateTime.now().toUtc().isBefore(until);
}
+ /// How long after first ever opening this app (see [firstUsedAt]) a new
+ /// user sees no interstitials at all — shared by the report and
+ /// fuel-save placements.
+ static const _newUserAdGracePeriod = Duration(minutes: 5);
+
+ /// Below this many total fuel entries ever saved, the fuel-save
+ /// interstitial doesn't show — on top of, not instead of,
+ /// [_newUserAdGracePeriod].
+ static const _fuelSaveAdGraceSaves = 4;
+
+ /// Watching the fuel-save interstitial buys a credit that lasts until
+ /// *either* of these runs out, whichever comes first.
+ static const _fuelSaveAdCreditDuration = Duration(minutes: 5);
+ static const _fuelSaveAdCreditSaves = 2;
+
+ /// Watching the report interstitial buys this much flat credit.
+ static const _reportAdCreditDuration = Duration(minutes: 10);
+
+ DateTime? _lastFuelSaveAdShownAt;
+ int _fuelSavesSinceAd = 0;
+ DateTime? _lastReportAdShownAt;
+
+ /// Preloads the report-export interstitial (see [AdService.preloadReportAd])
+ /// — call once when the Reports tab is first built, so an ad is ready by
+ /// the time [maybeShowReportAd] is called. A no-op for a user with an
+ /// active ad-free purchase.
+ void preloadReportAd() {
+ if (!adsCurrentlyDisabled) unawaited(adService.preloadReportAd());
+ }
+
+ /// Best-effort ad shown after a report export/share/print completes —
+ /// see [AdService.maybeShowReportAd]. A no-op for a user with an active
+ /// ad-free purchase, during the new-user grace period (see
+ /// [firstUsedAt]), or while a previously-watched report ad's credit is
+ /// still active.
+ Future maybeShowReportAd() async {
+ if (adsCurrentlyDisabled || _inNewUserGracePeriod) return;
+
+ final lastShown = _lastReportAdShownAt;
+ if (lastShown != null && DateTime.now().difference(lastShown) < _reportAdCreditDuration) {
+ return;
+ }
+
+ if (await adService.maybeShowReportAd()) {
+ _lastReportAdShownAt = DateTime.now();
+ }
+ }
+
+ /// Preloads the fuel-save interstitial (see [AdService.preloadFuelSaveAd])
+ /// — call once when the confirm-entry screen is first built. A no-op for
+ /// a user with an active ad-free purchase.
+ void preloadFuelSaveAd() {
+ if (!adsCurrentlyDisabled) unawaited(adService.preloadFuelSaveAd());
+ }
+
+ bool get _inNewUserGracePeriod {
+ final firstUsed = firstUsedAt;
+ 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;
+
+ final lastShown = _lastFuelSaveAdShownAt;
+ if (lastShown != null &&
+ DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration &&
+ _fuelSavesSinceAd < _fuelSaveAdCreditSaves) {
+ _fuelSavesSinceAd++;
+ return;
+ }
+
+ if (await adService.maybeShowFuelSaveAd()) {
+ _lastFuelSaveAdShownAt = DateTime.now();
+ _fuelSavesSinceAd = 0;
+ }
+ }
+
/// The store's own formatted, localized price for the ad-free-year
/// purchase (e.g. `"$4.99"`) — null until [PurchaseService.initialize]
/// has loaded it, or if the product isn't configured in the store yet.
@@ -186,6 +292,7 @@ class AppState extends ChangeNotifier {
try {
await database.init();
await _refreshFromDatabase();
+ firstUsedAt = await database.getOrCreateFirstUsedAt();
final prefs = await SharedPreferences.getInstance();
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
@@ -236,6 +343,7 @@ class AppState extends ChangeNotifier {
vehicles = await database.getVehicles();
fuelEntries = await database.getFuelEntries();
adFreeUntil = await database.getAdFreeUntil();
+ hasUnsyncedChanges = await database.hasDirtyRows();
}
CloudStorageProvider? _providerById(CloudProviderId id) {
@@ -384,13 +492,17 @@ class AppState extends ChangeNotifier {
// skips the gate entirely — beforeUpload stays null (CloudSyncService
// treats that as "nothing to check", same as any other call site that
// never passed one) and the ad SDK isn't even touched this sync.
- Future Function()? beforeUpload;
+ Future Function({required bool everSyncedBefore})? beforeUpload;
if (!adsCurrentlyDisabled) {
// Idempotent, and only ever reached once sync is actually configured
// — a user who never connects cloud storage never triggers any
// ad-related activity at all.
unawaited(adService.initialize());
- beforeUpload = () async {
+ beforeUpload = ({required everSyncedBefore}) async {
+ // The very first sync a cloud folder ever sees goes through for
+ // free — see [CloudSyncService.syncNow]'s `everSyncedBefore` doc.
+ if (!everSyncedBefore) return true;
+
final result = await adService.showGateAd();
// Fired, not awaited: the upsell dialog is purely informational —
// this sync (specifically the upload [beforeUpload] is about to
@@ -591,6 +703,7 @@ class AppState extends ChangeNotifier {
);
await database.saveFuelEntry(entry);
await _persist();
+ unawaited(_maybeShowFuelSaveAd());
return entry;
}
@@ -703,7 +816,17 @@ class AppState extends ChangeNotifier {
Future _persist() async {
await _refreshFromDatabase();
notifyListeners();
- unawaited(syncNow());
+ // Only a paid ("remove ads for a year") user gets synced automatically
+ // on every change — for anyone else this would mean an ad-gated
+ // syncNow() firing silently in the background, disconnected from
+ // anything the user just did, which is exactly the surprise-ad problem
+ // this app's ad placements are designed to avoid everywhere else.
+ // Sync still always happens on demand: `Data > Sync Now`, tapping
+ // `CloudBackupActionButton` when it shows "not synced", and connecting
+ // a provider/folder for the first time (see [selectAppFolder]) all
+ // call [syncNow] directly, and the very first sync a folder ever sees
+ // is free regardless (see [syncNow]'s `everSyncedBefore` handling).
+ if (adsCurrentlyDisabled) unawaited(syncNow());
}
}
diff --git a/lib/services/cloud_sync_service.dart b/lib/services/cloud_sync_service.dart
index ca6f812..2f598ed 100644
--- a/lib/services/cloud_sync_service.dart
+++ b/lib/services/cloud_sync_service.dart
@@ -165,10 +165,14 @@ class CloudSyncService {
/// `false` return means the gate is closed (see [AdService.showGateAd])
/// and this call returns [SyncResult.adGateBlocked] without uploading
/// anything, having still pulled/merged whatever the remote side had.
+ /// `everSyncedBefore` tells the caller whether a remote data file already
+ /// existed — `false` means this is the very first sync this shared cloud
+ /// folder has ever seen, which [AppState] uses to let that one upload
+ /// through for free, with no ad required.
Future syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
- Future Function()? beforeUpload,
+ Future Function({required bool everSyncedBefore})? beforeUpload,
}) async {
final appFolderId = _appFolderId;
if (!provider.isSignedIn || appFolderId == null) {
@@ -197,7 +201,8 @@ class CloudSyncService {
await _pullAndMerge(session, remoteInfo.id);
}
- if (beforeUpload != null && !await beforeUpload()) {
+ if (beforeUpload != null &&
+ !await beforeUpload(everSyncedBefore: remoteInfo != null)) {
return SyncResult.adGateBlocked();
}
@@ -257,6 +262,16 @@ class CloudSyncService {
if (remoteHasEntitlementTable) {
await db.execute(mergeAdFreeEntitlementSql);
}
+
+ // Same reasoning as ad_free_entitlement above — app_usage is newer
+ // than vehicles/fuel_entries, so an older remote snapshot genuinely
+ // won't have it.
+ final remoteHasAppUsageTable = (await db.rawQuery(
+ "SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'app_usage'",
+ )).isNotEmpty;
+ if (remoteHasAppUsageTable) {
+ await db.execute(mergeAppUsageSql);
+ }
} finally {
try {
await db.execute('DETACH DATABASE remote_db');
diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart
index 67755be..2f5380c 100644
--- a/lib/services/database_service.dart
+++ b/lib/services/database_service.dart
@@ -65,7 +65,7 @@ class DatabaseService {
final dbPath = p.join(_rootDirectory.path, dbFileName);
_db = await openDatabase(
dbPath,
- version: 5,
+ version: 6,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -76,6 +76,7 @@ class DatabaseService {
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
await db.execute(createAdFreeEntitlementTableSql);
+ await db.execute(createAppUsageTableSql);
}
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
@@ -94,6 +95,9 @@ class DatabaseService {
if (oldVersion < 5) {
await db.execute(createAdFreeEntitlementTableSql);
}
+ if (oldVersion < 6) {
+ await db.execute(createAppUsageTableSql);
+ }
}
Future _migrateToVehicleIdSchema(Database db) async {
@@ -188,6 +192,35 @@ class DatabaseService {
);
}
+ /// The earliest-known moment this app was ever used, across every device
+ /// this account has synced from — see [createAppUsageTableSql]. Recorded
+ /// once, the first time this is ever called with no existing row (a
+ /// fresh install with nothing to merge down yet); reinstalling *after*
+ /// cloud sync was connected instead pulls the original value back down
+ /// via [mergeAppUsageSql] before this is next called, so the new-user ad
+ /// grace period can't be replayed by reinstalling.
+ Future getOrCreateFirstUsedAt() async {
+ final rows = await _db.query('app_usage', where: 'id = 1', limit: 1);
+ if (rows.isNotEmpty) {
+ return DateTime.fromMillisecondsSinceEpoch(rows.single['first_used_at'] as int, isUtc: true);
+ }
+ final now = DateTime.now().toUtc();
+ await _db.insert('app_usage', {'id': 1, 'first_used_at': now.millisecondsSinceEpoch});
+ return now;
+ }
+
+ /// True if any vehicle or fuel entry row has local changes not yet
+ /// pushed to the cloud (see the `dirty` column note on this class).
+ /// Exposed only as this one yes/no signal, never the raw flag — it
+ /// drives [AppState.hasUnsyncedChanges], which the "not backed up" icon
+ /// reads.
+ Future hasDirtyRows() async {
+ final vehicleRows = await _db.query('vehicles', where: 'dirty = 1', limit: 1);
+ if (vehicleRows.isNotEmpty) return true;
+ final entryRows = await _db.query('fuel_entries', where: 'dirty = 1', limit: 1);
+ return entryRows.isNotEmpty;
+ }
+
/// True if an active (non-deleted) vehicle other than [excludeId] already
/// has this VIN. VIN must stay unique even though it's editable, so
/// callers adding a new vehicle (no [excludeId]) or changing an existing
diff --git a/lib/services/db_schema.dart b/lib/services/db_schema.dart
index 05d7da3..cfbfe6c 100644
--- a/lib/services/db_schema.dart
+++ b/lib/services/db_schema.dart
@@ -48,6 +48,29 @@ const createAdFreeEntitlementTableSql = '''
)
''';
+/// A single-row table (`id` is always `1`) holding the earliest-known
+/// moment this app was ever used — see `AppState.firstUsedAt` and
+/// `DatabaseService.getOrCreateFirstUsedAt`. Synced like
+/// `ad_free_entitlement`, for the same reason: without it, reinstalling
+/// would reset the new-user ad grace period every time.
+const createAppUsageTableSql = '''
+ CREATE TABLE app_usage (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ first_used_at INTEGER NOT NULL
+ )
+''';
+
+/// Unlike every other merge statement here, this keeps whichever row has
+/// the *smaller* `first_used_at` — merging in a device that's had the app
+/// longer can only push "first use" earlier, never later.
+const mergeAppUsageSql = '''
+ INSERT OR REPLACE INTO main.app_usage (id, first_used_at)
+ SELECT r.id, r.first_used_at
+ FROM remote_db.app_usage r
+ LEFT JOIN main.app_usage l ON l.id = r.id
+ WHERE l.id IS NULL OR r.first_used_at < l.first_used_at
+''';
+
/// Selects fuel entries whose receipt photo still needs uploading to
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
/// "has a local path": once a row is uploaded, its local copy may still be
diff --git a/lib/widgets/cloud_backup_action_button.dart b/lib/widgets/cloud_backup_action_button.dart
new file mode 100644
index 0000000..29993b3
--- /dev/null
+++ b/lib/widgets/cloud_backup_action_button.dart
@@ -0,0 +1,58 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../services/app_state.dart';
+import 'backup_reminder.dart';
+
+/// AppBar action flagging that this device's data isn't fully backed up —
+/// the same "shared widget dropped into every tab's AppBar" pattern
+/// [FaqActionButton] uses. Renders nothing once backup is configured *and*
+/// there's nothing unsynced (see [AppState.hasCloudBackupConfigured] /
+/// [AppState.hasUnsyncedChanges]); there's nothing left to flag at that
+/// point. Covers two different states behind one icon:
+/// - Backup was never set up — tapping opens the setup flow.
+/// - Backup *is* set up but something local hasn't been pushed up yet
+/// (most users, most of the time, since only a paid user auto-syncs on
+/// every change — see [AppState.hasUnsyncedChanges]) — tapping tries a
+/// sync right now, going through the normal ad gate.
+///
+/// The small play-triangle badge marks this as potentially leading to an
+/// ad: syncing needs a rewarded ad to push local changes up on every sync
+/// but the first (see `AdService.showGateAd`) — unless the user has an
+/// active ad-free purchase, in which case syncing is silent and free. The
+/// badge only shows for someone who'd actually see an ad, so a paid user
+/// gets a plain icon instead.
+class CloudBackupActionButton extends StatelessWidget {
+ const CloudBackupActionButton({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final appState = context.watch();
+ final configured = appState.hasCloudBackupConfigured;
+ if (configured && !appState.hasUnsyncedChanges) return const SizedBox.shrink();
+
+ final showAdBadge = !appState.adsCurrentlyDisabled;
+ final colors = Theme.of(context).colorScheme;
+ const icon = Icon(Icons.cloud_off_outlined);
+
+ return IconButton(
+ tooltip: configured ? 'Not synced yet — tap to sync now' : 'Not backed up — set up cloud backup',
+ onPressed: () {
+ if (configured) {
+ unawaited(appState.syncNow());
+ } else {
+ openCloudBackupSetup(context);
+ }
+ },
+ icon: showAdBadge
+ ? Badge(
+ backgroundColor: colors.tertiary,
+ label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary),
+ child: icon,
+ )
+ : icon,
+ );
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 2587d56..e5cc35d 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+2
+version: 1.0.0+4
environment:
sdk: ^3.12.2
@@ -93,8 +93,8 @@ dependencies:
printing: ^5.14.3
url_launcher: ^6.3.2
- # AdMob interstitial shown once per app session, right before the first
- # sync's upload-to-cloud phase — see lib/services/ad_service.dart.
+ # AdMob rewarded interstitial shown once per gate window, right before the
+ # first sync's upload-to-cloud phase — see lib/services/ad_service.dart.
google_mobile_ads: ^9.1.0
# One-time "remove ads for a year" purchase — see
diff --git a/test/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart
index adebf41..aba4e84 100644
--- a/test/cloud_sync_service_test.dart
+++ b/test/cloud_sync_service_test.dart
@@ -179,9 +179,11 @@ void main() {
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
+ var everSyncedBeforeSeen = false;
final result = await syncService.syncNow(
- beforeUpload: () async {
+ beforeUpload: ({required everSyncedBefore}) async {
calls.add('beforeUpload');
+ everSyncedBeforeSeen = everSyncedBefore;
return true;
},
);
@@ -190,6 +192,8 @@ void main() {
expect(result.adGateBlocked, isFalse);
expect(calls.first, 'download', reason: 'pull/merge happens first');
expect(calls[1], 'beforeUpload');
+ expect(everSyncedBeforeSeen, isTrue,
+ reason: 'a remote data file already existed for this sync');
// Everything after beforeUpload is an upload (the data-file push,
// possibly preceded by pending receipt uploads — none pending here).
expect(calls.skip(2), everyElement(startsWith('upload:')));
@@ -204,15 +208,19 @@ void main() {
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
+ var everSyncedBeforeSeen = true;
await syncService.syncNow(
- beforeUpload: () async {
+ beforeUpload: ({required everSyncedBefore}) async {
calls.add('beforeUpload');
+ everSyncedBeforeSeen = everSyncedBefore;
return true;
},
);
expect(calls.first, 'beforeUpload');
expect(calls, contains('upload:$dataFileName'));
+ expect(everSyncedBeforeSeen, isFalse,
+ reason: 'no remote data file existed — this is the first sync ever');
});
test('a false return blocks the upload but keeps whatever was already pulled/merged',
@@ -228,7 +236,8 @@ void main() {
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
- final result = await syncService.syncNow(beforeUpload: () async => false);
+ final result =
+ await syncService.syncNow(beforeUpload: ({required everSyncedBefore}) async => false);
expect(result.ranSync, isFalse);
expect(result.adGateBlocked, isTrue);
diff --git a/test/user_agreement_screen_test.dart b/test/user_agreement_screen_test.dart
index 0dc3f20..d568a5b 100644
--- a/test/user_agreement_screen_test.dart
+++ b/test/user_agreement_screen_test.dart
@@ -76,6 +76,8 @@ void main() {
await tester.scrollUntilVisible(find.text('See the FAQ for more'), 200,
scrollable: find.byType(Scrollable));
+ await tester.ensureVisible(find.text('See the FAQ for more'));
+ await tester.pumpAndSettle();
await tester.tap(find.text('See the FAQ for more'));
await tester.pumpAndSettle();