MO-Fuel-Tax-Back/lib/services/ad_service.dart

354 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import '../widgets/ad_placeholder_screen.dart';
import 'ad_config.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.
///
/// All three are gated behind [adsEnabled] (lib/services/ad_config.dart).
/// While that's false, every method here short-circuits to
/// [showAdPlaceholder] and the Mobile Ads SDK is never touched — no
/// requests, no failures — while the *decisions* about when each placement
/// fires stay exactly as they'd be with real ads, including the sync gate
/// still withholding uploads until the user acts.
///
/// 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<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 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 (!adsEnabled) return;
final alreadyInitialized = _sdkInitialized;
await _ensureSdkInitialized();
if (!alreadyInitialized) unawaited(_preload());
}
Future<void> _preload() {
final completer = Completer<void>();
_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<AdGateResult> showGateAd() async {
if (_gateOpen) return AdGateResult.alreadyOpen;
// Stand-in for the rewarded ad: the upload still waits on the user
// actively dismissing something, and still opens the same
// [gateValidity] window afterward, so the gate's behavior is unchanged
// apart from what's on screen. See lib/services/ad_config.dart.
if (!adsEnabled) {
await showAdPlaceholder(mustConfirm: true);
_lastShownAt = DateTime.now();
return AdGateResult.justShown;
}
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<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 {
if (!adsEnabled) return;
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 {
if (!adsEnabled) {
await showAdPlaceholder(mustConfirm: false);
return true;
}
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 {
if (!adsEnabled) return;
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 {
if (!adsEnabled) {
await showAdPlaceholder(mustConfirm: false);
return true;
}
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) {
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);
}
}