velocity_ads 0.4.1 copy "velocity_ads: ^0.4.1" to clipboard
velocity_ads: ^0.4.1 copied to clipboard

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

example/lib/main.dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:velocity_ads/velocity_ads.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'VelocityAds Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const ExampleHomePage(),
    );
  }
}

/// Snapshot of a single loaded ad entry.
class _AdEntry {
  final VelocityNativeAd ad;
  final bool isViewAd;
  final VelocityNativeAdViewSize viewSize;

  const _AdEntry({
    required this.ad,
    required this.isViewAd,
    required this.viewSize,
  });
}

class ExampleHomePage extends StatefulWidget {
  const ExampleHomePage({super.key});

  @override
  State<ExampleHomePage> createState() => _ExampleHomePageState();
}

class _ExampleHomePageState extends State<ExampleHomePage> {
  bool _isInitialized = false;
  bool _isLoading = false;

  // All live ad instances.
  final List<_AdEntry> _liveAds = [];

  String? _errorMessage;

  final TextEditingController _promptController = TextEditingController(
    text: 'Best running shoes for marathon training',
  );
  final TextEditingController _userIdController = TextEditingController();

  // --- Ad request parameter state ---
  final TextEditingController _additionalContextController =
      TextEditingController();
  final TextEditingController _adUnitIdController = TextEditingController();

  // --- Ad view configuration state ---
  VelocityNativeAdViewSize _selectedSize = VelocityNativeAdViewSize.m;
  bool? _darkThemeOverride; // null=system, false=light, true=dark
  AdColors _lightColors = AdColors.lightDefaults();
  AdColors _darkColors = AdColors.darkDefaults();
  // Per-slot typography overrides (null means use SDK default)
  AdTextStyle? _typoFontBrandName;
  AdTextStyle? _typoFontSponsoredLabel;
  AdTextStyle? _typoFontSponsoredBadgeText;
  AdTextStyle? _typoFontTitle;
  AdTextStyle? _typoFontDescription;
  AdTextStyle? _typoFontCtaButton;

  AdViewConfiguration? _buildAdViewConfiguration() {
    final bool colorsModified =
        _lightColors != AdColors.lightDefaults() ||
        _darkColors != AdColors.darkDefaults();
    final bool typoModified = _typoFontBrandName != null ||
        _typoFontSponsoredLabel != null ||
        _typoFontSponsoredBadgeText != null ||
        _typoFontTitle != null ||
        _typoFontDescription != null ||
        _typoFontCtaButton != null;

    if (_darkThemeOverride == null && !colorsModified && !typoModified) {
      return null;
    }

    AdColorScheme? colorScheme;
    if (colorsModified) {
      colorScheme = AdColorScheme(light: _lightColors, dark: _darkColors);
    }

    AdTypography? adTypography;
    if (typoModified) {
      // Build a baseline from SDK-like defaults, then overlay any overrides.
      const baseline = AdTextStyle(fontSize: 12);
      adTypography = AdTypography(
        brandName: _typoFontBrandName ?? baseline,
        sponsoredLabel: _typoFontSponsoredLabel ?? baseline,
        sponsoredBadgeText: _typoFontSponsoredBadgeText ?? baseline,
        title: _typoFontTitle ?? baseline,
        description: _typoFontDescription ?? baseline,
        ctaButton: _typoFontCtaButton ?? baseline,
      );
    }

    return AdViewConfiguration(
      darkTheme: _darkThemeOverride,
      colorScheme: colorScheme,
      adTypography: adTypography,
    );
  }

  double _adViewHeight(VelocityNativeAdViewSize size) {
    switch (size) {
      case VelocityNativeAdViewSize.s:
        return 50;
      case VelocityNativeAdViewSize.m:
        return 160;
      case VelocityNativeAdViewSize.l:
        return 300;
    }
  }

  @override
  void initState() {
    super.initState();
    _initializeSDK();
  }

  @override
  void dispose() {
    for (final entry in _liveAds) {
      entry.ad.destroy();
    }
    _promptController.dispose();
    _userIdController.dispose();
    _additionalContextController.dispose();
    _adUnitIdController.dispose();
    super.dispose();
  }

  Future<void> _initializeSDK() async {
    try {
      setState(() {
        _isLoading = true;
        _errorMessage = null;
      });

      // Use the app key for the current platform (iOS and Android keys are different)
      const iosAppKey = 'YOUR_IOS_APPLICATION_KEY';
      const androidAppKey = 'YOUR_ANDROID_APPLICATION_KEY';
      final appKey = Platform.isIOS ? iosAppKey : androidAppKey;

      await VelocityAds.initialize(appKey: appKey);

      setState(() {
        _isInitialized = true;
        _isLoading = false;
      });

      _showSnackBar('SDK initialized successfully', Colors.green);
    } on VelocityAdsError catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = 'Initialization failed: [${e.code}] ${e.message}';
      });
      _showSnackBar('Failed to initialize: ${e.message}', Colors.red);
    } catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = 'Initialization failed: $e';
      });
      _showSnackBar('Failed to initialize: $e', Colors.red);
    }
  }

  Future<void> _loadNativeAd() async {
    if (!_isInitialized) {
      _showSnackBar('Please initialize SDK first', Colors.orange);
      return;
    }

    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    final request = VelocityNativeAdRequest(
      prompt: _promptController.text,
      aiResponse: 'Here are some great options for marathon training shoes...',
      conversationHistory: [
        ConversationMessage.user('Best running shoes for marathon training'),
        ConversationMessage.assistant(
            'Here are some great options for marathon training shoes...'),
      ],
      additionalContext: _additionalContextController.text.trim().isEmpty
          ? null
          : _additionalContextController.text.trim(),
      adUnitId: _adUnitIdController.text.trim().isEmpty
          ? null
          : _adUnitIdController.text.trim(),
    );
    final ad = VelocityNativeAd(request);
    final entry = _AdEntry(
      ad: ad,
      isViewAd: false,
      viewSize: _selectedSize,
    );
    _liveAds.add(entry);
    final adListener = _ExampleAdListener(
      onLoaded: (_) {
        setState(() => _isLoading = false);
        _showSnackBar('✅ onAdLoaded', Colors.green);
      },
      onFailed: (_, error) {
        setState(() {
          _isLoading = false;
          _errorMessage = 'onAdFailedToLoad: $error';
        });
        _showSnackBar('❌ onAdFailedToLoad', Colors.red);
      },
      onImpression: (_) => _showSnackBar('onAdImpression', Colors.teal),
      onClicked: (_) => _showSnackBar('onAdClicked', Colors.teal),
    );

    try {
      await ad.load(listener: adListener);
    } catch (_) {
      // Errors are handled via listener.
    }
  }

  Future<void> _loadNativeAdView() async {
    if (!_isInitialized) {
      _showSnackBar('Please initialize SDK first', Colors.orange);
      return;
    }

    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    final request = VelocityNativeAdViewRequest(
      size: _selectedSize,
      adViewConfiguration: _buildAdViewConfiguration(),
      prompt: _promptController.text,
      aiResponse: 'Here are some great options for marathon training shoes...',
      conversationHistory: [
        ConversationMessage.user('Best running shoes for marathon training'),
        ConversationMessage.assistant(
            'Here are some great options for marathon training shoes...'),
      ],
      additionalContext: _additionalContextController.text.trim().isEmpty
          ? null
          : _additionalContextController.text.trim(),
      adUnitId: _adUnitIdController.text.trim().isEmpty
          ? null
          : _adUnitIdController.text.trim(),
    );
    final ad = VelocityNativeAd(request);
    final sizeSnapshot = _selectedSize;
    final entry = _AdEntry(
      ad: ad,
      isViewAd: true,
      viewSize: sizeSnapshot,
    );
    _liveAds.add(entry);
    final adListener = _ExampleAdListener(
      onLoaded: (loadedAd) async {
        try {
          await loadedAd.createAdView();
        } catch (e) {
          loadedAd.destroy();
          if (!mounted) return;
          setState(() {
            _liveAds.remove(entry);
            _isLoading = false;
            _errorMessage = 'createAdView failed: $e';
          });
          _showSnackBar('❌ createAdView failed', Colors.red);
          return;
        }
        if (!mounted) return;
        setState(() => _isLoading = false);
        _showSnackBar('✅ onAdLoaded (view)', Colors.green);
      },
      onFailed: (_, error) {
        setState(() {
          _isLoading = false;
          _errorMessage = 'onAdFailedToLoad: $error';
        });
        _showSnackBar('❌ onAdFailedToLoad', Colors.red);
      },
      onImpression: (_) => _showSnackBar('onAdImpression', Colors.teal),
      onClicked: (_) => _showSnackBar('onAdClicked', Colors.teal),
    );

    try {
      await ad.load(listener: adListener);
    } catch (_) {
      // Errors are handled via listener.
    }
  }

  void _showSnackBar(String message, Color backgroundColor) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: backgroundColor,
        duration: const Duration(seconds: 2),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('VelocityAds Example'),
        actions: [
          IconButton(
            icon: const Icon(Icons.settings),
            tooltip: 'Ad View Settings',
            onPressed: () async {
              final result = await Navigator.of(context).push<_AdViewSettings>(
                MaterialPageRoute(
                  builder: (_) => _AdViewSettingsPage(
                    initialSettings: _AdViewSettings(
                      darkThemeOverride: _darkThemeOverride,
                      lightColors: _lightColors,
                      darkColors: _darkColors,
                      typoFontBrandName: _typoFontBrandName,
                      typoFontSponsoredLabel: _typoFontSponsoredLabel,
                      typoFontSponsoredBadgeText: _typoFontSponsoredBadgeText,
                      typoFontTitle: _typoFontTitle,
                      typoFontDescription: _typoFontDescription,
                      typoFontCtaButton: _typoFontCtaButton,
                    ),
                  ),
                ),
              );
              if (result != null) {
                setState(() {
                  _darkThemeOverride = result.darkThemeOverride;
                  _lightColors = result.lightColors;
                  _darkColors = result.darkColors;
                  _typoFontBrandName = result.typoFontBrandName;
                  _typoFontSponsoredLabel = result.typoFontSponsoredLabel;
                  _typoFontSponsoredBadgeText = result.typoFontSponsoredBadgeText;
                  _typoFontTitle = result.typoFontTitle;
                  _typoFontDescription = result.typoFontDescription;
                  _typoFontCtaButton = result.typoFontCtaButton;
                });
              }
            },
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // SDK Status
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Row(
                  children: [
                    Icon(
                      _isInitialized ? Icons.check_circle : Icons.cancel,
                      color: _isInitialized ? Colors.green : Colors.red,
                    ),
                    const SizedBox(width: 8),
                    Text(
                      _isInitialized ? 'SDK Initialized' : 'Not Initialized',
                      style: Theme.of(context).textTheme.bodyLarge,
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // User ID
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('User ID',
                        style: Theme.of(context).textTheme.titleLarge),
                    const SizedBox(height: 16),
                    Row(
                      children: [
                        Expanded(
                          child: TextField(
                            controller: _userIdController,
                            decoration: const InputDecoration(
                              labelText: 'User ID (optional)',
                              border: OutlineInputBorder(),
                            ),
                          ),
                        ),
                        const SizedBox(width: 8),
                        ElevatedButton(
                          onPressed: () {
                            final userId = _userIdController.text.trim();
                            VelocityAds.setUserId(
                                userId.isEmpty ? null : userId);
                            _showSnackBar(
                              userId.isEmpty ? 'User ID cleared' : 'User ID set',
                              Colors.blue,
                            );
                          },
                          child: const Text('Set'),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Load Native Ad
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('Load Native Ad',
                        style: Theme.of(context).textTheme.titleLarge),
                    const SizedBox(height: 16),
                    TextField(
                      controller: _promptController,
                      decoration: const InputDecoration(
                        labelText: 'Ad Prompt',
                        border: OutlineInputBorder(),
                        hintText: 'Enter search query or user intent',
                      ),
                      maxLines: 2,
                    ),
                    const SizedBox(height: 16),
                    // Size selector — only applies to "Load Native Ad View"
                    Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          'Ad View Size',
                          style: Theme.of(context).textTheme.labelMedium,
                        ),
                        const SizedBox(height: 6),
                        SegmentedButton<VelocityNativeAdViewSize>(
                          segments: const [
                            ButtonSegment(
                              value: VelocityNativeAdViewSize.s,
                              label: Text('S  (50dp)'),
                            ),
                            ButtonSegment(
                              value: VelocityNativeAdViewSize.m,
                              label: Text('M (160dp)'),
                            ),
                            ButtonSegment(
                              value: VelocityNativeAdViewSize.l,
                              label: Text('L  (300dp)'),
                            ),
                          ],
                          selected: {_selectedSize},
                          onSelectionChanged: (s) =>
                              setState(() => _selectedSize = s.first),
                        ),
                        const SizedBox(height: 4),
                        Text(
                          'Size applies to "Load Native Ad View" only.',
                          style: Theme.of(context).textTheme.bodySmall,
                        ),
                      ],
                    ),
                    const SizedBox(height: 16),
                    const Divider(),
                    const SizedBox(height: 8),
                    Text('Ad Request Parameters',
                        style: Theme.of(context).textTheme.titleSmall),
                    const SizedBox(height: 12),
                    TextField(
                      controller: _additionalContextController,
                      decoration: const InputDecoration(
                        labelText: 'Additional Context',
                        border: OutlineInputBorder(),
                        hintText: 'e.g. User is a marathon runner',
                      ),
                    ),
                    const SizedBox(height: 12),
                    TextField(
                      controller: _adUnitIdController,
                      decoration: const InputDecoration(
                        labelText: 'Ad Unit ID',
                        border: OutlineInputBorder(),
                        hintText: 'e.g. home-screen-ad',
                      ),
                    ),
                    const SizedBox(height: 8),
                    const Divider(),
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        Expanded(
                          child: ElevatedButton.icon(
                            onPressed: _isLoading ? null : _loadNativeAdView,
                            icon: const Icon(Icons.view_quilt),
                            label: const Text('Load Native Ad View'),
                          ),
                        ),
                        const SizedBox(width: 8),
                        Expanded(
                          child: ElevatedButton.icon(
                            onPressed: _isLoading ? null : _loadNativeAd,
                            icon: const Icon(Icons.download),
                            label: const Text('Load Native Ad'),
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 8),
                    Text(
                      'Load Native Ad View: SDK renders the ad as a native view.\n'
                      'Load Native Ad: ad data returned, you build the UI.',
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Privacy
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('Privacy Controls',
                        style: Theme.of(context).textTheme.titleLarge),
                    const SizedBox(height: 8),
                    ListTile(
                      title: const Text('Grant GDPR Consent'),
                      leading: const Icon(Icons.privacy_tip),
                      onTap: () {
                        VelocityAds.setConsent(true);
                        _showSnackBar('GDPR consent granted', Colors.blue);
                      },
                    ),
                    ListTile(
                      title: const Text('Deny GDPR Consent'),
                      leading: const Icon(Icons.block),
                      onTap: () {
                        VelocityAds.setConsent(false);
                        _showSnackBar('GDPR consent denied', Colors.blue);
                      },
                    ),
                    const Divider(),
                    ListTile(
                      title: const Text('Enable CCPA Do Not Sell'),
                      leading: const Icon(Icons.shield),
                      onTap: () {
                        VelocityAds.setDoNotSell(true);
                        _showSnackBar('CCPA Do Not Sell enabled', Colors.blue);
                      },
                    ),
                    ListTile(
                      title: const Text('Disable CCPA Do Not Sell'),
                      leading: const Icon(Icons.shield_outlined),
                      onTap: () {
                        VelocityAds.setDoNotSell(false);
                        _showSnackBar('CCPA Do Not Sell disabled', Colors.blue);
                      },
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            if (_isLoading)
              const Center(child: CircularProgressIndicator()),

            if (_errorMessage != null)
              Card(
                color: Colors.red.shade50,
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Row(
                    children: [
                      const Icon(Icons.error, color: Colors.red),
                      const SizedBox(width: 8),
                      Expanded(
                        child: Text(_errorMessage!,
                            style: const TextStyle(color: Colors.red)),
                      ),
                    ],
                  ),
                ),
              ),

            if (_liveAds.isNotEmpty) ...[
              const SizedBox(height: 8),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text(
                    '${_liveAds.length} ad${_liveAds.length == 1 ? '' : 's'} loaded',
                    style: Theme.of(context).textTheme.labelMedium,
                  ),
                  TextButton.icon(
                    onPressed: () {
                      setState(() {
                        for (final entry in _liveAds) {
                          entry.ad.destroy();
                        }
                        _liveAds.clear();
                        _isLoading = false;
                      });
                    },
                    icon: const Icon(Icons.delete_outline, size: 18),
                    label: const Text('Clear All'),
                  ),
                ],
              ),
              ...List.generate(_liveAds.length, (i) {
                final entry = _liveAds[i];
                final child = entry.ad.data == null
                    ? _buildFailedAdCard(entry)
                    : entry.isViewAd
                        ? _buildAdView(entry)
                        : _buildAdDataDisplay(entry.ad);
                return KeyedSubtree(
                  key: ValueKey(entry.ad.instanceId),
                  child: child,
                );
              }),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildFailedAdCard(_AdEntry entry) {
    return Card(
      elevation: 2,
      color: Colors.red.shade50,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            const Icon(Icons.error_outline, color: Colors.red),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                entry.isViewAd
                    ? 'Native Ad View failed to load'
                    : 'Native Ad failed to load',
                style: Theme.of(context)
                    .textTheme
                    .bodyMedium
                    ?.copyWith(color: Colors.red.shade800),
              ),
            ),
            FilledButton.tonalIcon(
              onPressed: _isLoading
                  ? null
                  : () {
                      setState(() {
                        _isLoading = true;
                        _errorMessage = null;
                      });
                      final ad = entry.ad;
                      final adListener = _ExampleAdListener(
                        onLoaded: entry.isViewAd
                            ? (loadedAd) async {
                                try {
                                  await loadedAd.createAdView();
                                } catch (e) {
                                  loadedAd.destroy();
                                  if (!mounted) return;
                                  setState(() {
                                    _liveAds.remove(entry);
                                    _isLoading = false;
                                    _errorMessage = 'createAdView failed: $e';
                                  });
                                  _showSnackBar(
                                      '❌ createAdView failed', Colors.red);
                                  return;
                                }
                                if (!mounted) return;
                                setState(() => _isLoading = false);
                                _showSnackBar(
                                    '✅ onAdLoaded (view)', Colors.green);
                              }
                            : (_) {
                                setState(() => _isLoading = false);
                                _showSnackBar('✅ onAdLoaded', Colors.green);
                              },
                        onFailed: (_, error) {
                          setState(() {
                            _isLoading = false;
                            _errorMessage = 'onAdFailedToLoad: $error';
                          });
                          _showSnackBar('❌ onAdFailedToLoad', Colors.red);
                        },
                        onImpression: (_) =>
                            _showSnackBar('onAdImpression', Colors.teal),
                        onClicked: (_) =>
                            _showSnackBar('onAdClicked', Colors.teal),
                      );
                      ad.load(listener: adListener).catchError((_) {});
                    },
              icon: const Icon(Icons.refresh, size: 18),
              label: const Text('Retry'),
            ),
            const SizedBox(width: 8),
            IconButton(
              icon: const Icon(Icons.close, size: 18),
              onPressed: () {
                entry.ad.destroy();
                setState(() {
                  _liveAds.remove(entry);
                  if (_liveAds.isEmpty) _isLoading = false;
                });
              },
            ),
          ],
        ),
      ),
    );
  }

  // Displays the SDK-rendered native ad view widget.
  Widget _buildAdView(_AdEntry entry) {
    final w = entry.ad.widget;
    if (w == null) {
      return const Card(
        child: Padding(
          padding: EdgeInsets.all(16),
          child: Text('Ad view not available on this platform.'),
        ),
      );
    }
    return Card(
      elevation: 4,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Container(
            padding: const EdgeInsets.all(12),
            color: Theme.of(context).colorScheme.primaryContainer,
            child: Row(
              children: [
                const Icon(Icons.view_quilt),
                const SizedBox(width: 8),
                Text('Native Ad View',
                    style: Theme.of(context).textTheme.titleSmall),
              ],
            ),
          ),
          SizedBox(height: _adViewHeight(entry.viewSize), child: w),
        ],
      ),
    );
  }

  // Displays publisher-rendered ad from data fields.
  Widget _buildAdDataDisplay(VelocityNativeAd ad) {
    // VelocityNativeAdTracker handles impression tracking and click handling for data-only ads.
    return VelocityNativeAdTracker(
      ad: ad,
      child: Card(
        elevation: 4,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Container(
              padding: const EdgeInsets.all(16.0),
              color: Theme.of(context).colorScheme.primaryContainer,
              child: Row(
                children: [
                  const Icon(Icons.ad_units),
                  const SizedBox(width: 8),
                  Flexible(
                    child: Text('Native Ad',
                        overflow: TextOverflow.ellipsis,
                        style: Theme.of(context).textTheme.titleMedium),
                  ),
                  const SizedBox(width: 8),
                  const Text('Sponsored'),
                ],
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(ad.data!.title,
                      style: Theme.of(context)
                          .textTheme
                          .titleLarge
                          ?.copyWith(fontWeight: FontWeight.bold)),
                  const SizedBox(height: 8),
                  Text(ad.data!.description,
                      style: Theme.of(context).textTheme.bodyMedium),
                  const SizedBox(height: 12),
                  if ((ad.data!.largeImageUrl ?? ad.data!.squareImageUrl ?? '').isNotEmpty)
                    ClipRRect(
                      borderRadius: BorderRadius.circular(8),
                      child: Image.network(
                        ad.data!.largeImageUrl ?? ad.data!.squareImageUrl!,
                        height: 200,
                        width: double.infinity,
                        fit: BoxFit.cover,
                        errorBuilder: (_, __, ___) => Container(
                          height: 200,
                          color: Colors.grey.shade200,
                          child: const Center(child: Icon(Icons.image_not_supported)),
                        ),
                      ),
                    ),
                  const SizedBox(height: 12),
                  SizedBox(
                    width: double.infinity,
                    child: ElevatedButton(
                      onPressed: null, // SDK handles click via the interaction overlay in VelocityNativeAdTracker
                      child: Text(ad.data!.callToAction),
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text('By ${ad.data!.advertiserName}',
                      style: Theme.of(context).textTheme.bodySmall),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _ExampleAdListener extends VelocityNativeAdListener {
  final void Function(VelocityNativeAd) onLoaded;
  final void Function(VelocityNativeAd, VelocityAdsError) onFailed;
  final void Function(VelocityNativeAd)? onImpression;
  final void Function(VelocityNativeAd)? onClicked;

  const _ExampleAdListener({
    required this.onLoaded,
    required this.onFailed,
    this.onImpression,
    this.onClicked,
  });

  @override
  void onAdLoaded(VelocityNativeAd ad) => onLoaded(ad);

  @override
  void onAdFailedToLoad(VelocityNativeAd ad, VelocityAdsError error) =>
      onFailed(ad, error);

  @override
  void onAdImpression(VelocityNativeAd ad) {
    debugPrint('onAdImpression: ${ad.data?.adId ?? '<unknown>'}');
    onImpression?.call(ad);
  }

  @override
  void onAdClicked(VelocityNativeAd ad) {
    debugPrint('onAdClicked: ${ad.data?.adId ?? '<unknown>'}');
    onClicked?.call(ad);
  }
}

// ---------------------------------------------------------------------------
// Settings data model — passed to/from AdViewSettingsPage via Navigator
// ---------------------------------------------------------------------------

class _AdViewSettings {
  final bool? darkThemeOverride;
  final AdColors lightColors;
  final AdColors darkColors;
  final AdTextStyle? typoFontBrandName;
  final AdTextStyle? typoFontSponsoredLabel;
  final AdTextStyle? typoFontSponsoredBadgeText;
  final AdTextStyle? typoFontTitle;
  final AdTextStyle? typoFontDescription;
  final AdTextStyle? typoFontCtaButton;

  const _AdViewSettings({
    required this.darkThemeOverride,
    required this.lightColors,
    required this.darkColors,
    this.typoFontBrandName,
    this.typoFontSponsoredLabel,
    this.typoFontSponsoredBadgeText,
    this.typoFontTitle,
    this.typoFontDescription,
    this.typoFontCtaButton,
  });
}

// ---------------------------------------------------------------------------
// Settings Page
// ---------------------------------------------------------------------------

class _AdViewSettingsPage extends StatefulWidget {
  final _AdViewSettings initialSettings;

  const _AdViewSettingsPage({required this.initialSettings});

  @override
  State<_AdViewSettingsPage> createState() => _AdViewSettingsPageState();
}

class _AdViewSettingsPageState extends State<_AdViewSettingsPage> {
  late bool? _darkThemeOverride;
  late AdColors _lightColors;
  late AdColors _darkColors;
  late AdTextStyle? _typoFontBrandName;
  late AdTextStyle? _typoFontSponsoredLabel;
  late AdTextStyle? _typoFontSponsoredBadgeText;
  late AdTextStyle? _typoFontTitle;
  late AdTextStyle? _typoFontDescription;
  late AdTextStyle? _typoFontCtaButton;

  final Map<String, TextEditingController> _fontSizeControllers = {};

  @override
  void initState() {
    super.initState();
    final s = widget.initialSettings;
    _darkThemeOverride = s.darkThemeOverride;
    _lightColors = s.lightColors;
    _darkColors = s.darkColors;
    _typoFontBrandName = s.typoFontBrandName;
    _typoFontSponsoredLabel = s.typoFontSponsoredLabel;
    _typoFontSponsoredBadgeText = s.typoFontSponsoredBadgeText;
    _typoFontTitle = s.typoFontTitle;
    _typoFontDescription = s.typoFontDescription;
    _typoFontCtaButton = s.typoFontCtaButton;

    for (final entry in _typoSlots()) {
      _fontSizeControllers[entry.key] = TextEditingController(
        text: entry.current?.fontSize.toStringAsFixed(0) ?? '',
      );
    }
  }

  @override
  void dispose() {
    for (final c in _fontSizeControllers.values) {
      c.dispose();
    }
    super.dispose();
  }

  _AdViewSettings _currentSettings() => _AdViewSettings(
        darkThemeOverride: _darkThemeOverride,
        lightColors: _lightColors,
        darkColors: _darkColors,
        typoFontBrandName: _typoFontBrandName,
        typoFontSponsoredLabel: _typoFontSponsoredLabel,
        typoFontSponsoredBadgeText: _typoFontSponsoredBadgeText,
        typoFontTitle: _typoFontTitle,
        typoFontDescription: _typoFontDescription,
        typoFontCtaButton: _typoFontCtaButton,
      );

  List<_TypoSlotEntry> _typoSlots() => [
        _TypoSlotEntry(
          key: 'brandName',
          label: 'Brand Name',
          current: _typoFontBrandName,
          onUpdate: (v) => _typoFontBrandName = v,
        ),
        _TypoSlotEntry(
          key: 'sponsoredLabel',
          label: 'Sponsored Label',
          current: _typoFontSponsoredLabel,
          onUpdate: (v) => _typoFontSponsoredLabel = v,
        ),
        _TypoSlotEntry(
          key: 'sponsoredBadgeText',
          label: 'Sponsored Badge Text',
          current: _typoFontSponsoredBadgeText,
          onUpdate: (v) => _typoFontSponsoredBadgeText = v,
        ),
        _TypoSlotEntry(
          key: 'title',
          label: 'Title',
          current: _typoFontTitle,
          onUpdate: (v) => _typoFontTitle = v,
        ),
        _TypoSlotEntry(
          key: 'description',
          label: 'Description',
          current: _typoFontDescription,
          onUpdate: (v) => _typoFontDescription = v,
        ),
        _TypoSlotEntry(
          key: 'ctaButton',
          label: 'CTA Button',
          current: _typoFontCtaButton,
          onUpdate: (v) => _typoFontCtaButton = v,
        ),
      ];

  List<_ColorSlotEntry> _colorSlots(
      AdColors colors, AdColors Function(Color, String) updater) {
    return [
      _ColorSlotEntry('cardBackground', 'Card Background',
          colors.cardBackground, (c) => updater(c, 'cardBackground')),
      _ColorSlotEntry('sponsoredLabelText', 'Sponsored Label Text',
          colors.sponsoredLabelText, (c) => updater(c, 'sponsoredLabelText')),
      _ColorSlotEntry('titleText', 'Title Text', colors.titleText,
          (c) => updater(c, 'titleText')),
      _ColorSlotEntry('descriptionText', 'Description Text',
          colors.descriptionText, (c) => updater(c, 'descriptionText')),
      _ColorSlotEntry('brandText', 'Brand Text', colors.brandText,
          (c) => updater(c, 'brandText')),
      _ColorSlotEntry(
          'sponsoredBadgeBackground',
          'Badge Background',
          colors.sponsoredBadgeBackground,
          (c) => updater(c, 'sponsoredBadgeBackground')),
      _ColorSlotEntry('sponsoredBadgeText', 'Badge Text',
          colors.sponsoredBadgeText, (c) => updater(c, 'sponsoredBadgeText')),
      _ColorSlotEntry('ctaBackground', 'CTA Background', colors.ctaBackground,
          (c) => updater(c, 'ctaBackground')),
      _ColorSlotEntry('ctaText', 'CTA Text', colors.ctaText,
          (c) => updater(c, 'ctaText')),
      _ColorSlotEntry('chevronIconTint', 'Chevron Icon Tint',
          colors.chevronIconTint, (c) => updater(c, 'chevronIconTint')),
      _ColorSlotEntry('brandIconBorder', 'Brand Icon Border',
          colors.brandIconBorder, (c) => updater(c, 'brandIconBorder')),
    ];
  }

  AdColors _applyColorToLight(Color color, String slot) =>
      _patchColors(_lightColors, color, slot);

  AdColors _applyColorToDark(Color color, String slot) =>
      _patchColors(_darkColors, color, slot);

  AdColors _patchColors(AdColors base, Color color, String slot) {
    switch (slot) {
      case 'cardBackground':
        return base.copyWith(cardBackground: color);
      case 'sponsoredLabelText':
        return base.copyWith(sponsoredLabelText: color);
      case 'titleText':
        return base.copyWith(titleText: color);
      case 'descriptionText':
        return base.copyWith(descriptionText: color);
      case 'brandText':
        return base.copyWith(brandText: color);
      case 'sponsoredBadgeBackground':
        return base.copyWith(sponsoredBadgeBackground: color);
      case 'sponsoredBadgeText':
        return base.copyWith(sponsoredBadgeText: color);
      case 'ctaBackground':
        return base.copyWith(ctaBackground: color);
      case 'ctaText':
        return base.copyWith(ctaText: color);
      case 'chevronIconTint':
        return base.copyWith(chevronIconTint: color);
      case 'brandIconBorder':
        return base.copyWith(brandIconBorder: color);
      default:
        return base;
    }
  }

  Future<void> _openColorPicker({
    required BuildContext context,
    required String slotLabel,
    required Color initial,
    required void Function(Color) onPicked,
  }) async {
    Color picked = initial;
    await showDialog<void>(
      context: context,
      builder: (ctx) => StatefulBuilder(
        builder: (ctx, setDialogState) {
          return AlertDialog(
            title: Text(slotLabel),
            content: SingleChildScrollView(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Container(
                    height: 48,
                    decoration: BoxDecoration(
                      color: picked,
                      border: Border.all(color: Colors.grey),
                      borderRadius: BorderRadius.circular(8),
                    ),
                  ),
                  const SizedBox(height: 16),
                  _ColorChannel(
                    label: 'R',
                    value: picked.r * 255,
                    onChanged: (v) => setDialogState(() => picked =
                        Color.fromARGB(
                            (picked.a * 255).round(),
                            v.round(),
                            (picked.g * 255).round(),
                            (picked.b * 255).round())),
                  ),
                  _ColorChannel(
                    label: 'G',
                    value: picked.g * 255,
                    onChanged: (v) => setDialogState(() => picked =
                        Color.fromARGB(
                            (picked.a * 255).round(),
                            (picked.r * 255).round(),
                            v.round(),
                            (picked.b * 255).round())),
                  ),
                  _ColorChannel(
                    label: 'B',
                    value: picked.b * 255,
                    onChanged: (v) => setDialogState(() => picked =
                        Color.fromARGB(
                            (picked.a * 255).round(),
                            (picked.r * 255).round(),
                            (picked.g * 255).round(),
                            v.round())),
                  ),
                  _ColorChannel(
                    label: 'A',
                    value: picked.a * 255,
                    onChanged: (v) => setDialogState(() => picked =
                        Color.fromARGB(
                            v.round(),
                            (picked.r * 255).round(),
                            (picked.g * 255).round(),
                            (picked.b * 255).round())),
                  ),
                ],
              ),
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(ctx),
                child: const Text('Cancel'),
              ),
              FilledButton(
                onPressed: () {
                  onPicked(picked);
                  Navigator.pop(ctx);
                },
                child: const Text('Apply'),
              ),
            ],
          );
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Ad View Settings'),
        leading: BackButton(
          onPressed: () => Navigator.of(context).pop(_currentSettings()),
        ),
      ),
      body: PopScope(
        canPop: false,
        onPopInvokedWithResult: (didPop, _) {
          if (!didPop) Navigator.of(context).pop(_currentSettings());
        },
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            _buildDarkThemeSection(context),
            const SizedBox(height: 12),
            _buildColorSchemeSection(context),
            const SizedBox(height: 12),
            _buildTypographySection(context),
          ],
        ),
      ),
    );
  }

  Widget _buildDarkThemeSection(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Ad View Theme',
                style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 4),
            Text(
              'Override the dark/light appearance of SDK-rendered ad views.',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 12),
            SegmentedButton<_ThemeOption>(
              segments: const [
                ButtonSegment(
                  value: _ThemeOption.system,
                  icon: Icon(Icons.brightness_auto),
                  label: Text('System'),
                ),
                ButtonSegment(
                  value: _ThemeOption.light,
                  icon: Icon(Icons.light_mode),
                  label: Text('Light'),
                ),
                ButtonSegment(
                  value: _ThemeOption.dark,
                  icon: Icon(Icons.dark_mode),
                  label: Text('Dark'),
                ),
              ],
              selected: {
                _darkThemeOverride == null
                    ? _ThemeOption.system
                    : _darkThemeOverride!
                        ? _ThemeOption.dark
                        : _ThemeOption.light,
              },
              onSelectionChanged: (s) {
                setState(() {
                  switch (s.first) {
                    case _ThemeOption.system:
                      _darkThemeOverride = null;
                    case _ThemeOption.light:
                      _darkThemeOverride = false;
                    case _ThemeOption.dark:
                      _darkThemeOverride = true;
                  }
                });
              },
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildColorSchemeSection(BuildContext context) {
    return Card(
      child: ExpansionTile(
        title: const Text('Color Scheme'),
        subtitle: const Text('Customize light and dark palette'),
        children: [
          ExpansionTile(
            title: const Text('Light Colors'),
            tilePadding: const EdgeInsets.symmetric(horizontal: 16),
            children: [
              ..._colorSlots(_lightColors, _applyColorToLight).map(
                (slot) => _ColorSlotRow(
                  slot: slot,
                  onEdit: () async {
                    await _openColorPicker(
                      context: context,
                      slotLabel: slot.label,
                      initial: slot.color,
                      onPicked: (c) =>
                          setState(() => _lightColors = slot.applyColor(c)),
                    );
                  },
                ),
              ),
              Padding(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                child: OutlinedButton.icon(
                  onPressed: () =>
                      setState(() => _lightColors = AdColors.lightDefaults()),
                  icon: const Icon(Icons.refresh),
                  label: const Text('Reset Light Colors'),
                ),
              ),
            ],
          ),
          ExpansionTile(
            title: const Text('Dark Colors'),
            tilePadding: const EdgeInsets.symmetric(horizontal: 16),
            children: [
              ..._colorSlots(_darkColors, _applyColorToDark).map(
                (slot) => _ColorSlotRow(
                  slot: slot,
                  onEdit: () async {
                    await _openColorPicker(
                      context: context,
                      slotLabel: slot.label,
                      initial: slot.color,
                      onPicked: (c) =>
                          setState(() => _darkColors = slot.applyColor(c)),
                    );
                  },
                ),
              ),
              Padding(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                child: OutlinedButton.icon(
                  onPressed: () =>
                      setState(() => _darkColors = AdColors.darkDefaults()),
                  icon: const Icon(Icons.refresh),
                  label: const Text('Reset Dark Colors'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildTypographySection(BuildContext context) {
    final slots = _typoSlots();
    return Card(
      child: ExpansionTile(
        title: const Text('Typography'),
        subtitle: const Text('Override font size and weight per text slot'),
        children: [
          ...slots.map((slot) {
            final ctrl = _fontSizeControllers[slot.key]!;
            return Padding(
              padding:
                  const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(slot.label,
                      style: Theme.of(context)
                          .textTheme
                          .bodyMedium
                          ?.copyWith(fontWeight: FontWeight.w500)),
                  const SizedBox(height: 6),
                  Row(
                    children: [
                      Expanded(
                        child: TextField(
                          controller: ctrl,
                          keyboardType: const TextInputType.numberWithOptions(
                              decimal: true),
                          decoration: const InputDecoration(
                            labelText: 'Size (sp)',
                            border: OutlineInputBorder(),
                            isDense: true,
                          ),
                          onChanged: (v) {
                            final parsed = double.tryParse(v);
                            setState(() {
                              if (parsed != null && parsed > 0) {
                                slot.onUpdate(AdTextStyle(
                                  fontSize: parsed,
                                  fontWeight: slot.current?.fontWeight,
                                ));
                              } else if (v.isEmpty) {
                                slot.onUpdate(null);
                              }
                            });
                          },
                        ),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        flex: 2,
                        child: DropdownButtonFormField<AdFontWeight?>(
                          initialValue: slot.current?.fontWeight,
                          isDense: true,
                          isExpanded: true,
                          decoration: const InputDecoration(
                            labelText: 'Weight',
                            border: OutlineInputBorder(),
                            isDense: true,
                          ),
                          items: [
                            const DropdownMenuItem(
                              value: null,
                              child: Text('Default'),
                            ),
                            ...AdFontWeight.values.map(
                              (w) => DropdownMenuItem(
                                value: w,
                                child: Text(w.name),
                              ),
                            ),
                          ],
                          onChanged: (w) {
                            setState(() {
                              final size = slot.current?.fontSize;
                              if (size != null) {
                                slot.onUpdate(
                                    AdTextStyle(fontSize: size, fontWeight: w));
                              } else if (w != null) {
                                slot.onUpdate(
                                    AdTextStyle(fontSize: 12, fontWeight: w));
                                ctrl.text = '12';
                              }
                            });
                          },
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            );
          }),
          Padding(
            padding:
                const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: OutlinedButton.icon(
              onPressed: () {
                setState(() {
                  _typoFontBrandName = null;
                  _typoFontSponsoredLabel = null;
                  _typoFontSponsoredBadgeText = null;
                  _typoFontTitle = null;
                  _typoFontDescription = null;
                  _typoFontCtaButton = null;
                  for (final c in _fontSizeControllers.values) {
                    c.text = '';
                  }
                });
              },
              icon: const Icon(Icons.refresh),
              label: const Text('Reset Typography'),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Supporting enums and data types
// ---------------------------------------------------------------------------

enum _ThemeOption { system, light, dark }

class _TypoSlotEntry {
  final String key;
  final String label;
  final AdTextStyle? current;
  final void Function(AdTextStyle?) onUpdate;

  const _TypoSlotEntry({
    required this.key,
    required this.label,
    required this.current,
    required this.onUpdate,
  });
}

class _ColorSlotEntry {
  final String key;
  final String label;
  final Color color;
  final AdColors Function(Color) applyColor;

  const _ColorSlotEntry(this.key, this.label, this.color, this.applyColor);
}

// ---------------------------------------------------------------------------
// Supporting widgets
// ---------------------------------------------------------------------------

class _ColorSlotRow extends StatelessWidget {
  final _ColorSlotEntry slot;
  final VoidCallback onEdit;

  const _ColorSlotRow({required this.slot, required this.onEdit});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      dense: true,
      contentPadding:
          const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
      title: Text(slot.label,
          style: Theme.of(context).textTheme.bodyMedium),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 28,
            height: 28,
            decoration: BoxDecoration(
              color: slot.color,
              border: Border.all(color: Colors.grey.shade400),
              borderRadius: BorderRadius.circular(4),
            ),
          ),
          const SizedBox(width: 8),
          IconButton(
            icon: const Icon(Icons.edit, size: 18),
            onPressed: onEdit,
          ),
        ],
      ),
    );
  }
}

class _ColorChannel extends StatelessWidget {
  final String label;
  final double value;
  final void Function(double) onChanged;

  const _ColorChannel({
    required this.label,
    required this.value,
    required this.onChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        SizedBox(
          width: 20,
          child: Text(label,
              style: Theme.of(context).textTheme.bodySmall,
              textAlign: TextAlign.center),
        ),
        Expanded(
          child: Slider(
            value: value.clamp(0, 255),
            min: 0,
            max: 255,
            divisions: 255,
            onChanged: onChanged,
          ),
        ),
        SizedBox(
          width: 32,
          child: Text(
            value.round().toString(),
            style: Theme.of(context).textTheme.bodySmall,
            textAlign: TextAlign.right,
          ),
        ),
      ],
    );
  }
}
0
likes
160
points
210
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