applovin_admob_sdk 1.0.23
applovin_admob_sdk: ^1.0.23 copied to clipboard
Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Adapter pattern, state machine, VIP redeem with Cupertino dialog, GDPR/COPPA/CCPA flags, exponential backoff, AdEvent stream, debug overlay.
example/lib/main.dart
// ═══════════════════════════════════════════════════════════════════════════
// applovin_admob_sdk — single-file example
//
// 11 demo pages, all in this one file so a developer can read top-to-bottom
// without jumping between files. Sections in order:
// §1 Constants + DemoConfig + VIP validator
// §2 LogBuffer (in-memory ring of SDK logs)
// §3 Entry: main() + SplashScreen
// §4 Shared: DemoTile widget
// §5 HomePage (list of all demos)
// §6 Demo: Banner
// §7 Demo: Interstitial
// §8 Demo: Rewarded
// §9 Demo: App Open
// §10 Demo: VIP redeem (Cupertino dialog)
// §11 Demo: Consent / GDPR / COPPA / CCPA flags
// §12 Demo: Safety status + presets
// §13 Demo: Log viewer (ring buffer)
// §14 Demo: Revenue dashboard
// §15 Demo: Slot state panel (+ manual destroy/reinit)
// §16 Demo: AdEvent stream live viewer
//
// Note: provider (AdMob vs AppLovin) is chosen ONCE at app startup via
// `AdConfig.provider` and is **not** swappable at runtime. To switch
// providers, change `kProvider` in §1 and rebuild.
// ═══════════════════════════════════════════════════════════════════════════
import 'dart:async';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// ═══════════════════════════════════════════════════════════════════════════
// §1 Constants + DemoConfig + VIP validator
// ═══════════════════════════════════════════════════════════════════════════
/// Replace with your own AppLovin keys; AdMob test IDs below are public and
/// always valid (Google's test units).
const _kAppLovinSdkKey = 'YOUR_86_CHAR_SDK_KEY_FROM_APPLOVIN_DASHBOARD';
const _kAppLovinBannerId = 'YOUR_BANNER_AD_UNIT_ID';
const _kAppLovinInterstitialId = 'YOUR_INTERSTITIAL_AD_UNIT_ID';
const _kAppLovinAppOpenId = 'YOUR_APP_OPEN_AD_UNIT_ID';
const _kAppLovinRewardedId = 'YOUR_REWARDED_AD_UNIT_ID';
/// Provider for this app — chosen once. Replace the 5 YOUR_* constants
/// above with real values from dash.applovin.com BEFORE running.
const AdProvider kProvider = AdProvider.admob;
/// 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),
};
/// 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',
),
appLovin: const AppLovinConfig(
sdkKey: _kAppLovinSdkKey,
bannerId: _kAppLovinBannerId,
interstitialId: _kAppLovinInterstitialId,
appOpenId: _kAppLovinAppOpenId,
rewardedId: _kAppLovinRewardedId,
),
logLevel: AdLogLevel.verbose,
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),
// Demo always uses the loose preset (999 caps, 2 s throttle, 0 s
// warm-up) regardless of debug/release. Lets QA pound the buttons
// without hitting any safety wall.
// ⚠️ DO NOT copy this into a production app — use AdSafetyParams.auto
// (default) or AdSafetyParams.production there.
safety: kDemoSafetyParams,
// 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),
);
}
}
/// 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,
);
// ═══════════════════════════════════════════════════════════════════════════
// §2 LogBuffer — in-memory ring of SDK logs
// ═══════════════════════════════════════════════════════════════════════════
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;
}
// ═══════════════════════════════════════════════════════════════════════════
// §3 Entry: main() + SplashScreen
// ═══════════════════════════════════════════════════════════════════════════
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);
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(),
));
}
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
final ValueNotifier<bool> _navigated = ValueNotifier<bool>(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 ? _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.
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.
try {
final ump = await AdManager().requestUmpConsent();
debugPrint('UMP: canRequestAds=${ump.canRequestAds}');
} catch (e) {
debugPrint('UMP skipped: $e');
}
if (!mounted) return;
// 3) Initialize the SDK (fires the EventBus completion event).
AdManager().initialize(
config: DemoConfig.instance.build(),
onComplete: (success, gaid) {},
);
});
}
void _showAppOpen() {
AdManager().loadAppOpenAd(onAdLoaded: (loaded) {
if (_navigated.value) return;
if (!loaded || !mounted) {
_goHome();
return;
}
AdLoadingDialog.showAdBuffer(context, onComplete: () {
if (!mounted) {
_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.value) return;
_navigated.value = 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() {
_hardCap?.cancel();
final cb = _listener;
if (cb != null) SimpleEventBus().remove(cb);
_navigated.dispose();
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),
],
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// §4 Shared: DemoTile widget + edge-to-edge helpers
// ═══════════════════════════════════════════════════════════════════════════
/// 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);
}
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,
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// §5 HomePage — list of all demos
// ═══════════════════════════════════════════════════════════════════════════
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.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.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: 'Cupertino dialog + 7/30/90 day keys',
color: Colors.purple,
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())),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §6 Demo: Banner
// ═══════════════════════════════════════════════════════════════════════════
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),
),
),
],
),
),
],
),
);
}
}
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(),
]),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// §7 Demo: Interstitial
// ═══════════════════════════════════════════════════════════════════════════
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),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §8 Demo: Rewarded
// ═══════════════════════════════════════════════════════════════════════════
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>('—');
@override
void dispose() {
_coins.dispose();
_vipAutoGrant.dispose();
_last.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Rewarded demo')),
body: Padding(
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),
FilledButton(
onPressed: () {
showRewardedAd(
vipAutoGrant: _vipAutoGrant.value,
onEarnedReward: (earned) {
_last.value = earned ? 'earned 🏆' : 'skipped/blocked ❌';
if (earned) _coins.value = _coins.value + 10;
},
);
},
child: const Text('Watch ad for +10 coins'),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §9 Demo: App Open
// ═══════════════════════════════════════════════════════════════════════════
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) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(loaded ? 'App open ad ready ✅' : 'Load failed ❌'),
));
});
},
child: const Text('Force load App Open'),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §10 Demo: VIP redeem (Cupertino dialog)
// ═══════════════════════════════════════════════════════════════════════════
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,
);
}
/// 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,
);
},
);
}
@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)}'),
),
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),
// 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'),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §11 Demo: Consent / GDPR / COPPA / CCPA
// ═══════════════════════════════════════════════════════════════════════════
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);
@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();
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),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FilledButton(
onPressed: () async {
await AdManager().setConsent(AdConsent(
hasUserConsent: _hasConsent.value,
isAgeRestrictedUser: _isAge.value,
doNotSell: _doNotSell.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 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)),
],
),
),
),
),
),
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,
);
},
),
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),
),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §12 Demo: Safety status + presets
// ═══════════════════════════════════════════════════════════════════════════
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);
@override
void dispose() {
_refresh.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('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.resetSession();
_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'),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §13 Demo: Log viewer
// ═══════════════════════════════════════════════════════════════════════════
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)),
),
],
),
);
},
);
},
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §14 Demo: Revenue dashboard
// ═══════════════════════════════════════════════════════════════════════════
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),
),
),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §15 Demo: Slot state panel
// ═══════════════════════════════════════════════════════════════════════════
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: adapter == null
? const Center(child: Text('SDK not initialised yet'))
: ListView(
padding: _bottomSafe(context, const EdgeInsets.all(16)),
children: [
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),
_slotCard('Banner', adapter.bannerSlot),
const Divider(height: 32),
const Text(
'Lifecycle test',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () 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'),
),
],
),
);
}
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)),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// §16 Demo: AdEvent stream live viewer
// ═══════════════════════════════════════════════════════════════════════════
class EventsDemoPage extends StatefulWidget {
const EventsDemoPage({super.key});
@override
State<EventsDemoPage> createState() => _EventsDemoPageState();
}
class _EventsDemoPageState extends State<EventsDemoPage> {
/// Ring buffer of last N events. Bumped via revision so the list view
/// only rebuilds once per push (cheap).
static const int _max = 100;
final List<_EventRow> _rows = [];
final ValueNotifier<int> _rev = ValueNotifier<int>(0);
StreamSubscription<AdEvent>? _sub;
@override
void initState() {
super.initState();
_sub = AdManager().events.listen((event) {
if (!mounted) return;
_rows.insert(0, _EventRow(DateTime.now(), event));
if (_rows.length > _max) _rows.removeLast();
_rev.value = _rev.value + 1;
});
}
@override
void dispose() {
_sub?.cancel();
_sub = null;
_rev.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('AdEvent stream'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Clear',
onPressed: () {
_rows.clear();
_rev.value = _rev.value + 1;
},
),
],
),
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: _rev,
builder: (_, __, ___) {
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 _EventRow {
_EventRow(this.timestamp, this.event);
final DateTime timestamp;
final AdEvent event;
}
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.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) {
return (
'REV \$',
Colors.teal,
'\$${e.value.toStringAsFixed(6)} ${e.currencyCode}'
'${e.networkName != null ? ' via ${e.networkName}' : ''}',
);
}
return ('?', Colors.grey, '');
}
}