Skip to main content

velocity_ads 0.11.0 copy "velocity_ads: ^0.11.0" to clipboard
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);
  }
}
0
likes
140
points
425
downloads

Documentation

API reference

Publisher

verified publishervelocity.io

Weekly Downloads

A Flutter plugin for VelocityAds SDK - AI-powered contextual advertising for Android and iOS.

Homepage

License

Apache-2.0 (license)

Dependencies

flutter

More

Packages that depend on velocity_ads

Packages that implement velocity_ads