novvy_ads 1.2.1
novvy_ads: ^1.2.1 copied to clipboard
Flutter plugin for Novvy Ads SDK.
example/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:novvy_ads/novvy_ads.dart';
import 'story_demo_page.dart';
void main() {
runApp(const NovvyAdsExampleApp());
}
class NovvyAdsExampleApp extends StatelessWidget {
const NovvyAdsExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Novvy Ads Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF6C5CE7),
brightness: Brightness.light,
useMaterial3: true,
),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF6C5CE7),
brightness: Brightness.dark,
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
bool _initialized = false;
bool _initializing = false;
final _appIdController = TextEditingController(text: 'your_app_id');
final _endpointController = TextEditingController(text: 'your_end_point');
final _apiKeyController = TextEditingController(text: 'your_api_key');
final _interstitialAdUnitController =
TextEditingController(text: 'your_ad_unit_id');
final _rewardedAdUnitController =
TextEditingController(text: 'your_ad_unit_id');
final _midAdUnitController = TextEditingController(text: 'your_ad_unit_id');
final _endAdUnitController = TextEditingController(text: 'your_ad_unit_id');
final _pauseAdUnitController = TextEditingController(text: 'your_ad_unit_id');
final _scrollAdUnitController = TextEditingController(text: 'your_ad_unit_id');
final _insertAdUnitController = TextEditingController(text: 'your_ad_unit_id');
final _bannerAdUnitController = TextEditingController(text: 'your_ad_unit_id');
bool _showPause = false;
_DemoPlayerAdapter _pausePlayer = _DemoPlayerAdapter(playing: false);
NovvyPauseAd? _pauseAd;
NovvyScrollAd? _scrollAd;
bool _scrollReady = false;
NovvyBannerAd? _bannerAd;
NovvyBannerSize _bannerSize = NovvyBannerSize.standard;
int _bannerRotations = 0;
final _logs = <_LogEntry>[];
void _addLog(String message, {_LogLevel level = _LogLevel.info}) {
setState(() {
_logs.insert(0, _LogEntry(message: message, level: level));
if (_logs.length > 200) _logs.removeLast();
});
}
Future<void> _initialize() async {
if (_initializing) return;
setState(() => _initializing = true);
_addLog('Initializing Novvy SDK...');
NovvyAds.initialize(
appId: _appIdController.text.trim(),
endpoint: _endpointController.text.trim(),
apiKey: _apiKeyController.text.trim(),
onComplete: (success) {
if (!mounted) return;
if (success) {
_addLog('SDK initialized successfully', level: _LogLevel.success);
setState(() {
_initialized = true;
_initializing = false;
});
} else {
_addLog('SDK initialization failed', level: _LogLevel.error);
setState(() => _initializing = false);
}
},
);
}
void _setContentContext() {
NovvyAds.setContentContext(
NovvyContentContext(
seriesName: 'your_series_name',
episodeNumber: 1,
url: 'your_content_url',
),
);
_addLog('Content context updated', level: _LogLevel.success);
}
// ── Full-screen ads (Interstitial / Mid / End) ──
void _showFullScreen(String label, NovvyAd ad) {
_addLog('Requesting $label: ${ad.adUnitId}');
ad.onAdFailedToLoad = (e) {
_addLog('$label failed [${ad.adUnitId}]: $e', level: _LogLevel.error);
ad.destroy();
};
if (ad is NovvyInterstitialAd) {
ad.onAdLoaded = ad.show;
ad.onAdDismissed =
() => _addLog('$label dismissed', level: _LogLevel.success);
} else if (ad is NovvyMidAd) {
ad.onAdLoaded = ad.show;
ad.onAdDismissed =
() => _addLog('$label dismissed', level: _LogLevel.success);
} else if (ad is NovvyEndAd) {
ad.onAdLoaded = ad.show;
ad.onAdDismissed =
() => _addLog('$label dismissed', level: _LogLevel.success);
}
ad.load();
}
void _showInterstitial() => _showFullScreen(
'Interstitial', NovvyInterstitialAd(adUnitId: _interstitialAdUnitController.text.trim()));
void _showMid() =>
_showFullScreen('Mid', NovvyMidAd(adUnitId: _midAdUnitController.text.trim()));
void _showEnd() =>
_showFullScreen('End', NovvyEndAd(adUnitId: _endAdUnitController.text.trim()));
// ── Rewarded ──
void _showRewarded() {
final ad = NovvyRewardedAd(adUnitId: _rewardedAdUnitController.text.trim());
_addLog('Requesting rewarded: ${ad.adUnitId}');
var earned = false;
ad.onAdLoaded = ad.show;
ad.onUserEarnedReward = () => earned = true;
ad.onAdDismissed = () {
if (earned) {
_addLog('Rewarded dismissed: reward EARNED — grant it',
level: _LogLevel.success);
} else {
_addLog('Rewarded dismissed: no reward (skipped early)',
level: _LogLevel.warning);
}
ad.destroy();
};
ad.onAdFailedToLoad = (e) {
_addLog('Rewarded failed: $e', level: _LogLevel.error);
ad.destroy();
};
ad.load();
}
// ── Pause card ──
void _togglePause() {
setState(() {
_showPause = !_showPause;
if (_showPause) {
// Fresh paused player for the new pause-card session.
_pausePlayer = _DemoPlayerAdapter(playing: false);
final ad = NovvyPauseAd(adUnitId: _pauseAdUnitController.text.trim());
ad.onAdShowed = () => _addLog('Pause card shown', level: _LogLevel.success);
ad.onAdHidden = () => _addLog('Pause card hidden (resumed)');
ad.onAdFailedToLoad =
(e) => _addLog('Pause failed: $e', level: _LogLevel.error);
_pauseAd = ad;
} else {
_pauseAd?.destroy();
_pauseAd = null;
}
});
_addLog(_showPause ? 'Pause card added' : 'Pause card removed');
}
// ── Scroll (inline feed) ──
void _loadScroll() {
_scrollAd?.destroy();
final ad = NovvyScrollAd(adUnitId: _scrollAdUnitController.text.trim());
_addLog('Loading scroll ad: ${ad.adUnitId}');
ad.onAdLoaded = () {
_addLog('Scroll ad ready', level: _LogLevel.success);
setState(() => _scrollReady = true);
// Simulate the item being visible in the viewport.
ad.setPlaying(true);
};
ad.onAdFailedToLoad = (e) {
_addLog('Scroll ad failed: $e', level: _LogLevel.error);
ad.destroy();
};
ad.onAdHidden = () => _addLog('Scroll ad hidden');
ad.onAdPlaybackTick =
(remaining) => _addLog('Scroll tick: ${remaining}s remaining');
setState(() {
_scrollReady = false;
_scrollAd = ad;
});
ad.load();
}
void _disposeScroll() {
_scrollAd?.destroy();
setState(() {
_scrollAd = null;
_scrollReady = false;
});
_addLog('Scroll ad disposed');
}
// ── Banner (persistent rotating slot) ──
/// Mounting [NovvyBannerAdView] is what issues the request, so there is no
/// "load" button here — building the ad object and letting the view appear
/// is the whole flow.
void _toggleBanner() {
if (_bannerAd != null) {
_removeBanner();
return;
}
final ad = NovvyBannerAd(
adUnitId: _bannerAdUnitController.text.trim(),
size: _bannerSize,
// The floor the SDK clamps to; keeps the demo rotating often enough to
// watch the per-rotation callbacks arrive.
refreshIntervalSeconds: 30,
);
ad.onAdLoaded = () => _addLog('Banner creative loaded');
ad.onAdShowed = () {
// Fires once per rotation, not once per ad object.
setState(() => _bannerRotations++);
_addLog('Banner shown (rotation $_bannerRotations)',
level: _LogLevel.success);
};
ad.onAdClicked = () =>
_addLog('Banner clicked — SDK already opened the destination');
ad.onAdHidden = () => _addLog('Banner slot emptied');
// Not terminal: the SDK retries with backoff and any creative already on
// screen stays up, so the slot is deliberately left mounted.
ad.onAdFailedToLoad = (e) =>
_addLog('Banner refresh failed (will retry): $e',
level: _LogLevel.warning);
setState(() {
_bannerRotations = 0;
_bannerAd = ad;
});
_addLog('Banner slot mounted: ${_bannerSize.name}');
}
void _removeBanner() {
_bannerAd?.destroy();
setState(() {
_bannerAd = null;
_bannerRotations = 0;
});
_addLog('Banner slot removed');
}
/// The tier is fixed for the life of an ad object (the native banner takes it
/// in its constructor), so switching size rebuilds the slot.
void _setBannerSize(NovvyBannerSize size) {
if (size == _bannerSize) return;
final wasMounted = _bannerAd != null;
_removeBanner();
setState(() => _bannerSize = size);
if (wasMounted) _toggleBanner();
}
@override
void dispose() {
_appIdController.dispose();
_endpointController.dispose();
_apiKeyController.dispose();
_interstitialAdUnitController.dispose();
_rewardedAdUnitController.dispose();
_midAdUnitController.dispose();
_endAdUnitController.dispose();
_pauseAdUnitController.dispose();
_scrollAdUnitController.dispose();
_insertAdUnitController.dispose();
_bannerAdUnitController.dispose();
_pauseAd?.destroy();
_scrollAd?.destroy();
_bannerAd?.destroy();
super.dispose();
}
/// STORY lives inside the host's own player view, so the demo for it is a
/// separate full-page player feed rather than a card on this page.
void _openStoryDemo() {
final adUnitId = _insertAdUnitController.text.trim();
_addLog('Opening STORY player feed: $adUnitId');
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => StoryDemoPage(adUnitId: adUnitId),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Novvy Ads Demo'),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: Chip(
avatar: Icon(
Platform.isAndroid ? Icons.android : Icons.apple,
size: 18,
),
label: Text(Platform.isAndroid ? 'Android' : 'iOS'),
),
),
const SizedBox(height: 16),
// Init config
_SectionCard(
title: 'SDK Configuration',
icon: Icons.settings,
children: [
_ConfigField(
label: 'App ID',
controller: _appIdController,
enabled: !_initialized,
),
_ConfigField(
label: 'Endpoint',
controller: _endpointController,
enabled: !_initialized,
),
_ConfigField(
label: 'API Key',
controller: _apiKeyController,
enabled: !_initialized,
obscure: true,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _initialized || _initializing ? null : _initialize,
icon: _initializing
? SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
: Icon(_initialized ? Icons.check : Icons.rocket_launch),
label: Text(
_initialized
? 'Initialized'
: _initializing
? 'Initializing...'
: 'Initialize SDK',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _initialized ? _setContentContext : null,
icon: const Icon(Icons.tune),
label: const Text('Set Content Context'),
),
],
),
const SizedBox(height: 16),
// Full-screen ads
_SectionCard(
title: 'Full-screen Ads',
icon: Icons.fullscreen,
children: [
_ConfigField(
label: 'Interstitial Ad Unit ID',
controller: _interstitialAdUnitController,
),
const SizedBox(height: 4),
FilledButton.tonalIcon(
onPressed: _initialized ? _showInterstitial : null,
icon: const Icon(Icons.fullscreen),
label: const Text('Show Interstitial'),
),
const SizedBox(height: 16),
_ConfigField(
label: 'Rewarded Ad Unit ID',
controller: _rewardedAdUnitController,
),
const SizedBox(height: 4),
FilledButton.tonalIcon(
onPressed: _initialized ? _showRewarded : null,
icon: const Icon(Icons.card_giftcard),
label: const Text('Show Rewarded'),
),
const SizedBox(height: 16),
_ConfigField(
label: 'Mid-roll Ad Unit ID',
controller: _midAdUnitController,
),
const SizedBox(height: 4),
FilledButton.tonalIcon(
onPressed: _initialized ? _showMid : null,
icon: const Icon(Icons.skip_next),
label: const Text('Show Mid-roll'),
),
const SizedBox(height: 16),
_ConfigField(
label: 'End-roll Ad Unit ID',
controller: _endAdUnitController,
),
const SizedBox(height: 4),
FilledButton.tonalIcon(
onPressed: _initialized ? _showEnd : null,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('Show End-roll'),
),
],
),
const SizedBox(height: 16),
// Pause card
_SectionCard(
title: 'Pause Card',
icon: Icons.pause_circle_outline,
children: [
_ConfigField(
label: 'Pause Ad Unit ID',
controller: _pauseAdUnitController,
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: _initialized ? _togglePause : null,
icon: Icon(_showPause ? Icons.visibility_off : Icons.visibility),
label: Text(_showPause ? 'Remove Pause Card' : 'Show Pause Card'),
),
const SizedBox(height: 12),
// Simulated player area — pause card sits inside it.
Container(
width: 280,
height: 200,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: (_showPause && _pauseAd != null)
? NovvyPauseAdView(ad: _pauseAd!, player: _pausePlayer)
: Text('Player (paused)',
style: TextStyle(color: colorScheme.onSurfaceVariant)),
),
],
),
const SizedBox(height: 16),
// Scroll ad
_SectionCard(
title: 'Scroll Ad',
icon: Icons.view_stream_outlined,
children: [
_ConfigField(
label: 'Scroll Ad Unit ID',
controller: _scrollAdUnitController,
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: _initialized ? _loadScroll : null,
icon: const Icon(Icons.article_outlined),
label: const Text('Load Scroll Ad'),
),
if (_scrollReady && _scrollAd != null) ...[
const SizedBox(height: 12),
SizedBox(
height: 200,
child: NovvyScrollAdView(ad: _scrollAd!),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _disposeScroll,
icon: const Icon(Icons.close),
label: const Text('Dispose Scroll Ad'),
),
],
],
),
const SizedBox(height: 16),
// Banner — a persistent slot that rotates creatives on a timer.
_SectionCard(
title: 'Banner Ad',
icon: Icons.crop_16_9_outlined,
children: [
_ConfigField(
label: 'Banner Ad Unit ID',
controller: _bannerAdUnitController,
),
const SizedBox(height: 8),
// The tiers have mutually distinct aspect ratios — the server
// matches a creative to the slot by ratio, so the wrong tier
// gets no fill rather than a stretched ad.
Wrap(
spacing: 8,
runSpacing: 8,
children: NovvyBannerSize.values.map((size) {
final px = NovvyBannerAd.sizesInLogicalPixels[size]!;
return ChoiceChip(
label: Text(
'${size.name} '
'(${px.width.toInt()}×${px.height.toInt()})',
),
selected: _bannerSize == size,
onSelected:
_initialized ? (_) => _setBannerSize(size) : null,
);
}).toList(),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: _initialized ? _toggleBanner : null,
icon: Icon(_bannerAd != null
? Icons.visibility_off
: Icons.visibility),
label: Text(_bannerAd != null
? 'Remove Banner'
: 'Show Banner (30s rotation)'),
),
if (_bannerAd != null) ...[
const SizedBox(height: 12),
// The leaderboard tier is 728dp wide — wider than any phone —
// so the demo scrolls it rather than letting it overflow. A
// real host would use FittedBox or pick a narrower tier.
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: NovvyBannerAdView(ad: _bannerAd!),
),
const SizedBox(height: 8),
Text(
'Rotations shown: $_bannerRotations',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
const SizedBox(height: 16),
// Insert (STORY) ad — opens a full-page player feed, since this
// placement lives inside the host's own player view.
_SectionCard(
title: 'Insert Ad (STORY)',
icon: Icons.auto_stories_outlined,
children: [
_ConfigField(
label: 'Insert Ad Unit ID',
controller: _insertAdUnitController,
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: _initialized ? _openStoryDemo : null,
icon: const Icon(Icons.play_circle_outline),
label: const Text('Open Player Feed'),
),
],
),
const SizedBox(height: 16),
// Event log
_SectionCard(
title: 'Event Log',
icon: Icons.receipt_long,
trailing: _logs.isNotEmpty
? TextButton(
onPressed: () => setState(_logs.clear),
child: const Text('Clear'),
)
: null,
children: [
if (_logs.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'No events yet.\nInitialize the SDK and trigger an ad.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
...List.generate(
_logs.length.clamp(0, 50),
(i) => _LogTile(entry: _logs[i]),
),
],
),
const SizedBox(height: 32),
],
),
);
}
}
// ── Helper widgets ──
class _SectionCard extends StatelessWidget {
final String title;
final IconData icon;
final Widget? trailing;
final List<Widget> children;
const _SectionCard({
required this.title,
required this.icon,
this.trailing,
required this.children,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(title, style: theme.textTheme.titleMedium),
),
?trailing,
],
),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}
class _ConfigField extends StatelessWidget {
final String label;
final TextEditingController controller;
final bool enabled;
final bool obscure;
const _ConfigField({
required this.label,
required this.controller,
this.enabled = true,
this.obscure = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: controller,
enabled: enabled,
obscureText: obscure,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
labelText: label,
isDense: true,
border: const OutlineInputBorder(),
),
),
);
}
}
class _LogTile extends StatelessWidget {
final _LogEntry entry;
const _LogTile({required this.entry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (icon, color) = switch (entry.level) {
_LogLevel.info => (Icons.info_outline, theme.colorScheme.onSurface),
_LogLevel.success => (Icons.check_circle_outline, Colors.green),
_LogLevel.warning => (Icons.warning_amber, Colors.orange),
_LogLevel.error => (Icons.error_outline, Colors.redAccent),
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
entry.message,
style: theme.textTheme.bodySmall?.copyWith(color: color),
),
),
],
),
);
}
}
// ── Models ──
enum _LogLevel { info, success, warning, error }
class _LogEntry {
final String message;
final _LogLevel level;
final DateTime timestamp;
_LogEntry({
required this.message,
this.level = _LogLevel.info,
}) : timestamp = DateTime.now();
}
/// A simple player adapter for demo purposes.
class _DemoPlayerAdapter extends NovvyPlayerAdapter {
_DemoPlayerAdapter({required bool playing}) : _playing = playing;
final DateTime _start = DateTime.now();
final bool _playing;
@override
int get currentPositionMs =>
DateTime.now().difference(_start).inMilliseconds;
@override
bool get isPlaying => _playing;
}