restage 2.0.0
restage: ^2.0.0 copied to clipboard
Server-driven UI for Flutter. Build any surface — onboarding, messages, surveys, paywalls — in your own Flutter widgets and ship updates over the air. Content, not code.
Restage is a server-driven UI toolkit for Flutter. Build any part of your app with the widgets you already use and ship it over the air. Everything renders as real Flutter widgets in your app, using your theme.
This package is the runtime SDK. It renders what the build produced as real Flutter widgets in your widget tree, through Remote Flutter Widgets. Nothing it loads over the air is executable.
One runtime renders every surface: paywalls, onboarding, in-app messages, surveys, and whole screens.
Why Restage #
- Your widgets, your theme. The build compiles the code you wrote, and
Theme.of(context)resolves when the surface renders. - Any part of the app. A whole screen, a paywall, an onboarding flow, or
one card inside your own
Scaffold. - It ships only content. An update changes what your app shows. It runs no new code.
- It fails safe. A surface never reaches a client too old to render it, and a failed fetch renders your bundled copy.
Quick start #
Here's a paywall, written as a plain Flutter widget with one annotation:
import 'package:flutter/material.dart';
import 'package:restage/restage.dart';
@Paywall(id: 'pro_upgrade')
class ProUpgradePaywall extends StatelessWidget {
const ProUpgradePaywall({super.key});
@override
Widget build(BuildContext context) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Go Pro', style: Theme.of(context).textTheme.headlineMedium),
const Text('Choose the plan that fits your needs.'),
],
);
}
Compile it with restage_codegen:
dart run build_runner build
Render it anywhere in your app. The SDK loads the compiled artifact and draws it as real Flutter widgets:
RestagePaywall(id: 'pro_upgrade')
Change the widget, rebuild, and the surface updates. Push it with
restage surface push pro_upgrade, then make that revision live with
restage surface publish pro_upgrade, and installed apps pick it up over the
air. The Quickstart
walks through all of it, including the build.yaml for bundled assets.
Surfaces #
One host widget per surface kind:
- Screens: any single surface you write with
@Screen, such as a welcome page, a notice, or a settings card. The build generates a typed mount such asWelcomeScreenSurface(...)with the authored constructor arguments and typed events. If delivery or rendering becomes unavailable, including after an event rejection or render failure, it displays the compiled-in authored widget. UseRestageScreen(screen: welcomeScreenRef, ...)directly for a different unavailable policy. - Paywalls: a
@Paywallsurface for upgrade or subscription content. Mount it withRestagePaywall(id: 'pro_upgrade', ...). - Flows: a
@FlowGraphsequence of screens, such as onboarding, a survey, or a multi-step message. Mount it withRestageFlowGraph(flow: firstRunFlowRef, ...); the build generates the descriptor. See doc/flows.md for the full example, host actions, and data minimization, and doc/flow_navigation_and_customization.md for back navigation and the system-back policy.
Host data #
Every surface host accepts an optional context: map. Its values are available
to authored widgets under data.context.*.
Context accepts strings, integers, finite doubles, booleans, lists, and maps with string keys through 32 collection levels below the root. A context may contain up to 10,000 retained normalized nodes including the root and may inspect up to 100,000 map entries or list elements per normalization. Null map values are omitted, and null list elements are dropped and compacted. Normalization synchronously deep-copies accepted input. Invalid values, unreadable collections, and exceeded limits throw in debug; release reports diagnostics and omits the offending value or collection.
For example, a screen can render state already owned by the app:
RestageScreen(
screen: accountSummaryScreenRef,
unavailable: const SurfaceScreenUnavailablePolicy.hide(),
context: <String, Object?>{
'profile': <String, Object?>{
'displayName': profile.displayName,
'tasksRemaining': remainingTasks.length,
},
},
)
Accepted input is normalized and copied synchronously. Equal normalized
snapshots issue no renderer update. Setting context: back to null withdraws
the namespace.
Changing one scalar rebuilds the node whose own arguments read that path and the node's subtree; unchanged scalar siblings stay untouched. A list change rebuilds the list host and its rows. Reconciliation is positional: on insert and reorder, persistent row state stays with its position while authored row ids move.
Commerce #
Restage.commerce is the inert typed commerce seam. Using the facade with its
typed request and result types requires both imports:
import 'package:restage/commerce.dart' as commerce;
import 'package:restage/restage.dart' show Restage;
Commerce is inert in 2.0 and has no configuration hook. Any implementation requires explicit host opt-in; a package update alone never activates purchasing. Authored surfaces cannot initiate purchases or restores; explicit host-controlled code invokes the typed boundary.
Delivery #
Apps that bundle their artifacts use the asset resolvers. To fetch surfaces
from a server, call Restage.configure(baseUrl: ...) at startup. That
installs RestageVariantResolver, which fetches the active published surface
and falls back to the bundled asset when the fetch fails. Flows take the same
path through ServerFlowResolver (pass it as flowResolver:). Point baseUrl
at your own backend, or at the hosted service.
A surface never reaches a client that is too old to render it. If a fetch
fails, the SDK renders your bundled copy. For flows, FlowUnavailablePolicy
is required, so a flow that can't run falls back or hides instead of running
partway.
See doc/live_refresh.md for opt-in in-place updates to surfaces that are already on screen.
Build #
Apps that depend on restage must build with --no-tree-shake-icons, because
RFW builds IconData from runtime values:
flutter build ios --no-tree-shake-icons
flutter build appbundle --no-tree-shake-icons
flutter build web --wasm --no-tree-shake-icons
Telemetry and data #
Restage includes a conversion-analytics layer for surface and experiment results. It's built to be boring and honest:
- It's off until you connect a backend. Analytics activates only when you
pass
baseUrltoRestage.configure(...). In local mode (nobaseUrl) the SDK renders everything on device and calls no backend. - No endpoint is baked in. Events go to your configured
baseUrl(<baseUrl>/analytics/events), authenticated with your public key (rs_pk_...). Point it at Restage Cloud and your events power your dashboard and usage-based billing. Point it at your own backend and they go there. There is no hidden Restage host in the SDK. - The identity is pseudonymous. Each install gets a random UUID. It isn't
derived from any device or advertising identifier, and it resets on
uninstall or
Restage.reset(). It's a pseudonymous identifier rather than an anonymous one. Treat it as personal data under GDPR and similar laws, as we do. The SDK attaches no user identity of your own. No call binds your account id to an event. - What
Restage.reset()does, and doesn't do. It rotates the pseudonymous id on the device and rotates the session. That id is also the experiment assignment key, so the install becomes a new randomized unit: assignment is re-drawn on the next surface presentation, and nothing records a link between the old unit and the new one. It's a local call: it sends nothing and tells the server nothing. It does not erase or amend events already sent, and it does not clear the metering token described below. It isn't a deletion request. Treat it as rotating an identifier going forward, not as erasing a history.
What each event contains: a dedup id; the event name and a UTC timestamp;
which surface it was, with its id, version, and session; the pseudonymous
install id and an app-session id; an app context of platform, locale, SDK
version, and optional app version or build; conversion dimensions (variant and
experiment) where they apply; and the event's own typed fields, after a scrub
that keeps render and host context out of analytics.
What it never collects: advertising identifiers (IDFA/GAID), device fingerprints, location, contacts, or screen content. Beyond the fields listed above, it collects no personal data you don't explicitly attach. Ordinary request metadata such as an IP address is visible to whatever backend you point it at, as with any network call.
Delivery is fail-safe. Events are batched, capped, retried safely, and never throw into your app.
Turning it off: run in local mode (omit baseUrl) for zero telemetry, or
pass analyticsEnabled: false to Restage.configure(...) to keep hosted
delivery and disable analytics. If you use the hosted service, surface fetches
still include the metering token described below.
Disabling measurement at build time: pass measurementEnabled: false to
Restage.configure(...). The generator still produces ordinary surface
artifacts, but emits no measurement candidates or bindings, and the SDK opens
no measurement sessions. The setting defaults to true and is independent of
analyticsEnabled, hosted operational controls, and platform admission. None
of those controls can enable measurement in a build that disables it.
The metering token #
The hosted service is billed by monthly active users, so the SDK needs a way to count them. How it works:
- If you don't use the hosted service, there is no token. Without a
baseUrlthe SDK never creates one. Even with abaseUrl, the SDK creates the token only the first time it fetches a surface from the server. - It's a random UUID. The device generates it, stores it in
shared_preferences, and loses it when the app is uninstalled. It contains nothing about the user or the device, and it isn't connected to the analytics install id. - It's sent only with surface fetches. It goes in the body of the request
to
<baseUrl>/sdk/v1/surfaceand nowhere else. It never appears in analytics events. - It goes only to the server you configured. There is no baked-in Restage
endpoint. If your
baseUrlis your own backend, the token goes there, and your server is free to ignore the field. analyticsEnabled: falsedoesn't remove it. That flag turns off analytics. The metering token is how use of the hosted service is counted, so it stays as long as you fetch surfaces from the server. Run without abaseUrland the SDK sends nothing at all.
If you need to describe it in your own privacy policy: a random per-install
identifier, used only to count active users for billing, reset when the app is
uninstalled. The implementation is in lib/src/metering/ and it's short.
All of this is BSD-3-Clause and readable: see lib/src/analytics/ and
lib/src/metering/.
Linking a signed-in user #
By default, measurement knows no user. Events and experiment assignment key off the pseudonymous install id, and nothing ties them to an account.
If your app has signed-in users and you want measurement tied to a user or an account, you link one explicitly:
final result = await Restage.measurement.linkSubject(request);
final reset = await Restage.measurement.resetSubject(resetRequest);
final withdrawal = await Restage.measurement.withdrawConsent(withdrawalRequest);
Linking is deliberately not a one-liner. A request carries proof from your own
auth system that the user is signed in, the user's consent and region, and a
challenge from the service, so a link can't happen by accident. You get back a
receipt or a failure, and nothing you sent is ever echoed back.
resetSubject unlinks; withdrawConsent blocks future linking. Until your
app installs a verifier for these proofs, every call here fails closed and
does nothing.
License #
BSD-3-Clause. See LICENSE.