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

@ -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<void>? _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<void> initialize() async {
if (_sdkInitialized) return;
_sdkInitialized = true;
await MobileAds.instance.initialize();
unawaited(_preload());
final alreadyInitialized = _sdkInitialized;
await _ensureSdkInitialized();
if (!alreadyInitialized) unawaited(_preload());
}
Future<void> _preload() {
final completer = Completer<void>();
_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<bool>();
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<void> 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<bool> 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<void> 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<bool> 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<void> _ensureSdkInitialized() async {
if (_sdkInitialized) return;
_sdkInitialized = true;
await MobileAds.instance.initialize();
}
Future<void> _preloadPlainInterstitial({
required String adUnitId,
required String logLabel,
required void Function(InterstitialAd ad) onLoaded,
}) {
final completer = Completer<void>();
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<bool> _showPlainInterstitial(InterstitialAd? ad, {required String logLabel}) async {
if (ad == null) return false;
final showed = Completer<bool>();
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);
}
}

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());
}
}

View file

@ -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<SyncResult> syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
Future<bool> Function()? beforeUpload,
Future<bool> 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');

View file

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

View file

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