velocity_ads 0.3.0
velocity_ads: ^0.3.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:url_launcher/url_launcher.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(),
);
}
}
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key});
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
bool _isInitialized = false;
bool _isLoading = false;
// The current ad — holds data after load().
VelocityNativeAd? _currentAd;
bool _isViewAd = false;
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 ---
VelocityAdViewSize _selectedSize = VelocityAdViewSize.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;
AdConfiguration? _buildAdConfiguration() {
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 AdConfiguration(
darkTheme: _darkThemeOverride,
colorScheme: colorScheme,
adTypography: adTypography,
);
}
double _adViewHeight(VelocityAdViewSize size) {
switch (size) {
case VelocityAdViewSize.s:
return 50;
case VelocityAdViewSize.m:
return 100;
case VelocityAdViewSize.l:
return 300;
}
}
@override
void initState() {
super.initState();
_initializeSDK();
}
@override
void dispose() {
_currentAd?.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> _loadAd() async {
if (!_isInitialized) {
_showSnackBar('Please initialize SDK first', Colors.orange);
return;
}
_currentAd?.destroy();
setState(() {
_isLoading = true;
_errorMessage = null;
_currentAd = null;
_isViewAd = false;
});
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);
ad.listener = _ExampleAdListener(
onLoaded: (loadedAd) {
setState(() {
_currentAd = loadedAd;
_isLoading = false;
});
_showSnackBar('✅ onAdLoaded', Colors.green);
},
onFailed: (_, error) {
setState(() {
_isLoading = false;
_errorMessage = 'onAdFailedToLoad: [${error.code}] ${error.message}';
});
_showSnackBar('❌ onAdFailedToLoad', Colors.red);
},
onImpression: (_) => _showSnackBar('onAdImpression', Colors.teal),
onClicked: (_) => _showSnackBar('onAdClicked', Colors.teal),
);
try {
await ad.load();
} catch (_) {
// Errors are handled via listener.
}
}
Future<void> _loadAdView() async {
if (!_isInitialized) {
_showSnackBar('Please initialize SDK first', Colors.orange);
return;
}
_currentAd?.destroy();
setState(() {
_isLoading = true;
_errorMessage = null;
_currentAd = null;
_isViewAd = true;
});
final request = VelocityNativeAdViewRequest(
size: _selectedSize,
adConfiguration: _buildAdConfiguration(),
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);
ad.viewListener = _ExampleAdViewListener(
onLoaded: (loadedAd, adView) {
setState(() {
_currentAd = loadedAd;
_isLoading = false;
});
_showSnackBar('✅ onAdLoaded (view)', Colors.green);
},
onFailed: (_, error) {
setState(() {
_isLoading = false;
_isViewAd = false;
_errorMessage = 'onAdFailedToLoad: [${error.code}] ${error.message}';
});
_showSnackBar('❌ onAdFailedToLoad', Colors.red);
},
onImpression: (_) => _showSnackBar('onAdImpression', Colors.teal),
onClicked: (_) => _showSnackBar('onAdClicked', Colors.teal),
);
try {
await ad.load();
} catch (_) {
// Errors are handled via listener.
}
}
Future<void> _openAdUrl(String url) async {
final uri = Uri.parse(url);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (mounted) _showSnackBar('Could not open link', Colors.orange);
}
} catch (e) {
if (mounted) _showSnackBar('Could not open link: $e', Colors.red);
}
}
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 Ad
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Load 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 Ad View"
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ad View Size',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 6),
SegmentedButton<VelocityAdViewSize>(
segments: const [
ButtonSegment(
value: VelocityAdViewSize.s,
label: Text('S (50dp)'),
),
ButtonSegment(
value: VelocityAdViewSize.m,
label: Text('M (100dp)'),
),
ButtonSegment(
value: VelocityAdViewSize.l,
label: Text('L (300dp)'),
),
],
selected: {_selectedSize},
onSelectionChanged: (s) =>
setState(() => _selectedSize = s.first),
),
const SizedBox(height: 4),
Text(
'Size applies to "Load 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',
),
maxLines: 2,
),
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 : _loadAd,
icon: const Icon(Icons.download),
label: const Text('Load Ad (Data)'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _loadAdView,
icon: const Icon(Icons.view_quilt),
label: const Text('Load Ad View'),
),
),
],
),
const SizedBox(height: 8),
Text(
'Load Ad (Data): ad data returned, you build the UI.\n'
'Load Ad View: SDK renders the ad as a native view.',
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 (_currentAd != null)
_isViewAd
? _buildAdView(_currentAd!)
: _buildAdDataDisplay(_currentAd!),
],
),
),
);
}
// Displays the SDK-rendered native ad view widget.
Widget _buildAdView(VelocityNativeAd ad) {
final w = 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 (SDK-rendered)',
style: Theme.of(context).textTheme.titleSmall),
],
),
),
SizedBox(height: _adViewHeight(_selectedSize), child: w),
],
),
);
}
// Displays publisher-rendered ad from data fields.
Widget _buildAdDataDisplay(VelocityNativeAd ad) {
return 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),
Text('Native Ad (Publisher-rendered)',
style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
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: () => _openAdUrl(ad.data!.clickUrl),
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);
}
}
class _ExampleAdViewListener extends VelocityNativeAdViewListener {
final void Function(VelocityNativeAd, Widget) onLoaded;
final void Function(VelocityNativeAd, VelocityAdsError) onFailed;
final void Function(VelocityNativeAd)? onImpression;
final void Function(VelocityNativeAd)? onClicked;
const _ExampleAdViewListener({
required this.onLoaded,
required this.onFailed,
this.onImpression,
this.onClicked,
});
@override
void onAdLoaded(VelocityNativeAd ad, Widget adView) => onLoaded(ad, adView);
@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,
),
),
],
);
}
}