Updates to add more ads and fix ad types.

This commit is contained in:
Courtney Arnold 2026-08-31 20:17:22 -05:00
parent ad850d2be6
commit ad2d932842
17 changed files with 552 additions and 59 deletions

View file

@ -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<void> 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<void> _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<bool> Function()? beforeUpload;
Future<bool> 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<void> _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());
}
}