teads_flutter 1.1.0
teads_flutter: ^1.1.0 copied to clipboard
Teads in-app ads for Flutter: Feed, Media, Banner, Interstitial and Recommendations placements over the native Teads iOS and Android SDKs.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:teads_flutter/teads_flutter.dart';
import 'screens/article_screen.dart';
import 'screens/config_screen.dart';
import 'screens/feed_screen.dart';
import 'screens/interstitial_screen.dart';
import 'screens/recommendations_screen.dart';
/// Verification/CI hook: launch straight into the article page, which hosts the
/// wrapped Media placement (`--dart-define=AUTO_OPEN_ARTICLE=true`).
const bool autoOpenArticle = bool.fromEnvironment('AUTO_OPEN_ARTICLE');
/// Same, for the Dart-implemented Feed (`--dart-define=AUTO_OPEN_FEED=true`).
///
/// Worth more on iOS than on Android: the simulator has no scriptable tap
/// injection (no `adb shell input` equivalent), so without this there is no way to
/// reach the screen from a script at all.
const bool autoOpenFeed = bool.fromEnvironment('AUTO_OPEN_FEED');
/// Same, for the wrapped Interstitial (`--dart-define=AUTO_OPEN_INTERSTITIAL=true`).
///
/// Needed to inspect the Activity/task structure the interstitial presents itself
/// into: reaching it by tap means scrolling the config screen to a button whose
/// position moves, which is not something a script should depend on.
const bool autoOpenInterstitial =
bool.fromEnvironment('AUTO_OPEN_INTERSTITIAL');
/// Same, for Recommendations (`--dart-define=AUTO_OPEN_RECOMMENDATIONS=true`).
///
/// Recommendations has no config-screen widget-index footgun to avoid, but the
/// same "reach it without a fragile tap sequence" reasoning applies.
const bool autoOpenRecommendations =
bool.fromEnvironment('AUTO_OPEN_RECOMMENDATIONS');
/// Frame-timing harness (`--dart-define=FRAME_STATS=true`).
///
/// Exists because the obvious tools do not work here. `Choreographer: Skipped N
/// frames` only fires past ~30 dropped frames, so it is blind to the one- and
/// two-frame hitches that read as "a bit janky", and `dumpsys gfxinfo` reports
/// "Total frames rendered: 0" for a Flutter app because Impeller does not draw
/// through Android's HWUI pipeline at all. Flutter's own frame timings are the only
/// honest source, so this reads them directly.
const bool frameStats = bool.fromEnvironment('FRAME_STATS');
/// Reports build and raster percentiles every few seconds while scrolling.
///
/// Build and raster are separated on purpose: they fail for different reasons and
/// point at different fixes. A slow BUILD is Dart-side work — layout, setState
/// churn. A slow RASTER is the compositor, which is where a platform view the size
/// of a paginating feed shows up.
void _startFrameStats() {
final build = <int>[];
final raster = <int>[];
SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
for (final timing in timings) {
build.add(timing.buildDuration.inMicroseconds);
raster.add(timing.rasterDuration.inMicroseconds);
}
});
// The frame budget has to come from the panel, not a constant. A hardcoded
// 16.7ms is the 60Hz budget, and on a 120Hz phone it is twice too generous:
// 9-12ms frames pass the check while actually missing vsync, which is exactly
// the one- and two-frame hitch that reads as "a bit janky". Read per tick
// rather than once, because these panels switch rate adaptively and the rate
// during THIS window is the one the samples should be judged against.
double refreshRate() {
final views = SchedulerBinding.instance.platformDispatcher.views;
if (views.isEmpty) return 60;
final rate = views.first.display.refreshRate;
return rate > 0 ? rate : 60;
}
String summarise(String label, List<int> samples, double budgetUs) {
final sorted = List<int>.of(samples)..sort();
String at(double q) =>
(sorted[(sorted.length * q).clamp(0, sorted.length - 1).toInt()] / 1000)
.toStringAsFixed(1);
final over = samples.where((us) => us > budgetUs).length;
return '$label p50=${at(0.5)}ms p90=${at(0.9)}ms '
'worst=${(sorted.last / 1000).toStringAsFixed(1)}ms over=$over';
}
Timer.periodic(const Duration(seconds: 3), (_) {
if (build.isEmpty) return;
final hz = refreshRate();
final budgetUs = 1000000 / hz;
// The one print deliberately kept. This harness exists solely to emit it, and
// it only runs when FRAME_STATS is passed at build time — so it is opt-in
// diagnostics rather than the trace logging that was removed everywhere else.
debugPrint('FRAME STATS n=${build.length} '
'hz=${hz.toStringAsFixed(1)} '
'budget=${(budgetUs / 1000).toStringAsFixed(1)}ms | '
'${summarise("build", build, budgetUs)} | '
'${summarise("raster", raster, budgetUs)}');
build.clear();
raster.clear();
});
}
/// Test IDs used by the launch hooks above and as the config screen's defaults.
const _feedConfig = FeedPlacementConfig(
widgetId: 'MB_1',
installationKey: 'NANOWDGT01',
widgetIndex: 0,
articleUrl: 'https://mobile-demo.outbrain.com/',
);
const _mediaConfig = MediaPlacementConfig(
pid: 84242,
articleUrl: 'https://example.com/article',
);
// INT_MW_1 rather than the Feed's MB_1: the widget id is what selects the format,
// so reusing the Feed id here would serve a feed into a fullscreen slot.
const _interstitialConfig = InterstitialPlacementConfig(
articleUrl: 'https://mobile-demo.outbrain.com/',
widgetId: 'INT_MW_1',
installationKey: 'NANOWDGT01',
);
// SDK_1: matches the native iOS demo's default Recommendations widget id
// (AdPlacementViewModel.recommendationsConfig).
const _recommendationsConfig = RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: 'https://mobile-demo.outbrain.com/',
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
await TeadsSdk.configure('iOSSampleApp2014');
} catch (_) {
// Swallowed so the demo still opens; the placements report their own failures.
}
try {
// Only the admob_*_screen.dart demos need this; the config screen itself
// requests no ad, so a slow or failed init here cannot block the app opening.
await MobileAds.instance.initialize();
} catch (_) {
// Best-effort, same reasoning as TeadsSdk.configure above.
}
if (frameStats) _startFrameStats();
runApp(const InAppSdkFlutterDemoApp());
// ATT is requested AFTER the first frame, and awaiting it before runApp is a
// trap worth spelling out: iOS can only present the prompt once the app is
// active, so asking while there is still no UI can leave the completion
// handler unfired. `main()` then never reaches runApp and the app hangs on a
// white screen - which is exactly what happened on a physical iPhone, while
// the simulator looked fine because its ATT status was already determined and
// the call returned straight away.
//
// Still resolved before any ad request, which is the ordering that matters:
// the advertising id is read when a placement assembles its request, and the
// config screen is what comes up first - it builds no placement. Null means
// Android, which has no ATT prompt.
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
await TeadsPrivacy.requestTrackingAuthorization();
} catch (_) {
// Best-effort: a refusal or a failure here must not hold up the app.
}
});
}
class InAppSdkFlutterDemoApp extends StatelessWidget {
const InAppSdkFlutterDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'InApp SDK Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: _home(),
);
}
Widget _home() {
if (autoOpenFeed) {
return const FeedScreen(config: _feedConfig);
}
if (autoOpenArticle) {
return const ArticleScreen(mediaConfig: _mediaConfig);
}
if (autoOpenInterstitial) {
return const InterstitialScreen(config: _interstitialConfig);
}
if (autoOpenRecommendations) {
return const RecommendationsScreen(config: _recommendationsConfig);
}
return const ConfigScreen();
}
}