uplift_funnel_flutter 0.7.0
uplift_funnel_flutter: ^0.7.0 copied to clipboard
Native Flutter onboarding and paywall flows, authored in a dashboard and updated without an app release. Includes A/B experiments and conversion analytics.
example/lib/main.dart
// Demo host for the Uplift Funnel SDK.
//
// Nothing is baked in at build time: type a server URL, a public key
// (`fnl_pk_…` from the dashboard) and a flow key, then hit Start. One build
// points at any flow. The fields persist, so a hot restart keeps them.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uplift_funnel_flutter/uplift_funnel_flutter.dart';
/// Global navigator so the handlers below can put a dialog on screen — they
/// fire from inside the SDK, which has no `BuildContext` to hand them.
final navigatorKey = GlobalKey<NavigatorState>();
void main() => runApp(const UpliftFunnelExampleApp());
class UpliftFunnelExampleApp extends StatelessWidget {
const UpliftFunnelExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
title: 'Uplift Funnel demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFF6B35)),
useMaterial3: true,
),
home: const DemoHome(),
);
}
}
class DemoHome extends StatefulWidget {
const DemoHome({super.key});
@override
State<DemoHome> createState() => _DemoHomeState();
}
class _DemoHomeState extends State<DemoHome> {
// Android emulator can't see the host's `localhost` — use 10.0.2.2 there.
final _serverUrl = TextEditingController(text: 'http://localhost:3000');
final _apiKey = TextEditingController();
final _flowKey = TextEditingController(text: 'cal-ai-clone');
bool _experiment = false;
bool _forceRefresh = false;
bool _handlersRegistered = false;
String _lastResult = '—';
@override
void initState() {
super.initState();
// Rebuild as the fields change so Start enables/disables with them.
for (final controller in [_serverUrl, _apiKey, _flowKey]) {
controller.addListener(() => setState(() {}));
}
_restore();
}
@override
void dispose() {
_serverUrl.dispose();
_apiKey.dispose();
_flowKey.dispose();
super.dispose();
}
Future<void> _restore() async {
final prefs = await SharedPreferences.getInstance();
_serverUrl.text = prefs.getString('demo.serverUrl') ?? _serverUrl.text;
_apiKey.text = prefs.getString('demo.apiKey') ?? '';
_flowKey.text = prefs.getString('demo.flowKey') ?? _flowKey.text;
}
Future<void> _persist() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('demo.serverUrl', _serverUrl.text.trim());
await prefs.setString('demo.apiKey', _apiKey.text.trim());
await prefs.setString('demo.flowKey', _flowKey.text.trim());
}
bool get _canStart =>
_apiKey.text.trim().isNotEmpty && _flowKey.text.trim().isNotEmpty;
Future<void> _start() async {
await _persist();
await _configureSdk();
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _FlowScreen(
flowKey: _flowKey.text.trim(),
experiment: _experiment,
forceRefresh: _forceRefresh,
onCompleted: _showResult,
),
),
);
}
void _showResult(UpliftFunnelFlowResult result) {
final variables = result.variables.entries
.map((e) => '${e.key}=${e.value}')
.toList()
..sort();
setState(() {
_lastResult = [
'${result.endReason} · ${result.source.name}',
if (result.experiment != null)
'experiment: ${result.experiment!.experimentId}:'
'${result.experiment!.variantName ?? result.experiment!.variantId}',
...variables,
].join('\n');
});
navigatorKey.currentState?.pop();
}
Future<void> _configureSdk() async {
// Debug-only hook, set before configure. Empty means production.
final serverUrl = _serverUrl.text.trim();
UpliftFunnel.debugServerUrl = serverUrl.isEmpty ? null : serverUrl;
await UpliftFunnel.configure(
apiKey: _apiKey.text.trim(),
appVersion: '1.0.0-example',
);
if (_handlersRegistered) return;
_handlersRegistered = true;
// ── Native handoffs ──────────────────────────────────────────────────
// The flow JSON declares WHAT happens (a signin gate, a permission ask, a
// paywall CTA, a Terms link); these handlers are HOW your app does it.
// Every one is optional — wire them one at a time.
//
// This demo answers each with a confirm dialog rather than faking success,
// so the deny/cancel branches of a flow are walkable before you've wired a
// real integration. In a real app you'd call the commented-out API.
UpliftFunnel.registerSignInHandler((provider) async {
// e.g. SignInWithApple.getAppleIDCredential(…) when provider == 'apple'
return _confirm('Sign in with $provider?');
});
UpliftFunnel.registerPermissionHandler((permission) async {
// e.g. (await Permission.notification.request()).isGranted
return _confirm('Grant $permission permission?');
});
UpliftFunnel.registerPurchaseHandler((request) async {
// e.g. Purchases.purchaseStoreProduct(…) — map cancel/failure onto the
// matching PurchaseResult so the user stays on the paywall and the
// drop-off is recorded.
final bought = await _confirm(
'Purchase "${request.productId ?? request.planId ?? '—'}" '
'(plan ${request.planId ?? '—'})?',
confirmLabel: 'Buy',
);
return bought ? PurchaseResult.purchased : PurchaseResult.cancelled;
});
UpliftFunnel.registerRestoreHandler(() async {
// e.g. Purchases.restorePurchases() — return whether an active
// entitlement actually came back.
return _confirm('Restore purchases?', confirmLabel: 'Restore');
});
UpliftFunnel.registerPhotoUploadHandler((request) async {
// e.g. (await ImagePicker().pickImage(source: …))?.path
final picked = await _confirm(
'Pick a photo (source ${request.source}, ${request.shape})?',
confirmLabel: 'Pick',
);
return picked ? 'demo://photo' : null;
});
UpliftFunnel.registerLinkHandler((url) {
// e.g. launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication).
// The demo only reports it, so tapping Terms mid-flow doesn't kick you
// out of the app.
final context = navigatorKey.currentContext;
if (context == null) return;
ScaffoldMessenger.maybeOf(context)
?.showSnackBar(SnackBar(content: Text('Would open $url')));
});
// Fake catalog so {{price.…}} interpolation and plan_picker auto-binding
// light up. A real app maps its billing SDK's offerings here.
UpliftFunnel.setProducts(const [
UpliftFunnelProduct(
id: 'yearly_pro',
price: r'$59.99',
priceAmount: 59.99,
period: ProductPeriod.year,
trialDays: 7,
trialEligible: true,
),
UpliftFunnelProduct(
id: 'monthly_pro',
price: r'$9.99',
priceAmount: 9.99,
period: ProductPeriod.month,
),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Uplift Funnel')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_label('SERVER'),
_field(_serverUrl, 'Server URL'),
const SizedBox(height: 12),
_field(_apiKey, 'API key (fnl_pk_…)'),
const SizedBox(height: 24),
_label('FLOW'),
_field(_flowKey, 'Flow key'),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Experiment'),
subtitle: const Text('Route through UpliftFunnelFlow.experiment'),
value: _experiment,
onChanged: (v) => setState(() => _experiment = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Force refresh'),
subtitle: const Text('Bypass the cache and refetch'),
value: _forceRefresh,
onChanged: (v) => setState(() => _forceRefresh = v),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _canStart ? _start : null,
icon: const Icon(Icons.play_arrow),
label: const Text('Start flow'),
),
const SizedBox(height: 24),
_label('LAST RESULT'),
Text(
_lastResult,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
],
),
),
);
}
Widget _label(String text) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text,
style: const TextStyle(
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
fontSize: 12,
),
),
);
Widget _field(TextEditingController controller, String label) => TextField(
controller: controller,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
);
}
/// Stand-in for a real OS dialog / auth sheet / purchase sheet. Suspends until
/// the user answers, so both branches of a flow are reachable.
Future<bool> _confirm(String message, {String confirmLabel = 'Allow'}) async {
final context = navigatorKey.currentContext;
if (context == null) return false;
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text("Don't Allow"),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
),
);
return ok ?? false;
}
/// Hosts the flow full-screen. Its own screen so each run starts a fresh
/// session.
class _FlowScreen extends StatelessWidget {
const _FlowScreen({
required this.flowKey,
required this.experiment,
required this.forceRefresh,
required this.onCompleted,
});
final String flowKey;
final bool experiment;
final bool forceRefresh;
final void Function(UpliftFunnelFlowResult result) onCompleted;
@override
Widget build(BuildContext context) {
// UpliftFunnelFlow is full-bleed: it paints its own background edge-to-edge
// and runs its own SafeArea. Don't wrap it in SafeArea or padding — that
// insets the flow and exposes the Scaffold background as strips.
return Scaffold(
body: experiment
? UpliftFunnelFlow.experiment(
flowKey,
forceRefresh: forceRefresh,
onCompleted: onCompleted,
)
: UpliftFunnelFlow(
flowKey,
forceRefresh: forceRefresh,
onCompleted: onCompleted,
),
);
}
}