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/.
Pointing a debug build somewhere else
configure talks to the production API unless you hand it a serverUrl, and a
release build cannot carry one — passing it there traps, deliberately, so a
staging URL left behind fails on your machine rather than sending real users'
events somewhere nobody is reading.
Keep it out of your source and read it from the build:
const server = String.fromEnvironment('UPLIFT_SERVER_URL');
await UpliftFunnel.configure(
apiKey: 'fnl_pk_…',
serverUrl: server.isEmpty ? null : server,
);
flutter run --dart-define=UPLIFT_SERVER_URL=http://localhost:3000
Reaching an http://localhost API also needs App Transport Security to allow
it — add NSAppTransportSecurity → NSAllowsLocalNetworking to
ios/Runner/Info.plist, as example/ does.
Store prices — setProducts
A paywall renders without this, using the prices someone typed into the
dashboard. Those are a preview and an offline fallback, not what the store will
charge. Hand the SDK your real catalog and every {{product.*}} token on a plan
card resolves to store truth:
await UpliftFunnel.setProducts([
UpliftFunnelProduct(
id: 'yearly_pro', // must match the plan's product_id
price: r'$59.99', // exactly as the store formatted it
priceAmount: 59.99, // lets per-month / per-week be derived
currencyCode: 'USD',
period: ProductPeriod.year,
trialDays: 7,
trialEligible: await isEligibleForIntroOffer(), // only you can know
originalPrice: r'$155.88', // the struck-through one
savings: 'Save 61%', // your words, your language
),
]);
Call it after your billing stack has offerings, and again on refresh — it
replaces the catalog rather than merging. Each product also fans out into
<field>.<id> variables at session start, so authored copy interpolating
{{price.yearly_pro}} resolves and a flow condition can branch on
trial_eligible.yearly_pro.
savings is the one field the SDK cannot derive well: it is a phrase, in your
user's language. Leave it out and the engine falls back to an English
Save NN% computed from originalPrice and priceAmount, and shows nothing if
it cannot.
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. The same thing can be written as
UpliftFunnelFlow('paywall-copy-test', experiment: true); the named
constructor exists to make the key's meaning obvious at the call site.
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.
Reading the answers — the profile
Answers are readable while the flow is still on screen, and after it ends:
if (await UpliftFunnel.profileGet('goal') == 'muscle') showStrengthTab();
final answers = await UpliftFunnel.profileAll(); // {'goal': 'muscle', …}
UpliftFunnel.profileChanges.listen((change) { // react as they arrive
debugPrint('${change.key} = ${change.value ?? 'cleared'}');
});
Values persist across app launches and are cleared by resetIdentity(). Only
answers are stored — variable defaults, {{product.*}} values and variables you
pass in yourself are not. Values marked sensitive in the dashboard are readable
here and still never uploaded.
Letting the dashboard decide when a flow appears
Instead of choosing the moment yourself, register a presenter and let the rules in the dashboard choose. Nothing is asked of the server until you register one.
UpliftFunnel.registerPresenter((request) async {
if (!mounted) return false; // can't show it right now — say so
await showModalBottomSheet<void>(
context: context,
builder: (_) => UpliftFunnelFlow(request.flowKey),
);
return true;
});
Returning false is a real answer: the SDK records that nothing was shown, so
the trigger can be offered again later instead of being counted as delivered.
For a card or a banner inside one of your own screens, place a slot where you want it and give it a size. It renders nothing until something is decided for that slot id:
Column(
children: [
const SizedBox(height: 120, child: UpliftFunnelSlot(slotId: 'home_top')),
...the rest of your screen,
],
)
The SDK asks when your app comes to the front, when you track() an event a
rule mentions, and when a flow ends — at most once a minute. It never draws
anything into a place you did not make.
When a flow runs a model
A flow can analyse a photo the user just gave it and carry on with the result. That runs on the server; what your app owns is the photo.
Most apps need nothing here. The SDK reads file:// URLs, absolute paths
and data: URIs on its own, which is what a photo picker usually returns.
Register a resolver only when yours hands back something only your app can read
— an asset id in your own store, a cache key:
UpliftFunnel.registerInferenceMediaResolver((reference) async {
return myAssetStore.bytesFor(reference); // null if you don't know it either
});
Returning null is a real answer: the flow applies its own fallback and carries
on rather than stalling.
Before any photo leaves the device the SDK checks it, and a photo that fails makes no network call at all. The defaults are the safe ones; the one worth changing is the face check, which is off because a meal photo sent for calorie estimation is a legitimate analysis a face check would refuse:
UpliftFunnel.setInferencePreflight(
const InferencePreflight(requiresFace: true), // selfie flows
);
The others are size limits: minimumDimension (200) rejects an image too small
to analyse, maximumDimension (1536) downscales before upload,
maximumBytes (8 MB) is the ceiling after that, and compressionQuality (0.8)
is the JPEG quality used. Face detection is presence-only — the result is
discarded the moment it is counted, and no face template is computed.
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(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.
Libraries
- uplift_funnel_flutter
- Uplift Funnel Flutter SDK — native onboarding and paywall flows you can change without shipping an app update.