354 lines
16 KiB
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);
|
|
}
|
|
}
|