Temporary setup of alternative to advertisements
This commit is contained in:
parent
ad2d932842
commit
57a9793a40
14 changed files with 653 additions and 56 deletions
17
lib/services/ad_config.dart
Normal file
17
lib/services/ad_config.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/// The single switch that turns real AdMob ads on.
|
||||
///
|
||||
/// While this is `false`, the app behaves in every observable way as though
|
||||
/// ads were live — each placement is still *decided* on exactly the same
|
||||
/// schedule (new-user grace periods, earned credits, the ad-free purchase,
|
||||
/// the sync gate blocking uploads) — but where a real ad would appear, it
|
||||
/// shows `AdPlaceholderScreen` instead, and the Google Mobile Ads SDK is
|
||||
/// never initialized at all. No ad requests go out, so none of them can
|
||||
/// come back "Account not approved yet" or no-fill.
|
||||
///
|
||||
/// That means the pacing and gating can be exercised and tuned for real
|
||||
/// before AdMob approval lands.
|
||||
///
|
||||
/// Flipping this to `true` is the only change needed to go live: every
|
||||
/// placement picks up its real ad unit from [AdService] and the placeholder
|
||||
/// stops being reachable. Nothing else in the app branches on it.
|
||||
const bool adsEnabled = false;
|
||||
|
|
@ -3,6 +3,9 @@ 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
|
||||
|
|
@ -42,6 +45,13 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
|
|||
/// 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
|
||||
|
|
@ -115,6 +125,7 @@ class AdService {
|
|||
/// 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());
|
||||
|
|
@ -161,6 +172,16 @@ class AdService {
|
|||
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: () {});
|
||||
}
|
||||
|
|
@ -205,6 +226,7 @@ class AdService {
|
|||
/// 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,
|
||||
|
|
@ -224,6 +246,11 @@ class AdService {
|
|||
/// 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');
|
||||
|
|
@ -238,6 +265,7 @@ class AdService {
|
|||
/// 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,
|
||||
|
|
@ -249,6 +277,11 @@ class AdService {
|
|||
/// 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');
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ class AppState extends ChangeNotifier {
|
|||
|
||||
/// 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
|
||||
/// [maybeShowFuelSaveAd]/[maybeShowReportAd]); see
|
||||
/// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced
|
||||
/// rather than a local-only preference.
|
||||
DateTime? firstUsedAt;
|
||||
|
|
@ -247,34 +247,47 @@ class AppState extends ChangeNotifier {
|
|||
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;
|
||||
/// Ad shown on saving a fuel entry — see [AdService.maybeShowFuelSaveAd].
|
||||
///
|
||||
/// Called by `ConfirmFuelEntryScreen._save` *after* [addFuelEntry] has
|
||||
/// already committed the entry, and awaited before that screen pops. So
|
||||
/// it reads to the user as "tap Save, see the ad, land back on the
|
||||
/// list", while the entry itself is never actually waiting on the ad —
|
||||
/// which matters both for their data (nothing is lost if the app dies
|
||||
/// mid-ad) and for AdMob's rule that a plain interstitial must never
|
||||
/// gate access to app functionality. That's the same rule that forced
|
||||
/// the cloud-sync gate to a *rewarded* ad; the difference here is that
|
||||
/// nothing is being withheld pending the ad.
|
||||
///
|
||||
/// A no-op for a user with an active ad-free purchase, during the
|
||||
/// new-user grace period or the first [_fuelSaveAdGraceSaves] saves, or
|
||||
/// while a previously-watched fuel-save ad's credit is still active.
|
||||
///
|
||||
/// Returns whether an ad actually showed. The caller uses that to hold
|
||||
/// back [showBackupReminderDialog] for this one save: only ever one
|
||||
/// full-screen surface per save (the "post-save arbitration" rule), but
|
||||
/// the ad takes precedence rather than being suppressed outright — the
|
||||
/// reminder recurs on the next save anyway, whereas suppressing meant a
|
||||
/// user who never configured backup never saw this placement at all.
|
||||
Future<bool> maybeShowFuelSaveAd() async {
|
||||
if (adsCurrentlyDisabled) return false;
|
||||
if (_inNewUserGracePeriod) return false;
|
||||
if (fuelEntries.length <= _fuelSaveAdGraceSaves) return false;
|
||||
|
||||
final lastShown = _lastFuelSaveAdShownAt;
|
||||
if (lastShown != null &&
|
||||
DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration &&
|
||||
_fuelSavesSinceAd < _fuelSaveAdCreditSaves) {
|
||||
_fuelSavesSinceAd++;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await adService.maybeShowFuelSaveAd()) {
|
||||
_lastFuelSaveAdShownAt = DateTime.now();
|
||||
_fuelSavesSinceAd = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The store's own formatted, localized price for the ad-free-year
|
||||
|
|
@ -703,7 +716,6 @@ class AppState extends ChangeNotifier {
|
|||
);
|
||||
await database.saveFuelEntry(entry);
|
||||
await _persist();
|
||||
unawaited(_maybeShowFuelSaveAd());
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue