import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; /// This app has three ad placements: /// /// 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). /// /// 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 /// ad, which is the trigger for the "remove ads for a year" upsell dialog. /// [AdGateResult.blocked] means the caller must not proceed. enum AdGateResult { alreadyOpen, justShown, blocked } class AdService { /// 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; 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 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 { final alreadyInitialized = _sdkInitialized; await _ensureSdkInitialized(); if (!alreadyInitialized) unawaited(_preload()); } Future _preload() { final completer = Completer(); _loadCompleter = completer; RewardedAd.load( adUnitId: _rewardedAdUnitId, request: const AdRequest(), 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(); }, ), ); return completer.future; } /// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an /// ad was already shown within [gateValidity]; otherwise shows the /// 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. /// /// 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. Future showGateAd() async { if (_gateOpen) return AdGateResult.alreadyOpen; if (_preloadedAd == null) { await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {}); } final ad = _preloadedAd; _preloadedAd = null; if (ad == null) { unawaited(_preload()); 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) { 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(); return showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false); } }