uplift_funnel_flutter 0.6.0
uplift_funnel_flutter: ^0.6.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.
uplift_funnel_flutter #
Native onboarding and paywall flows you can change without shipping an app update — no WebView.
Add the SDK, point it at a flow you built in the dashboard, and it renders as native widgets. Changing the flow afterwards doesn't need an app release.
Install #
Requires Flutter 3.16+ / Dart 3.4+.
flutter pub add uplift_funnel_flutter
Quickstart #
import 'package:flutter/material.dart';
import 'package:uplift_funnel_flutter/uplift_funnel_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await UpliftFunnel.configure(apiKey: 'fnl_pk_…'); // one key, from the dashboard
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: UpliftFunnelFlow(
'my-onboarding', // flow key from the dashboard
onCompleted: (result) {
// result.endReason ("completed" / "abandoned" / …) +
// result.variables (everything the user answered)
},
),
);
}
}
A complete demo lives in example/.
Native handoffs — sign-in, permissions, purchases, links #
Flow JSON declares what happens on a screen (a sign-in gate, a permission
ask, a paywall CTA, a Terms link). The host app decides how via optional
global handlers, registered once after UpliftFunnel.configure. You wire them one
at a time; a flow is navigable before any of them exist.
Where a handler is missing the SDK degrades honestly rather than faking a result — a permission ask shows a stand-in dialog so the deny branch stays reachable, and a photo tile stays inert instead of storing a placeholder.
| Handler | Fires when | Return value |
|---|---|---|
registerSignInHandler |
a signin node's provider button is tapped (apple, google, facebook, email, anonymous) |
true → provider id saved to the node's save_to variable + flow advances; false → stays put |
registerPermissionHandler |
a permission node's CTA is tapped (notifications, health, camera, calendar, tracking, …) |
grant result saved as "true"/"false"; flow advances either way (branch on the variable in transitions) |
registerPurchaseHandler |
a button with action: "purchase" is tapped — receives a PurchaseRequest (plan id, platform-resolved store product id, flow/screen/session ids) |
a PurchaseResult; only purchased advances. Each outcome is tracked as its own purchase_* event |
registerRestoreHandler |
a restore button or [Restore](restore) link is tapped |
true → flow advances; false → no-op |
registerPhotoUploadHandler |
a photo_upload tile is tapped — receives a PhotoUploadRequest (source, shape) |
a reference to store in the node's variable (path, asset id, URL), or null if the user cancelled |
registerLinkHandler |
a markdown link [label](url) or a url: button is tapped |
— (open the URL) |
Real-world wiring (packages: sign_in_with_apple, google_sign_in,
permission_handler, purchases_flutter, url_launcher):
UpliftFunnel.registerSignInHandler((provider) async {
switch (provider) {
case 'apple':
final cred = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
await myBackend.signInWithApple(cred.identityToken!);
return true;
case 'google':
final account = await GoogleSignIn().signIn();
return account != null; // null = user dismissed the sheet
default:
return false;
}
});
UpliftFunnel.registerPermissionHandler((permission) async {
final p = switch (permission) {
'notifications' => Permission.notification,
'camera' => Permission.camera,
'photos' => Permission.photos,
'location' => Permission.locationWhenInUse,
'calendar' => Permission.calendarFullAccess,
'tracking' => Permission.appTrackingTransparency,
_ => null,
};
if (p == null) return false;
return (await p.request()).isGranted;
});
UpliftFunnel.registerPurchaseHandler((request) async {
final productId = request.productId; // platform-resolved by the SDK
if (productId == null) return PurchaseResult.failed;
try {
final offerings = await Purchases.getOfferings();
final pkg = offerings.current?.availablePackages
.firstWhere((p) => p.identifier == productId);
if (pkg == null) return PurchaseResult.failed;
await Purchases.purchasePackage(pkg);
return PurchaseResult.purchased; // the only result that advances
} on PlatformException catch (e) {
return PurchasesErrorHelper.getErrorCode(e) ==
PurchasesErrorCode.purchaseCancelledError
? PurchaseResult.cancelled
: PurchaseResult.failed;
}
});
UpliftFunnel.registerPhotoUploadHandler((request) async {
final file = await ImagePicker().pickImage(
source: request.source == 'camera' ? ImageSource.camera : ImageSource.gallery,
);
return file?.path; // null = cancelled, previous answer kept
});
UpliftFunnel.registerLinkHandler(
(url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
);
Three design points worth knowing:
-
Link schemes are allow-listed. A
url:href is authored content that arrives over the network, so the SDK only forwardshttps,http,mailto,telandsmsto your handler — everything else is dropped before it runs. To drive your own deep links from a flow, opt the scheme in:UpliftFunnel.registerLinkHandler( open, allowedSchemes: {...kDefaultAllowedLinkSchemes, 'myapp'}, ); -
Product ids come from the dashboard. Set each
plan_pickerplan'sproduct_id(withproduct_id_ios/product_id_androidwhere the stores differ); the SDK resolves the platform-correct one intoPurchaseRequest, so the handler needs no mapping code. -
Permission results are branchable. The grant lands in the node's
save_tovariable, so a flow can routenotifications_granted == falseinto a different screen — the SDK advances either way by default (advance_on_result: falseturns that off). -
Gated CTAs. A button can carry
enabled_when; it renders inactive and swallows taps until the condition holds. The same evaluator drives transitions, so the button and the rule behind it always agree.
A/B experiments #
Run a flow behind a dashboard experiment and the SDK buckets each user into a sticky variant, renders the chosen variant's flow, and tags analytics so the dashboard can attribute conversions directly. It's a one-line change from a plain flow:
UpliftFunnelFlow.experiment(
'paywall-copy-test', // the experiment key, not a flow key
onCompleted: (result) {
// Typed assignment — null for a non-experiment flow.
final variant = result.experiment; // UpliftFunnelExperimentAssignment?
analytics.log('onboarding_done', properties: {
'experiment_id': variant?.experimentId,
'variant_id': variant?.variantId,
'variant_name': variant?.variantName,
});
},
)
Loading / error / retry UX is identical to the default UpliftFunnelFlow
constructor — only the routing differs. For lower-level control, call
UpliftFunnel.startExperiment('key'), which returns a FlowSessionStart whose
.experiment field carries the same typed assignment.
What a user experiences #
- Sticky. Once someone is assigned a variant they keep it — across app
restarts and, after
identify(), across their devices. They never flip mid-experiment. identify()mid-flow changes who gets bucketed on the nextstartExperimentcall; a flow already on screen keeps the variant it started with.- Offline. A cached flow renders immediately and
result.experimentreports the variant that user was already on. - Events from an experiment session are tagged with the experiment and variant, so the dashboard can report per variant without any work on your side.
Leaving it in production after a decision #
You do not need to swap UpliftFunnelFlow.experiment back to UpliftFunnelFlow when
an experiment ends — the server handles the lifecycle:
- Stopped → the endpoint serves the baseline variant (no header /
result.experiment == null). - Rolled out → the endpoint serves the winning variant to everyone. Rollout is terminal, so the winner is what ships from then on.
So the safe pattern is: ship UpliftFunnelFlow.experiment(...), run the experiment,
pick a winner in the dashboard, and leave the app code exactly as is.
Full-funnel analytics — identity, tracking & attribution #
The SDK reports the whole journey from anonymous onboarding through revenue, so the dashboard can show completion → activation → trial → paid per variant.
- Anonymous by default. From the first
configure()a persistentanonymous_idis generated and attached to every event — no setup needed. UpliftFunnel.identify(userId:)links that anonymous device to your authenticated user. Call it right after login. Persisted across restarts. Use the sameuserIdyou pass to RevenueCat'sapp_user_id— that link is what attributes revenue back to the onboarding a user saw.UpliftFunnel.resetIdentity()on logout: clears the user and rotates the anonymous id (the next person on the device is a new subject).UpliftFunnel.track(name, {properties})records custom conversion events (e.g. your activation event) from anywhere — inside a flow or not. Names must match^[a-z][a-z0-9_:]*$. Pick the activation event name in the dashboard's App config → Conversion.UpliftFunnel.setAttribution({...})stores acquisition context (source/campaign/ad_set/creative) that rides along on every event.configure(appVersion:)— pass your app version string so it appears in the event context (the SDK stays dependency-free and can't read it itself).configure(bundleId:)— recommended: pass your app's bundle id / application id. It lets your key be locked to your app, so a leaked key is useless elsewhere. With package_info_plus use(await PackageInfo.fromPlatform()).packageName.
await UpliftFunnel.configure(
apiKey: 'fnl_pk_…',
appVersion: '2.4.0',
bundleId: 'com.example.myapp', // or (await PackageInfo.fromPlatform()).packageName
);
await UpliftFunnel.setAttribution({'source': 'meta_ads', 'campaign': 'summer'});
// after your login:
await UpliftFunnel.identify(userId: currentUser.id); // == RevenueCat app_user_id
// an activation event:
await UpliftFunnel.track('first_workout_completed', properties: {'type': 'beginner'});
// on logout:
await UpliftFunnel.resetIdentity();
Reporting never blocks app startup or onboarding, and nothing is lost if the app is killed or the device is offline. Revenue comes from your RevenueCat webhook (set up on the dashboard's Integrations page), not from the SDK.
Consent and what leaves the device #
Your code always gets every answer. onCompleted hands you the full
variable map — it's your user's data. What follows is only about what reaches
the Uplift API.
Mark a variable Private in the dashboard and the SDK reports it as answered, never as its content. Use it for anything that identifies a person or describes their body — name, email, phone, birth date, weight. Leave it off for the bounded answers segmentation runs on (choice, rating, scale, toggle), which keep their values. The server derives the flag from the input that writes each variable, so you don't have to go back through existing flows.
Two levers on the SDK side:
await UpliftFunnel.configure(
apiKey: 'fnl_pk_…',
// Start with analytics off and turn it on when the user consents.
trackingEnabled: false,
// Redact these too, whatever the flow says — handy for a flow you haven't
// re-authored, or for values you pass in via userVariables.
redactVariables: {'referral_note'},
);
UpliftFunnel.setTrackingEnabled(true); // consent granted
Turning tracking off drops whatever is already queued rather than holding it for later. Flows still fetch and render while it's off — gating that on consent would leave you with a blank screen instead of an onboarding.
Known limitations #
- A
lottienode renders its static poster image — the SDK stays free of a player dependency. paywall_handoffis a placeholder. Build paywalls withplan_pickerplus apurchasebutton (see the native handoffs above).- A node type this SDK version doesn't know renders as a labelled placeholder instead of failing the screen, so a newer flow degrades rather than breaking.
Development #
# Tests
flutter test
# Static analysis
flutter analyze
# Run the demo
cd example
flutter run
License #
Apache 2.0 — see LICENSE.