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

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

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

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

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
0
points
530
downloads

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

unknown (license)

Dependencies

flutter, meta

More

Packages that depend on uplift_funnel_flutter

Packages that implement uplift_funnel_flutter