uplift_funnel_flutter 0.10.1
uplift_funnel_flutter: ^0.10.1 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 [...]
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.
0.9.0 — The footer stops above the home indicator #
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_playerandgoogle_fonts. A host app inherits one transitive dependency instead of five;google_fontsin 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
barandanimated_list, on the assumption they advance themselves. That holds only when a duration (or a list of items) is present — an authored bar with noduration_msbuilt 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_msis optional and0is 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_setsends{name, redacted: true}, andflow_completedmoves the name into aredactedlist instead of leaving a placeholder invariablesthat a genuinetruewould 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 throughuserVariables.configure(trackingEnabled: false)andUpliftFunnel.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_flutter →
uplift_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.scalegainsmin_label/max_label, anaccentoverride, and a customemojisramp (used only when it covers every step).progresswithstyle: "animated_list"renders itsitemsas a checklist that ticks off acrossduration_ms.UpliftFunnel.registerPhotoUploadHandler— the host's picker forphoto_upload([PhotoUploadRequest] / [PhotoUploadHandler] inphoto_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 intext/rich_text. The pattern stopped at the first dot, so documented product tokens rendered literally everywhere except insideplan_picker. kUpliftFunnelSdkVersionhad drifted to 0.3.0 while pubspec said 0.4.0, mislabelingcontext.sdk_versionon every event. A test now pins the two together.slider/scaleno longer throw on a node missingmin/max.
Changed:
date_fieldopens the platform date picker instead of writing a fixed2000-01-01.- A
permissionnode 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_uploadwith no registered handler stays inert instead of storingasset://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. Aurl: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, afile://path, or one of your own auth-skipping routes. The SDK now forwards onlyhttps,http,mailto,telandsms. 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.serverUrlmust be an absolute http(s) URL, and cleartexthttp://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 throwArgumentError.- 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:httpre-sendsAuthorizationandX-Uplift-Subject-Idto 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-archetypecontentmodels,*Screensubclasses, chrome models, and theArchetypeenum frommodels.dart(~6,350 lines).FunnelScreenis now always aPrimitiveScreenModel. - Removed the custom-component API (
UpliftFunnel.registerComponent,CustomComponentBuilder,CustomContent). Acustomnode 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). FlowCacheabstraction withSharedPreferencesFlowCache(production) andInMemoryFlowCache(tests), keyed by flow id, persisting raw JSON- ETag + fetch timestamp.
FlowFetcherwith cache-first / ETag-revalidating semantics: serves cached entry instantly, fires conditional GET, accepts 304 Not Modified, falls back to cache on network failure.FunnelClient.startSessionFromUrlintegrating 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
FlowEnginewith linear + conditional branching, terminal transitions, condition evaluator, and back-navigation history. - Flutter
FlowSessionwrapper withValueListenable<FunnelScreen>andStream<FlowEvent>. FunnelOnboardingwidget 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.
FunnelClientsingleton 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.