ad_flow 5.2.1 copy "ad_flow: ^5.2.1" to clipboard
ad_flow: ^5.2.1 copied to clipboard

AdMob integration for Flutter with policy-aware defaults: banner, interstitial, rewarded, rewarded interstitial, native and app open ads with GDPR/UMP consent.

example/lib/main.dart

import 'dart:async';

import 'package:ad_flow/ad_flow.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

// ad_flow example — every ad format over Google's sample IDs, in BOTH of the
// two initialization modes:
//
//   • SIMPLE (useExplainer = false): the minimal drop-in. UMP handles the whole
//     consent flow, and — if you configure the IDFA message in the AdMob
//     console — the iOS ATT prompt too. No client-side priming screens.
//
//   • WITH EXPLAINER (useExplainer = true): the same, PLUS opt-in priming
//     screens — your own ATT primer before Apple's system prompt (client-driven
//     ATT, iOS) and your own consent primer before the UMP GDPR form (EEA).
//     Recommended for EEA audiences and iOS: a soft explainer lifts opt-in rates.
//
// BOTH modes are NON-BLOCKING (ADR-032): AdFlow.initialize() builds the graph
// and returns immediately; consent, ATT and SDK init all run in the BACKGROUND.
// We render HomeScreen on the first frame and the consent / ATT / explainer
// screens appear OVER the already-visible app — never a splash gate.
//
// Flip the flag below to switch modes. (Non-const on purpose, so BOTH init
// helpers stay referenced and the example analyzes clean either way.)
bool useExplainer = true;

/// Global navigator key so the consent/ATT/rewarded-intro presenters can push
/// screens from outside the widget tree (the package never holds a
/// BuildContext).
final navigatorKey = GlobalKey<NavigatorState>();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // NON-BLOCKING: initialize() returns on the next microtask — BEFORE consent /
  // ATT / SDK init, which run in the background. The `await` here only waits on
  // synchronous graph construction, never the network, so the first frame is
  // instant. NEVER gate your UI on this Future (that was v1's splash hang).
  final ads = await (useExplainer ? _initWithExplainer() : _initSimple());

  // Impression-level revenue (allowlisted AdMob accounts only). Assignable any
  // time — set it right after init so no paid event is missed. `slot` and
  // `adSourceName` (3.0.0) carry the format and the winning mediation network,
  // ready for an analytics ad_impression event.
  ads.onPaidEvent = (event) => debugPrint(
    '[ad_flow] paid: ${event.slot}/${event.adUnitId} '
    '${event.valueMicros / 1e6} ${event.currencyCode} '
    '(${event.precision.name}'
    '${event.adSourceName == null ? '' : ', via ${event.adSourceName}'})',
  );

  // "Why aren't my ads showing?" — every refused load/show reports its reason
  // (consent pending, Remove-Ads, frequency cap, expiry…). Most reasons are
  // NORMAL; wire this to your logger during rollout (2.1.0, ADR-045).
  ads.onAdBlocked = (slot, reason) =>
      debugPrint('[ad_flow] $slot blocked: ${reason.name}');

  runApp(ExampleApp(ads: ads));
}

/// SIMPLE mode's app-open config is `resumeOnly` (the default). This example
/// opts into `launchAndResume` so it can also demonstrate the cold-launch
/// opportunity below. Both helpers request it via `AdFlowConfig.test`.
const _appOpenTriggerMode = AppOpenTriggerMode.launchAndResume;

/// SIMPLE mode — the minimal drop-in. UMP drives the whole consent flow (and
/// iOS ATT if you set the console IDFA message); no client-side priming.
///
/// `rewardedIntroPresenter` is still required because this config includes the
/// rewarded-interstitial format, whose intro + skip screen must always show
/// first (AdMob policy). Drop the `rewardedInterstitial` slot and you can drop
/// the presenter too.
Future<AdFlow> _initSimple() {
  return AdFlow.initialize(
    // Google sample ads everywhere. Replace with your production config:
    //   AdFlowConfig(banner: BannerConfig(adUnitId: PlatformAdUnitId(...)), ...)
    AdFlowConfig.test(appOpenTriggerMode: _appOpenTriggerMode),
    rewardedIntroPresenter: (content) async {
      final context = navigatorKey.currentContext;
      if (context == null || !context.mounted) return false;
      return RewardedIntroScreen.show(context, content);
    },
  );
}

/// WITH-EXPLAINER mode — everything in [_initSimple], PLUS opt-in priming
/// screens. Each presenter resolves the navigatorKey's context itself, so the
/// package never holds a BuildContext.
///
/// - `attExplainer` shows your ATT primer, then (after a 200 ms delay) Apple's
///   system tracking prompt — client-driven ATT (iOS; a no-op elsewhere). In
///   this mode do NOT also configure the UMP IDFA message in the AdMob console,
///   or the user sees two prompts.
/// - `consentExplainer` shows your consent primer before the UMP GDPR form, and
///   only when a form will actually appear (EEA users only — non-EEA users
///   never see it).
Future<AdFlow> _initWithExplainer() {
  return AdFlow.initialize(
    AdFlowConfig.test(appOpenTriggerMode: _appOpenTriggerMode),
    rewardedIntroPresenter: (content) async {
      final context = navigatorKey.currentContext;
      if (context == null || !context.mounted) return false;
      return RewardedIntroScreen.show(context, content);
    },
    attExplainer: (content) async {
      final context = navigatorKey.currentContext;
      if (context == null || !context.mounted) return;
      await AttExplainerScreen.show(context, content);
    },
    consentExplainer: (content) async {
      final context = navigatorKey.currentContext;
      if (context == null || !context.mounted) return;
      await ConsentExplainerScreen.show(context, content);
    },
  );
}

class ExampleApp extends StatelessWidget {
  const ExampleApp({required this.ads, super.key});

  final AdFlow ads;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ad_flow example',
      navigatorKey: navigatorKey,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      // Rendered IMMEDIATELY — no FutureBuilder<AdFlow> gate. Consent, ATT and
      // the explainer screens push over this via navigatorKey as they resolve.
      // StartupScreen is a real loading screen: it does its startup work, takes
      // the one-shot cold-launch app-open opportunity, then reveals HomeScreen.
      home: StartupScreen(ads: ads),
    );
  }
}

/// A real loading/startup screen — the correct place to take the one-shot
/// cold-launch app-open opportunity (5.1).
///
/// It renders its own UI immediately (never a blank/blocked first frame), does
/// the app's genuine startup work, then — right before entering main content —
/// calls [AppOpenAdManager.showAtLaunchIfReady]. That call NEVER waits for a
/// load: if an ad is not already warm at this instant (the usual case at a true
/// cold launch, since consent + the first load have not finished yet) it
/// returns false immediately and we proceed. So this screen exists to do work,
/// not to wait for an ad.
class StartupScreen extends StatefulWidget {
  const StartupScreen({required this.ads, super.key});

  final AdFlow ads;

  @override
  State<StartupScreen> createState() => _StartupScreenState();
}

class _StartupScreenState extends State<StartupScreen> {
  @override
  void initState() {
    super.initState();
    unawaited(_startup());
  }

  Future<void> _startup() async {
    // Your genuine startup work goes here (restore state, remote config, warm
    // caches…). This example has none, so it only waits for the first frame to
    // paint — a real thing you'd do before an app-open ad, and NOT an
    // artificial delay. Do not busy-wait for an ad here.
    await WidgetsBinding.instance.endOfFrame;
    if (!mounted) return;

    // Immediately before entering main content: take the cold-launch
    // opportunity. Shows an already-ready ad (and awaits its dismissal); if
    // none is ready it returns false at once. One-shot per process launch.
    await widget.ads.appOpenOrNull?.showAtLaunchIfReady();
    if (!mounted) return;

    Navigator.of(context).pushReplacement(
      MaterialPageRoute<void>(builder: (_) => HomeScreen(ads: widget.ads)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            FlutterLogo(size: 72),
            SizedBox(height: 24),
            CircularProgressIndicator(),
            SizedBox(height: 16),
            Text('Loading…'),
          ],
        ),
      ),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({required this.ads, super.key});

  final AdFlow ads;

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _coins = 0;

  AdFlow get ads => widget.ads;

  void _grantReward(RewardEarned reward) {
    setState(() => _coins += reward.amount.toInt());
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Earned ${reward.amount} ${reward.type}!')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ad_flow example'),
        actions: [
          Center(
            child: Padding(
              padding: const EdgeInsets.only(right: 16),
              child: Text('coins: $_coins'),
            ),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _StateTile(
            title: 'Interstitial',
            subtitle: 'Natural breaks only; caps + action pacing apply.',
            state: ads.interstitial.state,
            onPressed: () {
              // Tell the pacer a natural break happened, then try to show.
              ads.interstitial.recordUserAction();
              ads.interstitial.show();
            },
            buttonLabel: 'Show interstitial',
          ),
          _StateTile(
            title: 'Rewarded',
            subtitle: 'User-initiated; grants coins via onReward.',
            state: ads.rewarded.state,
            onPressed: () => ads.rewarded.show(onReward: _grantReward),
            buttonLabel: 'Watch ad, earn coins',
          ),
          _StateTile(
            title: 'Rewarded interstitial',
            subtitle: 'Always shows the intro + skip screen first (policy).',
            state: ads.rewardedInterstitial.state,
            onPressed: () =>
                ads.rewardedInterstitial.show(onReward: _grantReward),
            buttonLabel: 'Show rewarded interstitial',
          ),
          _StateTile(
            title: 'App open',
            subtitle:
                'launchAndResume: shows on the first warm return, and at cold '
                'launch via the startup screen when an ad is already ready.',
            state: ads.appOpenController.state,
          ),
          // Remove-Ads hides the WHOLE decorated Card — title, padding and
          // border — not just the ad inside it. A parent decoration wrapped
          // around a child that collapses to zero stays visible otherwise (an
          // empty bordered card), which is exactly the residual-surface bug
          // this release fixes. The leading spacer is inside the conditional so
          // it disappears with the card.
          ValueListenableBuilder(
            valueListenable: ads.adsEnabled,
            builder: (context, enabled, _) => !enabled
                ? const SizedBox.shrink()
                : Column(
                    children: [
                      const SizedBox(height: 8),
                      Card(
                        child: Padding(
                          padding: const EdgeInsets.all(8),
                          child: Column(
                            children: [
                              const Text('Native (medium template)'),
                              const SizedBox(height: 8),
                              // Widget-first (3.0): the widget creates AND owns
                              // its controller internally, so the classic
                              // footgun — minting a fresh controller inside
                              // build(), restarting the load (and blanking the
                              // ad) on every setState — cannot happen.
                              AdFlowNativeAd(adFlow: ads),
                            ],
                          ),
                        ),
                      ),
                    ],
                  ),
          ),
          const SizedBox(height: 8),
          ValueListenableBuilder(
            valueListenable: ads.adsEnabled,
            builder: (context, enabled, _) => SwitchListTile(
              title: const Text('Ads enabled (Remove-Ads toggle)'),
              value: enabled,
              onChanged: (v) => v ? ads.enableAds() : ads.disableAds(),
            ),
          ),
          PrivacyOptionsButton(consent: ads.consent),
          TextButton(
            onPressed: ads.openAdInspector,
            child: const Text('Open Ad Inspector'),
          ),
        ],
      ),
      // Reserved height from the first frame — no layout shift. Widget-first
      // (3.0): AdFlowBanner creates and owns its controller.
      //
      // Remove-Ads must remove the COMPLETE bottom ad surface. Returning
      // SizedBox.shrink() BEFORE constructing SafeArea is the point: if the
      // banner merely collapsed to zero height INSIDE a retained SafeArea, the
      // safe-area inset would still reserve a strip of empty space at the
      // bottom. The parent surface has to go too.
      bottomNavigationBar: ValueListenableBuilder(
        valueListenable: ads.adsEnabled,
        builder: (context, enabled, _) => !enabled
            ? const SizedBox.shrink()
            : SafeArea(child: AdFlowBanner(adFlow: ads)),
      ),
    );
  }
}

class _StateTile extends StatelessWidget {
  const _StateTile({
    required this.title,
    required this.subtitle,
    required this.state,
    this.onPressed,
    this.buttonLabel,
  });

  final String title;
  final String subtitle;
  final ValueListenable<AdLoadState> state;
  final VoidCallback? onPressed;
  final String? buttonLabel;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(title),
        subtitle: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(subtitle),
            ValueListenableBuilder(
              valueListenable: state,
              builder: (context, s, _) => Text(
                'state: ${switch (s) {
                  AdIdle() => 'idle',
                  AdLoading() => 'loading…',
                  AdLoaded() => 'ready',
                  AdShowing() => 'showing',
                  // 3.0: a refused load is a first-class state — consent
                  // pending, Remove-Ads on, cap active… no more guessing
                  // from a bare 'idle'.
                  AdBlocked(:final reason) => 'blocked (${reason.name})',
                  AdFailed(:final error) => 'failed (${error.message})',
                }}',
                style: Theme.of(context).textTheme.labelSmall,
              ),
            ),
            if (buttonLabel != null)
              FilledButton.tonal(
                onPressed: onPressed,
                child: Text(buttonLabel!),
              ),
          ],
        ),
      ),
    );
  }
}
4
likes
0
points
305
downloads

Publisher

verified publisherfaizahmaddae.com

Weekly Downloads

AdMob integration for Flutter with policy-aware defaults: banner, interstitial, rewarded, rewarded interstitial, native and app open ads with GDPR/UMP consent.

Repository (GitHub)
View/report issues

Topics

#admob #ads #monetization #gdpr

License

unknown (license)

Dependencies

app_tracking_transparency, flutter, google_mobile_ads, shared_preferences

More

Packages that depend on ad_flow