Skip to main content

Teads Flutter SDK Integration Guide

This guide covers every placement the plugin offers, with complete examples. Whether you're building a news app, a content platform, or anything else in Flutter, start here after installation.

info

Before starting: complete the Installation Guide — the dependency, the Android Maven repository and AdMob ID, and the iOS deployment target.

Imports​

Everything public comes from one barrel:

import 'package:teads_flutter/teads_flutter.dart';

Configure the SDK​

Call configure once at app start, before any placement is created. It throws a PlatformException if the native SDK rejects your key.

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await TeadsSdk.configure('YOUR_APP_KEY');
runApp(const MyApp());
}

On iOS you also need to request App Tracking Transparency, after your first frame is on screen — see Privacy & Consent.

Media (InRead) Placement​

A premium video or display ad rendered inline in your content. The widget sizes itself: it reserves 250 logical pixels until the native SDK reports the creative's real height, then resizes.

Basic implementation​

import 'package:flutter/material.dart';
import 'package:teads_flutter/teads_flutter.dart';

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


Widget build(BuildContext context) {
return ListView(
children: [
const Text('Article content here…'),
MediaPlacement(
config: MediaPlacementConfig(
pid: 84242,
articleUrl: 'https://example.com/article/123',
),
),
const Text('More article content…'),
],
);
}
}

With event callbacks​

MediaPlacement(
config: MediaPlacementConfig(
pid: 84242,
articleUrl: 'https://example.com/article/123',
),
onReady: () => debugPrint('Media ad ready'),
onFailed: (TeadsAdError error) => debugPrint('Media failed: ${error.reason}'),
onClicked: (TeadsAdClick click) {
// The SDK opens the click-through itself. Use this for your own analytics.
analytics.track('ad_clicked', {'url': click.url});
},
onEvent: (TeadsAdEvent event, Map<String, dynamic>? data) {
debugPrint('Media event: $event');
},
)
Do not open the click URL yourself

The SDK handles the click-through. Opening click.url from onClicked as well results in the browser opening twice. Use the callback for tracking only.

Validation mode​

enableValidationMode forces a test creative, so the placement renders without a live campaign. Useful while wiring up layout — remove it before release.

MediaPlacementConfig(pid: 84242, enableValidationMode: true)

Feed (SmartFeed) Placement​

A self-paginating content recommendations feed. Drop it into a scrolling list and it handles the rest.

Basic implementation​

FeedPlacement(
config: FeedPlacementConfig(
widgetId: 'MB_1',
installationKey: 'NANOWDGT01',
articleUrl: 'https://example.com/article/123',
),
)

Pagination is automatic​

As the user scrolls to within 600 logical pixels of the end, the placement requests the next chunk and grows to fit it, until the feed is exhausted. There is nothing to call and no "load more" control to build.

widgetIndex is not a visual position​

widgetIndex is a coordination key, not a position

Only a placement constructed with widgetIndex: 0 fetches its ad directly. Any other value waits for a widgetIndex: 0 placement on the same page to publish shared bridge parameters before it fetches at all.

If no widgetIndex: 0 placement is guaranteed to load on the same page, the ad never loads, and no error is reported. This mirrors the native SDKs exactly.

Use widgetIndex: 0 unless you have another Feed placement guaranteed to load first with widgetIndex: 0.

// Two feeds on one page: index 0 loads first and unblocks index 1.
ListView(
children: [
FeedPlacement(
config: FeedPlacementConfig(
widgetId: 'MB_1',
installationKey: 'NANOWDGT01',
articleUrl: articleUrl,
widgetIndex: 0, // fetches directly
),
),
const Text('More content…'),
FeedPlacement(
config: FeedPlacementConfig(
widgetId: 'MB_2',
installationKey: 'NANOWDGT01',
articleUrl: articleUrl,
widgetIndex: 1, // waits for index 0
),
),
],
)

Organic clicks​

Organic recommendations point at your content, not an advertiser's, so the SDK hands you the URL instead of opening a browser over your app. Wire onOrganicClick up to keep the reader inside your own UI; leave it null and the article opens in an external browser.

FeedPlacement(
config: feedConfig,
onOrganicClick: (Uri organicUrl) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArticleScreen(url: organicUrl.toString())),
);
},
)

The click is registered either way — that happens before the callback fires and does not depend on it.

Fullscreen video (zeta cards)​

The feed can ask to go fullscreen for a video card. It can only grow its own slot — it cannot paint over your app bar or the system bars — so your app must fold its chrome away when asked.

import 'package:flutter/services.dart'; // for SystemChrome

class _ArticleScreenState extends State<ArticleScreen> {
bool _fullScreen = false;


Widget build(BuildContext context) {
return Scaffold(
appBar: _fullScreen ? null : AppBar(title: const Text('Article')),
body: ListView(
physics: _fullScreen
? const NeverScrollableScrollPhysics() // stop the page moving
: null,
children: [
// Collapse siblings, do NOT remove them from the tree.
Visibility(
visible: !_fullScreen,
maintainState: true,
child: const Text('Article content…'),
),
FeedPlacement(
config: feedConfig,
onFullScreenChange: (bool fullScreen) {
setState(() => _fullScreen = fullScreen);
SystemChrome.setEnabledSystemUIMode(
fullScreen ? SystemUiMode.immersive : SystemUiMode.edgeToEdge,
);
},
),
],
),
);
}
}
Do not change the widget structure around the placement

Collapse surrounding content with Visibility(maintainState: true) or a zero height — never by removing it from the tree. Removing a sibling re-parents the WebView and it stops painting entirely.

Dark mode​

Rebuild with a new config; the placement pushes the change into the already-loaded page. There is no controller call for this.

FeedPlacement(config: feedConfig.copyWith(darkMode: isDarkMode))

To follow the system theme:

final isDark = MediaQuery.platformBrightnessOf(context) == Brightness.dark;
FeedPlacement(config: feedConfig.copyWith(darkMode: isDark));

Diagnostics​

onUnsupported is the only route for parity gaps and page-reported errors — the plugin logs nothing of its own for them. Wire it up in any build you intend to debug.

FeedPlacement(
config: feedConfig,
onUnsupported: (TeadsAdError error) => debugPrint('Feed unsupported: ${error.reason}'),
onEvent: (TeadsAdEvent event, Map<String, dynamic>? data) => debugPrint('Feed: ${event.identifier}'),
)

Capping the height​

By default the feed grows to its full content height. maxHeight caps the WebView so the feed scrolls internally instead:

FeedPlacement(config: feedConfig, maxHeight: 1200)

Leaving it null matches native behavior.

Natively, Banner and Feed are the same widget with a different format, so BannerPlacementConfig takes the same fields as FeedPlacementConfig. Banner is a fixed-purpose slot rather than a scrolling surface, and it reserves 100 logical pixels until the real height arrives.

BannerPlacement(
config: BannerPlacementConfig(
articleUrl: 'https://example.com/article/123',
widgetId: 'MB_10',
installationKey: 'NANOWDGT01',
),
onReady: () => debugPrint('Banner ready'),
onFailed: (error) => debugPrint('Banner failed: ${error.reason}'),
)

Toggling dark mode at runtime​

BannerPlacementConfig.darkMode only sets the value the banner is constructed with. To change it on a loaded banner, hold a BannerController:

class _BannerScreenState extends State<BannerScreen> {
final _controller = BannerController();
bool _darkMode = false;


Widget build(BuildContext context) {
return Column(
children: [
BannerPlacement(config: bannerConfig, controller: _controller),
Switch(
value: _darkMode,
onChanged: (value) {
setState(() => _darkMode = value);
_controller.toggleDarkMode(value);
},
),
],
);
}
}

Calls made before the banner has loaded are buffered and applied as soon as it does.

iOS: forward rotation events​

Android needs nothing — the plugin observes configuration changes process-wide. UIKit has no equivalent: it only hands a transition coordinator to a real view controller, so your FlutterViewController must forward it. In ios/Runner/AppDelegate.swift (or your FlutterViewController subclass):

override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
TeadsFlutterPlugin.forwardViewWillTransition(to: size, with: coordinator)
}

Skip this and Banner still works — the ad simply never hears about a rotation.

One live Banner at a time

The native SDK's test-display flag is a process-wide singleton, and the plugin does not yet guard against multiple concurrent wrapped Banner placements — the last one created wins. Keep to one live BannerPlacement at a time.

Interstitial Placement​

Not a widget. The native placement presents itself fullscreen — its own Activity on Android, over the key window on iOS — so there is nothing to put in your widget tree.

The lifecycle is preload-then-present: create → load → isReady → show.

class _MyScreenState extends State<MyScreen> {
InterstitialPlacement? _interstitial;


void initState() {
super.initState();
_preload();
}

Future<void> _preload() async {
final interstitial = await InterstitialPlacement.create(
config: InterstitialPlacementConfig(
articleUrl: 'https://example.com/article/123',
widgetId: 'INT_MW_1',
installationKey: 'NANOWDGT01',
),
);
interstitial.onReady = () => debugPrint('Interstitial ready');
interstitial.onFailed = (error) => debugPrint('Failed: ${error.reason}');
interstitial.onClosed = () => debugPrint('Dismissed — resume the app');
await interstitial.load();
setState(() => _interstitial = interstitial);
}

Future<void> _showAtNaturalBreak() async {
final interstitial = _interstitial;
if (interstitial == null) return;
// Re-check immediately before showing — see below.
if (await interstitial.isReady) {
await interstitial.show();
}
}


void dispose() {
_interstitial?.dispose();
super.dispose();
}
}
Check isReady immediately before show(), never cache it

Ads expire on a native TTL (driven by remote config, about an hour by default). When it lapses the placement invalidates itself and isReady becomes false on its own.

load() completes when the request has been dispatched, not when the ad is ready — wait for onReady or poll isReady for that. show() returns false when there was nothing to show rather than throwing.

Always call dispose()

The native side holds a WebView and, unlike Feed and Banner, an Open Measurement session. Nothing else releases them.

Floor price​

Optional bid floor, in US dollars:

InterstitialPlacementConfig(
articleUrl: 'https://example.com/article/123',
widgetId: 'INT_MW_1',
installationKey: 'NANOWDGT01',
floorPrice: 2.5, // $2.50
)

Both native SDKs still type their floor price as a whole integer, so this is rounded to the nearest whole dollar when it crosses the platform channel.

Recommendations — Data Only, No Widget​

Structurally different from every other placement: the SDK fetches data and renders nothing. You build the list UI. There is no native view and no dispose() — each fetch is an independent request/response round trip. Call Recommendations.release(recommendations) once a list is no longer shown, to free the native entries that registerClick resolves against; it is optional, since that cache is also bounded.

final recommendations = await Recommendations.fetch(
RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: 'https://example.com/article/123',
),
);

RecommendationsPlacementConfig has two mutually exclusive named constructors, mirroring the native request shapes:

// URL-based
RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: 'https://example.com/article/123',
externalId: 'optional-external-id',
);

// Platform-based: for an app or portal rather than one article.
// One of contentUrl / portalUrl / bundleUrl must be set, and lang is required
// by the server.
RecommendationsPlacementConfig.platform(
widgetId: 'SDK_1',
portalUrl: 'https://example.com',
lang: 'en',
);

Rendering the list​

ListView.builder(
itemCount: recommendations.length,
itemBuilder: (context, index) {
final rec = recommendations[index];
return ListTile(
leading: rec.thumbnail?.url != null
? Image.network(rec.thumbnail!.url!)
: null,
title: Text(rec.content ?? ''),
subtitle: Text(rec.sourceName ?? ''),
trailing: rec.shouldDisplayDisclosureIcon && rec.disclosure?.iconUrl != null
? Image.network(rec.disclosure!.iconUrl!)
: null,
onTap: () => _onRecommendationTap(rec),
);
},
)

AdChoices and disclosure icons — required​

Two icons are required to ship a recommendations widget:

  • A clickable AdChoices icon above the list, at least 30×30 points, hidden until recommendations load. When tapped, open the URL from Recommendations.adChoicesUrl.
  • The per-item disclosure icon on paid items: show rec.disclosure when rec.shouldDisplayDisclosureIcon is true (as in the list above), opening rec.disclosure!.clickUrl when tapped.
// launchUrl comes from package:url_launcher.
final adChoicesUrl = await Recommendations.adChoicesUrl;

IconButton(
icon: const Icon(Icons.info_outline),
iconSize: 30,
onPressed: adChoicesUrl == null
? null
: () => launchUrl(Uri.parse(adChoicesUrl)),
)

To see paid items (and so the disclosure icons) while testing, enable test mode and testRTB before fetching — testRTB does nothing outside test mode:

await TeadsSdk.setTestMode(true);
await Recommendations.setTestRtb(true);

Both are process-wide — remove them before release.

Handling a tap​

Future<void> _onRecommendationTap(TeadsRecommendation rec) async {
final url = await Recommendations.registerClick(rec);
if (url == null) return;
// Open `url` however your app opens links — an external browser for a paid item,
// your own reader UI for an organic one (`rec.isPaid` tells you which).
}
Call registerClick only at the moment of an actual tap

registerClick resolves the destination URL and reports the click — for an organic recommendation it fires the click-tracking request as a side effect. Calling it eagerly (for example right after fetch, to pre-compute URLs) reports a phantom click for every organic item whether the user taps it or not.

This is why TeadsRecommendation has no clickUrl field.

registerClick returns null if the item has no click handle, or if the native side no longer has it cached.

Available fields​

TeadsRecommendation exposes only the fields present, with the same meaning, on both platforms:

FieldTypeNotes
contentString?The recommendation's title
authorString?
sourceNameString?Publisher/source name to display
positionString?Position within the fetched set
publishDateDateTime?
isPaidbooltrue for a paid (3rd-party) item, false for organic
isVideoboolAlways false on iOS — that is native's own behavior
sameSourcebool
shouldDisplayDisclosureIconboolShow disclosure when true
thumbnailTeadsRecommendationThumbnail?url, width, height
disclosureTeadsRecommendationDisclosure?iconUrl, clickUrl
reqIdString?Needed for viewability reporting

Android-only extras (description, advertiserName, ctaText, and others) are deliberately dropped rather than sent as null on iOS.

Viewability​

Because you render the list, impression reporting is yours to drive too. See Recommendations Viewability — it is required for revenue on recommendation placements.

Events​

Media, Banner and Interstitial share one event vocabulary:

MediaPlacement(
config: config,
onReady: () {},
onFailed: (TeadsAdError error) {},
onClicked: (TeadsAdClick click) {},
onClickedOrganic: (TeadsAdClick click) {},
onEvent: (TeadsAdEvent event, Map<String, dynamic>? data) {},
)

Feed speaks the bridge protocol rather than a native placement's event stream, but its onEvent uses the same typed TeadsAdEvent vocabulary: onEvent(TeadsAdEvent event, Map<String, dynamic>? data). It adds onUnsupported, onOrganicClick and onFullScreenChange.

See Placement Events for every event and when it fires.

Depending on an Interface​

If your architecture holds the SDK behind an infrastructure adapter rather than depending on concrete classes, each entry point has an abstract contract:

Concrete classInterface
TeadsSdkTeadsSdkApi, via the injectable TeadsSdk.instance
TeadsPrivacyTeadsPrivacyApi, via TeadsPrivacy.instance
RecommendationsRecommendationsApi, via Recommendations.instance
InterstitialPlacementInterstitial
BannerControllerBannerApi

The statics keep working unchanged — they are thin pass-throughs to .instance, so swapping .instance affects both call styles identically.

class TeadsAdRepository {
TeadsAdRepository(this._sdk, this._recommendations);
final TeadsSdkApi _sdk;
final RecommendationsApi _recommendations;
}

BannerApi is not named Banner because package:flutter/widgets.dart already declares an unrelated Banner class.

Next Steps​


tip

Pro tip: test on real iOS and Android devices before release. The API is identical across platforms, but rendering, rotation and fullscreen behavior are not.