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