velocity_ads 0.11.0
velocity_ads: ^0.11.0 copied to clipboard
A Flutter plugin for VelocityAds SDK - AI-powered contextual advertising for Android and iOS.
example/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:velocity_ads/velocity_ads.dart';
// Demo-only UI (form fields, event log, native view styling) lives in these
// files; every Velocity Ads SDK call is in this file.
import 'ad_view_settings_page.dart';
import 'demo_common.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'VelocityAds Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ExampleHomePage(),
);
}
}
enum _InitState { initializing, initialized, failed }
// ══════════════════════════════════════════════════════════════════════════
// Home — SDK initialization, user ID, privacy, and one entry per ad format
// ══════════════════════════════════════════════════════════════════════════
/// Home screen: SDK setup (initialization, user ID, privacy) and the entry
/// point to each ad format's screen.
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key});
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
// Use the app key for the current platform (iOS and Android keys differ).
static const _iosAppKey = 'YOUR_IOS_APPLICATION_KEY';
static const _androidAppKey = 'YOUR_ANDROID_APPLICATION_KEY';
final _userIdController = TextEditingController();
_InitState _initState = _InitState.initializing;
String? _initError;
@override
void initState() {
super.initState();
_initializeSDK();
}
@override
void dispose() {
_userIdController.dispose();
super.dispose();
}
Future<void> _initializeSDK() async {
setState(() {
_initState = _InitState.initializing;
_initError = null;
});
try {
await VelocityAds.initialize(
appKey: Platform.isIOS ? _iosAppKey : _androidAppKey,
);
if (!mounted) return;
setState(() => _initState = _InitState.initialized);
} on VelocityAdsError catch (e) {
if (!mounted) return;
setState(() {
_initState = _InitState.failed;
_initError = '[${e.code}] ${e.message}';
});
} catch (e) {
if (!mounted) return;
setState(() {
_initState = _InitState.failed;
_initError = '$e';
});
}
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message),
duration: const Duration(seconds: 2),
));
}
void _open(Widget page) {
FocusScope.of(context).unfocus();
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('VelocityAds Example'),
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16),
children: [
_buildStatusCard(),
const SizedBox(height: 16),
_buildAdFormatsCard(),
const SizedBox(height: 16),
_buildUserIdCard(),
const SizedBox(height: 16),
_buildPrivacyCard(),
],
),
),
);
}
Widget _buildStatusCard() {
final (icon, color, label) = switch (_initState) {
_InitState.initializing => (
Icons.hourglass_top,
Colors.orange,
'Initializing SDK…'
),
_InitState.initialized => (
Icons.check_circle,
Colors.green,
'SDK initialized'
),
_InitState.failed => (
Icons.error,
Colors.red,
'SDK initialization failed'
),
};
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodyLarge),
if (_initError != null)
Text(_initError!,
style: Theme.of(context).textTheme.bodySmall),
],
),
),
if (_initState == _InitState.failed)
TextButton(onPressed: _initializeSDK, child: const Text('Retry')),
],
),
),
);
}
Widget _buildAdFormatsCard() {
final ready = _initState == _InitState.initialized;
Widget format(IconData icon, String title, String subtitle, Widget page) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _open(page),
);
}
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text('Ad Formats',
style: Theme.of(context).textTheme.titleLarge),
),
if (!ready)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Initialize the SDK first — loads fail with '
'SDK_NOT_INITIALIZED until then.',
style: Theme.of(context).textTheme.bodySmall),
),
format(Icons.chat_bubble_outline, 'Native',
'SDK-rendered or manually rendered', const NativeAdPage()),
format(Icons.view_day_outlined, 'Banner',
'Inline or pinned to the bottom', const BannerAdPage()),
format(Icons.fullscreen, 'Interstitial', 'Fullscreen, load then show',
const InterstitialAdPage()),
format(Icons.card_giftcard_outlined, 'Rewarded',
'Fullscreen, grants a reward', const RewardedAdPage()),
const SizedBox(height: 8),
],
),
);
}
Widget _buildUserIdCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('User ID', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _userIdController,
decoration: const InputDecoration(
labelText: 'User ID',
helperText: 'Optional',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
final userId = _userIdController.text.trim();
VelocityAds.setUserId(userId.isEmpty ? null : userId);
_showSnackBar(
userId.isEmpty ? 'User ID cleared' : 'User ID set');
},
child: const Text('Set'),
),
],
),
],
),
),
);
}
Widget _buildPrivacyCard() {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child:
Text('Privacy', style: Theme.of(context).textTheme.titleLarge),
),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('Grant GDPR Consent'),
onTap: () {
VelocityAds.setConsent(true);
_showSnackBar('GDPR consent granted');
},
),
ListTile(
leading: const Icon(Icons.block),
title: const Text('Deny GDPR Consent'),
onTap: () {
VelocityAds.setConsent(false);
_showSnackBar('GDPR consent denied');
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.shield),
title: const Text('Enable CCPA Do Not Sell'),
onTap: () {
VelocityAds.setDoNotSell(true);
_showSnackBar('CCPA Do Not Sell enabled');
},
),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: const Text('Disable CCPA Do Not Sell'),
onTap: () {
VelocityAds.setDoNotSell(false);
_showSnackBar('CCPA Do Not Sell disabled');
},
),
const SizedBox(height: 8),
],
),
);
}
}
// ══════════════════════════════════════════════════════════════════════════
// Native ads
// ══════════════════════════════════════════════════════════════════════════
/// One loaded (or failed) native ad on the screen.
class _NativeAdEntry {
_NativeAdEntry({required this.ad, required this.viewSize});
final VelocityNativeAd ad;
/// Size of the SDK-rendered view, or `null` for a manually rendered ad.
final VelocityNativeAdViewSize? viewSize;
/// Set when the last load attempt failed; cleared on retry.
bool failed = false;
bool get isSdkRendered => viewSize != null;
}
enum _TriState { notSet, off, on }
/// Native ad screen, covering both rendering paths:
///
/// * **SDK-rendered** — a [VelocityNativeAdViewRequest]; the SDK draws the ad
/// in a native view of the chosen size, styled by the Ad View Settings.
/// * **Manual** — a [VelocityNativeAdRequest]; the app builds the UI from
/// `ad.data` and wraps it in a [VelocityNativeAdTracker].
class NativeAdPage extends StatefulWidget {
const NativeAdPage({super.key});
@override
State<NativeAdPage> createState() => _NativeAdPageState();
}
class _NativeAdPageState extends State<NativeAdPage> {
static const _aiResponse =
'Here are some great options for marathon training shoes...';
final _adUnitIdController = TextEditingController();
final _additionalContextController = TextEditingController();
final _promptController = TextEditingController(
text: 'Best running shoes for marathon training',
);
final _log = EventLog();
final List<_NativeAdEntry> _ads = [];
VelocityNativeAdViewSize _viewSize = VelocityNativeAdViewSize.m;
bool? _includePreliminaryText;
AdViewSettings _viewSettings = AdViewSettings.defaults();
int _pendingLoads = 0;
@override
void dispose() {
for (final entry in _ads) {
entry.ad.destroy();
}
_adUnitIdController.dispose();
_additionalContextController.dispose();
_promptController.dispose();
super.dispose();
}
void _record(String message) {
if (!mounted) return;
setState(() => _log.add(message));
}
Future<void> _openSettings() async {
final result = await Navigator.of(context).push<AdViewSettings>(
MaterialPageRoute(
builder: (_) => AdViewSettingsPage(initialSettings: _viewSettings),
),
);
if (result != null) setState(() => _viewSettings = result);
}
VelocityNativeAdRequest _buildRequest({required bool sdkRendered}) {
final adUnitId = _adUnitIdController.text.trim();
final prompt = optionalText(_promptController);
final additionalContext = optionalText(_additionalContextController);
final history = [
if (prompt != null) ConversationMessage.user(prompt),
ConversationMessage.assistant(_aiResponse),
];
if (sdkRendered) {
return VelocityNativeAdViewRequest(
adUnitId: adUnitId,
size: _viewSize,
adViewConfiguration: _viewSettings.toConfiguration(),
prompt: prompt,
aiResponse: _aiResponse,
conversationHistory: history,
additionalContext: additionalContext,
includePreliminaryText: _includePreliminaryText,
);
}
return VelocityNativeAdRequest(
adUnitId: adUnitId,
prompt: prompt,
aiResponse: _aiResponse,
conversationHistory: history,
additionalContext: additionalContext,
includePreliminaryText: _includePreliminaryText,
);
}
void _load({required bool sdkRendered}) {
final request = _buildRequest(sdkRendered: sdkRendered);
final entry = _NativeAdEntry(
ad: VelocityNativeAd(request),
viewSize: sdkRendered ? _viewSize : null,
);
setState(() => _ads.add(entry));
_startLoad(entry);
}
Future<void> _startLoad(_NativeAdEntry entry) async {
final kind =
entry.isSdkRendered ? 'SDK-rendered ${entry.viewSize!.name}' : 'manual';
setState(() {
entry.failed = false;
_pendingLoads++;
});
_record('load(${entry.ad.adRequest.adUnitId}) $kind');
try {
await entry.ad.load(listener: _NativeListener(this, entry));
} catch (e) {
_record('load threw: $e');
} finally {
if (mounted) setState(() => _pendingLoads--);
}
}
Future<void> _onLoaded(_NativeAdEntry entry) async {
_record('onAdLoaded');
if (!entry.isSdkRendered) return;
try {
await entry.ad.createAdView();
} catch (e) {
_record('createAdView failed: $e');
_remove(entry);
return;
}
if (mounted) setState(() {});
}
void _remove(_NativeAdEntry entry) {
entry.ad.destroy();
if (!mounted) return;
setState(() => _ads.remove(entry));
}
void _removeAll() {
for (final entry in _ads) {
entry.ad.destroy();
}
setState(_ads.clear);
_record('all native ads removed (destroyed)');
}
@override
Widget build(BuildContext context) {
final loading = _pendingLoads > 0;
return DemoScaffold(
title: 'Native Ads',
actions: [
IconButton(
icon: const Icon(Icons.palette_outlined),
tooltip: 'Ad View Settings',
onPressed: _openSettings,
),
],
children: [
DemoSection(
title: 'Ad Request',
children: [
AdRequestFields(
adUnitIdController: _adUnitIdController,
additionalContextController: _additionalContextController,
),
const SizedBox(height: 12),
TextField(
controller: _promptController,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Prompt',
helperText: 'Optional',
hintText: 'Enter search query or user intent',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text('Include Preliminary Text',
style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 6),
SegmentedButton<_TriState>(
expandedInsets: EdgeInsets.zero,
segments: const [
ButtonSegment(value: _TriState.notSet, label: Text('Not Set')),
ButtonSegment(value: _TriState.off, label: Text('False')),
ButtonSegment(value: _TriState.on, label: Text('True')),
],
selected: {
switch (_includePreliminaryText) {
null => _TriState.notSet,
false => _TriState.off,
true => _TriState.on,
},
},
onSelectionChanged: (s) => setState(() {
_includePreliminaryText = switch (s.first) {
_TriState.notSet => null,
_TriState.off => false,
_TriState.on => true,
};
}),
),
],
),
DemoSection(
title: 'Ad',
children: [
Text('SDK-rendered view size',
style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 6),
SegmentedButton<VelocityNativeAdViewSize>(
expandedInsets: EdgeInsets.zero,
segments: const [
ButtonSegment(
value: VelocityNativeAdViewSize.xs, label: Text('XS')),
ButtonSegment(
value: VelocityNativeAdViewSize.s, label: Text('S')),
ButtonSegment(
value: VelocityNativeAdViewSize.m, label: Text('M')),
ButtonSegment(
value: VelocityNativeAdViewSize.l, label: Text('L')),
],
selected: {_viewSize},
onSelectionChanged: (s) => setState(() => _viewSize = s.first),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: loading ? null : () => _load(sdkRendered: true),
icon: const Icon(Icons.view_quilt),
label: const Text('SDK View'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: loading ? null : () => _load(sdkRendered: false),
icon: const Icon(Icons.draw_outlined),
label: const Text('Manual'),
),
),
],
),
const SizedBox(height: 8),
Text(
'Both buttons load an ad.\n'
'SDK View: the SDK renders the ad in a view of the selected size.\n'
'Manual: the ad data is returned and the app builds the UI.',
style: Theme.of(context).textTheme.bodySmall,
),
if (_ads.isNotEmpty)
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: _removeAll,
icon: const Icon(Icons.delete_outline, size: 18),
label: Text('Remove all (${_ads.length})'),
),
),
],
),
for (final entry in _ads)
KeyedSubtree(
key: ValueKey(entry.ad.instanceId),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: _buildAd(entry),
),
),
EventLogSection(
log: _log,
onClear: () => setState(_log.clear),
),
],
);
}
Widget _buildAd(_NativeAdEntry entry) {
if (entry.ad.data == null) return _buildPlaceholder(entry);
final preliminaryText = entry.ad.data!.preliminaryText;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (preliminaryText != null && preliminaryText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
preliminaryText,
style: TextStyle(
fontSize: 13,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
),
),
entry.isSdkRendered ? _buildSdkView(entry) : _buildManualAd(entry.ad),
],
);
}
/// Shown while the ad loads, and after a failed load with a retry action.
Widget _buildPlaceholder(_NativeAdEntry entry) {
final failed = entry.failed;
return Card(
color: failed ? Colors.red.shade50 : null,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
if (failed)
const Icon(Icons.error_outline, color: Colors.red)
else
const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 12),
Expanded(
child: Text(failed ? 'Failed to load' : 'Loading…'),
),
if (failed)
TextButton(
onPressed: () => _startLoad(entry),
child: const Text('Retry'),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: 'Remove',
onPressed: () => _remove(entry),
),
],
),
),
);
}
Widget _buildSdkView(_NativeAdEntry entry) {
final view = entry.ad.widget;
return SizedBox(
height: entry.viewSize!.height,
child: view ?? const Center(child: CircularProgressIndicator()),
);
}
Widget _buildManualAd(VelocityNativeAd ad) {
final data = ad.data!;
final imageUrl = data.largeImageUrl ?? data.squareImageUrl;
final textTheme = Theme.of(context).textTheme;
return VelocityNativeAdTracker(
ad: ad,
child: Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sponsored · ${data.advertiserName}',
style: textTheme.labelSmall),
const SizedBox(height: 8),
Text(data.title,
style: textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(data.description, style: textTheme.bodyMedium),
if (imageUrl != null && imageUrl.isNotEmpty) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
imageUrl,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
height: 180,
color: Colors.grey.shade200,
child:
const Center(child: Icon(Icons.image_not_supported)),
),
),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
// The tracker handles the click for the whole card.
child: FilledButton(
onPressed: null,
child: Text(data.callToAction),
),
),
],
),
),
),
);
}
}
class _NativeListener extends VelocityNativeAdListener {
_NativeListener(this._state, this._entry);
final _NativeAdPageState _state;
final _NativeAdEntry _entry;
@override
void onAdLoaded(VelocityNativeAd ad) => _state._onLoaded(_entry);
@override
void onAdFailedToLoad(VelocityNativeAd ad, VelocityAdsError error) {
_entry.failed = true;
_state._record('onAdFailedToLoad [${error.code}] ${error.message}');
}
@override
void onAdImpression(VelocityNativeAd ad) => _state._record('onAdImpression');
@override
void onAdClicked(VelocityNativeAd ad) => _state._record('onAdClicked');
}
// ══════════════════════════════════════════════════════════════════════════
// Banner ads
// ══════════════════════════════════════════════════════════════════════════
enum _BannerPlacement { inline, bottom }
enum _BannerSizeOption { banner, mrec, leaderboard, adaptive }
/// Banner screen: pick a size and a placement, then load.
///
/// Inline places the banner between content rows so its impression fires only
/// once it is scrolled ≥ 50 % into view for one second; bottom pins it below
/// the content, clear of the home indicator / navigation bar.
class BannerAdPage extends StatefulWidget {
const BannerAdPage({super.key});
@override
State<BannerAdPage> createState() => _BannerAdPageState();
}
class _BannerAdPageState extends State<BannerAdPage> {
final _adUnitIdController = TextEditingController();
final _additionalContextController = TextEditingController();
final _log = EventLog();
// What the picker will apply to the next load.
_BannerPlacement _placement = _BannerPlacement.inline;
// Where the current banner is shown — fixed at load time like its size, so
// flipping the picker does not move an already loaded banner.
_BannerPlacement _loadedPlacement = _BannerPlacement.inline;
_BannerSizeOption _sizeOption = _BannerSizeOption.banner;
// Empty means "use the screen width".
final _adaptiveWidthController = TextEditingController();
VelocityBannerAdRequest? _request;
// Bumped on every load so VelocityBannerAdWidget mounts a fresh banner even
// when the request is unchanged.
int _generation = 0;
@override
void dispose() {
_adUnitIdController.dispose();
_additionalContextController.dispose();
_adaptiveWidthController.dispose();
super.dispose();
}
void _record(String message) {
if (!mounted) return;
setState(() => _log.add(message));
}
VelocityBannerAdSize _resolveSize() {
switch (_sizeOption) {
case _BannerSizeOption.banner:
return VelocityBannerAdSize.banner;
case _BannerSizeOption.mrec:
return VelocityBannerAdSize.mrec;
case _BannerSizeOption.leaderboard:
return VelocityBannerAdSize.leaderboard;
case _BannerSizeOption.adaptive:
return VelocityBannerAdSize.adaptive(width: _adaptiveWidth());
}
}
/// The typed adaptive width, or the screen width when the field is empty
/// or not a positive number.
double _adaptiveWidth() {
final typed = double.tryParse(_adaptiveWidthController.text.trim());
if (typed != null && typed.isFinite && typed > 0) return typed;
return MediaQuery.sizeOf(context).width;
}
static const _sizeNames = {
_BannerSizeOption.banner: 'Banner',
_BannerSizeOption.mrec: 'MREC',
_BannerSizeOption.leaderboard: 'Leaderboard',
_BannerSizeOption.adaptive: 'Adaptive',
};
// Horizontal padding inside each size segment.
static const _sizeSegmentPadding = 4.0;
/// The size picker, with one font size for all four labels: the largest
/// size at which the widest name ("Leaderboard") still fits its segment.
/// Scaling each label on its own would leave them visibly mismatched.
Widget _sizePicker() {
final base = Theme.of(context).textTheme.labelLarge ?? const TextStyle();
final textScaler = MediaQuery.textScalerOf(context);
final textDirection = Directionality.of(context);
return LayoutBuilder(
builder: (context, constraints) {
// Each segment gets an equal share; subtract its padding and the
// 1 px dividers between segments.
final available = constraints.maxWidth / _sizeNames.length -
2 * _sizeSegmentPadding -
2;
var widest = 0.0;
for (final name in _sizeNames.values) {
final painter = TextPainter(
text: TextSpan(text: name, style: base),
textDirection: textDirection,
textScaler: textScaler,
maxLines: 1,
)..layout();
if (painter.width > widest) widest = painter.width;
painter.dispose();
}
final baseSize = base.fontSize ?? 14;
final fontSize = widest > available && widest > 0
? baseSize * available / widest
: baseSize;
final style = base.copyWith(fontSize: fontSize);
return SegmentedButton<_BannerSizeOption>(
expandedInsets: EdgeInsets.zero,
showSelectedIcon: false,
style: ButtonStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: _sizeSegmentPadding)),
textStyle: WidgetStatePropertyAll(style),
),
segments: [
for (final entry in _sizeNames.entries)
ButtonSegment(
value: entry.key,
label: Text(entry.value, maxLines: 1, softWrap: false),
),
],
selected: {_sizeOption},
onSelectionChanged: (s) => setState(() => _sizeOption = s.first),
);
},
);
}
String _sizeCaption() {
final size = _resolveSize();
final dimensions = '${size.width.round()} × ${size.height.round()}';
return _sizeOption == _BannerSizeOption.adaptive
? '$dimensions (height derived from width)'
: dimensions;
}
void _load() {
final request = VelocityBannerAdRequest(
adUnitId: _adUnitIdController.text.trim(),
adSize: _resolveSize(),
additionalContext: optionalText(_additionalContextController),
);
setState(() {
_generation++;
_request = request;
_loadedPlacement = _placement;
});
_record('load(${request.adUnitId}) '
'${request.adSize.width.round()}×${request.adSize.height.round()} '
'${_placement.name}');
}
void _remove() {
setState(() => _request = null);
_record('banner removed (destroyed)');
}
Widget _banner() {
final request = _request;
if (request == null) return const SizedBox.shrink();
// heightFactor: 1 keeps the slot as tall as the banner; a plain Center
// expands to every pixel a Scaffold's bottomNavigationBar allows.
return Center(
heightFactor: 1,
child: VelocityBannerAdWidget(
key: ValueKey(_generation),
adRequest: request,
listener: _BannerListener(this),
),
);
}
List<Widget> _contentRows(int from, int to) => [
for (var i = from; i < to; i++)
ListTile(
leading: const Icon(Icons.article_outlined),
title: Text('Content row ${i + 1}'),
),
];
@override
Widget build(BuildContext context) {
final inline = _loadedPlacement == _BannerPlacement.inline;
return DemoScaffold(
title: 'Banner',
bottomNavigationBar: inline ? null : SafeArea(child: _banner()),
children: [
DemoSection(
title: 'Ad Request',
children: [
AdRequestFields(
adUnitIdController: _adUnitIdController,
additionalContextController: _additionalContextController,
),
const SizedBox(height: 12),
Text('Size', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 6),
_sizePicker(),
if (_sizeOption == _BannerSizeOption.adaptive) ...[
const SizedBox(height: 12),
TextField(
controller: _adaptiveWidthController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
// Rebuild so the caption below previews the derived height.
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: 'Width (logical px)',
hintText: 'Screen width: '
'${MediaQuery.sizeOf(context).width.round()}',
border: const OutlineInputBorder(),
),
),
],
const SizedBox(height: 6),
Text(
_sizeCaption(),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
DemoSection(
title: 'Ad',
children: [
Text('Placement', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 6),
SegmentedButton<_BannerPlacement>(
expandedInsets: EdgeInsets.zero,
segments: const [
ButtonSegment(
value: _BannerPlacement.bottom,
label: Text('Bottom'),
icon: Icon(Icons.vertical_align_bottom)),
ButtonSegment(
value: _BannerPlacement.inline,
label: Text('Inline'),
icon: Icon(Icons.view_stream)),
],
selected: {_placement},
onSelectionChanged: (s) => setState(() => _placement = s.first),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.download),
label: const Text('Load'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _request == null ? null : _remove,
icon: const Icon(Icons.delete_outline),
label: const Text('Remove'),
),
),
],
),
],
),
if (inline && _request != null) ...[
const SizedBox(height: 8),
// Scroll the banner into view to watch the impression fire only
// after it has been ≥ 50 % visible for one second.
..._contentRows(0, 8),
_banner(),
..._contentRows(8, 14),
],
EventLogSection(
log: _log,
onClear: () => setState(_log.clear),
),
],
);
}
}
class _BannerListener extends VelocityBannerAdListener {
_BannerListener(this._state);
final _BannerAdPageState _state;
@override
void onAdLoaded(VelocityBannerAd ad) {
final size = ad.size;
_state._record(size == null
? 'onAdLoaded'
: 'onAdLoaded ${size.width.round()}×${size.height.round()}');
}
@override
void onAdFailedToLoad(VelocityBannerAd ad, VelocityAdsError error) =>
_state._record('onAdFailedToLoad [${error.code}] ${error.message}');
@override
void onAdImpression(VelocityBannerAd ad) => _state._record('onAdImpression');
@override
void onAdClicked(VelocityBannerAd ad) => _state._record('onAdClicked');
@override
void onAdFailedToShow(VelocityBannerAd ad, VelocityAdsError error) =>
_state._record('onAdFailedToShow [${error.code}] ${error.message}');
}
// ══════════════════════════════════════════════════════════════════════════
// Interstitial ads
// ══════════════════════════════════════════════════════════════════════════
/// Interstitial screen: load, then show when ready.
///
/// An interstitial is single-use — once it is dismissed (or fails to show) it
/// is destroyed and a new one has to be loaded.
class InterstitialAdPage extends StatefulWidget {
const InterstitialAdPage({super.key});
@override
State<InterstitialAdPage> createState() => _InterstitialAdPageState();
}
class _InterstitialAdPageState extends State<InterstitialAdPage> {
final _adUnitIdController = TextEditingController();
final _additionalContextController = TextEditingController();
final _log = EventLog();
VelocityInterstitialAd? _ad;
bool _loading = false;
bool _loaded = false;
@override
void dispose() {
_ad?.destroy();
_adUnitIdController.dispose();
_additionalContextController.dispose();
super.dispose();
}
void _record(String message) {
if (!mounted) return;
setState(() => _log.add(message));
}
Future<void> _load() async {
_ad?.destroy();
final ad = VelocityInterstitialAd(VelocityInterstitialAdRequest(
adUnitId: _adUnitIdController.text.trim(),
additionalContext: optionalText(_additionalContextController),
));
_ad = ad;
setState(() {
_loading = true;
_loaded = false;
});
_record('load(${ad.adRequest.adUnitId})');
try {
// Completes once onAdLoaded or onAdFailedToLoad has fired.
await ad.load(listener: _InterstitialListener(this));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _show() async {
final ad = _ad;
if (ad == null) return;
// A loaded ad can expire; check before showing.
final ready = await ad.isReady();
if (!mounted) return;
if (!ready) {
_record('isReady → false');
setState(() => _loaded = false);
return;
}
await ad.show();
}
/// Fullscreen ads are single-use: destroy after dismissal or a failed show.
void _finish(VelocityFullscreenAd ad) {
ad.destroy();
if (!mounted) return;
setState(() {
if (identical(_ad, ad)) _ad = null;
_loaded = false;
});
}
@override
Widget build(BuildContext context) {
return DemoScaffold(
title: 'Interstitial',
children: [
DemoSection(
title: 'Ad Request',
children: [
AdRequestFields(
adUnitIdController: _adUnitIdController,
additionalContextController: _additionalContextController,
),
],
),
DemoSection(
title: 'Ad',
children: [
LoadShowButtons(
loading: _loading,
canShow: _loaded,
onLoad: _load,
onShow: _show,
),
],
),
EventLogSection(log: _log, onClear: () => setState(_log.clear)),
],
);
}
}
class _InterstitialListener extends VelocityInterstitialAdListener {
_InterstitialListener(this._page);
final _InterstitialAdPageState _page;
// ── Required ──
@override
void onAdLoaded(VelocityFullscreenAd ad) {
_page._loaded = true;
_page._record('onAdLoaded');
}
@override
void onAdFailedToLoad(VelocityFullscreenAd ad, VelocityAdsError error) =>
_page._record('onAdFailedToLoad [${error.code}] ${error.message}');
// ── Optional ──
@override
void onAdShown(VelocityFullscreenAd ad) {
_page._loaded = false;
_page._record('onAdShown');
}
@override
void onAdImpression(VelocityFullscreenAd ad) =>
_page._record('onAdImpression');
@override
void onAdClicked(VelocityFullscreenAd ad) => _page._record('onAdClicked');
@override
void onAdFailedToShow(VelocityFullscreenAd ad, VelocityAdsError error) {
_page._record('onAdFailedToShow [${error.code}] ${error.message}');
_page._finish(ad);
}
@override
void onAdDismissed(VelocityFullscreenAd ad) {
_page._record('onAdDismissed');
_page._finish(ad);
}
}
// ══════════════════════════════════════════════════════════════════════════
// Rewarded ads
// ══════════════════════════════════════════════════════════════════════════
/// Rewarded screen: load, show when ready, and grant the reward in
/// `onUserRewarded`.
///
/// A rewarded ad is single-use — once it is dismissed (or fails to show) it
/// is destroyed and a new one has to be loaded.
class RewardedAdPage extends StatefulWidget {
const RewardedAdPage({super.key});
@override
State<RewardedAdPage> createState() => _RewardedAdPageState();
}
class _RewardedAdPageState extends State<RewardedAdPage> {
final _adUnitIdController = TextEditingController();
final _additionalContextController = TextEditingController();
final _log = EventLog();
VelocityRewardedAd? _ad;
bool _loading = false;
bool _loaded = false;
int _rewards = 0;
@override
void dispose() {
_ad?.destroy();
_adUnitIdController.dispose();
_additionalContextController.dispose();
super.dispose();
}
void _record(String message) {
if (!mounted) return;
setState(() => _log.add(message));
}
Future<void> _load() async {
_ad?.destroy();
final ad = VelocityRewardedAd(VelocityRewardedAdRequest(
adUnitId: _adUnitIdController.text.trim(),
additionalContext: optionalText(_additionalContextController),
));
_ad = ad;
setState(() {
_loading = true;
_loaded = false;
});
_record('load(${ad.adRequest.adUnitId})');
try {
// Completes once onAdLoaded or onAdFailedToLoad has fired.
await ad.load(listener: _RewardedListener(this));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _show() async {
final ad = _ad;
if (ad == null) return;
// A loaded ad can expire; check before showing.
final ready = await ad.isReady();
if (!mounted) return;
if (!ready) {
_record('isReady → false');
setState(() => _loaded = false);
return;
}
await ad.show();
}
/// Fullscreen ads are single-use: destroy after dismissal or a failed show.
void _finish(VelocityFullscreenAd ad) {
ad.destroy();
if (!mounted) return;
setState(() {
if (identical(_ad, ad)) _ad = null;
_loaded = false;
});
}
@override
Widget build(BuildContext context) {
return DemoScaffold(
title: 'Rewarded',
children: [
DemoSection(
title: 'Ad Request',
children: [
AdRequestFields(
adUnitIdController: _adUnitIdController,
additionalContextController: _additionalContextController,
),
],
),
DemoSection(
title: 'Ad',
children: [
LoadShowButtons(
loading: _loading,
canShow: _loaded,
onLoad: _load,
onShow: _show,
),
const SizedBox(height: 12),
Text('Rewards earned: $_rewards',
style: Theme.of(context).textTheme.bodyLarge),
],
),
EventLogSection(log: _log, onClear: () => setState(_log.clear)),
],
);
}
}
class _RewardedListener extends VelocityRewardedAdListener {
_RewardedListener(this._page);
final _RewardedAdPageState _page;
// ── Required ──
@override
void onAdLoaded(VelocityFullscreenAd ad) {
_page._loaded = true;
_page._record('onAdLoaded');
}
@override
void onAdFailedToLoad(VelocityFullscreenAd ad, VelocityAdsError error) =>
_page._record('onAdFailedToLoad [${error.code}] ${error.message}');
// ── Optional ──
@override
void onAdShown(VelocityFullscreenAd ad) {
_page._loaded = false;
_page._record('onAdShown');
}
@override
void onAdImpression(VelocityFullscreenAd ad) =>
_page._record('onAdImpression');
@override
void onAdClicked(VelocityFullscreenAd ad) => _page._record('onAdClicked');
@override
void onUserRewarded(VelocityFullscreenAd ad) {
// Grant the reward here.
_page._rewards++;
_page._record('onUserRewarded');
}
@override
void onAdFailedToShow(VelocityFullscreenAd ad, VelocityAdsError error) {
_page._record('onAdFailedToShow [${error.code}] ${error.message}');
_page._finish(ad);
}
@override
void onAdDismissed(VelocityFullscreenAd ad) {
_page._record('onAdDismissed');
_page._finish(ad);
}
}