appdna_sdk 1.0.21
appdna_sdk: ^1.0.21 copied to clipboard
AppDNA SDK for Flutter — analytics, experiments, paywalls, surveys, and more.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appdna_sdk/appdna_sdk.dart';
/// Opt-in launch values for test harnesses (absent → the example behaves as before):
/// appdnaApiKey — configure with this key instead of the APPDNA_API_KEY dart-define
/// appdnaOnboardingId — adds a "Present onboarding: <id>" button
/// appdnaHostDataDemo — `items` | `empty`: sample host data via onBeforeStepRender;
/// `showmore`: paging host — "Show more" (`refresh_step`)
/// The sign-in timeout floor device rows:
/// appdnaSignInDelaySeconds — `onBeforeStepAdvance` waits n s for the FIRST sign-in action of
/// each presentation, then answers `proceed`; later attempts in the
/// same presentation proceed at once
/// appdnaVetoTimeout — passed to `AppDNAOptions.vetoTimeout`
/// appdnaStepAdvanceDelaySeconds — for a NON-sign-in step, `onBeforeStepAdvance` waits n s …
/// appdnaStepAdvanceReply — … then answers `proceed` (default) or `stay`
/// The billing, local-server and location device rows:
/// appdnaBillingProvider — `storeKit2` (default) | `revenueCat` | `none` | `adapty:<publicKey>`
/// appdnaEnv — `sandbox` when the native base-URL override is set (read natively from
/// the `ai.appdna.sdk.BASE_URL_OVERRIDE` meta-data / `AppDNABaseURLOverride`
/// plist entry, never passed by hand); configures `AppDNAEnvironment.staging`
/// appdnaHostProductId — the product the iOS "Host buy (no finish)" button buys
/// (default `ai.appdna.test.monthly`)
/// appdnaLocationFlowId — the "Location flow" button's flow id (also the
/// `APPDNA_E2E_LOCATION_FLOW_ID` dart-define)
/// appdnaPermissionsFlowId — the "Permissions flow" button's flow id (also the
/// `APPDNA_E2E_PERMISSIONS_FLOW_ID` dart-define)
/// Android: `adb shell am start ... --es appdnaApiKey <key>`; iOS: launch arguments
/// (`-appdnaApiKey <key>`). Read by the example's own MainActivity / AppDelegate.
const _launchChannel = MethodChannel('appdna_example/launch');
Future<Map<String, String>> _readLaunchValues() async {
try {
final m = await _launchChannel.invokeMapMethod<String, String>('launchValues');
return m ?? const {};
} on MissingPluginException {
return const {};
} on PlatformException {
return const {};
}
}
/// Sample host data for `appdnaHostDataDemo`. Public placeholder images only.
const _hostDataDemoItems = [
{'id': 'w1', 'name': 'Maple Farm', 'subtitle': 'Toronto', 'imageUrl': 'https://picsum.photos/seed/w1/400/300'},
{'id': 'w2', 'name': 'Oak Hall', 'subtitle': 'Nashville', 'imageUrl': 'https://picsum.photos/seed/w2/400/300'},
{'id': 'w3', 'name': 'Birch Mill', 'subtitle': 'Dublin', 'imageUrl': 'https://picsum.photos/seed/w3/400/300'},
];
/// The `showmore` paging host's list: item i is `a`, `b`, … with the page it arrived on
/// as its subtitle. Public placeholder images only.
List<Map<String, String>> _showMoreItems(int count) => [
for (var i = 0; i < count; i++)
{
'id': String.fromCharCode(97 + i),
'name': 'Venue ${String.fromCharCode(65 + i)}',
'subtitle': 'page ${i ~/ 4 + 1}',
'imageUrl': 'https://picsum.photos/seed/p1b-${String.fromCharCode(97 + i)}/400/300',
},
];
/// The sign-in actions a host must answer — the SDK's own bridge-floor set, held equal
/// to the core by `check:auth-action-parity`. The example only uses it to decide which delay applies.
const _signInActions = {
'social_login', 'login', 'register', 'reset_password', 'magic_link', 'verify_email',
'resend_verification', 'enable_biometric', 'email_login', 'request_otp', 'verify_otp',
'logout', 'change_password', 'set_new_password', 'delete_account', 'update_profile',
};
/// Hands every step a `dataContext` (`hook_data.recommendations`) — what a
/// `{{hook_data.recommendations}}` repeat reads — and logs the lifecycle. [mode] is null when no host
/// data demo is on (the delegate may be registered only for the step-advance delays).
class _HostDataDemoDelegate extends AppDNAOnboardingDelegate {
_HostDataDemoDelegate(
this.mode,
this.log, {
this.signInDelaySeconds,
this.stepAdvanceDelaySeconds,
this.stepAdvanceReply = 'proceed',
this.locationFieldStep = false,
});
final String? mode;
/// Log `AppDNA-E2E location <json|null>` in `onBeforeStepRender` for
/// `step_after`.
final bool locationFieldStep;
final void Function(String) log;
final int? signInDelaySeconds;
final int? stepAdvanceDelaySeconds;
final String stepAdvanceReply;
/// Whether this presentation's first sign-in action has already been delayed.
bool _signInDelayed = false;
/// `showmore`: pages shown so far, per step — page 1 is a–d, every `refresh` adds four (accumulate,
/// at most 20). A revisit answers with the list the user last saw.
final _pages = <String, int>{};
@override
void onOnboardingStarted(String flowId) {
_signInDelayed = false;
log('onboarding started $flowId');
}
@override
Future<Map<String, dynamic>> onBeforeStepAdvance(String flowId, String fromStepId, int stepIndex,
String stepType, Map<String, dynamic> responses, Map<String, dynamic>? stepData) async {
final action = stepData?['action'] as String?;
// No step-advance launch value set → the delegate's default answer, exactly as before
// (the host-data demo registered this delegate without overriding this hook).
if (signInDelaySeconds == null && stepAdvanceDelaySeconds == null) {
return super.onBeforeStepAdvance(flowId, fromStepId, stepIndex, stepType, responses, stepData);
}
if (action != null && _signInActions.contains(action)) {
final delay = signInDelaySeconds;
if (delay == null) {
return super.onBeforeStepAdvance(flowId, fromStepId, stepIndex, stepType, responses, stepData);
}
if (!_signInDelayed) {
_signInDelayed = true;
log('onBeforeStepAdvance($fromStepId, $action) — signing in for ${delay}s');
await Future<void>.delayed(Duration(seconds: delay));
}
log('onBeforeStepAdvance($fromStepId, $action) → proceed');
return {'type': 'proceed'};
}
final delay = stepAdvanceDelaySeconds;
if (delay != null) {
log('onBeforeStepAdvance($fromStepId) — waiting ${delay}s');
await Future<void>.delayed(Duration(seconds: delay));
log('onBeforeStepAdvance($fromStepId) → $stepAdvanceReply');
return {'type': stepAdvanceReply};
}
return super.onBeforeStepAdvance(flowId, fromStepId, stepIndex, stepType, responses, stepData);
}
@override
void onOnboardingStepChanged(String flowId, String stepId, int stepIndex, int totalSteps) =>
log('step changed $stepId ($stepIndex/$totalSteps)');
@override
void onOnboardingCompleted(String flowId, Map<String, dynamic> responses) =>
log('onboarding completed $flowId responses=$responses');
@override
Future<Map<String, dynamic>?> onBeforeStepRender(
String flowId, String stepId, int stepIndex, String stepType, Map<String, dynamic> responses) async {
if (locationFieldStep && stepId == 'step_after') {
final loc = await AppDNA.deepLinks.getLocationData('e2e_location');
log('AppDNA-E2E location ${loc == null ? 'null' : jsonEncode(_locationJson(loc))}');
}
if (mode == null) return null;
final recommendations = mode == 'showmore'
? _showMoreItems(4 * (_pages[stepId] ?? 1))
: mode == 'items'
? _hostDataDemoItems
: const <Map<String, String>>[];
log('onBeforeStepRender($stepId) → dataContext: ${recommendations.length} recommendation(s)');
return {'dataContext': {'recommendations': recommendations}};
}
@override
Future<Map<String, dynamic>?> onElementInteraction(String flowId, String stepId, String blockId,
String action, String? value, Map<String, dynamic> inputValues) async {
log('onElementInteraction($stepId/$blockId, $action, value=${value ?? '<none>'})');
if (mode != 'showmore' || action != 'refresh') return null;
// Slow enough that the tapped button's spinner is visible.
await Future<void>.delayed(const Duration(milliseconds: 1500));
final next = (_pages[stepId] ?? 1) + 1;
final pages = next > 5 ? 5 : next;
_pages[stepId] = pages;
final recommendations = _showMoreItems(4 * pages);
log('onElementInteraction($stepId/$blockId) → dataContext: ${recommendations.length} recommendation(s)');
// The WHOLE list so far (accumulate), under the key the Select repeats over.
return {'dataContext': {'recommendations': recommendations}, 'advance': false};
}
}
/// The snake_case shape the device row greps (every key, null for a missing field).
Map<String, Object?> _locationJson(LocationData l) => {
'formatted_address': l.formattedAddress,
'raw_query': l.rawQuery,
'city': l.city,
'state': l.state,
'state_code': l.stateCode,
'country': l.country,
'country_code': l.countryCode,
'postal_code': l.postalCode,
'latitude': l.latitude,
'longitude': l.longitude,
'timezone': l.timezone,
'timezone_offset': l.timezoneOffset,
};
/// Logs the paywall purchase outcome lines the device rows assert.
class _E2EPaywallDelegate extends AppDNAPaywallDelegate {
_E2EPaywallDelegate(this.log);
final void Function(String) log;
@override
void onPaywallPurchaseStarted(String paywallId, String productId) =>
log('AppDNA-E2E onPaywallPurchaseStarted $productId');
@override
void onPaywallPurchaseCompleted(String paywallId, String productId, Map<String, dynamic> transaction) =>
log('AppDNA-E2E onPaywallPurchaseCompleted $productId ${transaction['transactionId']}');
@override
void onPaywallPurchaseFailed(String paywallId, Object error, String errorType, String? productId) =>
log('AppDNA-E2E onPaywallPurchaseFailed $errorType $productId');
}
/// `appdnaBillingProvider` → the Dart provider. Unknown → null (the SDK default, storeKit2).
AppDNABillingProvider? _billingProvider(String? raw) {
switch (raw) {
case 'storeKit2':
return AppDNABillingProvider.storeKit2;
case 'revenueCat':
return AppDNABillingProvider.revenueCat;
case 'none':
return AppDNABillingProvider.none;
}
if (raw != null && raw.startsWith('adapty:')) return AppDNABillingProvider.adapty(raw.substring(7));
return null;
}
/// The iOS host's own StoreKit calls (`AppDelegate.swift`): a purchase the host does
/// NOT finish, and the `AppDNA-E2E unfinished=<ids>` / `all=<ids>` lines. Android has no equivalent here
/// (its E2E host is the native sample), so the call reports unsupported.
const _hostChannel = MethodChannel('appdna_example/host');
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AppDNA SDK Example',
theme: ThemeData(colorSchemeSeed: const Color(0xFF6366f1), useMaterial3: true),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
// Surface targets injected at build time via --dart-define (no customer IDs
// committed). Fall back to placeholders for source readers.
static const _onboardingId =
String.fromEnvironment('APPDNA_ONBOARDING_ID', defaultValue: 'default');
static const _paywallId =
String.fromEnvironment('APPDNA_PAYWALL_ID', defaultValue: 'default');
static const _paywallId2 =
String.fromEnvironment('APPDNA_PAYWALL_ID_2', defaultValue: 'default');
static const _surveyId =
String.fromEnvironment('APPDNA_SURVEY_ID', defaultValue: 'default');
static const _messageEvent =
String.fromEnvironment('APPDNA_MESSAGE_EVENT', defaultValue: 'session_start');
/// The location device row's flow (a DEV flow, never committed).
static const _definedLocationFlowId = String.fromEnvironment('APPDNA_E2E_LOCATION_FLOW_ID');
/// The permissions device row's flow (a DEV flow, never committed).
static const _definedPermissionsFlowId = String.fromEnvironment('APPDNA_E2E_PERMISSIONS_FLOW_ID');
String? _permissionsFlowId;
String _status = 'Not configured';
String? _launchOnboardingId;
String? _locationFlowId;
String _hostProductId = 'ai.appdna.test.monthly';
final List<String> _log = [];
void _append(String line) {
// ignore: avoid_print
print('[AppDNAExample] $line');
if (mounted) setState(() => _log.add(line));
}
String? _webEntitlement;
String? _deepLink;
@override
void initState() {
super.initState();
_initSdk();
}
Future<void> _initSdk() async {
// 1. Configure SDK. The API key is injected at build time via
// --dart-define=APPDNA_API_KEY=... (D12: no key committed to the example);
// falls back to a placeholder for source readers.
const definedKey =
String.fromEnvironment('APPDNA_API_KEY', defaultValue: 'YOUR_API_KEY');
final launch = await _readLaunchValues();
final apiKey = launch['appdnaApiKey'] ?? definedKey;
_launchOnboardingId = launch['appdnaOnboardingId'];
final demoValue = launch['appdnaHostDataDemo'];
final demo = (demoValue == 'items' || demoValue == 'empty' || demoValue == 'showmore') ? demoValue : null;
final signInDelay = int.tryParse(launch['appdnaSignInDelaySeconds'] ?? '');
final stepDelay = int.tryParse(launch['appdnaStepAdvanceDelaySeconds'] ?? '');
final vetoTimeout = int.tryParse(launch['appdnaVetoTimeout'] ?? '');
final locationFlow = launch['appdnaLocationFlowId'] ??
(_definedLocationFlowId.isEmpty ? null : _definedLocationFlowId);
_locationFlowId = locationFlow;
_permissionsFlowId = launch['appdnaPermissionsFlowId'] ??
(_definedPermissionsFlowId.isEmpty ? null : _definedPermissionsFlowId);
_hostProductId = launch['appdnaHostProductId'] ?? _hostProductId;
if (demo != null || signInDelay != null || stepDelay != null || locationFlow != null) {
AppDNA.onboarding.setDelegate(_HostDataDemoDelegate(
demo,
_append,
signInDelaySeconds: signInDelay,
stepAdvanceDelaySeconds: stepDelay,
stepAdvanceReply: launch['appdnaStepAdvanceReply'] == 'stay' ? 'stay' : 'proceed',
locationFieldStep: locationFlow != null,
));
}
// The paywall outcome lines, always on (they only log).
AppDNA.paywall.setDelegate(_E2EPaywallDelegate(_append));
final provider = _billingProvider(launch['appdnaBillingProvider']);
// `appdnaEnv=sandbox` is set natively only when the base-URL override is configured for this
// build (local-server runs); every other run is production.
final env = launch['appdnaEnv'] == 'sandbox' ? AppDNAEnvironment.staging : AppDNAEnvironment.production;
await AppDNA.configure(
apiKey: apiKey,
env: env,
options: (vetoTimeout == null && provider == null)
? null
: AppDNAOptions(vetoTimeout: vetoTimeout, billingProvider: provider),
);
setState(() => _status = 'Configured');
// The local-server precheck line: the base URL the SDK resolved, read from
// diagnose() (`base_url: <v>`), and the environment this host configured.
final report = await AppDNA.diagnose() ?? '';
final baseUrl = RegExp(r'base_url: (\S+)').firstMatch(report)?.group(1) ?? 'unknown';
_append('AppDNA-E2E base_url=$baseUrl env=${env == AppDNAEnvironment.staging ? 'sandbox' : 'production'}');
if (launch.isNotEmpty) {
_append('wrapper sdkVersion=${await AppDNA.getSdkVersion()} hostDataDemo=${demo ?? 'off'} '
'signInDelay=${signInDelay ?? 'off'} stepAdvanceDelay=${stepDelay ?? 'off'} vetoTimeout=${vetoTimeout ?? 'default'} '
'billingProvider=${launch['appdnaBillingProvider'] ?? 'default'} env=${env.name}');
}
// 2. Identify user. The user id can be overridden at build time via
// --dart-define=APPDNA_USER_ID=... (used to exercise per-user-frequency
// surfaces like in-app messages with a fresh identity).
const userId =
String.fromEnvironment('APPDNA_USER_ID', defaultValue: 'user_123');
await AppDNA.identify(userId, traits: {'email': 'demo@example.com'});
setState(() => _status = 'Identified');
// 3. Check for deferred deep link (first launch)
final link = await AppDNA.checkDeferredDeepLink();
if (link != null) {
setState(() => _deepLink = '${link.screen} (${link.params})');
}
// 4. Listen for web entitlement changes
AppDNA.onWebEntitlementChanged.listen((entitlement) {
setState(() {
_webEntitlement = entitlement != null
? '${entitlement.planName} (${entitlement.status})'
: 'None';
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('AppDNA SDK Example')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_infoCard('SDK Status', _status),
_infoCard('Web Entitlement', _webEntitlement ?? 'Not loaded'),
_infoCard('Deferred Deep Link', _deepLink ?? 'None'),
const SizedBox(height: 24),
if (_launchOnboardingId != null) ...[
FilledButton(
onPressed: () async =>
_append('presentOnboarding → ${await AppDNA.presentOnboarding(_launchOnboardingId!)}'),
child: Text('Present onboarding: $_launchOnboardingId'),
),
const SizedBox(height: 12),
],
if (_locationFlowId != null) ...[
FilledButton(
onPressed: () async =>
_append('presentOnboarding(location) → ${await AppDNA.presentOnboarding(_locationFlowId!)}'),
child: const Text('Location flow'),
),
const SizedBox(height: 12),
],
if (_permissionsFlowId != null) ...[
FilledButton(
onPressed: () async =>
_append('presentOnboarding(permissions) → ${await AppDNA.presentOnboarding(_permissionsFlowId!)}'),
child: const Text('Permissions flow'),
),
const SizedBox(height: 12),
],
// Restore, with the lines the restore rows assert.
OutlinedButton(
onPressed: _restore,
child: const Text('Restore'),
),
// The host's OWN StoreKit purchase, deliberately never finished (iOS), and
// the transaction listing the device rows assert on.
OutlinedButton(
onPressed: () => _host('hostBuy', {'productId': _hostProductId}),
child: Text('Host buy (no finish): $_hostProductId'),
),
OutlinedButton(
onPressed: () => _host('logTransactions', const {}),
child: const Text('Log unfinished transactions'),
),
const SizedBox(height: 12),
for (final line in _log) Text(line, style: const TextStyle(fontSize: 11)),
if (_log.isNotEmpty) const SizedBox(height: 12),
// Track Event
FilledButton.icon(
onPressed: () => AppDNA.track('button_tapped', properties: {'button': 'demo'}),
icon: const Icon(Icons.analytics),
label: const Text('Track Event'),
),
const SizedBox(height: 12),
// Present Paywall
FilledButton.icon(
onPressed: () => AppDNA.presentPaywall(_paywallId),
icon: const Icon(Icons.shopping_cart),
label: const Text('Present Paywall'),
),
const SizedBox(height: 12),
// Present a second paywall (e.g. a winback variant).
FilledButton.icon(
onPressed: () => AppDNA.presentPaywall(_paywallId2),
icon: const Icon(Icons.card_giftcard),
label: const Text('Present Paywall 2'),
),
const SizedBox(height: 12),
// Present Onboarding
FilledButton.icon(
onPressed: () => AppDNA.presentOnboarding(_onboardingId),
icon: const Icon(Icons.rocket_launch),
label: const Text('Present Onboarding'),
),
const SizedBox(height: 12),
// Present Survey
FilledButton.icon(
onPressed: () => AppDNA.showSurvey(_surveyId),
icon: const Icon(Icons.assignment),
label: const Text('Present Survey'),
),
const SizedBox(height: 12),
// Trigger In-App Message (fires the native trigger evaluation)
FilledButton.icon(
onPressed: () => AppDNA.track(_messageEvent),
icon: const Icon(Icons.campaign),
label: const Text('Trigger In-App Message'),
),
const SizedBox(height: 12),
// Remote Config
FilledButton.icon(
onPressed: () async {
final value = await AppDNA.getRemoteConfig('welcome_message');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Remote config: $value')),
);
}
},
icon: const Icon(Icons.settings_remote),
label: const Text('Get Remote Config'),
),
const SizedBox(height: 12),
// Experiment Variant
FilledButton.icon(
onPressed: () async {
final variant = await AppDNA.getExperimentVariant('onboarding_test');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Variant: $variant')),
);
}
},
icon: const Icon(Icons.science),
label: const Text('Get Experiment Variant'),
),
const SizedBox(height: 12),
// Feature Flag
FilledButton.icon(
onPressed: () async {
final enabled = await AppDNA.isFeatureEnabled('dark_mode');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Feature enabled: $enabled')),
);
}
},
icon: const Icon(Icons.flag),
label: const Text('Check Feature Flag'),
),
const SizedBox(height: 12),
// Run Diagnose — returns the SDK health report String on BOTH platforms.
FilledButton.icon(
onPressed: () async {
final report = await AppDNA.diagnose();
if (context.mounted) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('SDK Diagnose'),
content: SingleChildScrollView(
child: Text(report ?? '(null)',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Close'),
),
],
),
);
}
},
icon: const Icon(Icons.health_and_safety),
label: const Text('Run Diagnose'),
),
],
),
);
}
Future<void> _restore() async {
try {
final restored = await AppDNA.billing.restorePurchases();
_append('AppDNA-E2E onRestoreCompleted ${restored.map((e) => e.productId).join(',')}');
} on PlatformException catch (e) {
// The native hosts' format, one token: `restoreFailed <errorType>`, from
// `details['errorType']`, falling back to `unknown` (the exception code is not an error type).
final details = e.details;
final errorType = details is Map ? details['errorType'] : null;
_append('AppDNA-E2E restoreFailed ${errorType ?? 'unknown'}');
} catch (e) {
// Anything that is not a PlatformException carries no error type: the same one-token format.
// The error itself goes to the debug log, so the cause is not lost.
debugPrint('$e');
_append('AppDNA-E2E restoreFailed unknown');
}
}
Future<void> _host(String method, Map<String, Object?> args) async {
try {
_append('$method → ${await _hostChannel.invokeMethod<Object?>(method, args)}');
} on PlatformException catch (e) {
_append('$method ✗ ${e.code} ${e.message}');
} on MissingPluginException {
_append('$method ✗ not available on this platform');
}
}
Widget _infoCard(String title, String value) {
return Card(
child: ListTile(
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(value),
),
);
}
}