velocity_ads 0.4.1
velocity_ads: ^0.4.1 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:velocity_ads/velocity_ads.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(),
);
}
}
/// Snapshot of a single loaded ad entry.
class _AdEntry {
final VelocityNativeAd ad;
final bool isViewAd;
final VelocityNativeAdViewSize viewSize;
const _AdEntry({
required this.ad,
required this.isViewAd,
required this.viewSize,
});
}
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key});
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
bool _isInitialized = false;
bool _isLoading = false;
// All live ad instances.
final List<_AdEntry> _liveAds = [];
String? _errorMessage;
final TextEditingController _promptController = TextEditingController(
text: 'Best running shoes for marathon training',
);
final TextEditingController _userIdController = TextEditingController();
// --- Ad request parameter state ---
final TextEditingController _additionalContextController =
TextEditingController();
final TextEditingController _adUnitIdController = TextEditingController();
// --- Ad view configuration state ---
VelocityNativeAdViewSize _selectedSize = VelocityNativeAdViewSize.m;
bool? _darkThemeOverride; // null=system, false=light, true=dark
AdColors _lightColors = AdColors.lightDefaults();
AdColors _darkColors = AdColors.darkDefaults();
// Per-slot typography overrides (null means use SDK default)
AdTextStyle? _typoFontBrandName;
AdTextStyle? _typoFontSponsoredLabel;
AdTextStyle? _typoFontSponsoredBadgeText;
AdTextStyle? _typoFontTitle;
AdTextStyle? _typoFontDescription;
AdTextStyle? _typoFontCtaButton;
AdViewConfiguration? _buildAdViewConfiguration() {
final bool colorsModified =
_lightColors != AdColors.lightDefaults() ||
_darkColors != AdColors.darkDefaults();
final bool typoModified = _typoFontBrandName != null ||
_typoFontSponsoredLabel != null ||
_typoFontSponsoredBadgeText != null ||
_typoFontTitle != null ||
_typoFontDescription != null ||
_typoFontCtaButton != null;
if (_darkThemeOverride == null && !colorsModified && !typoModified) {
return null;
}
AdColorScheme? colorScheme;
if (colorsModified) {
colorScheme = AdColorScheme(light: _lightColors, dark: _darkColors);
}
AdTypography? adTypography;
if (typoModified) {
// Build a baseline from SDK-like defaults, then overlay any overrides.
const baseline = AdTextStyle(fontSize: 12);
adTypography = AdTypography(
brandName: _typoFontBrandName ?? baseline,
sponsoredLabel: _typoFontSponsoredLabel ?? baseline,
sponsoredBadgeText: _typoFontSponsoredBadgeText ?? baseline,
title: _typoFontTitle ?? baseline,
description: _typoFontDescription ?? baseline,
ctaButton: _typoFontCtaButton ?? baseline,
);
}
return AdViewConfiguration(
darkTheme: _darkThemeOverride,
colorScheme: colorScheme,
adTypography: adTypography,
);
}
double _adViewHeight(VelocityNativeAdViewSize size) {
switch (size) {
case VelocityNativeAdViewSize.s:
return 50;
case VelocityNativeAdViewSize.m:
return 160;
case VelocityNativeAdViewSize.l:
return 300;
}
}
@override
void initState() {
super.initState();
_initializeSDK();
}
@override
void dispose() {
for (final entry in _liveAds) {
entry.ad.destroy();
}
_promptController.dispose();
_userIdController.dispose();
_additionalContextController.dispose();
_adUnitIdController.dispose();
super.dispose();
}
Future<void> _initializeSDK() async {
try {
setState(() {
_isLoading = true;
_errorMessage = null;
});
// Use the app key for the current platform (iOS and Android keys are different)
const iosAppKey = 'YOUR_IOS_APPLICATION_KEY';
const androidAppKey = 'YOUR_ANDROID_APPLICATION_KEY';
final appKey = Platform.isIOS ? iosAppKey : androidAppKey;
await VelocityAds.initialize(appKey: appKey);
setState(() {
_isInitialized = true;
_isLoading = false;
});
_showSnackBar('SDK initialized successfully', Colors.green);
} on VelocityAdsError catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Initialization failed: [${e.code}] ${e.message}';
});
_showSnackBar('Failed to initialize: ${e.message}', Colors.red);
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Initialization failed: $e';
});
_showSnackBar('Failed to initialize: $e', Colors.red);
}
}
Future<void> _loadNativeAd() async {
if (!_isInitialized) {
_showSnackBar('Please initialize SDK first', Colors.orange);
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final request = VelocityNativeAdRequest(
prompt: _promptController.text,
aiResponse: 'Here are some great options for marathon training shoes...',
conversationHistory: [
ConversationMessage.user('Best running shoes for marathon training'),
ConversationMessage.assistant(
'Here are some great options for marathon training shoes...'),
],
additionalContext: _additionalContextController.text.trim().isEmpty
? null
: _additionalContextController.text.trim(),
adUnitId: _adUnitIdController.text.trim().isEmpty
? null
: _adUnitIdController.text.trim(),
);
final ad = VelocityNativeAd(request);
final entry = _AdEntry(
ad: ad,
isViewAd: false,
viewSize: _selectedSize,
);
_liveAds.add(entry);
final adListener = _ExampleAdListener(
onLoaded: (_) {
setState(() => _isLoading = false);
_showSnackBar('✅ onAdLoaded', Colors.green);
},
onFailed: (_, error) {
setState(() {
_isLoading = false;
_errorMessage = 'onAdFailedToLoad: $error';
});
_showSnackBar('❌ onAdFailedToLoad', Colors.red);
},
onImpression: (_) => _showSnackBar('onAdImpression', Colors.teal),
onClicked: (_) => _showSnackBar('onAdClicked', Colors.teal),
);
try {
await ad.load(listener: adListener);
} catch (_) {
// Errors are handled via listener.
}
}
Future<void> _loadNativeAdView() async {
if (!_isInitialized) {
_showSnackBar('Please initialize SDK first', Colors.orange);
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final request = VelocityNativeAdViewRequest(
size: _selectedSize,
adViewConfiguration: _buildAdViewConfiguration(),
prompt: _promptController.text,
aiResponse: 'Here are some great options for marathon training shoes...',
conversationHistory: [
ConversationMessage.user('Best running shoes for marathon training'),
ConversationMessage.assistant(
'Here are some great options for marathon training shoes...'),
],
additionalContext: _additionalContextController.text.trim().isEmpty
? null
: _additionalContextController.text.trim(),
adUnitId: _adUnitIdController.text.trim().isEmpty
? null
: _adUnitIdController.text.trim(),
);
final ad = VelocityNativeAd(request);
final sizeSnapshot = _selectedSize;
final entry = _AdEntry(
ad: ad,
isViewAd: true,
viewSize: sizeSnapshot,
);
_liveAds.add(entry);
final adListener = _ExampleAdListener(
onLoaded: (loadedAd) async {
try {
await loadedAd.createAdView();
} catch (e) {
loadedAd.destroy();
if (!mounted) return;
setState(() {
_liveAds.remove(entry);
_isLoading = false;
_errorMessage = 'createAdView failed: $e';
});
_showSnackBar('❌ createAdView failed', Colors.red);
return;
}
if (!mounted) return;
setState(() => _isLoading = false);
_showSnackBar('✅ onAdLoaded (view)', Colors.green);
},
onFailed: (_, error) {
setState(() {
_isLoading = false;
_errorMessage = 'onAdFailedToLoad: $error';
});
_showSnackBar('❌ onAdFailedToLoad', Colors.red);
},
onImpression: (_) => _showSnackBar('onAdImpression', Colors.teal),
onClicked: (_) => _showSnackBar('onAdClicked', Colors.teal),
);
try {
await ad.load(listener: adListener);
} catch (_) {
// Errors are handled via listener.
}
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('VelocityAds Example'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Ad View Settings',
onPressed: () async {
final result = await Navigator.of(context).push<_AdViewSettings>(
MaterialPageRoute(
builder: (_) => _AdViewSettingsPage(
initialSettings: _AdViewSettings(
darkThemeOverride: _darkThemeOverride,
lightColors: _lightColors,
darkColors: _darkColors,
typoFontBrandName: _typoFontBrandName,
typoFontSponsoredLabel: _typoFontSponsoredLabel,
typoFontSponsoredBadgeText: _typoFontSponsoredBadgeText,
typoFontTitle: _typoFontTitle,
typoFontDescription: _typoFontDescription,
typoFontCtaButton: _typoFontCtaButton,
),
),
),
);
if (result != null) {
setState(() {
_darkThemeOverride = result.darkThemeOverride;
_lightColors = result.lightColors;
_darkColors = result.darkColors;
_typoFontBrandName = result.typoFontBrandName;
_typoFontSponsoredLabel = result.typoFontSponsoredLabel;
_typoFontSponsoredBadgeText = result.typoFontSponsoredBadgeText;
_typoFontTitle = result.typoFontTitle;
_typoFontDescription = result.typoFontDescription;
_typoFontCtaButton = result.typoFontCtaButton;
});
}
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// SDK Status
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
_isInitialized ? Icons.check_circle : Icons.cancel,
color: _isInitialized ? Colors.green : Colors.red,
),
const SizedBox(width: 8),
Text(
_isInitialized ? 'SDK Initialized' : 'Not Initialized',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
const SizedBox(height: 16),
// User ID
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
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 (optional)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
final userId = _userIdController.text.trim();
VelocityAds.setUserId(
userId.isEmpty ? null : userId);
_showSnackBar(
userId.isEmpty ? 'User ID cleared' : 'User ID set',
Colors.blue,
);
},
child: const Text('Set'),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Load Native Ad
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Load Native Ad',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _promptController,
decoration: const InputDecoration(
labelText: 'Ad Prompt',
border: OutlineInputBorder(),
hintText: 'Enter search query or user intent',
),
maxLines: 2,
),
const SizedBox(height: 16),
// Size selector — only applies to "Load Native Ad View"
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ad View Size',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 6),
SegmentedButton<VelocityNativeAdViewSize>(
segments: const [
ButtonSegment(
value: VelocityNativeAdViewSize.s,
label: Text('S (50dp)'),
),
ButtonSegment(
value: VelocityNativeAdViewSize.m,
label: Text('M (160dp)'),
),
ButtonSegment(
value: VelocityNativeAdViewSize.l,
label: Text('L (300dp)'),
),
],
selected: {_selectedSize},
onSelectionChanged: (s) =>
setState(() => _selectedSize = s.first),
),
const SizedBox(height: 4),
Text(
'Size applies to "Load Native Ad View" only.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Text('Ad Request Parameters',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
TextField(
controller: _additionalContextController,
decoration: const InputDecoration(
labelText: 'Additional Context',
border: OutlineInputBorder(),
hintText: 'e.g. User is a marathon runner',
),
),
const SizedBox(height: 12),
TextField(
controller: _adUnitIdController,
decoration: const InputDecoration(
labelText: 'Ad Unit ID',
border: OutlineInputBorder(),
hintText: 'e.g. home-screen-ad',
),
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _loadNativeAdView,
icon: const Icon(Icons.view_quilt),
label: const Text('Load Native Ad View'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _loadNativeAd,
icon: const Icon(Icons.download),
label: const Text('Load Native Ad'),
),
),
],
),
const SizedBox(height: 8),
Text(
'Load Native Ad View: SDK renders the ad as a native view.\n'
'Load Native Ad: ad data returned, you build the UI.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
const SizedBox(height: 16),
// Privacy
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Privacy Controls',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
ListTile(
title: const Text('Grant GDPR Consent'),
leading: const Icon(Icons.privacy_tip),
onTap: () {
VelocityAds.setConsent(true);
_showSnackBar('GDPR consent granted', Colors.blue);
},
),
ListTile(
title: const Text('Deny GDPR Consent'),
leading: const Icon(Icons.block),
onTap: () {
VelocityAds.setConsent(false);
_showSnackBar('GDPR consent denied', Colors.blue);
},
),
const Divider(),
ListTile(
title: const Text('Enable CCPA Do Not Sell'),
leading: const Icon(Icons.shield),
onTap: () {
VelocityAds.setDoNotSell(true);
_showSnackBar('CCPA Do Not Sell enabled', Colors.blue);
},
),
ListTile(
title: const Text('Disable CCPA Do Not Sell'),
leading: const Icon(Icons.shield_outlined),
onTap: () {
VelocityAds.setDoNotSell(false);
_showSnackBar('CCPA Do Not Sell disabled', Colors.blue);
},
),
],
),
),
),
const SizedBox(height: 16),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
if (_errorMessage != null)
Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(_errorMessage!,
style: const TextStyle(color: Colors.red)),
),
],
),
),
),
if (_liveAds.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_liveAds.length} ad${_liveAds.length == 1 ? '' : 's'} loaded',
style: Theme.of(context).textTheme.labelMedium,
),
TextButton.icon(
onPressed: () {
setState(() {
for (final entry in _liveAds) {
entry.ad.destroy();
}
_liveAds.clear();
_isLoading = false;
});
},
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Clear All'),
),
],
),
...List.generate(_liveAds.length, (i) {
final entry = _liveAds[i];
final child = entry.ad.data == null
? _buildFailedAdCard(entry)
: entry.isViewAd
? _buildAdView(entry)
: _buildAdDataDisplay(entry.ad);
return KeyedSubtree(
key: ValueKey(entry.ad.instanceId),
child: child,
);
}),
],
],
),
),
);
}
Widget _buildFailedAdCard(_AdEntry entry) {
return Card(
elevation: 2,
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
entry.isViewAd
? 'Native Ad View failed to load'
: 'Native Ad failed to load',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.red.shade800),
),
),
FilledButton.tonalIcon(
onPressed: _isLoading
? null
: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final ad = entry.ad;
final adListener = _ExampleAdListener(
onLoaded: entry.isViewAd
? (loadedAd) async {
try {
await loadedAd.createAdView();
} catch (e) {
loadedAd.destroy();
if (!mounted) return;
setState(() {
_liveAds.remove(entry);
_isLoading = false;
_errorMessage = 'createAdView failed: $e';
});
_showSnackBar(
'❌ createAdView failed', Colors.red);
return;
}
if (!mounted) return;
setState(() => _isLoading = false);
_showSnackBar(
'✅ onAdLoaded (view)', Colors.green);
}
: (_) {
setState(() => _isLoading = false);
_showSnackBar('✅ onAdLoaded', Colors.green);
},
onFailed: (_, error) {
setState(() {
_isLoading = false;
_errorMessage = 'onAdFailedToLoad: $error';
});
_showSnackBar('❌ onAdFailedToLoad', Colors.red);
},
onImpression: (_) =>
_showSnackBar('onAdImpression', Colors.teal),
onClicked: (_) =>
_showSnackBar('onAdClicked', Colors.teal),
);
ad.load(listener: adListener).catchError((_) {});
},
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Retry'),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
entry.ad.destroy();
setState(() {
_liveAds.remove(entry);
if (_liveAds.isEmpty) _isLoading = false;
});
},
),
],
),
),
);
}
// Displays the SDK-rendered native ad view widget.
Widget _buildAdView(_AdEntry entry) {
final w = entry.ad.widget;
if (w == null) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Ad view not available on this platform.'),
),
);
}
return Card(
elevation: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
const Icon(Icons.view_quilt),
const SizedBox(width: 8),
Text('Native Ad View',
style: Theme.of(context).textTheme.titleSmall),
],
),
),
SizedBox(height: _adViewHeight(entry.viewSize), child: w),
],
),
);
}
// Displays publisher-rendered ad from data fields.
Widget _buildAdDataDisplay(VelocityNativeAd ad) {
// VelocityNativeAdTracker handles impression tracking and click handling for data-only ads.
return VelocityNativeAdTracker(
ad: ad,
child: Card(
elevation: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(16.0),
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
const Icon(Icons.ad_units),
const SizedBox(width: 8),
Flexible(
child: Text('Native Ad',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(width: 8),
const Text('Sponsored'),
],
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ad.data!.title,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(ad.data!.description,
style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 12),
if ((ad.data!.largeImageUrl ?? ad.data!.squareImageUrl ?? '').isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
ad.data!.largeImageUrl ?? ad.data!.squareImageUrl!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
height: 200,
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.image_not_supported)),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: null, // SDK handles click via the interaction overlay in VelocityNativeAdTracker
child: Text(ad.data!.callToAction),
),
),
const SizedBox(height: 8),
Text('By ${ad.data!.advertiserName}',
style: Theme.of(context).textTheme.bodySmall),
],
),
),
],
),
),
);
}
}
class _ExampleAdListener extends VelocityNativeAdListener {
final void Function(VelocityNativeAd) onLoaded;
final void Function(VelocityNativeAd, VelocityAdsError) onFailed;
final void Function(VelocityNativeAd)? onImpression;
final void Function(VelocityNativeAd)? onClicked;
const _ExampleAdListener({
required this.onLoaded,
required this.onFailed,
this.onImpression,
this.onClicked,
});
@override
void onAdLoaded(VelocityNativeAd ad) => onLoaded(ad);
@override
void onAdFailedToLoad(VelocityNativeAd ad, VelocityAdsError error) =>
onFailed(ad, error);
@override
void onAdImpression(VelocityNativeAd ad) {
debugPrint('onAdImpression: ${ad.data?.adId ?? '<unknown>'}');
onImpression?.call(ad);
}
@override
void onAdClicked(VelocityNativeAd ad) {
debugPrint('onAdClicked: ${ad.data?.adId ?? '<unknown>'}');
onClicked?.call(ad);
}
}
// ---------------------------------------------------------------------------
// Settings data model — passed to/from AdViewSettingsPage via Navigator
// ---------------------------------------------------------------------------
class _AdViewSettings {
final bool? darkThemeOverride;
final AdColors lightColors;
final AdColors darkColors;
final AdTextStyle? typoFontBrandName;
final AdTextStyle? typoFontSponsoredLabel;
final AdTextStyle? typoFontSponsoredBadgeText;
final AdTextStyle? typoFontTitle;
final AdTextStyle? typoFontDescription;
final AdTextStyle? typoFontCtaButton;
const _AdViewSettings({
required this.darkThemeOverride,
required this.lightColors,
required this.darkColors,
this.typoFontBrandName,
this.typoFontSponsoredLabel,
this.typoFontSponsoredBadgeText,
this.typoFontTitle,
this.typoFontDescription,
this.typoFontCtaButton,
});
}
// ---------------------------------------------------------------------------
// Settings Page
// ---------------------------------------------------------------------------
class _AdViewSettingsPage extends StatefulWidget {
final _AdViewSettings initialSettings;
const _AdViewSettingsPage({required this.initialSettings});
@override
State<_AdViewSettingsPage> createState() => _AdViewSettingsPageState();
}
class _AdViewSettingsPageState extends State<_AdViewSettingsPage> {
late bool? _darkThemeOverride;
late AdColors _lightColors;
late AdColors _darkColors;
late AdTextStyle? _typoFontBrandName;
late AdTextStyle? _typoFontSponsoredLabel;
late AdTextStyle? _typoFontSponsoredBadgeText;
late AdTextStyle? _typoFontTitle;
late AdTextStyle? _typoFontDescription;
late AdTextStyle? _typoFontCtaButton;
final Map<String, TextEditingController> _fontSizeControllers = {};
@override
void initState() {
super.initState();
final s = widget.initialSettings;
_darkThemeOverride = s.darkThemeOverride;
_lightColors = s.lightColors;
_darkColors = s.darkColors;
_typoFontBrandName = s.typoFontBrandName;
_typoFontSponsoredLabel = s.typoFontSponsoredLabel;
_typoFontSponsoredBadgeText = s.typoFontSponsoredBadgeText;
_typoFontTitle = s.typoFontTitle;
_typoFontDescription = s.typoFontDescription;
_typoFontCtaButton = s.typoFontCtaButton;
for (final entry in _typoSlots()) {
_fontSizeControllers[entry.key] = TextEditingController(
text: entry.current?.fontSize.toStringAsFixed(0) ?? '',
);
}
}
@override
void dispose() {
for (final c in _fontSizeControllers.values) {
c.dispose();
}
super.dispose();
}
_AdViewSettings _currentSettings() => _AdViewSettings(
darkThemeOverride: _darkThemeOverride,
lightColors: _lightColors,
darkColors: _darkColors,
typoFontBrandName: _typoFontBrandName,
typoFontSponsoredLabel: _typoFontSponsoredLabel,
typoFontSponsoredBadgeText: _typoFontSponsoredBadgeText,
typoFontTitle: _typoFontTitle,
typoFontDescription: _typoFontDescription,
typoFontCtaButton: _typoFontCtaButton,
);
List<_TypoSlotEntry> _typoSlots() => [
_TypoSlotEntry(
key: 'brandName',
label: 'Brand Name',
current: _typoFontBrandName,
onUpdate: (v) => _typoFontBrandName = v,
),
_TypoSlotEntry(
key: 'sponsoredLabel',
label: 'Sponsored Label',
current: _typoFontSponsoredLabel,
onUpdate: (v) => _typoFontSponsoredLabel = v,
),
_TypoSlotEntry(
key: 'sponsoredBadgeText',
label: 'Sponsored Badge Text',
current: _typoFontSponsoredBadgeText,
onUpdate: (v) => _typoFontSponsoredBadgeText = v,
),
_TypoSlotEntry(
key: 'title',
label: 'Title',
current: _typoFontTitle,
onUpdate: (v) => _typoFontTitle = v,
),
_TypoSlotEntry(
key: 'description',
label: 'Description',
current: _typoFontDescription,
onUpdate: (v) => _typoFontDescription = v,
),
_TypoSlotEntry(
key: 'ctaButton',
label: 'CTA Button',
current: _typoFontCtaButton,
onUpdate: (v) => _typoFontCtaButton = v,
),
];
List<_ColorSlotEntry> _colorSlots(
AdColors colors, AdColors Function(Color, String) updater) {
return [
_ColorSlotEntry('cardBackground', 'Card Background',
colors.cardBackground, (c) => updater(c, 'cardBackground')),
_ColorSlotEntry('sponsoredLabelText', 'Sponsored Label Text',
colors.sponsoredLabelText, (c) => updater(c, 'sponsoredLabelText')),
_ColorSlotEntry('titleText', 'Title Text', colors.titleText,
(c) => updater(c, 'titleText')),
_ColorSlotEntry('descriptionText', 'Description Text',
colors.descriptionText, (c) => updater(c, 'descriptionText')),
_ColorSlotEntry('brandText', 'Brand Text', colors.brandText,
(c) => updater(c, 'brandText')),
_ColorSlotEntry(
'sponsoredBadgeBackground',
'Badge Background',
colors.sponsoredBadgeBackground,
(c) => updater(c, 'sponsoredBadgeBackground')),
_ColorSlotEntry('sponsoredBadgeText', 'Badge Text',
colors.sponsoredBadgeText, (c) => updater(c, 'sponsoredBadgeText')),
_ColorSlotEntry('ctaBackground', 'CTA Background', colors.ctaBackground,
(c) => updater(c, 'ctaBackground')),
_ColorSlotEntry('ctaText', 'CTA Text', colors.ctaText,
(c) => updater(c, 'ctaText')),
_ColorSlotEntry('chevronIconTint', 'Chevron Icon Tint',
colors.chevronIconTint, (c) => updater(c, 'chevronIconTint')),
_ColorSlotEntry('brandIconBorder', 'Brand Icon Border',
colors.brandIconBorder, (c) => updater(c, 'brandIconBorder')),
];
}
AdColors _applyColorToLight(Color color, String slot) =>
_patchColors(_lightColors, color, slot);
AdColors _applyColorToDark(Color color, String slot) =>
_patchColors(_darkColors, color, slot);
AdColors _patchColors(AdColors base, Color color, String slot) {
switch (slot) {
case 'cardBackground':
return base.copyWith(cardBackground: color);
case 'sponsoredLabelText':
return base.copyWith(sponsoredLabelText: color);
case 'titleText':
return base.copyWith(titleText: color);
case 'descriptionText':
return base.copyWith(descriptionText: color);
case 'brandText':
return base.copyWith(brandText: color);
case 'sponsoredBadgeBackground':
return base.copyWith(sponsoredBadgeBackground: color);
case 'sponsoredBadgeText':
return base.copyWith(sponsoredBadgeText: color);
case 'ctaBackground':
return base.copyWith(ctaBackground: color);
case 'ctaText':
return base.copyWith(ctaText: color);
case 'chevronIconTint':
return base.copyWith(chevronIconTint: color);
case 'brandIconBorder':
return base.copyWith(brandIconBorder: color);
default:
return base;
}
}
Future<void> _openColorPicker({
required BuildContext context,
required String slotLabel,
required Color initial,
required void Function(Color) onPicked,
}) async {
Color picked = initial;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
return AlertDialog(
title: Text(slotLabel),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 48,
decoration: BoxDecoration(
color: picked,
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 16),
_ColorChannel(
label: 'R',
value: picked.r * 255,
onChanged: (v) => setDialogState(() => picked =
Color.fromARGB(
(picked.a * 255).round(),
v.round(),
(picked.g * 255).round(),
(picked.b * 255).round())),
),
_ColorChannel(
label: 'G',
value: picked.g * 255,
onChanged: (v) => setDialogState(() => picked =
Color.fromARGB(
(picked.a * 255).round(),
(picked.r * 255).round(),
v.round(),
(picked.b * 255).round())),
),
_ColorChannel(
label: 'B',
value: picked.b * 255,
onChanged: (v) => setDialogState(() => picked =
Color.fromARGB(
(picked.a * 255).round(),
(picked.r * 255).round(),
(picked.g * 255).round(),
v.round())),
),
_ColorChannel(
label: 'A',
value: picked.a * 255,
onChanged: (v) => setDialogState(() => picked =
Color.fromARGB(
v.round(),
(picked.r * 255).round(),
(picked.g * 255).round(),
(picked.b * 255).round())),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onPicked(picked);
Navigator.pop(ctx);
},
child: const Text('Apply'),
),
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Ad View Settings'),
leading: BackButton(
onPressed: () => Navigator.of(context).pop(_currentSettings()),
),
),
body: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) Navigator.of(context).pop(_currentSettings());
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDarkThemeSection(context),
const SizedBox(height: 12),
_buildColorSchemeSection(context),
const SizedBox(height: 12),
_buildTypographySection(context),
],
),
),
);
}
Widget _buildDarkThemeSection(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ad View Theme',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Override the dark/light appearance of SDK-rendered ad views.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SegmentedButton<_ThemeOption>(
segments: const [
ButtonSegment(
value: _ThemeOption.system,
icon: Icon(Icons.brightness_auto),
label: Text('System'),
),
ButtonSegment(
value: _ThemeOption.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: _ThemeOption.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {
_darkThemeOverride == null
? _ThemeOption.system
: _darkThemeOverride!
? _ThemeOption.dark
: _ThemeOption.light,
},
onSelectionChanged: (s) {
setState(() {
switch (s.first) {
case _ThemeOption.system:
_darkThemeOverride = null;
case _ThemeOption.light:
_darkThemeOverride = false;
case _ThemeOption.dark:
_darkThemeOverride = true;
}
});
},
),
],
),
),
);
}
Widget _buildColorSchemeSection(BuildContext context) {
return Card(
child: ExpansionTile(
title: const Text('Color Scheme'),
subtitle: const Text('Customize light and dark palette'),
children: [
ExpansionTile(
title: const Text('Light Colors'),
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
children: [
..._colorSlots(_lightColors, _applyColorToLight).map(
(slot) => _ColorSlotRow(
slot: slot,
onEdit: () async {
await _openColorPicker(
context: context,
slotLabel: slot.label,
initial: slot.color,
onPicked: (c) =>
setState(() => _lightColors = slot.applyColor(c)),
);
},
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: OutlinedButton.icon(
onPressed: () =>
setState(() => _lightColors = AdColors.lightDefaults()),
icon: const Icon(Icons.refresh),
label: const Text('Reset Light Colors'),
),
),
],
),
ExpansionTile(
title: const Text('Dark Colors'),
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
children: [
..._colorSlots(_darkColors, _applyColorToDark).map(
(slot) => _ColorSlotRow(
slot: slot,
onEdit: () async {
await _openColorPicker(
context: context,
slotLabel: slot.label,
initial: slot.color,
onPicked: (c) =>
setState(() => _darkColors = slot.applyColor(c)),
);
},
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: OutlinedButton.icon(
onPressed: () =>
setState(() => _darkColors = AdColors.darkDefaults()),
icon: const Icon(Icons.refresh),
label: const Text('Reset Dark Colors'),
),
),
],
),
],
),
);
}
Widget _buildTypographySection(BuildContext context) {
final slots = _typoSlots();
return Card(
child: ExpansionTile(
title: const Text('Typography'),
subtitle: const Text('Override font size and weight per text slot'),
children: [
...slots.map((slot) {
final ctrl = _fontSizeControllers[slot.key]!;
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(slot.label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: TextField(
controller: ctrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
labelText: 'Size (sp)',
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (v) {
final parsed = double.tryParse(v);
setState(() {
if (parsed != null && parsed > 0) {
slot.onUpdate(AdTextStyle(
fontSize: parsed,
fontWeight: slot.current?.fontWeight,
));
} else if (v.isEmpty) {
slot.onUpdate(null);
}
});
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: DropdownButtonFormField<AdFontWeight?>(
initialValue: slot.current?.fontWeight,
isDense: true,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Weight',
border: OutlineInputBorder(),
isDense: true,
),
items: [
const DropdownMenuItem(
value: null,
child: Text('Default'),
),
...AdFontWeight.values.map(
(w) => DropdownMenuItem(
value: w,
child: Text(w.name),
),
),
],
onChanged: (w) {
setState(() {
final size = slot.current?.fontSize;
if (size != null) {
slot.onUpdate(
AdTextStyle(fontSize: size, fontWeight: w));
} else if (w != null) {
slot.onUpdate(
AdTextStyle(fontSize: 12, fontWeight: w));
ctrl.text = '12';
}
});
},
),
),
],
),
],
),
);
}),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_typoFontBrandName = null;
_typoFontSponsoredLabel = null;
_typoFontSponsoredBadgeText = null;
_typoFontTitle = null;
_typoFontDescription = null;
_typoFontCtaButton = null;
for (final c in _fontSizeControllers.values) {
c.text = '';
}
});
},
icon: const Icon(Icons.refresh),
label: const Text('Reset Typography'),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Supporting enums and data types
// ---------------------------------------------------------------------------
enum _ThemeOption { system, light, dark }
class _TypoSlotEntry {
final String key;
final String label;
final AdTextStyle? current;
final void Function(AdTextStyle?) onUpdate;
const _TypoSlotEntry({
required this.key,
required this.label,
required this.current,
required this.onUpdate,
});
}
class _ColorSlotEntry {
final String key;
final String label;
final Color color;
final AdColors Function(Color) applyColor;
const _ColorSlotEntry(this.key, this.label, this.color, this.applyColor);
}
// ---------------------------------------------------------------------------
// Supporting widgets
// ---------------------------------------------------------------------------
class _ColorSlotRow extends StatelessWidget {
final _ColorSlotEntry slot;
final VoidCallback onEdit;
const _ColorSlotRow({required this.slot, required this.onEdit});
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
title: Text(slot.label,
style: Theme.of(context).textTheme.bodyMedium),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: slot.color,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.edit, size: 18),
onPressed: onEdit,
),
],
),
);
}
}
class _ColorChannel extends StatelessWidget {
final String label;
final double value;
final void Function(double) onChanged;
const _ColorChannel({
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 20,
child: Text(label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center),
),
Expanded(
child: Slider(
value: value.clamp(0, 255),
min: 0,
max: 255,
divisions: 255,
onChanged: onChanged,
),
),
SizedBox(
width: 32,
child: Text(
value.round().toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.right,
),
),
],
);
}
}