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

Changelog #

0.10.1 — The inference API gets its documentation #

No behaviour change and no API change. registerInferenceMediaResolver and setInferencePreflight went out in 0.10.0 with no mention anywhere a host would look, and pub.dev renders the README from the published archive rather than from the repository — so the only way to correct that is a release.

The preflight is the part worth reading: it decides what is checked before a user's photo leaves the device, and a photo that fails makes no network call at all. requiresFace is off by default, because a meal photo sent for calorie estimation is a legitimate analysis a face check would refuse.

0.10.0 — Your app stops naming flows #

UpliftFunnel.registerPresenter. Until now the host decided which flow to show and when, which meant adding a flow was a code change. Register a presenter and the server answers that question instead: it asks when your app comes to the foreground, when you track an event a rule mentions, and when a flow ends — at most once a minute, and never at all until you register one.

UpliftFunnel.registerPresenter((request) async {
  if (!mounted) return false;              // not a good moment
  await showModalBottomSheet<void>(
    context: context,
    builder: (_) => UpliftFunnelFlow(request.flowKey),
  );
  return true;
});

It takes false for an answer. A host mid-checkout says so, and the engine records that nothing was shown rather than assuming it did — the trigger's frequency cap is not spent on a screen nobody saw, so the same flow can be offered again later. No handler at all answers false for the same reason.

UpliftFunnelSlot. For content that belongs inside one of your screens rather than over it — a card on a home tab, a banner above a list. Give it the slot id you chose in the dashboard; a decision for any other slot is not shown there. It hosts the engine's own slot rather than reimplementing it, and it is empty most of the time, which is also what it draws where the engine does not run.

A flow can run a model. registerInferenceMediaResolver and setInferencePreflight are the two things the engine cannot do on its own. The resolver only matters if your photo picker hands back an id that only your asset store understands — the engine already reads file:// URLs, absolute paths and data: URIs, so most hosts never register one. Returning null means "I do not know this either", which lands the flow on its inference fallback instead of throwing across the channel mid-upload. setInferencePreflight exposes what is checked before a photo leaves the device; the defaults are safe.

UpliftFunnel.profileGet / profileAll / profileChanges. Until now the answers reached your app in exactly one place: the completion callback, once, at the end. Branching your own content on "which goal did they pick" meant catching that callback and keeping your own copy. Now you can read an answer the moment it is given, and after the app restarts.

if (await UpliftFunnel.profileGet('goal') == 'muscle') showStrengthTab();

Only answers are stored — variable defaults, {{product.*}} values and variables you passed in through userVariables are not answers, and keeping them out is what makes profileAll() mean something. Values persist across launches, are cleared by resetIdentity(), and keep filling while analytics is off: consent governs what leaves the device, not what your app may know about its own user. Values marked sensitive in the dashboard are readable here and still never uploaded.

purchase_succeeded carries the price. price_amount and currency_code come from the catalog you publish through setProducts — so a paywall's revenue shows up in the dashboard even before a billing provider is connected. Only on the successful stage, and absent entirely when the product was never published: the SDK does not ask the store, and a guessed price would be worse than a gap. Your billing provider's webhooks stay the authoritative record.

Screen render timing. The engine now reports how long each screen took to paint and to settle, so a flow that got slower shows which screen did it. Nothing to wire up.

Breaking: UpliftFunnelFlowResult.experiment is a class, not a record. UpliftFunnelExperimentAssignment replaces ({String experimentId, String variantId}), and carries variantName — the one field of the three a human reads. Field access is unchanged, so result.experiment?.variantId still compiles; annotating the type or destructuring the record does not. A record cannot grow a field without breaking every host that took it apart, which is the reason to stop being one now rather than at the next addition.

A pinned footer landed under the home indicator. SafeArea.bottom was declared in the engine and never called, so the solver was handed a body that ran to the physical bottom of the display, and a CTA authored "24pt from the bottom" drew inside the 34pt indicator band on every notched device. The dashboard reserved that inset all along — which is what made this hard to see instead of obvious: it looked right in the preview and wrong on the phone. The screen's own background still reaches the bottom of the display, so a dark paywall keeps its fill rather than showing a pale strip.

UpliftFunnelProduct.savings. The phrase a plan card's badge shows, in your words and your language (Save 44%, %44 tasarruf). Only you can localize it; absent, the engine derives an English Save NN% from originalPrice and priceAmount, and shows nothing when it cannot. iOS has had this field, and product.card binds it — so the badge rendered on a native app and blank on a Flutter one.

kDefaultAllowedLinkSchemes is exported, so opting a scheme in reads as {...kDefaultAllowedLinkSchemes, 'myapp'} — which is what the README has always shown, against a constant that was not public. A test reads the set out of the engine's LinkPolicy.swift and fails if the two drift.

The example wires every host handoff. Purchase, restore, products, sign-in, permission, photo and link, plus identity, analytics and an A/B run, each appending to an on-screen log so walking a flow shows which screen triggers what. Its "Server URL" field is gone: the override is compiled in (--dart-define=UPLIFT_SERVER_URL) and empty means production, which is what a published build should do with no help.

0.8.2 — Five things the engine drew wrong #

All five were found by walking one real flow on a device, and in every case the document was correct and the renderer was not. Four share a shape: a field the schema declares, the API serves, and the engine never reads.

A multi-select behaved as a single-select. The document says behavior.group.mode: "multi"; the engine read a multi boolean that no document has ever contained, so every tap replaced the previous answer. Fixing that exposed the second half — the selected state compared the stored answer to the option's value, and no JSON array is string-equal to one of its own members, so no card in a multi group could light up either. min and max are read now as well: a group at max refuses a further tap rather than evicting an answer the user chose.

A group could be completely unselectable. The tap wrote to the group's bind.save_to and the selected state read back the group's NAME. Where an author used the same string for both — most groups — it worked by coincidence; where they differed, the row could be tapped all day and never change. Both sides resolve one key now. The same mistake was in the purchase path, which is where it cost money: the plan being bought was resolved by group name and by scalar equality, so a paywall could charge for whichever plan carried default: true instead of the one the user selected.

Every paywall printed {{product.price}}. setProducts was reaching the flat price.<id> variables the whole time, so this looked wired; what was missing is that the renderer's product map was declared, passed down, and never assigned. A plan card scopes {{product.*}} from that map. Seventeen unbreakable characters also squeeze the card's flexible column to nothing, which is what made those rows tall and empty rather than merely wrong. An unresolved product token now draws as nothing — braces are never shown to a user.

A loading screen never advanced. behavior.countdown.on_complete has been in the schema, in the templates and in served documents since v3, and no renderer fired it: a screen whose only exit was its timer hung until the user force-quit. It fires once now, and a stale timer from a screen the user has already left is dropped. A missing, zero or negative duration_ms falls back to three seconds rather than completing on arrival.

countdown.done comes with it — published once the timer reaches zero, absent while it runs — so a CTA on a loading screen can carry visible: { when: { var: "countdown.done", op: "is_set" } } and appear only when the wait is over. Note it is scoped to the countdown's subtree, like {{countdown.minutes}}, so the behavior.countdown has to be on an ancestor of the button.

Images with a token in their URL never loaded. They were fetched under the literal {{…}} and looked up after substitution; a localized { "key": … } URL was dropped entirely. One expression produces both keys now.

Also: visible.when accepts the operator names the schema actually defines — is_not_set (which the engine spelled not_set), not_contains, and >, <, >=, <=. Every one of them previously fell through to "show the node", so a condition using them did nothing at all.

And the version this SDK reports in analytics was two releases behind: the constant said 0.7.1 while the package was 0.8.1 and the podspec said 0.8.0. All four now move together, with a test that says so.

0.8.1 — The host handoffs are back, and 0.8.0 is retracted #

0.8.0 could not charge anyone. The engine has seven hooks for the things only an app can do — take a payment, restore one, sign a user in, ask for a permission, pick a photo, open a link — and the bridge forwarded none of them. A paywall rendered, the buy button advanced the flow, and nothing was charged. restore no-opped, which App Store review expects to work on any screen that sells a subscription. The README documented all seven the whole time; they had no Dart side to call.

0.8.0 has been retracted on pub.dev. Upgrade to this version.

The correction is also to the 0.8.0 note here, which said the engine "buys, uploads photos, themes itself and plays video natively — none of it needed a Dart counterpart." Half true, and the wrong half: the engine does those things through the host, and a Flutter host had no way to offer itself.

await UpliftFunnel.configure(apiKey: 'fnl_pk_…');

await UpliftFunnel.setProducts([
  UpliftFunnelProduct(
    id: 'yearly_pro', price: r'$59.99', priceAmount: 59.99,
    period: ProductPeriod.year, trialDays: 7, originalPrice: r'$119.88',
  ),
]);

await UpliftFunnel.registerPurchaseHandler((request) async {
  final ok = await myBilling.buy(request.productId!);
  return ok ? PurchaseResult.purchased : PurchaseResult.cancelled;
});
await UpliftFunnel.registerRestoreHandler(() => myBilling.restore());

setProducts is what makes a paywall show the store's prices instead of the ones typed into the dashboard: each product fans out into price.<id>, price_per_month.<id>, trial_days.<id>, original_price.<id> and trial_eligible.<id>, so authored copy interpolates them and a plan card with a matching product_id binds on its own.

Also here: registerSignInHandler, registerPermissionHandler, registerPhotoUploadHandler and registerLinkHandler — the last taking an optional allowedSchemes, since a url: action carries an href that arrived over the network and the engine filters it before your opener sees it.

Two behaviours worth knowing before you ship, both the SDK's and both documented on the methods: with no purchase handler purchase advances, and with no sign-in handler the tap counts as a success. They exist so an unwired flow can be walked. Neither is a state to release in.

Handlers registered before configure are held and installed when it runs, so wiring them next to your billing setup works.

0.8.0 — The engine is native (breaking) #

This package stops being a second implementation of the product and becomes a window onto the first. UpliftFunnelFlow hosts the native engine in your widget tree, so a Flutter app and a native app render a flow with the same code and cannot drift.

They had drifted. Two renderers meant two chances to be right about the same document, and a type ramp was wrong in four of six roles without a single test failing — each suite was asserting its own implementation. 11,761 lines of Dart are gone: the renderer, the flow engine, the condition evaluator, the fetch/cache path and the event uploader. What replaced them is the Swift code that already existed and already had to be correct.

What changes for you

  • UpliftFunnelFlow('flow-id', onCompleted: …) is the whole surface. The exports went from twelve to three.
  • Gone with the Dart engine: UpliftFunnelFlowView, the purchase and product delegates, PhotoUploadHandler, FunnelTheme, the session object and the model types. The engine buys, uploads photos, themes itself and plays video natively — none of it needed a Dart counterpart.
  • A Flutter host can no longer read session state synchronously, because there is no session on this side. Answers arrive when the flow ends, in UpliftFunnelFlowResult.variables. This is the named cost of removing the duplication, and the only migration that isn't a rename.
  • Four dependencies went too — http, shared_preferences, video_player and google_fonts. A host app inherits one transitive dependency instead of five; google_fonts in particular was pushing a Flutter floor onto apps that never asked for it.

iOS only, loudly. An unsupported platform throws UpliftFunnelUnsupportedPlatform with an explanation rather than failing deeper — guard with UpliftFunnel.isSupported. Android is a separate program, not a fallback.

configure's serverUrl is documented for what it does: it points a debug build at a non-production API, and passing it from a release build traps rather than quietly shipping an app that talks to staging.

0.7.1 — A progress screen can no longer be a dead end #

A loading screen could render its bar at the default 60% and sit there forever. Three things had to line up, and all three did:

  • The timeout that rescues a progress node was skipped for bar and animated_list, on the assumption they advance themselves. That holds only when a duration (or a list of items) is present — an authored bar with no duration_ms built no animation, so nothing ever fired. Deciding this from the declared style also meant a typo'd style armed both paths at once: two transitions, one screen silently skipped. The decision now follows what the chosen branch actually does.
  • Every path was gated on advance_on_complete. If the author never set it, nothing helped. A progress node on a screen with nothing to interact with — no button, input, choice, plan picker or top-bar skip — now advances on its own. A screen that asks the user something is never auto-advanced past.
  • duration_ms is optional and 0 is valid, but neither means "advance instantly". Absent, zero, negative and non-finite now fall back to 3s.

Also fixed: a timer firing after the user had already left could skip a screen, because advancing isn't idempotent — actions now carry the screen that raised them and stale ones are dropped. And the timers key off whether there's anywhere to send the action rather than off reduce-motion, so the accessibility setting no longer suppresses the advance along with the animation.

Static gauges — an authored value, no advance_on_complete, a real control elsewhere on the screen — are unchanged, and a test pins that.

0.7.0 — The API host is no longer a knob (breaking) #

configure no longer takes serverUrl. A shipped app talks to https://api.upliftfunnel.com and nothing else, so the override was public surface that could only be misused — a staging URL left in a release build would quietly send real users' events somewhere nobody is looking.

Pointing at a local API during development still works, through a hook that can't survive into production:

UpliftFunnel.debugServerUrl = 'http://localhost:3000';
await UpliftFunnel.configure(apiKey: 'fnl_pk_…');

Setting it in a release build throws. Nothing else changes — if you never passed serverUrl, this release is a no-op for you.

0.6.0 — Answers stay on the device unless they're worth sending #

Every input value used to be uploaded verbatim: variable_set carried the text the user typed and flow_completed the whole answer map, with no way to hold any of it back. Onboarding funnels routinely ask for email, name, birth date and body measurements, so that was personal data leaving the device by default.

The cut isn't "send values or don't" — it's identity versus buckets. Email, phone, name, date of birth and body measurements describe a person, and nobody segments on them anyway (identity already travels as user_id / anonymous_id). Choice, rating, scale and toggle write values the author defined, which is exactly what segmentation runs on and carries no identity. So the decision is per variable.

  • Variables can be marked sensitive. Mark one in the dashboard and the SDK reports it as answered, never as its content: variable_set sends {name, redacted: true}, and flow_completed moves the name into a redacted list instead of leaving a placeholder in variables that a genuine true would be indistinguishable from. The server derives the flag from the input that writes each variable, so flows authored before this release arrive classified — nothing to re-author, nothing to re-publish.
  • configure(redactVariables: {...}) redacts extra names on top of that — your lever for a flow you haven't touched yet, and for variables you pass in yourself through userVariables.
  • configure(trackingEnabled: false) and UpliftFunnel.setTrackingEnabled — a consent switch. Off drops what's already queued rather than holding it: consent withdrawn isn't consent deferred. Flows keep fetching and rendering either way; gating that on consent would leave you with a blank screen instead of an onboarding.

Your app is unaffected by all of this. onCompleted still hands you every answer — it's your user's data and collecting it is the point. Redaction is a boundary on what reaches the Uplift API, not on what your code sees.

Also: the pubspec description now fits pub.dev's 180-char limit, and the google_fonts constraint was widened to >=6.2.0 <9.0.0 so modern apps resolve 8.x (pinning ^8 would have forced every host onto Flutter 3.38).

0.5.0 — Editor parity + production hygiene #

Adds the primitives the dashboard can now author, and replaces the remaining stubs with either a real implementation or an honest no-op.

Rebranded to Uplift Funnel. Package renamed funnon_flutteruplift_funnel_flutter; all public FunnOn* symbols are now UpliftFunnel* (UpliftFunnel.configure, UpliftFunnelFlow, UpliftFunnelFlowView, UpliftFunnelFlowResult, UpliftFunnelProduct, UpliftFunnelExperimentAssignment, UpliftFunnelNotConfigured). Wire headers renamed X-Funnon-*X-Uplift-* and the default server is now https://api.upliftfunnel.com. No deprecated aliases — no prior version was ever published.

Added:

  • button.props.enabled_when — gate a CTA on a [Condition]. Evaluated by the same code the flow engine uses for transitions, so a button can't look tappable while the rule behind it would refuse to fire.
  • scale gains min_label / max_label, an accent override, and a custom emojis ramp (used only when it covers every step).
  • progress with style: "animated_list" renders its items as a checklist that ticks off across duration_ms.
  • UpliftFunnel.registerPhotoUploadHandler — the host's picker for photo_upload ([PhotoUploadRequest] / [PhotoUploadHandler] in photo_upload.dart, mirroring the purchase handoff). The SDK still ships no camera dependency.
  • Named icons (icon.props.name) resolve to ~40 Material glyphs instead of a 10-entry emoji map, so they scale and tint like the rest of the UI.

Fixed:

  • Dotted {{price.yearly_pro}} tokens now interpolate in text / rich_text. The pattern stopped at the first dot, so documented product tokens rendered literally everywhere except inside plan_picker.
  • kUpliftFunnelSdkVersion had drifted to 0.3.0 while pubspec said 0.4.0, mislabeling context.sdk_version on every event. A test now pins the two together.
  • slider / scale no longer throw on a node missing min / max.

Changed:

  • date_field opens the platform date picker instead of writing a fixed 2000-01-01.
  • A permission node with no registered handler shows a stand-in dialog rather than granting silently — the deny branch is reachable in previews, and a missing handler is visible before it ships.
  • photo_upload with no registered handler stays inert instead of storing asset://demo; debug builds say why.

Server-side (no SDK change required): paywall screens now carry Terms, Privacy and Restore links, lowered into a caption row by the serve-time expansion. The existing markdown-link handling renders them as-is.

Security:

  • url: actions are scheme-limited. A url: href is authored content that arrives over the network; it used to reach your link handler verbatim, which was enough for a flow to make the app follow a third-party deep link, a file:// path, or one of your own auth-skipping routes. The SDK now forwards only https, http, mailto, tel and sms. Apps that drive their own deep links from a flow opt the scheme in: registerLinkHandler(open, allowedSchemes: {...kDefaultAllowedLinkSchemes, 'myapp'}). Debug builds log every dropped link.
  • serverUrl must be an absolute http(s) URL, and cleartext http:// is now debug-only. A release build that shipped it put the API key, the subject id and every event on the wire in the clear, and let anyone on the path serve the flow JSON the SDK renders and acts on. Release builds throw ArgumentError.
  • Flow and experiment keys are percent-encoded as a single path segment, so a key carrying /, ? or # can't rewrite the request.
  • No request follows redirects. package:http re-sends Authorization and X-Uplift-Subject-Id to the redirect target; the Uplift API never redirects.

0.4.0 — Primitive-only renderer (breaking) #

The server now serves a pre-expanded primitive node tree for every screen (archetype→primitive lowering happens server-side), so the SDK is a thin primitive interpreter. Archetype knowledge no longer ships to the device.

Breaking:

  • Removed the archetype render path (archetypes.dart) and all per-archetype content models, *Screen subclasses, chrome models, and the Archetype enum from models.dart (~6,350 lines). FunnelScreen is now always a PrimitiveScreenModel.
  • Removed the custom-component API (UpliftFunnel.registerComponent, CustomComponentBuilder, CustomContent). A custom node renders via the primitive renderer. Host-injected custom widgets can return in primitive terms as a follow-up if needed.
  • Dropped the dead funnel_schema (generated Dart) dependency — it was never imported.

Unchanged: flow engine, event/analytics layer, fetcher/cache, theme tokens, and the entire render/primitive/ renderer.

0.2.0 — Phase 1.5: full archetype set + remote fetch #

  • 4 new archetypes: multi_choice (with min/max selection enforcement), slider (kg/lb and cm/ft unit toggling), text_input (regex validation, auto-focus, platform keyboard hints), loading (timed faux-loading with three layouts including animated step rotator).
  • FlowCache abstraction with SharedPreferencesFlowCache (production) and InMemoryFlowCache (tests), keyed by flow id, persisting raw JSON
    • ETag + fetch timestamp.
  • FlowFetcher with cache-first / ETag-revalidating semantics: serves cached entry instantly, fires conditional GET, accepts 304 Not Modified, falls back to cache on network failure.
  • FunnelClient.startSessionFromUrl integrating cache + fetcher: one call from URL to running session.
  • Cal AI demo flow expanded from 7 to 10 screens, exercising every archetype end-to-end.

Tests: 25/25 passing (engine: 11, session: 3, fetcher: 6, cache: 2, parseHexColor: 3).

0.1.0 — Phase 1 MVP #

Initial development release.

  • Pure-Dart FlowEngine with linear + conditional branching, terminal transitions, condition evaluator, and back-navigation history.
  • Flutter FlowSession wrapper with ValueListenable<FunnelScreen> and Stream<FlowEvent>.
  • FunnelOnboarding widget with theme injection and animated screen transitions.
  • 4 archetype widgets: welcome, single_choice (list / grid_2x2 / emoji_list layouts), scale (dots / numeric layouts), finale.
  • Theme token system: colors, typography, shape, spacing, with dark-mode variant.
  • FunnelClient singleton for client lifecycle, identification, and session creation from JSON.
  • Hex color parser supporting #RGB, #RRGGBB, #RRGGBBAA.
  • Full Cal AI clone demo in example/ rendered from JSON.

Not in this release (planned for next iterations):

  • Network fetching, SharedPreferences cache, HTTP event upload.
  • Remaining 14 archetypes.
  • Lottie and video asset rendering.
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