ad_flow 3.0.0
ad_flow: ^3.0.0 copied to clipboard
Policy-compliant AdMob integration for Flutter: banner, interstitial, rewarded, rewarded interstitial, native and app open ads with built-in GDPR/UMP consent.
ad_flow #
Easy, policy-compliant AdMob integration for Flutter — banner, interstitial, rewarded, rewarded interstitial, native and app open ads, with UMP consent, frequency capping, retry with backoff, and revenue callbacks built in.
v2 is a ground-up rewrite on google_mobile_ads ^9.0.0. Coming from 1.x?
Read MIGRATION.
What you get for free:
- Consent first, always. No ad loads before the UMP gate opens (GDPR/EEA form, ATT coordination, privacy-options entry point).
- Policy-safe defaults. App open only on warm starts with the 4-hour expiry; interstitials frequency-capped and action-paced; the rewarded interstitial intro/skip screen is mandatory by construction; banners reserve their height so layouts never shift.
- Revenue-minded plumbing. Every full-screen format keeps one ad warm
(load → show → reload on dismiss); failed loads retry with exponential
backoff + jitter and re-arm after a cooldown;
onPaidEventreports impression-level revenue. - Testable. Everything runs behind an
AdSdkseam;package:ad_flow/ad_flow_testing.dartshipsFakeAdSdkso you can unit test your integration without a device.
Set up with AI (copy-paste) #
Prefer to let your AI assistant (Claude Code, Cursor, Copilot, …) do the wiring? Copy the matching prompt below into your assistant inside your project. Each prompt tells it to read this package's real API first, then integrate or migrate following the recommended patterns — so it never guesses an API and never blocks your app's first frame.
🆕 New setup — add ad_flow (fresh, or replacing your existing ad code) #
Set up the ad_flow Flutter package (AdMob) in my project. Do it idiomatically — do not guess the API.
1. FIRST read the real API: this package's README and its public API
(package:ad_flow/ad_flow.dart, e.g. in the pub cache). Use only symbols that exist there.
2. Scan my project for existing ad code (google_mobile_ads usage, AdMob, banner/interstitial/
rewarded/app-open, or another ads wrapper).
• If you find any: show me what it is, then REPLACE it with ad_flow equivalents and remove the
old implementation (and the direct google_mobile_ads dependency if nothing else uses it).
• If none: do a clean fresh integration.
3. Add ad_flow: ^3.0.0 to pubspec and meet its min versions (Flutter >=3.38.1, iOS 13,
Android minSdk 24 / compileSdk 36). Platform setup: Android APPLICATION_ID meta-data, iOS
GADApplicationIdentifier + NSUserTrackingUsageDescription. Remind me to publish & verify app-ads.txt.
4. Ask me which formats I want and for my ad unit IDs (or use AdFlowConfig.test() for now).
5. Follow the README best practices EXACTLY: non-blocking init (never gate the first frame / a
splash on it), use the widget-first ad widgets (AdFlowBanner(adFlow: ads) — they own their controllers), consent-first, and —
if I want the consent/ATT priming screens — wire the explainer presenters.
6. Verify: flutter analyze is clean and the app builds; show me where each ad renders.
Ask me anything you need (formats, IDs, EEA/iOS) before writing code. Keep changes minimal and explained.
🔁 Migrate — upgrade from ad_flow 1.x/2.x to 3.0.0 #
Migrate my project from ad_flow 1.x/2.x to 3.0.0 (v2 was a ground-up rewrite; 3.0 refines its API). Be careful — the API changed a lot.
1. FIRST read ad_flow's MIGRATION.md plus the 3.0.0 README and public API. Use only real v2 symbols.
2. Bump ad_flow to ^3.0.0 and meet v2's min versions (Flutter >=3.38.1, Dart >=3.10, iOS 13,
Android minSdk 24 / compileSdk 36; adopt the iOS UISceneDelegate lifecycle if I have a custom AppDelegate).
3. Find EVERY v1 ad_flow usage (AdFlow.instance, initialize / initializeWithExplainer, EasyBannerAd,
the old managers/widgets, the broad google_mobile_ads re-export). List them, then migrate each to
its v2 equivalent per MIGRATION.md.
4. Apply the v2 best practices while you're in there: non-blocking init (drop any FutureBuilder/await
that gates the UI on init), widget-first ad widgets (AdFlowBanner(adFlow: ads)), the presenter-based
consent/ATT explainer (the v2 replacement for initializeWithExplainer), and ValueListenable state.
5. Remove whatever is now dead from v1; keep my ad unit IDs and behavior intact.
6. Verify: flutter analyze is clean and the app builds.
Show me the v1 → v2 mapping before large edits, and flag any behavior change (e.g. EEA users now see
the GDPR consent form even if they denied ATT).
These prompts intentionally defer to the package's own README / MIGRATION for exact symbols, so they stay correct as the package evolves.
1. Install #
dependencies:
ad_flow: ^3.0.0
Requirements (from google_mobile_ads 9.x): Flutter ≥ 3.38.1, Dart ≥ 3.10,
iOS 13+, Android minSdk 24 / compileSdk 36.
2. Platform setup #
app-ads.txt — do not skip this #
Since January 2025 AdMob requires a verified app-ads.txt for full ad
serving. Publish one at https://your-developer-domain/app-ads.txt with the
line AdMob gives you, and verify it in the AdMob console — otherwise your
apps silently under-serve.
Android #
android/app/src/main/AndroidManifest.xml, inside <application>:
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY"/>
iOS #
ios/Runner/Info.plist:
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY</string>
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads to you.</string>
NSUserTrackingUsageDescription is required if you pass attExplainer
(client-driven ATT): iOS terminates the app when the tracking prompt is
requested without it.
Also add SKAdNetworkItems to Info.plist. SKAdNetwork is how iOS attributes
installs to ads when the user has not granted tracking permission — which is most
users. Without these entries, ad networks cannot receive attribution for your app,
which depresses your iOS eCPM with no error and no signal anywhere:
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string> <!-- Google/AdMob -->
</dict>
<!-- plus one entry per mediation network you use -->
</array>
Copy the current, full list from Google's docs — it changes as networks are added: https://developers.google.com/admob/ios/quick-start#update_your_infoplist. If you use mediation, add each partner network's identifier too.
Recent Flutter templates are already scene-based; if you maintain a custom
AppDelegate, adopt the UISceneDelegate lifecycle (required by the v9
plugin).
The application ID cannot be set from Dart — AdFlowConfig carries ad
unit IDs only.
3. Quick start #
Step 1 — platform setup. Do §2 first (app IDs +
app-ads.txt).
Step 2 — initialize (non-blocking) and drop in a banner. initialize()
returns immediately; consent, ATT and ad loading all run in the background.
Render your UI on the first frame — never await-block it behind a splash.
final navigatorKey = GlobalKey<NavigatorState>();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// The await resolves on the next microtask (graph construction only) — it
// NEVER waits on the network, so the first frame is instant.
final ads = await AdFlow.initialize(
AdFlowConfig.test(), // dev: Google sample ads. Swap for your production config.
rewardedIntroPresenter: (c) =>
RewardedIntroScreen.show(navigatorKey.currentContext!, c),
);
runApp(MyApp(ads: ads, navigatorKey: navigatorKey)); // renders immediately
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) => Scaffold(
bottomNavigationBar: SafeArea(
// Widget-first (3.0): the widget creates AND owns its controller,
// so the "fresh controller minted in build()" footgun (a blank ad
// on every setState) cannot happen.
child: AdFlowBanner(adFlow: ads),
),
);
}
Nothing loads before request configuration is applied and the consent gate
opens — this holds even for a first-frame banner/native. Do not wrap your
app in a FutureBuilder<AdFlow> splash gate (that was v1's hang). Need the
consent result? await ads.whenReady (Future<bool>) — optional, never gate
UI on it.
Add consent + ATT priming (recommended for EEA / iOS) #
Show your own soft primer before the UMP GDPR form and Apple's ATT prompt — opt-in, additive, better opt-in rates. Add two presenters (details + copy localization in §5):
await AdFlow.initialize(
AdFlowConfig.test(),
rewardedIntroPresenter: (c) => RewardedIntroScreen.show(navigatorKey.currentContext!, c),
attExplainer: (c) => AttExplainerScreen.show(navigatorKey.currentContext!, c),
consentExplainer: (c) => ConsentExplainerScreen.show(navigatorKey.currentContext!, c),
);
The example runs both modes behind one
useExplainer flag.
Production config #
Only configured slots ever load:
final ads = await AdFlow.initialize(
AdFlowConfig(
banner: const BannerConfig(
adUnitId: PlatformAdUnitId(android: 'ca-app-pub-…/1', ios: 'ca-app-pub-…/2'),
),
interstitial: const InterstitialConfig(
adUnitId: PlatformAdUnitId(android: 'ca-app-pub-…/3', ios: 'ca-app-pub-…/4'),
cap: FrequencyCap(minGap: Duration(seconds: 30), maxPerHour: 6),
minActionsBetween: 2,
),
rewarded: const RewardedConfig(
adUnitId: PlatformAdUnitId(android: 'ca-app-pub-…/5', ios: 'ca-app-pub-…/6'),
),
appOpen: const AppOpenConfig(
adUnitId: PlatformAdUnitId(android: 'ca-app-pub-…/7', ios: 'ca-app-pub-…/8'),
),
globalFrequencyCap: const FrequencyCap(minGap: Duration(seconds: 15)),
testDeviceIds: ['YOUR-HASHED-DEVICE-ID'],
),
);
Configure BOTH platforms' IDs for every slot you use on both platforms. A slot
missing the current platform's ID is simply unconfigured there: its
throwing getter (ads.interstitial) throws, and ads.interstitialOrNull
returns null — use the …OrNull getters in shared cross-platform code that
should degrade rather than crash. initialize also validates the config
(empty ID strings, negative durations, …) and fails fast with
AdFlowError(invalidConfig) instead of silently no-filling in production.
Set testMode: true (or use AdFlowConfig.test()) during development —
it swaps every configured slot to Google's sample IDs. Never ship it.
4. Formats #
Banner #
Scaffold(
bottomNavigationBar: SafeArea(
child: AdFlowBanner(adFlow: ads), // creates + owns its controller (3.0)
),
)
Widget-first is the safe path (3.0). The widget creates and owns its controller internally, so the classic footgun — minting a fresh controller inside
build()and restarting (blanking) the load on everysetState— cannot happen. Advanced:ads.banner()still mints a standaloneBannerAdControlleryou can pass viacontroller:when you need to inspect itsstate/responseelsewhere — create it ONCE, as a field.
Anchored adaptive by default (Google's revenue recommendation); the widget
reserves its height from the first frame so content never shifts under a
loading ad. Inline adaptive, fixed sizes and collapsible banners are
configured via BannerConfig(kind:, fixedSize:, collapsible:). Refresh is
client-driven every minRefresh — off by default (minRefresh: null), because
AdMob already auto-refreshes banner ad units server-side from the console; set the
rate there instead. When opted in, values under 30s are
clamped).
Adaptive banners have no pure-width height formula — Google documents
50–90dp depending on device and width. AdFlowBanner reserves a
device-height-aware estimate (15% of screen height, clamped to that
50–90dp range) rather than a flat guess, but if you already know the real
height for a placement (e.g. from a previous load), pass it explicitly via
placeholderHeight to eliminate any residual shift entirely.
Interstitial #
// At natural break points (level end, screen change):
ads.interstitial.recordUserAction();
await ads.interstitial.show();
Preloaded at init and after every dismissal. show() is a no-op (returns
false) while consent is closed, a cap is active, another full-screen ad is
visible, or — once you start calling recordUserAction() — fewer than
minActionsBetween actions happened since the last interstitial.
Rewarded #
await ads.rewarded.show(onReward: (reward) {
wallet.add(reward.amount); // fires at most once per ad
});
High-value rewards: set RewardedConfig.ssv for server-side verification —
and update it at runtime as your app learns more (3.0.0):
// After login, and again right before showing (e.g. which mission this is):
await ads.rewarded.setServerSideVerification(
ServerSideVerification(userId: user.id, customData: 'mission-7'),
);
The update applies to the already-loaded ad AND every future load, and throws if attaching fails — when you grant high-value rewards, you want to know your verification payload did not make it.
Preloaded full-screen ads also expire (Google documents ~1 hour): ad_flow
timestamps every load, proactively replaces a warm ad that goes stale
(maxAdAge, default 55 minutes for interstitial/rewarded formats, 4 hours for
app-open) and never shows an expired one.
A rewarded ad is one the user asked for, so the global frequency cap never
blocks it (ADR-039) — a user who taps "watch an ad for 100 coins" must never be
silently refused because an interstitial happened to fire moments earlier. Its
impression is still recorded globally, so an involuntary interstitial cannot
fire straight after one. Both rewarded formats are uncapped by default; set
RewardedConfig.cap / RewardedInterstitialConfig.cap if you want a per-format
limit.
Rewarded interstitial #
await ads.rewardedInterstitial.show(onReward: grantReward);
AdMob policy requires an intro screen with clear reward messaging and a
skip option before the ad plays. ad_flow enforces this by construction:
the rewardedIntroPresenter you pass to initialize runs first, and the
ad shows only if the user didn't skip. RewardedIntroScreen.show is the
ready-made presenter; customize copy via RewardedInterstitialConfig.intro.
Native #
AdFlowNativeAd(adFlow: ads) // creates + owns its controller (3.0)
// per-placement override: AdFlowNativeAd(adFlow: ads, config: NativeConfig(...))
Template rendering (NativeConfig(templateKind: NativeTemplateKind.small | .medium))
needs no native code. For fully custom layouts register a platform
NativeAdFactory (see the official guide)
and use NativeConfig(factoryId: 'yourFactoryId').
App open #
Nothing to call. The AppOpenAdManager (started by initialize) shows a
preloaded ad when the app returns to the foreground — never on cold launch,
never over another full-screen ad, never past the 4-hour expiry (stale ads
are discarded and proactively replaced). App-open ads show on the first
genuine warm return of a session; a cold launch emits no foreground event, so
there is nothing to show on one (AppOpenConfig.showOnColdStart was removed
in 3.0 for the same reason — it never could do anything).
Policy note: Google prohibits app-open ads in "Designed for Families" apps. If your app is in the Families program, leave the
appOpenslot unconfigured.
An app-open ad is never shown when the user returns from a banner/native ad they clicked. If a screen shows a large, blocking banner or native ad, tell ad_flow so no app-open ad covers it:
ads.setBlockingViewAdVisible(true); // in initState
ads.setBlockingViewAdVisible(false); // in dispose
ad_flow cannot judge that for you — whether a banner is "blocking" depends on your layout — so ad placement remains partly your responsibility.
5. Consent & privacy #
UMP runs inside initialize. GDPR requires a persistent "Manage consent"
entry point when applicable:
PrivacyOptionsButton(consent: ads.consent) // renders nothing when not required
Consent failures never throw from initialize — the flow degrades to the
SDK's own canRequestAds() answer and surfaces the failure on
ads.consent.lastError.
ATT (iOS App Tracking Transparency) — two modes #
- UMP-driven (default). Pass no
attExplainer. On iOS, UMP drives the ATT explainer and system prompt for you — configure the IDFA message in AdMob's Privacy & messaging.ad_flowmakes no ATT calls itself. - Client-driven (opt-in, like v1). Pass an
attExplainer(below). Thenad_flowruns your own ATT primer → a short delay (200 ms, Apple's guidance) → Apple's system prompt, before the GDPR flow. In this mode do not also configure the UMP IDFA message in the AdMob console — that would double-prompt.
Either way, add NSUserTrackingUsageDescription to Info.plist.
Consent & ATT explainers (priming) #
Opt-in priming screens — the v2 equivalent of v1's initializeWithExplainer,
decoupled from BuildContext via the same presenter pattern as the
rewarded-interstitial intro. Show your own localizable screen explaining what
the next system dialog will ask; the real UMP form / ATT prompt always
follows. The consent primer appears only when a form will actually show
(non-EEA users never see it). Everything is additive — pass nothing and
behaviour is exactly as before.
final navigatorKey = GlobalKey<NavigatorState>();
// ...MaterialApp(navigatorKey: navigatorKey, ...)
final ads = await AdFlow.initialize(
myConfig,
// iOS: your ATT primer → 200 ms → Apple's system prompt, before GDPR.
attExplainer: (content) async {
final context = navigatorKey.currentContext;
if (context == null || !context.mounted) return;
await AttExplainerScreen.show(context, content);
},
// Shown before the GDPR form (EEA only).
consentExplainer: (content) async {
final context = navigatorKey.currentContext;
if (context == null || !context.mounted) return;
await ConsentExplainerScreen.show(context, content);
},
// Optional: localize / customize the copy.
// attExplainerContent: const AttExplainerContent(title: 'Autoriser le suivi ?'),
);
AttExplainerScreen and ConsentExplainerScreen are ready-made; pass your
own presenter to use custom UI, keeping it context-safe (the package never
holds a BuildContext — the callback resolves it). skipConsentPrimerIfAttDenied
(default true) skips the optional consent primer when the user just denied
ATT. It never suppresses the GDPR form itself: a required consent form
(EEA/UK/CH) is always shown, because ATT (Apple) and GDPR (EU) are independent
regimes — denying tracking does not satisfy GDPR consent.
Testing EEA behavior:
await AdFlow.initialize(config, consentDebug: const ConsentDebugOptions(
geography: ConsentDebugGeography.eea,
testIdentifiers: ['YOUR-HASHED-DEVICE-ID'],
));
6. Remove-Ads, kill switch, revenue, inspector #
ads.disableAds(); // user bought Remove-Ads: live/warm ads are DROPPED
// (mounted banner/native widgets fall back to their
// placeholder) and every future load/show is blocked
ads.enableAds(); // re-warms inventory at once
ads.adsEnabled; // ValueListenable<bool> — hide ad widgets reactively
ads.canRequestAds; // ValueListenable<bool> — LIVE consent answer (3.0):
// follows a late consent grant AND a withdrawal, unlike
// the one-shot whenReady snapshot
ads.onPaidEvent = (e) => analytics.logAdImpression(
value: e.valueMicros / 1e6,
currency: e.currencyCode,
adFormat: e.slot, // 'banner', 'interstitial', … (3.0.0)
adSource: e.adSourceName, // winning mediation network, when known (3.0.0)
);
ads.interstitial.response; // AdResponseSummary? — which network filled the
// warm ad (mediation diagnostics, 3.0.0)
await ads.openAdInspector(); // debug overlay on a test device
Emergency kill switch #
disableAds() is also your remote kill switch: gate it on a remote flag so
you can stop serving ads fleet-wide without an app update (an AdMob policy
review, a broken mediation adapter, a bad creative):
// e.g. Firebase Remote Config, at startup and on config refresh:
if (remoteConfig.getBool('ads_kill_switch')) {
ads.disableAds();
} else {
ads.enableAds();
}
"Why aren't my ads showing?" #
A refused load is a first-class state (3.0): the slot reports
AdBlocked(reason) instead of an ambiguous idle, so
ValueListenableBuilder UIs can react to it directly. Refused shows
(a frequency cap, user-action pacing) never change the load state — they
report through the callback channel:
ads.onAdBlocked = (slot, reason) =>
log.info('ad_flow: $slot blocked — ${reason.name}');
ads.interstitial.lastBlockReason; // AdBlockReason? — per-slot snapshot
AdBlockReason is one of adsDisabled (Remove-Ads on), consentNotGranted
(the user declined, or consent hasn't succeeded yet — e.g. offline),
frequencyCapped, otherAdShowing, notReady (nothing warm yet),
userActionPacing, expired (a stale app-open ad), introSkipped (the user
skipped the rewarded intro).
Most reasons are normal — a cap doing its job, a user declining an ad. This is a diagnostic channel, not an error channel. Wire it to your logger during a rollout and you can see, per app, exactly why a slot is quiet.
7. Testing your integration #
import 'package:ad_flow/ad_flow_testing.dart';
final sdk = FakeAdSdk()..canRequestAdsResult = true;
final ads = await AdFlow.initialize(
config,
sdk: sdk,
store: InMemoryKeyValueStore(),
platform: AdPlatform.android,
);
// initialize() is NON-BLOCKING: it returns before consent resolves, so at this
// point nothing has preloaded yet. In a test, wait for the background startup
// (and then for the preload itself) before asserting on loaded ads — otherwise
// `sdk.interstitials` is still empty.
await ads.whenReady;
await pumpEventQueue(); // flutter_test; or `await Future<void>.delayed(...)`
await ads.interstitial.show();
sdk.interstitials.single.simulateDismissed(); // drive SDK behavior
FakeAdSdk also models the failure modes worth testing: consentUpdateError
(an offline launch), onConsentInfoUpdate (the network coming back),
alwaysLoadError / nextLoadError (no-fill), loadHold and initializeHold
(a hung network), and enforceConsentGate = true, which throws if anything
requests an ad before consent allows it.
8. Next-Gen SDK (experimental, Android-only) #
The v9 plugin can swap its native Android dependency to Google's Next-Gen GMA SDK at build time — same Dart API, no code changes:
flutter build apk --dart-define=USE_NEXT_GEN_SDK=true
iOS ignores the flag. It is experimental in Flutter; keep it off in production until Google declares Flutter support GA.
9. Mediation #
Add the official gma_mediation_* adapter packages to your app, register
the partners in AdMob's Privacy & messaging for consent forwarding, and —
on iOS — add the partners' SKAdNetworkItems to Info.plist. Mediation
needs no ad_flow changes; adapters raise fill and eCPM transparently.
10. Policy compliance checklist #
- ❌
app-ads.txtpublished and verified (required since Jan 2025) - ❌ Production ad unit IDs only in
AdFlowConfig;testModeoff - ❌ Never click your own live ads; use test ads / registered test devices
- ❌ Interstitials only at natural breaks (
recordUserActionpacing on) - ❌ Banners not flush against tappable controls
- ❌ App open not combined with a banner on the same surface
- ❌ Privacy-options button reachable (e.g. in settings)
- ❌ Rewarded interstitial intro copy states the reward clearly
- ❌ iOS:
NSUserTrackingUsageDescriptioninInfo.plist(required whenever you passattExplainer— iOS terminates the app on the ATT prompt without it) - ❌ iOS:
SKAdNetworkItemsinInfo.plist(see §2 — missing entries cost real iOS revenue silently) - ❌ iOS ATT — pick exactly ONE, never both:
- client-driven (you pass
attExplainer): do not configure the IDFA message in the AdMob console, or the user is prompted twice; - UMP-driven (no
attExplainer): configure the IDFA message in the AdMob console and let UMP show it.
- client-driven (you pass
License #
MIT — see LICENSE.