Updates to add more ads and fix ad types.
This commit is contained in:
parent
ad850d2be6
commit
ad2d932842
17 changed files with 552 additions and 59 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue