uplift_funnel_flutter 0.10.1 copy "uplift_funnel_flutter: ^0.10.1" to clipboard
uplift_funnel_flutter: ^0.10.1 copied to clipboard

PlatformiOS

Native onboarding and paywall flows for iOS, authored in a dashboard and updated without an app release. Rendered by the native engine, so a Flutter app and a native app draw a flow identically. A/B e [...]

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 NSAppTransportSecurityNSAllowsLocalNetworking 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.

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 forwards https, http, mailto, tel and sms to 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_picker plan's product_id (with product_id_ios / product_id_android where the stores differ); the SDK resolves the platform-correct one into PurchaseRequest, so the handler needs no mapping code.

  • Permission results are branchable. The grant lands in the node's save_to variable, so a flow can route notifications_granted == false into a different screen — the SDK advances either way by default (advance_on_result: false turns 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 next startExperiment call; a flow already on screen keeps the variant it started with.
  • Offline. A cached flow renders immediately and result.experiment reports 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 persistent anonymous_id is 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 same userId you pass to RevenueCat's app_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.

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 lottie node renders its static poster image — the SDK stays free of a player dependency.
  • paywall_handoff is a placeholder. Build paywalls with plan_picker plus a purchase button (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.

1
likes
140
points
530
downloads

Documentation

API reference

Publisher

verified publisherupliftfunnel.com

Weekly Downloads

Native onboarding and paywall flows for iOS, authored in a dashboard and updated without an app release. Rendered by the native engine, so a Flutter app and a native app draw a flow identically. A/B experiments and conversion analytics included.

Repository (GitHub)
View/report issues

Topics

#onboarding #funnel #sdk #oaas

License

Apache-2.0 (license)

Dependencies

flutter, meta

More

Packages that depend on uplift_funnel_flutter

Packages that implement uplift_funnel_flutter