applovin_admob_sdk 2.9.20
applovin_admob_sdk: ^2.9.20 copied to clipboard
Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Offline-verified VIP codes, GDPR/COPPA/CCPA consent, safety caps, AdEvent stream, debug overlay.
example/lib/main.dart
// ═══════════════════════════════════════════════════════════════════════════
// applovin_admob_sdk — example app, single file by design.
//
// Every demo page lives in this one file on purpose: pub.flutter-io.cn's "Example"
// tab on https://pub.flutter-io.cn/packages/applovin_admob_sdk/example renders only
// the example app's entry-point .dart file, not files it imports/exports —
// so a visitor evaluating the package before installing it only ever sees
// whatever main.dart itself contains. Splitting demos into separate files
// (T117) is a good repo-hygiene move for day-to-day editing but makes the
// package look unfinished on pub.flutter-io.cn, since the tab then shows a ~90-line
// stub of import/export statements instead of any working demo code.
// Kept as one file after that tradeoff was made explicit — length is an
// accepted cost, not an oversight; do not re-split without checking this
// tradeoff still holds.
//
// Sections below, in order: main()/navigator key, config (provider + ad
// unit IDs), shared widgets/state (LogBuffer, EventBuffer, layout helpers,
// DemoTile, HomePage), the splash screen, then one demo page per ad
// surface/feature (Banner, MREC, Native, Interstitial, Rewarded, Rewarded
// Interstitial, App Open, Adaptive surface, VIP, Consent, Compliance,
// Diagnostics, Events, Log viewer, Revenue, Safety, State panel, Test
// device hash).
//
// Provider (AdMob vs AppLovin) is chosen ONCE at app startup via
// `AdConfig.provider` and is **not** swappable at runtime. Default is
// AppLovin; pass --dart-define=AD_PROVIDER_ADMOB=true to build/test the
// AdMob path instead (see kProvider below).
// ═══════════════════════════════════════════════════════════════════════════
import 'dart:async';
import 'dart:convert' show JsonEncoder;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';
import 'package:applovin_max/applovin_max.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
final _navigatorKey = GlobalKey<NavigatorState>();
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Edge-to-edge: status bar + nav bar transparent, content paints behind them.
// Required on Android 15+ (target SDK 35) and recommended on older versions.
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
));
// ⚠️ Required: register navigator key BEFORE runApp so the SDK can show
// loading dialogs from lifecycle observer (App Open on resume).
AdManager().setNavigatorKey(_navigatorKey);
// Round-27 backlog B5 — destroy() closes AdManager().events and opens a
// FRESH stream on the next initialize() (T31). A subscription taken out
// once here, at startup, receives `done` on that first destroy() and
// never follows the new stream — the "Slot state panel" demo's own
// Destroy/Re-initialize buttons silently killed the Event stream/Revenue
// dashboard pages for the rest of the run. Rebind on every initRevision
// change (already the SDK's own signal for "adapter/session identity
// changed", used elsewhere in this file) instead of subscribing once.
StreamSubscription<AdEvent>? eventBufferSub;
void rebindEventBuffer() {
eventBufferSub?.cancel();
eventBufferSub = AdManager().events.listen(EventBuffer.instance.onEvent);
}
rebindEventBuffer();
AdManager().initRevision.addListener(rebindEventBuffer);
runApp(MaterialApp(
title: 'ad_sdk demo',
debugShowCheckedModeBanner: kDebugMode,
navigatorKey: _navigatorKey,
// ⚠️ Required: register route observers for RouteAware banner lifecycle.
navigatorObservers: [adRouteObserver, AdScreenRouteLogger()],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
// DebugAdOverlay floats over every screen (kDebugMode only). Wrapping
// here instead of inside HomePage means the overlay stays visible while
// the user navigates into any demo page.
builder: (context, child) {
if (child == null) return const SizedBox.shrink();
return Stack(
children: [
child,
const DebugAdOverlay(),
],
);
},
home: const SplashScreen(),
));
}
// ─────────────────────────────────────────────────────────────────────────
// config/demo_config.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — DemoConfig + example-only constants (AppLovin placeholder ad-unit
// IDs, VIP demo keys, safety preset). Split out of main.dart.
// ═══════════════════════════════════════════════════════════════════════════
// §1 Constants + DemoConfig + VIP validator
// ═══════════════════════════════════════════════════════════════════════════
// T41 — no real AppLovin credentials are committed to source. Placeholders
// below make the demo build/run out of the box (native init simply fails to
// load real creative — safe default, not a crash). Anyone who needs to
// exercise real ads locally passes their own IDs via --dart-define, e.g.:
// flutter run --dart-define=APPLOVIN_SDK_KEY=... \
// --dart-define=APPLOVIN_BANNER_ID_ANDROID=... \
// --dart-define=APPLOVIN_BANNER_ID_IOS=... (…and _INTERSTITIAL_/_APPOPEN_/_REWARDED_ for each platform)
// See README.md "Compliance checklist".
const String _kAppLovinSdkKey = String.fromEnvironment('APPLOVIN_SDK_KEY',
defaultValue: 'YOUR_86_CHAR_SDK_KEY_FROM_APPLOVIN_DASHBOARD');
final String _kAppLovinBannerId = Platform.isIOS
? const String.fromEnvironment('APPLOVIN_BANNER_ID_IOS',
defaultValue: 'YOUR_BANNER_AD_UNIT_ID')
: const String.fromEnvironment('APPLOVIN_BANNER_ID_ANDROID',
defaultValue: 'YOUR_BANNER_AD_UNIT_ID');
final String _kAppLovinInterstitialId = Platform.isIOS
? const String.fromEnvironment('APPLOVIN_INTERSTITIAL_ID_IOS',
defaultValue: 'YOUR_INTERSTITIAL_AD_UNIT_ID')
: const String.fromEnvironment('APPLOVIN_INTERSTITIAL_ID_ANDROID',
defaultValue: 'YOUR_INTERSTITIAL_AD_UNIT_ID');
final String _kAppLovinAppOpenId = Platform.isIOS
? const String.fromEnvironment('APPLOVIN_APPOPEN_ID_IOS',
defaultValue: 'YOUR_APP_OPEN_AD_UNIT_ID')
: const String.fromEnvironment('APPLOVIN_APPOPEN_ID_ANDROID',
defaultValue: 'YOUR_APP_OPEN_AD_UNIT_ID');
final String _kAppLovinRewardedId = Platform.isIOS
? const String.fromEnvironment('APPLOVIN_REWARDED_ID_IOS',
defaultValue: 'YOUR_REWARDED_AD_UNIT_ID')
: const String.fromEnvironment('APPLOVIN_REWARDED_ID_ANDROID',
defaultValue: 'YOUR_REWARDED_AD_UNIT_ID');
final String _kAppLovinMrecId = Platform.isIOS
? const String.fromEnvironment('APPLOVIN_MREC_ID_IOS',
defaultValue: 'YOUR_MREC_AD_UNIT_ID')
: const String.fromEnvironment('APPLOVIN_MREC_ID_ANDROID',
defaultValue: 'YOUR_MREC_AD_UNIT_ID');
final String _kAppLovinNativeId = Platform.isIOS
? const String.fromEnvironment('APPLOVIN_NATIVE_ID_IOS',
defaultValue: 'YOUR_NATIVE_AD_UNIT_ID')
: const String.fromEnvironment('APPLOVIN_NATIVE_ID_ANDROID',
defaultValue: 'YOUR_NATIVE_AD_UNIT_ID');
/// Provider for this app — defaults to `AdProvider.appLovin` since AppLovin is
/// the harder-to-exercise path (AdMob demo IDs are Google's public test
/// units; AppLovin needs real per-account IDs passed via --dart-define, see
/// above). Do not change this default to `AdProvider.admob` in source.
///
/// For integration-test runs that need to exercise the AdMob path, pass
/// `--dart-define=AD_PROVIDER_ADMOB=true` instead of editing this constant.
///
/// F8 — this `--dart-define` compile-time switch is a dev/test convenience
/// specific to this example app. Don't copy it verbatim into a real app: a
/// production app almost always hard-codes one provider (or picks it from a
/// remote config it controls), not an env flag toggled at build time.
const AdProvider kProvider = bool.fromEnvironment('AD_PROVIDER_ADMOB')
? AdProvider.admob
: AdProvider.appLovin;
/// T41 — the loose QA safety preset ([kDemoSafetyParams]) is opt-in only, so
/// a release build of this example never ships with fraud/frequency caps
/// disabled. Pass `--dart-define=QA_AD_STRESS=true` to enable it locally.
const bool kQaAdStress = bool.fromEnvironment('QA_AD_STRESS');
// QA seam for the EEA consent path. Without it, a tester outside the EEA can
// never reach UMP's `required` branch: UMP resolves `notRequired`, no form is
// served, and every EEA-only code path stays unexercised (that blind spot is
// what let two consent bugs ship). `UMP_TEST_ID` is the hashed device id UMP
// prints to the log on first run — required, or debugGeography is ignored.
// flutter run --dart-define=UMP_EEA_DEBUG=true --dart-define=UMP_TEST_ID=<hash>
const bool kUmpEeaDebug = bool.fromEnvironment('UMP_EEA_DEBUG');
const String kUmpTestId = String.fromEnvironment('UMP_TEST_ID');
/// Placeholder privacy-policy link shown by the consent dialog demo.
/// Real apps should point this at their own published policy.
const String kDemoPrivacyPolicyUrl = 'https://example.com/privacy';
/// VIP demo keys (Q28 — user-supplied).
const Map<String, Duration> kDemoVipKeys = {
'TEST_VIP_7': Duration(days: 7),
'TEST_VIP_30': Duration(days: 30),
'TEST_VIP_90': Duration(days: 90),
};
/// ⚠️ DEMO KEYPAIR — DO NOT SHIP THIS.
///
/// This public key and the signed codes below are published in the SDK's
/// example app, so they are public knowledge: any app that ships this exact
/// public key grants VIP to anyone who pastes one of the demo codes.
///
/// Before releasing an app, generate your own keypair with
/// `dart run tool/vip_keygen.dart`, keep the PRIVATE key off the repo, and
/// mint real codes with `dart run tool/vip_mint.dart`. Only the public key
/// belongs in your binary.
///
/// T18 — offline SIGNED VIP keys. The public key below verifies the keys; the
/// matching private key (never shipped) minted them via tool/vip_mint.dart.
const String kDemoVipPublicKey = 'nqmoUYYjAH_dVDcO5fZk8EagjLIq688hPbAzIYD0DWY=';
const Map<String, String> kDemoSignedVipKeys = {
'1d':
'AVP1.ODY0MDB8ZGVtbzFk.NFrAVXDD8FUNpZzBQG_MDq_dgKVyE6HmRTn7TTxmbWT0_hIZX2_9PO1tX2SBMMWh-Mp5nt3d3hnNSbYuDI-tCA==',
'7d':
'AVP1.NjA0ODAwfGRlbW83ZA==.7lj_TWdPk3h8LWcBAQzU5dfwmfMeu0--inrlLckEgqtlx3LpNpPNOX4TNZ7ypHmfKRamSWErp6uyRDP54jAaAg==',
'30d':
'AVP1.MjU5MjAwMHxkZW1vMzBk.nCPvlNoexldaulVWw5IycTDM1Cr_pUmQQMuf0myVogbnTcrccs69LB40t1MtvPLNhakK0OPIM3e_GaXOsXKrDg==',
};
/// Validator wired into [AdConfig.vipKeyValidator] — only the demo keys above
/// are valid. In a real app this calls your server.
Future<bool> demoVipValidator(String key) async {
await Future<void>.delayed(const Duration(milliseconds: 600));
return kDemoVipKeys.containsKey(key);
}
/// Builds the [AdConfig] used by the demo. Provider is a compile-time const
/// (`kProvider` above) — runtime swap is intentionally NOT exposed because
/// the SDK is designed to be initialised once per app process.
class DemoConfig {
DemoConfig._();
static final DemoConfig instance = DemoConfig._();
AdConfig build() {
return AdConfig(
provider: kProvider,
admob: const AdMobConfig(
bannerId: 'ca-app-pub-3940256099942544/6300978111',
interstitialId: 'ca-app-pub-3940256099942544/1033173712',
appOpenId: 'ca-app-pub-3940256099942544/9257395921',
rewardedId: 'ca-app-pub-3940256099942544/5224354917',
// Round-31 audit fix (MAJOR) — MREC is a banner at a different
// size (see mrec_ad_widget.dart), not a distinct AdMob ad format,
// so it must use the BANNER test id, not the Native Advanced one
// this line and `nativeId` below were both wrongly sharing.
mrecId: 'ca-app-pub-3940256099942544/6300978111',
nativeId: 'ca-app-pub-3940256099942544/2247696110',
// Round-31 audit fix (MAJOR) — Rewarded Interstitial is AdMob-only
// (see README); without this the dedicated demo page for it
// (added specifically to close this coverage gap) could never
// show an ad on the one provider that supports the format at all.
rewardedInterstitialId: 'ca-app-pub-3940256099942544/5354046379',
// Optional per-platform overrides (T15) — omit to use the same id
// on both platforms, as above:
// androidBannerId: 'ca-app-pub-.../android-banner',
// iosBannerId: 'ca-app-pub-.../ios-banner',
),
appLovin: AppLovinConfig(
sdkKey: _kAppLovinSdkKey,
bannerId: _kAppLovinBannerId,
interstitialId: _kAppLovinInterstitialId,
appOpenId: _kAppLovinAppOpenId,
rewardedId: _kAppLovinRewardedId,
mrecId: _kAppLovinMrecId,
nativeId: _kAppLovinNativeId,
),
// Verbose logs include the raw device advertising ID (GAID) — keep
// this off release builds, matching AdConfig's own safe default.
logLevel: kDebugMode ? AdLogLevel.verbose : AdLogLevel.warning,
onLog: LogBuffer.instance.sink,
vipKeyValidator: demoVipValidator,
// Cap the total stacked VIP window (cộng dồn) — demo at 90 days. null = uncapped.
maxVipStackDuration: const Duration(days: 90),
adNotReadyMessage: 'Ad not ready — please wait.',
adLoadingMessage: 'Loading…',
splashMaxDuration: const Duration(seconds: 8),
umpDebugGeography: kUmpEeaDebug ? DebugGeography.debugGeographyEea : null,
umpTestIdentifiers: kUmpTestId.isEmpty ? const [] : const [kUmpTestId],
// T41 — the loose preset (999 caps, 2 s throttle, 0 s warm-up) only
// applies with --dart-define=QA_AD_STRESS=true, so QA can pound the
// buttons on demand without every debug/release build shipping with
// fraud/frequency caps effectively disabled.
// ⚠️ DO NOT copy kDemoSafetyParams into a production app — use
// AdSafetyParams.auto (default) or AdSafetyParams.production there.
safety: kQaAdStress ? kDemoSafetyParams : AdSafetyParams.auto,
// First-install VIP grace: 30 s in debug (so QA can verify "after
// grace expires, ads return" without waiting 24 h), 24 h in release.
// Other options:
// FirstInstallVipGrace.disabled → never grant
// FirstInstallVipGrace.day → force 24 h both modes
// FirstInstallVipGrace(Duration(hours: 12)) → custom
firstInstallVipGrace: FirstInstallVipGrace.auto,
// Auto-show Cupertino consent dialog ~1 s after splash → home (skipped
// for VIP users — first 30 s of debug install stays silent because
// grace is active). Strings default to English; consumers override
// via consentDialogStrings: ConsentDialogStrings.vi etc.
autoShowConsentDialog: true,
consentDialogPostSplashDelay: const Duration(seconds: 1),
// Demo wires a real privacy-policy URL so the dialog's link isn't
// silently hidden — a bare `debugPrint` handler is enough here since
// this is a demo harness, not a shipping app (a real app would
// `launchUrl(Uri.parse(url))`, e.g. via package:url_launcher).
consentDialogStrings:
const ConsentDialogStrings(privacyPolicyUrl: kDemoPrivacyPolicyUrl),
onPrivacyPolicyTap: (url) =>
debugPrint('[example] privacy policy tapped: $url'),
);
}
}
/// Single set of safety params used by this demo for both debug and release.
/// All caps are 999 / throttle 2 s / no warm-up — chosen for easy QA testing.
const AdSafetyParams kDemoSafetyParams = AdSafetyParams(
minTimeBetweenFullscreenAds: 2000, // 2 s between fullscreen ads
maxFullscreenAdsPerSession: 999,
maxFullscreenAdsPerHour: 999,
maxFullscreenAdsPerDay: 999,
minSessionDurationBeforeAd: 0,
minTimeAppOpenResume: 0,
maxClicksPerMinute: 999,
suspiciousCtrThreshold: 1.0, // CTR fraud check effectively disabled
maxRapidResumesPerMinute: 999,
dryRun: false,
);
// ─────────────────────────────────────────────────────────────────────────
// shared/log_buffer.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — in-memory ring buffer of SDK logs, feeding LogViewerDemoPage.
// Split out of main.dart.
class LogBuffer {
LogBuffer._();
static final LogBuffer instance = LogBuffer._();
static const int _maxEntries = 200;
final List<LogEntry> _entries = [];
final ValueNotifier<int> revision = ValueNotifier<int>(0);
void Function(AdLogLevel, String, String) get sink => _onLog;
void _onLog(AdLogLevel level, String tag, String message) {
_entries.add(LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
));
if (_entries.length > _maxEntries) {
_entries.removeAt(0);
}
revision.value = revision.value + 1;
}
List<LogEntry> snapshot() => List.unmodifiable(_entries);
void clear() {
_entries.clear();
revision.value = revision.value + 1;
}
}
class LogEntry {
const LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
});
final DateTime timestamp;
final AdLogLevel level;
final String tag;
final String message;
}
// ─────────────────────────────────────────────────────────────────────────
// shared/event_buffer.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — in-memory ring buffer of AdEvent stream entries, feeding
// EventsDemoPage. Split out of main.dart. EventRow lives here (not in
// demos/events_demo_page.dart) since EventBuffer constructs it directly.
class EventBuffer {
EventBuffer._();
static final EventBuffer instance = EventBuffer._();
static const int _maxEntries = 100;
final List<EventRow> _rows = [];
final ValueNotifier<int> revision = ValueNotifier<int>(0);
void onEvent(AdEvent event) {
_rows.insert(0, EventRow(DateTime.now(), event));
if (_rows.length > _maxEntries) _rows.removeLast();
revision.value = revision.value + 1;
}
List<EventRow> snapshot() => List.unmodifiable(_rows);
void clear() {
_rows.clear();
revision.value = revision.value + 1;
}
}
class EventRow {
EventRow(this.timestamp, this.event);
final DateTime timestamp;
final AdEvent event;
}
// ─────────────────────────────────────────────────────────────────────────
// shared/layout_helpers.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — edge-to-edge layout helper shared by every demo page. Split out of
// main.dart (used to be private to that one file; now needs to be public
// since it's called from many files).
/// Adds the system nav-bar inset to the bottom of [base] so the last item in
/// a scrollable isn't hidden behind the (transparent) Android nav bar in
/// edge-to-edge mode.
EdgeInsets bottomSafe(BuildContext context, EdgeInsets base) {
final inset = MediaQuery.paddingOf(context).bottom;
return EdgeInsets.fromLTRB(
base.left, base.top, base.right, base.bottom + inset);
}
// ─────────────────────────────────────────────────────────────────────────
// shared/demo_tile.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — HomePage's list-tile widget. Split out of main.dart.
class DemoTile extends StatelessWidget {
const DemoTile({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.color = Colors.blue,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) => Card(
elevation: 1,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(icon, color: color),
),
title:
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: onTap,
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// shared/home_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — top-level list of all demos. Split out of main.dart.
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ad_sdk demo'),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
// DebugAdOverlay is mounted at MaterialApp.builder so it floats over
// every page, not just HomePage.
body: ListView(
padding: bottomSafe(context, const EdgeInsets.symmetric(vertical: 8)),
children: [
DemoTile(
icon: Icons.image,
title: 'Banner ad',
subtitle: 'Anchored adaptive banner with route lifecycle',
color: Colors.blue,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const BannerDemoPage())),
),
DemoTile(
icon: Icons.crop_landscape,
title: 'MREC ad',
subtitle: 'Fixed 300x250 rectangle with route lifecycle',
color: Colors.blueGrey,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const MrecDemoPage())),
),
DemoTile(
icon: Icons.aspect_ratio,
title: 'Adaptive surface (T124)',
subtitle: 'One widget picks banner vs MREC by width',
color: Colors.teal,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdaptiveSurfaceDemoPage())),
),
DemoTile(
icon: Icons.view_agenda,
title: 'Native ad',
subtitle: 'AdMob template vs AppLovin custom layout',
color: Colors.brown,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const NativeDemoPage())),
),
DemoTile(
icon: Icons.fullscreen,
title: 'Interstitial ad',
subtitle: 'Show + safety gate + counter',
color: Colors.indigo,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const InterstitialDemoPage())),
),
DemoTile(
icon: Icons.star,
title: 'Rewarded ad',
subtitle: 'Show + reward + VIP auto-grant toggle',
color: Colors.orange,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const RewardedDemoPage())),
),
DemoTile(
icon: Icons.stars,
title: 'Rewarded interstitial ad',
subtitle: 'Disclosure screen + show + reward',
color: Colors.deepOrange,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const RewardedInterstitialDemoPage())),
),
DemoTile(
icon: Icons.open_in_new,
title: 'App-open ad',
subtitle: 'Background → foreground triggers',
color: Colors.green,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const AppOpenDemoPage())),
),
DemoTile(
icon: Icons.workspace_premium,
title: 'VIP / redeem',
subtitle: 'Shared VipRedeemScreen (identical to host)',
color: Colors.purple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VipRedeemScreen(
publicKeyBase64: kDemoVipPublicKey,
onPrivacyPolicyTap: () =>
debugPrint('[example] privacy policy tapped'),
onPrivacyOptionsTap: () => AdManager().showPrivacyOptions(),
),
),
),
),
DemoTile(
icon: Icons.science_outlined,
title: 'VIP API playground',
subtitle: 'Raw redeem / signed keys / watch-ad buttons',
color: Colors.deepPurple,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const VipDemoPage())),
),
DemoTile(
icon: Icons.privacy_tip,
title: 'Consent / GDPR',
subtitle: 'Consent flags + provider propagation',
color: Colors.teal,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const ConsentDemoPage())),
),
DemoTile(
icon: Icons.shield,
title: 'Safety status',
subtitle: 'Caps, throttle, dryRun mode, presets',
color: Colors.red,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SafetyDemoPage())),
),
DemoTile(
icon: Icons.terminal,
title: 'Log viewer',
subtitle: 'Ring buffer of SDK logs',
color: Colors.grey,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const LogViewerDemoPage())),
),
DemoTile(
icon: Icons.attach_money,
title: 'Revenue dashboard',
subtitle: '\$ from onPaidEvent stream',
color: Colors.lightGreen,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const RevenueDemoPage())),
),
DemoTile(
icon: Icons.dashboard,
title: 'Slot state panel',
subtitle: 'Live AdSlot state + manual destroy/reinit',
color: Colors.cyan,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const StatePanelDemoPage())),
),
DemoTile(
icon: Icons.stream,
title: 'AdEvent stream',
subtitle: 'All load/show/click/reward/revenue events live',
color: Colors.deepOrange,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const EventsDemoPage())),
),
DemoTile(
icon: Icons.fact_check,
title: 'Compliance report',
subtitle: 'Export event log + safety + consent snapshot (T23)',
color: Colors.brown,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const ComplianceDemoPage())),
),
DemoTile(
icon: Icons.health_and_safety,
title: 'Diagnostics & self-check',
subtitle:
'Waterfall/fill-rate/arbitrator snapshot + debug checklist',
color: Colors.teal,
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const DiagnosticsDemoPage())),
),
DemoTile(
icon: Icons.fingerprint,
title: 'AdMob test-device hash',
subtitle: 'GAID vs the logcat-only AdMob test-device hash',
color: Colors.pink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const TestDeviceHashDemoPage())),
),
DemoTile(
icon: Icons.cloud_sync,
title: 'Remote safety provider (T88)',
subtitle: 'RemoteAdSafetyProvider — live push, no app release',
color: Colors.indigo,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const RemoteSafetyDemoPage())),
),
DemoTile(
icon: Icons.rocket_launch,
title: 'Splash shortcut (T94)',
subtitle: 'AdReadinessSplashController — the same flow, wrapped',
color: Colors.deepPurple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ReadinessControllerDemoPage())),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// bootstrap/splash_screen.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — SDK init splash screen (ATT/UMP-aware). Split out of main.dart.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
// Compile-time (not Platform.environment — unreliable via `simctl launch`
// env injection on iOS). Pass `--dart-define=SKIP_SPLASH_AD=true` when
// building for scripted Simulator test runs, so the App-Open MAX test
// creative (which can auto-click into an undismissable Safari sheet) never
// fires on splash.
static const _skipSplashAd = bool.fromEnvironment('SKIP_SPLASH_AD');
// ponytail: the real ATT system dialog has no simctl pre-grant (no
// "tracking" TCC service exists) and reprompts on every fresh install, so
// it wedges every scripted `flutter test integration_test/...` run behind
// an untappable system alert. Skip the real prompt under this flag; pass
// `--dart-define=SKIP_ATT=true` for Simulator/CI integration-test runs.
//
// Known gap (2026-07-12 investigation): SKIP_ATT only suppresses THIS
// Dart-level `AdManager().requestAtt()` call below — there is no second
// Dart call site (checked packages/ad_sdk/lib + example/lib). But tapping
// an App-Open ad's click-through can open AppLovin's in-app browser
// (applovin.com), which independently triggers iOS's native ATT prompt
// outside any Dart code path — no dart-define can reach or suppress that.
// If a scripted run wedges on ATT despite SKIP_ATT=true, it's this path;
// the test needs a UI-automation tap on "Allow"/"Ask App Not to Track"
// (same class of fix as the App-Open dismiss-button handling), not a
// change to this flag.
static const _skipAtt = bool.fromEnvironment('SKIP_ATT');
// ponytail: requestUmpConsentFlow() awaits a real ConsentForm dismiss
// callback with no timeout (packages/ad_sdk/lib/src/core/ump_consent.dart)
// — if the Simulator's IP/locale resolves to an EEA-like region this run,
// Google serves a real consent form that no scripted test taps, wedging
// the splash chain forever. Skip the Dart-level call for scripted runs;
// pass `--dart-define=SKIP_UMP=true`. Same known gap as SKIP_ATT above:
// only suppresses this call site, not any native prompt triggered from
// elsewhere.
static const _skipUmp = bool.fromEnvironment('SKIP_UMP');
// T103 — plain bool, not ValueNotifier: nothing ever listens to this, it's
// used purely as a guard flag. A ValueNotifier read/written after its own
// dispose() throws ("A ValueNotifier was used after being disposed"); a
// native ad-load callback arriving late (after the splash widget itself
// disposed) hit exactly that. A plain field never has this problem —
// reading/writing it after State.dispose() is always safe.
bool _navigated = false;
Timer? _hardCap;
void Function(BoolEvent)? _listener;
@override
void initState() {
super.initState();
AdManager().markSplashActive();
AdManager().incrementSplashCount();
if (AdManager().countInitSplashScreen > 1) {
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
return;
}
_hardCap = Timer(const Duration(seconds: 8), _goHome);
// ⚠️ Register listener BEFORE calling initialize() — EventBus only
// delivers fire events to listeners that registered first.
void onEvent(BoolEvent e) =>
(e.value && !_skipSplashAd) ? _showAppOpen() : _goHome();
_listener = onEvent;
SimpleEventBus().listen(onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) async {
// Consent ordering (recommended): ATT → UMP → initialize.
// 1) iOS App Tracking Transparency. No-op on Android; never throws.
// Must run from the splash (UI is up), NOT from main() before runApp.
if (!_skipAtt) {
try {
final att = await AdManager().requestAtt();
debugPrint('ATT status: ${att.status.name}');
} catch (e) {
debugPrint('ATT skipped: $e');
}
}
// 2) Google UMP consent form for EEA/UK users — before the first ad request.
if (!_skipUmp) {
try {
final ump = await AdManager().requestUmpConsent(
// See kUmpEeaDebug — debugGeography is only honoured when
// testMode is on, and only for a device in testIdentifiers.
testMode: kUmpEeaDebug,
debugGeography:
kUmpEeaDebug ? DebugGeography.debugGeographyEea : null,
testIdentifiers: kUmpTestId.isEmpty ? const [] : const [kUmpTestId],
);
debugPrint('UMP: canRequestAds=${ump.canRequestAds} '
'status=${ump.status} formShown=${ump.formShown} '
'error=${ump.error}');
// The IAB strings a CMP leaves behind. Worth printing in the sample:
// these are what a third-party SDK asks the host for, and until the
// round-5 audit the getters silently returned null on every device.
debugPrint('IAB: tcf=${await AdManager().tcfConsentString} '
'usPrivacyOptedOut=${await AdManager().usPrivacyOptedOut} '
'gpp=${await AdManager().gppConsentString}');
} catch (e) {
debugPrint('UMP skipped: $e');
}
}
// When SKIP_UMP=true we never call requestUmpConsent() above, so
// AdManager's own consent-footgun check (ad_manager.dart's
// consentFootgunWarning) has no signal that consent was handled and
// trips `assert(false, ...)` inside initialize() (debug/test builds
// only — see F4/N2). That's a real safeguard for production hosts,
// but a false alarm for scripted test runs that deliberately skip the
// real dialog. Record a stub consent to satisfy the check.
if (_skipUmp) {
await AdManager().setConsent(AdConsent.conservative);
}
// 3) Initialize the SDK (fires the EventBus completion event). Must run
// even if the splash's hard-cap timer already navigated away (unmounting
// this State) — otherwise a slow ATT/UMP dialog permanently skips SDK
// init for the rest of the session.
AdManager().initialize(
config: DemoConfig.instance.build(),
onComplete: (success, gaid) {},
);
});
}
void _showAppOpen() {
AdManager().loadAppOpenAd(onAdLoaded: (loaded) {
if (_navigated) return;
if (!loaded || !mounted) {
_goHome();
return;
}
AdLoadingDialog.showAdBuffer(context, onComplete: () {
// Round-32 audit fix (MAJOR) — `_hardCap` is only cancelled a few
// lines below (AFTER this buffer wait), so it can still fire and
// call `_goHome()` (`_navigated = true`, pushReplacement to
// HomePage) WHILE this buffer is running. `mounted` alone doesn't
// catch that: the old route's State stays mounted through the
// transition, so without `_navigated` here the App Open ad still
// gets shown right after the user is already on HomePage.
if (!mounted || _navigated) {
_goHome();
return;
}
// Cancel hard cap BEFORE showing ad — ad takes over the timer's job.
_hardCap?.cancel();
_hardCap = null;
AdManager().showAppOpenAd(
bypassSafety: true, // splash flow is the ONE place safety is bypassed
onAdDismiss: (_) => _goHome(),
);
});
});
}
void _goHome() {
if (_navigated) return;
_navigated = true;
_hardCap?.cancel();
_hardCap = null;
final cb = _listener;
if (cb != null) SimpleEventBus().remove(cb);
_listener = null;
AdManager().markSplashInactive();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomePage()),
);
}
@override
void dispose() {
// T103 — set BEFORE anything else: a native ad-load callback already
// handed to the platform SDK before this dispose() can still arrive
// after it. That callback calls _goHome(), which must see _navigated
// already true and bail out immediately instead of touching
// Navigator/context on a widget mid-teardown.
_navigated = true;
_hardCap?.cancel();
final cb = _listener;
if (cb != null) SimpleEventBus().remove(cb);
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: Colors.deepPurple.shade900,
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.ads_click, size: 80, color: Colors.white),
SizedBox(height: 24),
Text('ad_sdk',
style: TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 2)),
SizedBox(height: 32),
CircularProgressIndicator(color: Colors.white),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// demos/adaptive_surface_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T124 — AdaptiveAdSurface demo. Drag the slider to change the surface's
// own width and watch it flip between banner and MREC at the 600pt
// breakpoint (after the resize debounce settles).
class AdaptiveSurfaceDemoPage extends StatefulWidget {
const AdaptiveSurfaceDemoPage({super.key});
@override
State<AdaptiveSurfaceDemoPage> createState() =>
_AdaptiveSurfaceDemoPageState();
}
class _AdaptiveSurfaceDemoPageState extends State<AdaptiveSurfaceDemoPage> {
double _width = 320;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Adaptive surface demo')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Width: ${_width.round()}pt — banner below 600pt, MREC at/above.',
),
),
Slider(
min: 300,
max: 800,
value: _width,
onChanged: (v) => setState(() => _width = v),
),
Center(
child: SizedBox(
width: _width,
child: const AdaptiveAdSurface(
placement: AdPlacement.home,
resizeDebounce: Duration(milliseconds: 200),
),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/app_open_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — app open demo page. Split out of main.dart.
class AppOpenDemoPage extends StatelessWidget {
const AppOpenDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('App-open demo')),
body: Padding(
padding: bottomSafe(context, const EdgeInsets.all(24)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'How to test:\n'
'1. Press the home button to background the app.\n'
'2. Wait > 5 s.\n'
'3. Tap the app icon to return — you should see the App Open ad.\n'
'\n'
'Cold start protection skips the very first foreground event.',
),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
AdManager().loadAppOpenAd(onAdLoaded: (loaded) {
// Round-31 audit fix (MAJOR) — loadAppOpenAd() is async
// (a real network load); every other async-then-context
// use in this file guards with `context.mounted` — this
// one didn't, so backing out before the load finishes
// threw "Looking up a deactivated widget's ancestor is
// unsafe". This is demo/sample code other apps copy, so
// the omission would have spread.
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(loaded ? 'App open ad ready ✅' : 'Load failed ❌'),
));
});
},
child: const Text('Force load App Open'),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/banner_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — banner demo page. Split out of main.dart.
class BannerDemoPage extends AdScreen {
const BannerDemoPage({super.key});
@override
State<BannerDemoPage> createState() => _BannerDemoPageState();
}
class _BannerDemoPageState extends AdScreenState<BannerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Banner demo')),
body: Column(
children: [
buildBanner(),
Expanded(
child: ListView(
padding: bottomSafe(context, EdgeInsets.zero),
children: [
ListTile(
leading: const Icon(Icons.navigate_next),
title:
const Text('Push another screen (verifies pause/resume)'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const _BannerSecondScreen()),
),
),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Banner refreshes here. Push the second screen — banner '
'pauses on AppLovin / hides on AdMob. Pop back to resume.',
style: TextStyle(color: Colors.grey),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Second instance below — proves both banners load and '
'refresh independently (T65 keyed-by-instance).',
style: TextStyle(color: Colors.grey),
),
),
buildBanner(),
],
),
),
],
),
);
}
}
class _BannerSecondScreen extends AdScreen {
const _BannerSecondScreen();
@override
State<_BannerSecondScreen> createState() => _BannerSecondScreenState();
}
class _BannerSecondScreenState extends AdScreenState<_BannerSecondScreen> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Second route')),
body: SafeArea(
top: false,
child: Column(children: [
const Expanded(
child: Center(child: Text('Banner pauses on previous'))),
buildBanner(),
]),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// demos/compliance_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — compliance demo page. Split out of main.dart.
class ComplianceDemoPage extends StatefulWidget {
const ComplianceDemoPage({super.key});
@override
State<ComplianceDemoPage> createState() => _ComplianceDemoPageState();
}
class _ComplianceDemoPageState extends State<ComplianceDemoPage> {
String? _reportJson;
int _eventCount = 0;
void _generate() {
final report = AdManager().exportComplianceReport();
setState(() {
_eventCount = report.events.length;
_reportJson = report.toJsonString(pretty: true);
});
}
void _copy() {
final json = _reportJson;
if (json == null) return;
Clipboard.setData(ClipboardData(text: json));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Report JSON copied to clipboard')),
);
}
@override
Widget build(BuildContext context) {
final json = _reportJson;
return Scaffold(
appBar: AppBar(
title: const Text('Compliance report'),
actions: [
if (json != null)
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy JSON',
onPressed: _copy,
),
],
),
body: Padding(
padding: bottomSafe(context, const EdgeInsets.all(16)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Combines the persisted ad event log, safety status snapshot, '
'consent flags and VIP state into one JSON document — hand to '
'a partner/reviewer as evidence of policy compliance.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _generate,
icon: const Icon(Icons.description_outlined),
label: const Text('Generate report'),
),
const SizedBox(height: 12),
if (json == null)
const Expanded(
child: Center(child: Text('(no report generated yet)')),
)
else
Expanded(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Text('$_eventCount event(s) in log',
style:
const TextStyle(fontWeight: FontWeight.bold)),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: SelectableText(
json,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11),
),
),
),
],
),
),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/consent_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — consent demo page. Split out of main.dart.
class ConsentDemoPage extends StatefulWidget {
const ConsentDemoPage({super.key});
@override
State<ConsentDemoPage> createState() => _ConsentDemoPageState();
}
class _ConsentDemoPageState extends State<ConsentDemoPage> {
final ValueNotifier<bool> _hasConsent = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isAge = ValueNotifier<bool>(false);
final ValueNotifier<bool> _doNotSell = ValueNotifier<bool>(false);
final TextEditingController _countryController = TextEditingController();
/// Bumped after setConsent so the "effective personalization" card below
/// reflects the just-applied AdManager().consent state.
final ValueNotifier<int> _appliedRev = ValueNotifier<int>(0);
@override
void initState() {
super.initState();
_hasConsent.value = AdManager().consent.hasUserConsent;
_isAge.value = AdManager().consent.isAgeRestrictedUser;
_doNotSell.value = AdManager().consent.doNotSell;
}
@override
void dispose() {
_hasConsent.dispose();
_isAge.dispose();
_doNotSell.dispose();
_appliedRev.dispose();
_countryController.dispose();
super.dispose();
}
Widget _row(String label, ValueNotifier<bool> n, String help) {
return ValueListenableBuilder<bool>(
valueListenable: n,
builder: (_, on, __) => SwitchListTile(
value: on,
onChanged: (v) => n.value = v,
title: Text(label),
subtitle: Text(help),
),
);
}
void _syncFromSdk() {
_hasConsent.value = AdManager().consent.hasUserConsent;
_isAge.value = AdManager().consent.isAgeRestrictedUser;
_doNotSell.value = AdManager().consent.doNotSell;
}
@override
Widget build(BuildContext context) {
// Rebuild + resync local toggles whenever SDK destroy/reinit fires.
return ValueListenableBuilder<int>(
valueListenable: AdManager().initRevision,
builder: (context, _, __) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_syncFromSdk();
});
return _build(context);
},
);
}
Widget _build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Consent demo')),
body: ListView(
padding: bottomSafe(context, EdgeInsets.zero),
children: [
_row('GDPR consent (hasUserConsent)', _hasConsent,
'EEA users — set after UMP form ACCEPT.'),
_row('Age-restricted (COPPA)', _isAge,
'App targets children < 13 → tagForChildDirectedTreatment=YES.'),
_row('Do-not-sell (CCPA)', _doNotSell,
'California users opt-out of personal-data sale.'),
const SizedBox(height: 24),
// T120 — pure preview of what applying the toggles above would send
// to each provider, with zero platform-channel calls (no
// AdManager().setConsent() yet). Useful for a QA compliance check
// to walk every GDPR/COPPA/CCPA combination without a device.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () {
final result = simulateConsentOutcome(AdConsent(
hasUserConsent: _hasConsent.value,
isAgeRestrictedUser: _isAge.value,
doNotSell: _doNotSell.value,
));
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('simulateConsentOutcome() preview'),
content: Text(
'AppLovin hasUserConsent: ${result.appLovinHasUserConsent}\n'
'AppLovin doNotSell: ${result.appLovinDoNotSell}\n'
'AdMob tagForChildDirectedTreatment: '
'${result.admobTagForChildDirectedTreatment}\n'
'AdMob tagForUnderAgeOfConsent: '
'${result.admobTagForUnderAgeOfConsent}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
},
child: const Text('Preview outcome (no device call)'),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FilledButton(
onPressed: () async {
await AdManager().setConsent(AdConsent(
hasUserConsent: _hasConsent.value,
isAgeRestrictedUser: _isAge.value,
doNotSell: _doNotSell.value,
));
_appliedRev.value++;
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Consent applied to both providers ✅')));
}
},
child: const Text('Apply consent to providers'),
),
),
const SizedBox(height: 12),
// Effective per-request personalization (T02): AdMob attaches npa=1 to
// every AdRequest when the applied consent has hasUserConsent=false.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ValueListenableBuilder<int>(
valueListenable: _appliedRev,
builder: (context, _, __) {
final npa = !AdManager().consent.hasUserConsent;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: npa
? Colors.orange.withValues(alpha: 0.12)
: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
npa
? '📵 AdMob ad requests: NON-personalized (npa=1)'
: '🎯 AdMob ad requests: personalized',
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
),
);
},
),
),
const Divider(height: 32),
// ─── ConsentManager (Cupertino dialog) ──────────────────────────
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'ConsentManager — built-in Cupertino dialog (auto-shown post-splash on first launch)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
),
ValueListenableBuilder<ConsentSettings>(
valueListenable: ConsentManager.instance.listenable,
builder: (_, s, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
color: s.hasBeenAsked
? Colors.green.shade50
: Colors.amber.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.hasBeenAsked
? '✅ User has been asked'
: '⚠️ Not asked yet',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'consent=${s.hasUserConsent} coppa=${s.isAgeRestrictedUser} ccpa=${s.doNotSell}',
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11),
),
if (s.askedAt != null)
Text(
'askedAt=${s.askedAt!.toLocal().toIso8601String().substring(0, 19)}',
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11)),
Text(
'country=${s.country ?? '(not set — host-supplied only, see below)'}',
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11)),
],
),
),
),
),
),
const SizedBox(height: 12),
// Consent country analytics (T27) — SDK never infers this itself
// (UMP only exposes EEA/non-EEA); host app must supply it.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _countryController,
decoration: const InputDecoration(
labelText: 'Consent country (e.g. DE, US)',
isDense: true,
),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: () async {
final country = _countryController.text.trim();
await ConsentManager.instance.set(
ConsentManager.instance.current
.copyWith(country: country.isEmpty ? null : country),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(country.isEmpty
? 'Consent country cleared'
: 'Consent country set to $country')));
}
},
child: const Text('Set'),
),
],
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
icon: const Icon(Icons.help_outline),
label: const Text('Show consent dialog'),
onPressed: () async {
await ConsentManager.instance.showDialog(
context,
config: AdManager().config,
onPrivacyPolicyTap:
AdManager().config?.onPrivacyPolicyTap,
);
},
),
OutlinedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Reset (re-prompt next launch)'),
onPressed: () async {
await ConsentManager.instance
.reset(config: AdManager().config);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Consent reset — next init will re-prompt')),
);
}
},
),
],
),
),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Note: the SDK auto-shows the binary dialog ~1s AFTER markSplashInactive '
'on first launch (default behaviour, controlled by AdConfig.autoShowConsentDialog). '
'iOS ATT prompt is still caller responsibility — see README.',
style: TextStyle(color: Colors.grey),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/diagnostics_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — diagnostics demo page. Split out of main.dart.
class DiagnosticsDemoPage extends StatefulWidget {
const DiagnosticsDemoPage({super.key});
@override
State<DiagnosticsDemoPage> createState() => _DiagnosticsDemoPageState();
}
class _DiagnosticsDemoPageState extends State<DiagnosticsDemoPage> {
static const _encoder = JsonEncoder.withIndent(' ');
String? _diagnosticsJson;
SelfCheckResult? _selfCheck;
bool _runningSelfCheck = false;
void _runDiagnostics() {
final diag = AdManager().diagnostics();
setState(() => _diagnosticsJson = _encoder.convert(diag.toJson()));
}
Future<void> _runSelfCheck() async {
setState(() => _runningSelfCheck = true);
final result = await AdManager().runIntegrationSelfCheck();
if (!mounted) return;
setState(() {
_selfCheck = result;
_runningSelfCheck = false;
});
}
@override
Widget build(BuildContext context) {
final diagJson = _diagnosticsJson;
final selfCheck = _selfCheck;
return Scaffold(
appBar: AppBar(title: const Text('Diagnostics & self-check')),
body: ListView(
padding: bottomSafe(context, const EdgeInsets.all(16)),
children: [
const Text(
'AdManager.diagnostics() — one-shot snapshot of mediation '
'waterfall, fill rate and arbitrator stats, for "why is eCPM low '
'today" without cross-referencing 3 separate pages.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _runDiagnostics,
icon: const Icon(Icons.query_stats),
label: const Text('Run diagnostics()'),
),
if (diagJson != null) ...[
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(diagJson,
style:
const TextStyle(fontFamily: 'monospace', fontSize: 11)),
),
),
],
const Divider(height: 32),
const Text(
'AdManager.runIntegrationSelfCheck() — debug-only checklist '
'(init → consent → per-slot load) so a partner integrating the '
'SDK doesn\'t have to click through every demo page by hand. '
'No-op (skipped) outside debug builds.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _runningSelfCheck ? null : _runSelfCheck,
icon: _runningSelfCheck
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.checklist),
label: const Text('Run runIntegrationSelfCheck()'),
),
if (selfCheck != null) ...[
const SizedBox(height: 8),
Card(
color: selfCheck.allPassed
? Colors.green.withValues(alpha: 0.08)
: Colors.red.withValues(alpha: 0.08),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Text(
selfCheck.allPassed ? 'All checks passed' : 'FAILED',
style: const TextStyle(fontWeight: FontWeight.bold)),
),
const Divider(height: 1),
for (final item in selfCheck.items)
ListTile(
dense: true,
leading: Icon(
switch (item.status) {
SelfCheckStatus.pass => Icons.check_circle,
SelfCheckStatus.fail => Icons.error,
SelfCheckStatus.skipped =>
Icons.remove_circle_outline,
},
color: switch (item.status) {
SelfCheckStatus.pass => Colors.green,
SelfCheckStatus.fail => Colors.red,
SelfCheckStatus.skipped => Colors.grey,
}),
title: Text(item.name),
subtitle: item.detail != null ? Text(item.detail!) : null,
),
],
),
),
],
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/events_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — AdEvent stream live viewer. Split out of main.dart. EventRow
// itself lives in shared/event_buffer.dart.
class EventsDemoPage extends StatelessWidget {
const EventsDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('AdEvent stream'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Clear',
onPressed: () => EventBuffer.instance.clear(),
),
],
),
body: Column(
children: [
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Tap any other demo (banner, inter, rewarded, app-open) and '
'come back — every load/show/click/reward/revenue event from '
'the SDK is logged here in real time.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
const Divider(height: 1),
Expanded(
child: ValueListenableBuilder<int>(
valueListenable: EventBuffer.instance.revision,
builder: (_, __, ___) {
final rows = EventBuffer.instance.snapshot();
if (rows.isEmpty) {
return const Center(
child: Text('(no events yet — trigger an ad somewhere)',
style: TextStyle(color: Colors.grey)),
);
}
return ListView.separated(
padding: bottomSafe(context, EdgeInsets.zero),
itemCount: rows.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final row = rows[i];
return _EventTile(row: row);
},
);
},
),
),
],
),
);
}
}
class _EventTile extends StatelessWidget {
const _EventTile({required this.row});
final EventRow row;
@override
Widget build(BuildContext context) {
final e = row.event;
final time = row.timestamp.toIso8601String().substring(11, 19);
final (label, color, detail) = _describe(e);
return ListTile(
dense: true,
leading: Container(
width: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
child: Text(label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
fontSize: 10)),
),
title: Text(
e is AdAnomalyEvent
? e.reason
: '${e.providerTag} ${e.type.name} @${e.placement.id}',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
subtitle: Text(
'$time $detail',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
);
}
(String, Color, String) _describe(AdEvent e) {
if (e is AdLoadEvent) {
return (
e.success ? 'LOAD✓' : 'LOAD✗',
e.success ? Colors.blue : Colors.red,
e.success ? 'loaded' : 'errCode=${e.errorCode ?? '?'}',
);
}
if (e is AdShowEvent) {
return (
e.success ? 'SHOW✓' : 'SHOW✗',
e.success ? Colors.green : Colors.orange,
e.success ? 'shown' : 'skipped',
);
}
if (e is AdClickEvent) return ('CLICK', Colors.purple, 'user clicked');
if (e is AdRewardEvent) {
return (
'REWARD',
Colors.amber.shade700,
'${e.label ?? '?'} × ${e.amount ?? 0}'
);
}
if (e is AdRevenueEvent) {
final waterfall = e.mediationWaterfall;
return (
'REV \$',
Colors.teal,
'\$${e.value.toStringAsFixed(6)} ${e.currencyCode}'
'${e.networkName != null ? ' via ${e.networkName}' : ''}'
'${waterfall != null && waterfall.isNotEmpty ? '\nwaterfall: ${waterfall.join(' > ')}' : ''}',
);
}
if (e is AdAnomalyEvent) {
return (
'ANOMALY',
Colors.redAccent,
'violation #${e.violationCount} · paused ${e.pauseDurationMs ~/ 60000}min',
);
}
if (e is ArbitratorNudgeEvent) {
return (
'NUDGE',
Colors.indigo,
'vetoed low-eCPM ad · trailing eCPM=\$${e.estimatedEcpmMicros / 1e6}',
);
}
return ('?', Colors.grey, '');
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/interstitial_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — interstitial demo page. Split out of main.dart.
class InterstitialDemoPage extends AdScreen {
const InterstitialDemoPage({super.key});
@override
State<InterstitialDemoPage> createState() => _InterstitialDemoPageState();
}
class _InterstitialDemoPageState extends AdScreenState<InterstitialDemoPage> {
final ValueNotifier<int> _shownCount = ValueNotifier<int>(0);
final ValueNotifier<String> _lastResult = ValueNotifier<String>('—');
@override
void dispose() {
_shownCount.dispose();
_lastResult.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Interstitial demo')),
body: Padding(
padding: bottomSafe(context, const EdgeInsets.all(24)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValueListenableBuilder<int>(
valueListenable: _shownCount,
builder: (_, c, __) => Text('Shown: $c times',
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 8),
ValueListenableBuilder<String>(
valueListenable: _lastResult,
builder: (_, r, __) =>
Text('Last: $r', style: const TextStyle(color: Colors.grey)),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
// `placement` tags the impression for revenue analytics — it
// flows into `AdShowEvent.placement` / `AdRevenueEvent`. Use a
// preset (home/shop/levelComplete/gameOver/settings) or
// `AdPlacement.custom('my_screen')`.
showInterstitialAd(
placement: AdPlacement.levelComplete,
onDone: (shown) {
_lastResult.value = shown ? 'shown ✅' : 'skipped/blocked ❌';
if (shown) _shownCount.value = _shownCount.value + 1;
},
);
},
child: const Text('Show interstitial (placement: levelComplete)'),
),
const SizedBox(height: 12),
const Text(
'SDK runs: pre-check (canShowInterstitial) → 1 s loading dialog → '
'native show. If safety blocks, "skipped" returns immediately.',
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/log_viewer_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — log viewer demo page. Split out of main.dart.
class LogViewerDemoPage extends StatelessWidget {
const LogViewerDemoPage({super.key});
Color _colorFor(AdLogLevel l) => switch (l) {
AdLogLevel.verbose => Colors.grey,
AdLogLevel.warning => Colors.orange,
AdLogLevel.error => Colors.red,
AdLogLevel.none => Colors.black,
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Log viewer'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => LogBuffer.instance.clear(),
tooltip: 'Clear',
),
],
),
body: ValueListenableBuilder<int>(
valueListenable: LogBuffer.instance.revision,
builder: (_, __, ___) {
final entries = LogBuffer.instance.snapshot();
if (entries.isEmpty) {
return const Center(child: Text('(no logs yet)'));
}
return ListView.builder(
reverse: true,
padding: bottomSafe(context, EdgeInsets.zero),
itemCount: entries.length,
itemBuilder: (_, i) {
final e = entries[entries.length - 1 - i];
final time = e.timestamp.toIso8601String().substring(11, 19);
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(time,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
color: Colors.grey)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _colorFor(e.level).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
child: Text(e.level.name.toUpperCase(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 9,
color: _colorFor(e.level),
fontWeight: FontWeight.bold)),
),
const SizedBox(width: 6),
Expanded(
child: Text('[${e.tag}] ${e.message}',
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11)),
),
],
),
);
},
);
},
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/mrec_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — mrec demo page. Split out of main.dart.
class MrecDemoPage extends AdScreen {
const MrecDemoPage({super.key});
@override
State<MrecDemoPage> createState() => _MrecDemoPageState();
}
class _MrecDemoPageState extends AdScreenState<MrecDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('MREC demo')),
body: Column(
children: [
buildMrec(),
Expanded(
child: ListView(
padding: bottomSafe(context, EdgeInsets.zero),
children: [
ListTile(
leading: const Icon(Icons.navigate_next),
title:
const Text('Push another screen (verifies pause/resume)'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const _MrecSecondScreen()),
),
),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'MREC is a fixed 300x250 rectangle. Push the second '
'screen — it pauses on AppLovin / hides on AdMob. Pop '
'back to resume.',
style: TextStyle(color: Colors.grey),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Second instance below — proves both MRECs load '
'independently (T65 keyed-by-instance).',
style: TextStyle(color: Colors.grey),
),
),
buildMrec(),
],
),
),
],
),
);
}
}
class _MrecSecondScreen extends AdScreen {
const _MrecSecondScreen();
@override
State<_MrecSecondScreen> createState() => _MrecSecondScreenState();
}
class _MrecSecondScreenState extends AdScreenState<_MrecSecondScreen> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Second route')),
body: SafeArea(
top: false,
child: Column(children: [
const Expanded(
child: Center(child: Text('MREC pauses on previous'))),
buildMrec(),
]),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// demos/native_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — native demo page. Split out of main.dart.
class NativeDemoPage extends AdScreen {
const NativeDemoPage({super.key});
@override
State<NativeDemoPage> createState() => _NativeDemoPageState();
}
class _NativeDemoPageState extends AdScreenState<NativeDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Native demo')),
body: ListView(
padding: bottomSafe(context, EdgeInsets.zero),
children: [
buildNative(),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Native ad v1: fixed layout, no auto-refresh/route-pause. '
'AdMob renders its own template + "Ad" badge; AppLovin renders '
'a custom Dart layout with a package-drawn "Ad" badge.',
style: TextStyle(color: Colors.grey),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Second instance below — proves both natives load '
'independently (T65 keyed-by-instance).',
style: TextStyle(color: Colors.grey),
),
),
buildNative(),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/revenue_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — revenue demo page. Split out of main.dart.
class RevenueDemoPage extends StatelessWidget {
const RevenueDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Revenue dashboard')),
body: Padding(
padding: bottomSafe(context, const EdgeInsets.all(16)),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RevenuePanel(),
SizedBox(height: 16),
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Revenue is reported by AdMob/AppLovin via the OnPaidEvent '
'hook on each impression. The dashboard subscribes to '
'AdManager().events and accumulates AdRevenueEvent values.\n'
'\n'
'Pipe the same stream into your Firebase / AppsFlyer LTV '
'tracking — see README.',
style: TextStyle(color: Colors.grey),
),
),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/rewarded_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — rewarded demo page. Split out of main.dart.
class RewardedDemoPage extends AdScreen {
const RewardedDemoPage({super.key});
@override
State<RewardedDemoPage> createState() => _RewardedDemoPageState();
}
class _RewardedDemoPageState extends AdScreenState<RewardedDemoPage> {
final ValueNotifier<int> _coins = ValueNotifier<int>(0);
final ValueNotifier<bool> _vipAutoGrant = ValueNotifier<bool>(false);
final ValueNotifier<String> _last = ValueNotifier<String>('—');
final TextEditingController _ssvCtrl = TextEditingController();
@override
void dispose() {
_coins.dispose();
_vipAutoGrant.dispose();
_last.dispose();
_ssvCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Rewarded demo')),
body: SingleChildScrollView(
padding: bottomSafe(context, const EdgeInsets.all(24)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValueListenableBuilder<int>(
valueListenable: _coins,
builder: (_, c, __) => Text('Coins: $c',
style: const TextStyle(
fontSize: 28, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 8),
ValueListenableBuilder<String>(
valueListenable: _last,
builder: (_, r, __) =>
Text('Last: $r', style: const TextStyle(color: Colors.grey)),
),
const SizedBox(height: 24),
ValueListenableBuilder<bool>(
valueListenable: _vipAutoGrant,
builder: (_, on, __) => SwitchListTile(
value: on,
onChanged: (v) => _vipAutoGrant.value = v,
title: const Text('VIP auto-grant'),
subtitle: const Text(
'When VIP, auto-mark reward earned (Q12B: opt-in only).'),
),
),
const SizedBox(height: 12),
TextField(
controller: _ssvCtrl,
decoration: const InputDecoration(
labelText: 'SSV user id (optional)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () {
final ssvUserId = _ssvCtrl.text.isEmpty ? null : _ssvCtrl.text;
showRewardedAd(
vipAutoGrant: _vipAutoGrant.value,
ssvUserId: ssvUserId,
onEarnedReward: (earned) {
_last.value = earned
? 'earned 🏆${ssvUserId != null ? ' (pending SSV confirmation)' : ''}'
: 'skipped/blocked ❌';
if (earned) _coins.value = _coins.value + 10;
},
);
},
child: const Text('Watch ad for +10 coins'),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/rewarded_interstitial_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// Round-27 audit — showRewardedInterstitialAd() had zero demo coverage
// anywhere in the example app despite being a fully-supported ad surface
// (README's "Rewarded interstitial" section). Unlike rewarded, this format
// shows a built-in disclosure/intro screen before the ad by default
// (showDisclosure: true) — the demo below exercises that default path.
class RewardedInterstitialDemoPage extends AdScreen {
const RewardedInterstitialDemoPage({super.key});
@override
State<RewardedInterstitialDemoPage> createState() =>
_RewardedInterstitialDemoPageState();
}
class _RewardedInterstitialDemoPageState
extends AdScreenState<RewardedInterstitialDemoPage> {
final ValueNotifier<int> _coins = ValueNotifier<int>(0);
final ValueNotifier<String> _last = ValueNotifier<String>('—');
@override
void dispose() {
_coins.dispose();
_last.dispose();
super.dispose();
}
void _watch() {
showRewardedInterstitialAd(
onDone: (shown, earned) {
if (!mounted) return;
_last.value = 'shown=$shown earned=$earned';
if (earned) _coins.value = _coins.value + 10;
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Rewarded interstitial demo')),
body: SingleChildScrollView(
padding: bottomSafe(context, const EdgeInsets.all(24)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValueListenableBuilder<int>(
valueListenable: _coins,
builder: (_, c, __) => Text('Coins: $c',
style: const TextStyle(
fontSize: 28, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 8),
ValueListenableBuilder<String>(
valueListenable: _last,
builder: (_, r, __) =>
Text('Last: $r', style: const TextStyle(color: Colors.grey)),
),
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Shows a disclosure/intro screen first (mandatory unless '
'the host passes showDisclosure: false and supplies its '
'own), then the ad. Declining the intro reports '
'(shown: false, earned: false) at no ad-budget cost — '
'same earned-only reward contract as the plain rewarded '
'format.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _watch,
child: const Text('Watch rewarded interstitial for +10 coins'),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/safety_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — safety demo page. Split out of main.dart.
class SafetyDemoPage extends StatefulWidget {
const SafetyDemoPage({super.key});
@override
State<SafetyDemoPage> createState() => _SafetyDemoPageState();
}
class _SafetyDemoPageState extends State<SafetyDemoPage> {
final ValueNotifier<int> _refresh = ValueNotifier<int>(0);
final ValueNotifier<bool> _arbitratorEnabled =
ValueNotifier<bool>(AdManager().arbitrator != null);
final ValueNotifier<bool> _fillRateMonitorEnabled =
ValueNotifier<bool>(AdManager().fillRateMonitor != null);
@override
void dispose() {
_refresh.dispose();
_arbitratorEnabled.dispose();
_fillRateMonitorEnabled.dispose();
super.dispose();
}
AdSafetyParams get _activeParams =>
AdManager().config?.safety ?? AdSafetyParams.auto;
Widget _paramsCard(String title, AdSafetyParams p, {Color? color}) {
return Card(
color: color,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
Text(
'between=${p.minTimeBetweenFullscreenAds}ms\n'
'session=${p.maxFullscreenAdsPerSession} / hour=${p.maxFullscreenAdsPerHour} / day=${p.maxFullscreenAdsPerDay}\n'
'warmup=${p.minSessionDurationBeforeAd}ms / resume=${p.minTimeAppOpenResume}ms\n'
'clicks/min=${p.maxClicksPerMinute} / ctr=${p.suspiciousCtrThreshold}\n'
'rapidResume=${p.maxRapidResumesPerMinute} / dryRun=${p.dryRun}',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
// Wrap with initRevision so destroy/reinit refreshes _activeParams display.
return ValueListenableBuilder<int>(
valueListenable: AdManager().initRevision,
builder: (context, _, __) => _build(context),
);
}
Widget _build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Safety demo')),
body: ValueListenableBuilder<int>(
valueListenable: _refresh,
builder: (_, __, ___) => ListView(
padding: bottomSafe(context, const EdgeInsets.all(16)),
children: [
_paramsCard(
'Active params (demo: same for debug + release)',
_activeParams,
color: Colors.blue.shade50,
),
const SizedBox(height: 12),
_paramsCard(
'Preset: AdSafetyParams.production', AdSafetyParams.production),
const SizedBox(height: 8),
_paramsCard('Preset: AdSafetyParams.debug', AdSafetyParams.debug),
const SizedBox(height: 8),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'How to customize from your app:\n'
'\n'
'// 1) Use a built-in preset\n'
'safety: AdSafetyParams.debug\n'
'\n'
'// 2) Auto-pick (default — debug in dev, prod in release)\n'
'safety: AdSafetyParams.auto\n'
'\n'
'// 3) Override only the knobs you care about\n'
'safety: AdSafetyParams.production.copyWith(\n'
' maxFullscreenAdsPerDay: 10,\n'
' dryRun: kDebugMode,\n'
')',
style: TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
),
const Divider(height: 24),
const Text('Live status',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(AdSafetyConfig.getStatus(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
const SizedBox(height: 12),
const Text('Policy risk score (T24, dev/partner signal only)',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
ValueListenableBuilder<int>(
valueListenable: AdManager().policyRiskScore,
builder: (_, score, __) {
final color = score < 30
? Colors.green
: (score < 70 ? Colors.orange : Colors.red);
return Row(
children: [
Container(
width: 12,
height: 12,
decoration:
BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(
'$score / 100',
style: TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
color: color,
),
),
],
);
},
),
const SizedBox(height: 12),
const Text('Smart Monetization Arbitrator (opt-in)',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
ValueListenableBuilder<bool>(
valueListenable: _arbitratorEnabled,
builder: (_, enabled, __) {
final arbitrator = AdManager().arbitrator;
if (!enabled || arbitrator == null) {
return const Text('disabled (default)',
style: TextStyle(fontFamily: 'monospace', fontSize: 11));
}
return Text(
'estimatedEcpm=${arbitrator.estimatedEcpmMicros}µ '
'vetoRate=${arbitrator.vetoRate.toStringAsFixed(2)}\n'
'perSlotThreshold: interstitial=8000000µ rewarded=3000000µ, '
'maxVetoRate=0.5',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
);
},
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
AdManager().enableArbitrator(MonetizationArbitrator(
// Demo per-slot thresholds — rewarded ads tend to earn
// more than interstitials, so give interstitial a higher
// eCPM bar before nudging VIP. maxVetoRate is the
// guardrail: if >50% of decisions veto, it stops vetoing
// and falls back to showAd rather than starve the user.
perSlotThresholdMicros: const {
AdSlotType.interstitial: 8000000, // $8.00 eCPM
AdSlotType.rewarded: 3000000, // $3.00 eCPM
},
maxVetoRate: 0.5,
));
_arbitratorEnabled.value = true;
_refresh.value = _refresh.value + 1;
},
child:
const Text('Enable Smart Arbitrator (per-slot + guardrail)'),
),
const SizedBox(height: 12),
const Text('Fill-rate monitor (opt-in)',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
ValueListenableBuilder<bool>(
valueListenable: _fillRateMonitorEnabled,
builder: (_, enabled, __) {
final monitor = AdManager().fillRateMonitor;
if (!enabled || monitor == null) {
return const Text('fill-rate monitor disabled (default)',
style: TextStyle(fontFamily: 'monospace', fontSize: 11));
}
return Text(
'interstitial=${monitor.fillRate(AdSlotType.interstitial).toStringAsFixed(2)} '
'rewarded=${monitor.fillRate(AdSlotType.rewarded).toStringAsFixed(2)} '
'appOpen=${monitor.fillRate(AdSlotType.appOpen).toStringAsFixed(2)}',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
);
},
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
AdManager().enableFillRateMonitor(FillRateMonitor());
_fillRateMonitorEnabled.value = true;
},
child: const Text('Enable Fill-rate Monitor'),
),
const SizedBox(height: 12),
const Text('Latest fullscreen check',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Builder(builder: (_) {
final r = AdSafetyConfig.canShowFullscreenAd();
return Text(
'canShow=${r.canShow}\nreason=${r.reason}',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
);
}),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () {
AdSafetyConfig.resetSessionCounters();
_refresh.value = _refresh.value + 1;
},
child: const Text('Reset session counters'),
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => _refresh.value = _refresh.value + 1,
child: const Text('Refresh'),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/state_panel_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — state panel demo page. Split out of main.dart.
class StatePanelDemoPage extends StatelessWidget {
const StatePanelDemoPage({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: AdManager().initRevision,
builder: (context, _, __) => _build(context),
);
}
Widget _build(BuildContext context) {
final adapter = AdManager().adapter;
return Scaffold(
appBar: AppBar(title: const Text('Slot state panel')),
body: ListView(
padding: bottomSafe(context, const EdgeInsets.all(16)),
children: [
// T109 — one ValueListenable instead of gluing 5 separate ones.
const Text('AdSdkStateSnapshot',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 8),
ValueListenableBuilder<AdSdkStateSnapshot>(
valueListenable: AdManager().stateSnapshot,
builder: (context, snapshot, __) => Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('isInitialised: ${snapshot.isInitialised}'),
Text('canRequestAds: ${snapshot.canRequestAds}'),
Text('isOffline: ${snapshot.isOffline}'),
Text('isVipActive: ${snapshot.isVipActive}'),
Text('fullscreenBusy: ${snapshot.fullscreenBusy}'),
],
),
),
),
),
const Divider(height: 32),
if (adapter != null) ...[
Text('Provider: ${adapter.tag}',
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 12),
_slotCard('App Open', adapter.appOpenSlot),
_slotCard('Interstitial', adapter.interstitialSlot),
_slotCard('Rewarded', adapter.rewardedSlot),
// T65 (phase 2): banner is now keyed per BannerAdWidget instance
// — no single slot to show here, same as mrec/native already.
] else
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('SDK not initialised yet')),
),
const Divider(height: 32),
const Text(
'Lifecycle test',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: adapter == null
? null
: () async {
await AdManager().destroy();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('SDK destroyed — adapter null')),
);
}
},
child: const Text('Destroy SDK'),
),
const SizedBox(height: 8),
FilledButton(
onPressed: () async {
await AdManager().initialize(
config: DemoConfig.instance.build(),
onComplete: (_, __) {},
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('SDK re-initialized')),
);
}
},
child: const Text('Re-initialize SDK'),
),
if (kDebugMode) ...[
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => _openAdInspector(context),
child: const Text(
kProvider == AdProvider.appLovin
? 'Open AppLovin mediation debugger'
: 'Open AdMob ad inspector',
),
),
],
],
),
);
}
// Both providers ship their own native debug UI — no need to build one.
void _openAdInspector(BuildContext context) {
if (kProvider == AdProvider.appLovin) {
AppLovinMAX.showMediationDebugger();
return;
}
MobileAds.instance.openAdInspector((error) {
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ad inspector error: ${error.message}')),
);
}
});
}
Widget _slotCard(String label, AdSlot slot) {
return ValueListenableBuilder<AdSlotState>(
valueListenable: slot.state,
builder: (_, state, __) => Card(
child: ListTile(
title: Text(label),
subtitle: Text(
'state=${state.name}\n'
'fails=${slot.consecutiveFailures}\n'
'lastError=${slot.lastErrorAt?.toIso8601String() ?? '—'}\n'
'lastLoaded=${slot.lastLoadedAt?.toIso8601String() ?? '—'}',
),
trailing: _badge(state),
),
),
);
}
Widget _badge(AdSlotState s) {
final color = switch (s) {
AdSlotState.idle => Colors.grey,
AdSlotState.loading => Colors.blue,
AdSlotState.ready => Colors.green,
AdSlotState.showing => Colors.purple,
AdSlotState.cooldown => Colors.orange,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(s.name,
style: TextStyle(color: color, fontWeight: FontWeight.bold)),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/test_device_hash_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — test device hash demo page. Split out of main.dart.
class TestDeviceHashDemoPage extends StatelessWidget {
const TestDeviceHashDemoPage({super.key});
Future<void> _copy(BuildContext context, String label, String value) async {
await Clipboard.setData(ClipboardData(text: value));
if (!context.mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$label copied to clipboard')));
}
@override
Widget build(BuildContext context) {
final gaid = AdManager().currentDeviceGaid;
final hint = AdManager().adMobTestDeviceHashHint();
return Scaffold(
appBar: AppBar(title: const Text('AdMob test-device hash')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current device GAID',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
SelectableText(
gaid.isEmpty ? '(empty — init not done yet, or LAT on)' : gaid),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy GAID (not the AdMob hash!)'),
onPressed:
gaid.isEmpty ? null : () => _copy(context, 'GAID', gaid),
),
const SizedBox(height: 20),
Text('adMobTestDeviceHashHint()',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Expanded(child: SingleChildScrollView(child: SelectableText(hint))),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy hint text'),
onPressed: () => _copy(context, 'Hint', hint),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// demos/vip_demo_page.dart
// ─────────────────────────────────────────────────────────────────────────
// T117 — vip demo page. Split out of main.dart.
class VipDemoPage extends StatefulWidget {
const VipDemoPage({super.key});
@override
State<VipDemoPage> createState() => _VipDemoPageState();
}
class _VipDemoPageState extends State<VipDemoPage> {
final TextEditingController _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _redeem(String key, Duration duration) async {
final vip = AdManager().vip;
if (vip == null) return;
await vip.redeemVip(
context,
key: key,
duration: duration,
validator: AdManager().config?.vipKeyValidator,
strings: AdManager().config?.vipDialogStrings ?? const VipDialogStrings(),
// stack: true → global stacking: ADDS time on top of the latest expiry
// across ALL VIP entries (cộng dồn toàn cục) instead of latest-wins.
stack: true,
);
// vip.activeListenable only fires on true/false transitions, so stacking
// more time while already active wouldn't otherwise refresh the card below.
if (mounted) setState(() {});
}
/// T18 — redeem an offline SIGNED VIP key (Ed25519, verified against the
/// embedded public key; no network; per-device one-time-use).
Future<void> _redeemSigned(String code) async {
final vip = AdManager().vip;
if (vip == null) return;
final r = await vip.redeemSignedKey(code,
publicKeyBase64: kDemoVipPublicKey, stack: true);
if (!mounted) return;
setState(() {});
final msg = switch (r.status) {
VipRedeemStatus.success => '✅ Signed key OK — VIP granted',
VipRedeemStatus.alreadyUsed => '⏭️ Key already used on this device',
VipRedeemStatus.invalid => '❌ Invalid key: ${r.error}',
};
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
/// Watch a real rewarded ad to EXTEND VIP — works even while already VIP
/// (`bypassVipGuard: true` plays a real ad; the SDK loads it on demand). The
/// reward is granted into a fixed key with `stack: true` so repeats add up.
Future<void> _watchAdToExtend() async {
final vip = AdManager().vip;
if (vip == null) return;
AdManager().showRewardedAd(
bypassVipGuard: true,
onEarnedReward: (earned) {
if (!earned) return;
vip.addVip(
key: 'REWARDED_VIP',
duration: const Duration(days: 3),
stack: true,
);
if (mounted) setState(() {});
},
);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: AdManager().initRevision,
builder: (context, _, __) => _build(context),
);
}
Widget _build(BuildContext context) {
final vip = AdManager().vip;
return Scaffold(
appBar: AppBar(title: const Text('VIP demo')),
body: ListView(
padding: bottomSafe(context, const EdgeInsets.all(16)),
children: [
// GAID allow-list — a second VIP mechanism alongside key redeem:
// mark specific devices VIP by their Google Advertising ID. The
// SUPPORTED way is the startup config `AdConfig.vipDeviceGaids:
// ['gaid1', ...]` (auto-migrated to VipManager entries on first
// init). `AdManager().isVIPMember()` reports the current state.
// (The runtime add/deleteVIPMember mutators are deprecated — prefer
// `AdManager().vip.addVip(...)` / `revokeVip(...)`.)
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('GAID VIP allow-list',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text(
'Set at startup via AdConfig.vipDeviceGaids: [...]. '
'Tap to read the live VIP state:',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () {
final isVip = AdManager().isVIPMember();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('isVIPMember() = $isVip')),
);
},
child: const Text('Check isVIPMember()'),
),
],
),
),
),
const SizedBox(height: 8),
// Status card
if (vip != null)
ValueListenableBuilder<bool>(
valueListenable: vip.activeListenable,
builder: (_, active, __) {
final exp = vip.expiresAt;
return Card(
color: active ? Colors.purple.shade50 : Colors.grey.shade100,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
active ? '🟣 VIP active' : '⚪ VIP inactive',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
if (active && exp != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Until ${exp.toLocal().toIso8601String().substring(0, 16)}'),
),
ValueListenableBuilder<bool>(
valueListenable: vip.graceNudgeDueListenable,
builder: (_, due, __) {
if (!due) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.warning_amber,
color: Colors.orange, size: 18),
const SizedBox(width: 4),
const Expanded(
child: Text(
'⏳ VIP expiring soon — grace nudge due')),
TextButton(
onPressed: vip.acknowledgeGraceNudge,
child: const Text('Ack'),
),
],
),
);
},
),
if (vip.entries.isNotEmpty) ...[
const Divider(),
const Text('Entries:',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
...vip.entries.map((e) => Text(
'• ${e.key} → ${e.expiresAt.toLocal().toIso8601String().substring(0, 16)}',
style: const TextStyle(fontSize: 12),
)),
],
],
),
),
);
},
),
const SizedBox(height: 16),
// Quick redeem buttons
const Text('Quick redeem',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: kDemoVipKeys.entries
.map((e) => OutlinedButton(
onPressed: () => _redeem(e.key, e.value),
child: Text('${e.key}\n(${e.value.inDays} days)',
textAlign: TextAlign.center),
))
.toList(),
),
const SizedBox(height: 24),
// T18 — signed offline keys (Ed25519). Redeeming twice shows the
// per-device one-time-use guard ("already used").
const Text('Signed keys (T18 — offline, forge-proof)',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: kDemoSignedVipKeys.entries
.map((e) => FilledButton.tonal(
onPressed: () => _redeemSigned(e.value),
child: Text('signed ${e.key}'),
))
.toList(),
),
const SizedBox(height: 24),
// Custom redeem
const Text('Custom key (1-day duration)',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _ctrl,
decoration: const InputDecoration(
hintText: 'enter key',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => _redeem(_ctrl.text, const Duration(days: 1)),
child: const Text('Redeem'),
),
],
),
const SizedBox(height: 24),
// Watch ad → +3 days VIP (stacks; works even while already VIP)
const Text('Extend by watching an ad',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _watchAdToExtend,
icon: const Icon(Icons.play_circle_outline),
label: const Text('Watch ad → +3 days VIP (stack)'),
),
const SizedBox(height: 24),
// Revoke
FilledButton.tonal(
onPressed: () async {
await vip?.revokeAll();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All VIP entries revoked')),
);
}
},
child: const Text('Revoke ALL'),
),
const SizedBox(height: 24),
// Round-39 audit (MINOR): this demo previously never showed how to
// wire VipRevocationProvider/refreshRevocationList — a partner
// copying this example verbatim could ship VIP-code revocation
// completely inert without realising it, since the SDK has no way
// to warn about a feature it was simply never asked to use. A real
// host app would call this from a `Timer.periodic` (see
// VipRevocationProvider's own doc comment) with a provider that
// fetches `tool/vip_crl_mint.dart`'s output from its own backend —
// this button just demonstrates the call shape with a stub.
OutlinedButton.icon(
onPressed: () async {
await vip?.refreshRevocationList(
publicKeyBase64: kDemoVipPublicKey,
revocationProvider: _DemoCrlProvider(),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'Checked for a revocation list (demo stub — wire '
'your own backend via VipRevocationProvider)')));
}
},
icon: const Icon(Icons.block_flipped),
label: const Text('Refresh revocation list (CRL)'),
),
],
),
);
}
}
/// Demo-only stub — a real host implements [fetchSignedCrl] against its own
/// backend (see [VipRevocationProvider]'s own doc comment). Returning `null`
/// is a normal, safe result: `refreshRevocationList` fails open and leaves
/// whatever was already cached untouched.
class _DemoCrlProvider implements VipRevocationProvider {
@override
Future<String?> fetchSignedCrl() async {
debugPrint('[example] fetchSignedCrl: no real CRL backend in this demo');
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// RemoteAdSafetyProvider demo (T88) — README "Remote-controlled AdSafetyParams"
// ─────────────────────────────────────────────────────────────────────────
/// Demo-only stand-in for a real backend (Firebase Remote Config, a
/// self-hosted config API, ...) — see README's "Remote-controlled
/// AdSafetyParams" section. A real host reads its own remote-config SDK
/// inside [fetchSafetyParamOverrides]; this demo reads [overrides] instead,
/// which the demo page's own sliders act as the "remote" source of truth.
class DemoRemoteAdSafetyProvider implements RemoteAdSafetyProvider {
final ValueNotifier<Map<String, dynamic>?> overrides =
ValueNotifier<Map<String, dynamic>?>(null);
@override
Future<Map<String, dynamic>?> fetchSafetyParamOverrides() async {
debugPrint('[example] fetchSafetyParamOverrides: ${overrides.value}');
return overrides.value;
}
}
class RemoteSafetyDemoPage extends StatefulWidget {
const RemoteSafetyDemoPage({super.key});
// Round-40 audit round 2 (independent re-review, R2-02) — the double-tap
// regression tests could only assert on converged end-state ("wired" /
// "one demo page"), which two racing operations could equally reach.
// These count actual invocations past the `_busy` guard so a test can
// assert exactly one real destroy()/initialize() ran, not just that the
// UI looks fine afterward. Test-only — never read outside integration
// tests.
@visibleForTesting
static int debugApplyCallCount = 0;
@visibleForTesting
static int debugPushCallCount = 0;
@visibleForTesting
static int debugRestoreCallCount = 0;
// Round-40 audit round 4 (follow-up to R3-01) — a real
// `AdManager().initialize()` failure is network-dependent and not
// reliably forceable from a test, so `onComplete(false, ...)`'s branch
// (see R3-01) had no deterministic regression coverage. When set, this
// skips the real destroy()/initialize() call entirely and uses the given
// value as `success` directly — the real call itself is already proven
// on-device by the other round40 integration tests; this isolates just
// the success/failure branch handling so it can run as a fast, reliable
// plain widget test. Test-only; reset to `null` in `tearDown`.
@visibleForTesting
static bool? debugForceApplyResult;
@visibleForTesting
static bool? debugForceRestoreResult;
@override
State<RemoteSafetyDemoPage> createState() => _RemoteSafetyDemoPageState();
}
class _RemoteSafetyDemoPageState extends State<RemoteSafetyDemoPage> {
final _provider = DemoRemoteAdSafetyProvider();
double _maxPerDay = 20;
bool _dryRun = false;
bool _wired = false;
// Round-40 audit (independent review, IMPORTANT) — without this, a fast
// double-tap on "Apply provider" (or "Push update") started a second
// destroy()/initialize() (or refresh) before the first one's await
// resolved, since only `_wired` gated the button and it only flips
// after everything finishes. Guards every button below.
bool _busy = false;
String _status = 'Not wired yet — tap "Apply provider" below first.';
Future<void> _applyProvider() async {
if (_busy) return;
setState(() {
_busy = true;
_status = 'Destroying + re-initializing with provider...';
});
RemoteSafetyDemoPage.debugApplyCallCount++;
// Round-40 audit round 3 (independent re-review, R3-01) — `onComplete`
// was ignored, so a legitimate `onComplete(false, ...)` (SDK init
// failing without throwing) still fell through to the success branch
// below, claiming "Provider wired" while the SDK was actually left
// uninitialised post-destroy().
var success = false;
try {
final forced = RemoteSafetyDemoPage.debugForceApplyResult;
if (forced != null) {
success = forced;
} else {
await AdManager().destroy();
await AdManager().initialize(
config: DemoConfig.instance.build(),
remoteSafetyProvider: _provider,
onComplete: (ok, _) => success = ok,
);
}
if (!mounted) return;
if (!success) {
setState(() =>
_status = 'Failed to apply provider — the SDK did not initialize.');
return;
}
setState(() {
_wired = true;
_status = 'Provider wired. Adjust below, then "Push update".';
});
} catch (e) {
if (mounted) setState(() => _status = 'Failed to apply provider: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pushUpdate() async {
if (_busy) return;
setState(() {
_busy = true;
_status = 'Fetching...';
});
RemoteSafetyDemoPage.debugPushCallCount++;
_provider.overrides.value = {
'maxFullscreenAdsPerDay': _maxPerDay.round(),
'dryRun': _dryRun,
};
try {
await AdManager().refreshRemoteSafetyParams();
if (!mounted) return;
setState(() => _status = 'Applied — see "Live status" below.');
} catch (e) {
if (mounted) setState(() => _status = 'Failed to push update: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
// Round-40 audit (independent review, MINOR) — "Apply provider" mutates
// the whole app's live AdSafetyConfig, not just this page; it used to
// stay mutated with no way back short of restarting the app, which could
// make every other demo screen visited afterward confusing (dry-run ads,
// a lowered daily cap). Detaches the provider and puts the SDK back on
// DemoConfig's own defaults.
Future<void> _restoreDefaults() async {
if (_busy) return;
setState(() {
_busy = true;
_status = 'Restoring demo defaults...';
});
RemoteSafetyDemoPage.debugRestoreCallCount++;
// R3-01 (see _applyProvider) — same capture-and-branch fix.
var success = false;
try {
final forced = RemoteSafetyDemoPage.debugForceRestoreResult;
if (forced != null) {
success = forced;
} else {
await AdManager().destroy();
await AdManager().initialize(
config: DemoConfig.instance.build(),
onComplete: (ok, _) => success = ok,
);
}
if (!mounted) return;
if (!success) {
setState(() => _status =
'Failed to restore defaults — the SDK did not initialize.');
return;
}
setState(() {
_wired = false;
_maxPerDay = 20;
_dryRun = false;
_status = 'Restored — provider detached, SDK back on demo defaults.';
});
} catch (e) {
if (mounted) setState(() => _status = 'Failed to restore defaults: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Remote safety provider demo')),
body: ListView(
padding: bottomSafe(context, const EdgeInsets.all(16)),
children: [
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Simulates a backend (Firebase Remote Config, your own API, '
'...) pushing new AdSafetyParams without an app store '
'release. The controls below stand in for "what the backend '
'returns" — a real host reads them from its own remote-config '
'SDK instead. See README "Remote-controlled AdSafetyParams".',
),
),
),
const SizedBox(height: 8),
const Card(
color: Color(0xFFFFF3E0),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'⚠️ "Apply provider" mutates the whole app\'s live '
'AdSafetyParams, not just this page — other demo screens '
'will reflect it too, until you tap "Restore demo defaults" '
'below.',
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: (_busy || _wired) ? null : _applyProvider,
child: Text(_wired
? 'Provider already wired'
: _busy
? 'Applying...'
: 'Apply provider (destroy + re-initialize)'),
),
const SizedBox(height: 16),
Text(
'Simulated remote maxFullscreenAdsPerDay: ${_maxPerDay.round()}'),
Slider(
value: _maxPerDay,
min: 1,
max: 50,
divisions: 49,
label: '${_maxPerDay.round()}',
onChanged: (_wired && !_busy)
? (v) => setState(() => _maxPerDay = v)
: null,
),
SwitchListTile(
title: const Text('Simulated remote dryRun'),
value: _dryRun,
onChanged: (_wired && !_busy)
? (v) => setState(() => _dryRun = v)
: null,
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: (_wired && !_busy) ? _pushUpdate : null,
child: const Text('Push update (refreshRemoteSafetyParams)'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: (_wired && !_busy) ? _restoreDefaults : null,
child: const Text('Restore demo defaults (destroy + re-initialize)'),
),
const Divider(height: 32),
Text(_status),
const SizedBox(height: 12),
const Text('Live status',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(AdSafetyConfig.getStatus(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// AdReadinessSplashController demo (T94) — README's splash-wrapper shortcut
// ─────────────────────────────────────────────────────────────────────────
class ReadinessControllerDemoPage extends StatefulWidget {
const ReadinessControllerDemoPage({super.key});
// Round-40 audit round 2 (independent re-review, R2-02) — test-only
// counter so a double-tap test can assert exactly one real replay ran
// past the `_busy` guard, not just that the end state looks converged.
@visibleForTesting
static int debugReplayCallCount = 0;
@override
State<ReadinessControllerDemoPage> createState() =>
_ReadinessControllerDemoPageState();
}
class _ReadinessControllerDemoPageState
extends State<ReadinessControllerDemoPage> {
// Round-40 audit (independent review, IMPORTANT) — without this, a fast
// double-tap could push two `_ReadinessControllerSplash` routes on top of
// a single `destroy()`, racing two controllers against one SDK instance
// (the second short-circuits via `countInitSplashScreen > 1`, but which
// one "wins" the pop back becomes timing-dependent). Held true for the
// whole time the splash route is on screen, not just during destroy() —
// `Navigator.push`'s Future only resolves once it's popped.
bool _busy = false;
Future<void> _replay(BuildContext context) async {
if (_busy) return;
setState(() => _busy = true);
ReadinessControllerDemoPage.debugReplayCallCount++;
try {
await AdManager().destroy();
if (!context.mounted) return;
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const _ReadinessControllerSplash(),
));
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Replay failed: $e')));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('AdReadinessSplashController demo')),
body: ListView(
padding: bottomSafe(context, const EdgeInsets.all(16)),
children: [
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'This example\'s real splash screen (SplashScreen) wires '
'everything by hand, to demo the full manual flow the '
'README documents. AdReadinessSplashController (T94) wraps '
'that exact same sequence — subscribe-before-init, hard-cap '
'timer, splash-active bookkeeping, buffered App Open ad — '
'behind one start()/onReady call, for hosts that do not need '
'the manual flow\'s extra steps.\n\n'
'Tapping below destroys the SDK (the same "Destroy SDK" '
'action used elsewhere in this app) and re-initializes it '
'through the controller instead, so it runs for real.',
),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _busy ? null : () => _replay(context),
child: Text(_busy
? 'Replaying...'
: 'Destroy SDK + replay via controller'),
),
],
),
);
}
}
class _ReadinessControllerSplash extends StatefulWidget {
const _ReadinessControllerSplash();
@override
State<_ReadinessControllerSplash> createState() =>
_ReadinessControllerSplashState();
}
class _ReadinessControllerSplashState
extends State<_ReadinessControllerSplash> {
late final _controller =
AdReadinessSplashController(config: DemoConfig.instance.build());
@override
void initState() {
super.initState();
_controller.start(context, onReady: () {
if (mounted) Navigator.of(context).pop();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => const Scaffold(
backgroundColor: Colors.deepPurple,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text('AdReadinessSplashController running...',
style: TextStyle(color: Colors.white)),
],
),
),
);
}