Skip to main content

paywall

PackageKindProvides the roleNeedsChoose with
smf_paywallfeaturenonepurchases, values in .env, a router, a state manager-m paywall

paywall is the screen that sells the premium access of the app, and the screen that shows it to a user who has it. The paywall shows the plans of the current offer, each with the price that the store gives, and buys the plan that the user selects. The screen of the subscription shows the plan of the user, when it renews or ends, and the way to the page of the store where the user manages it.

The module keeps no purchases. It requires a module that provides the purchases role, such as revenuecat, and reaches it only through appPurchases, so the screens are the same whichever module that is; see Purchases. In an app with a settings screen, it adds a row that leads to the screen of the subscription. In an app with an onboarding, it adds a step that offers premium access.

The screens​

ScreenRoutePathWhat it shows
PaywallScreenpaywall.paywall/paywallThe plans of the current offer and the button that buys the selected one, with what the stores ask a paywall to show.
SubscriptionScreenpaywall.subscription/paywall/subscriptionThe plan of the user, when it renews or ends, or that the access is for good, and a button to the page of the store where the user manages the plan.

From the top, the paywall shows:

  • a close button, outside what scrolls, whenever a page below is there to go back to;
  • a card in the colours of Say My Frame, with the app as a cell of the periodic table and Premium above its name. The symbol and the number in the cell come from the name of the app, as on the card of the start screen. On the right of the card the cells of a table light up one after another, and the last one shows the icon of premium access;
  • three benefits, each with a check: "Every feature of the app, without limits", "New features as soon as they arrive" and "Support for the work on the app";
  • a card for each plan, in the order of the provider of purchases. A card shows the name of the plan, such as Yearly, its price with its period, such as $29.99 / year, and its free trial or lower price at first, such as 1 week free. A yearly plan that costs less than twelve months of a monthly plan in the same currency has the badge Best value. The paywall selects the first yearly plan, or else the first plan. Grey cards stand in while the plans load;
  • the button that buys the selected plan: Start free trial for a plan with a free trial, Buy for a plan paid once, and Subscribe for any other plan. Below it, for a plan with an intro offer, what the plan costs after it, such as 1 week free, then $29.99 / year;
  • the terms of the store of the platform, and the links Restore purchases, Terms of Use and Privacy Policy;
  • in a debug build, a note for you, which names the keys of .env that are empty and what the provider of purchases says is missing, such as its key. A tap on a key copies it.

On a phone with room for it, the button and what follows it stay in sight at the bottom while the plans scroll. On a small phone, or with a large text size, they scroll with the rest, so that the plans keep their room.

The paywall over the settings screen, in the light theme, in English: a close button, the dark green card with the cell Ma 5, Premium and My App, three benefits with checks, the selected plan Yearly with the badge Best value, $29.99 / year and 1 week free, the plan Monthly with $4.99 / month, and at the bottom the button Start free trial with 1 week free, then $29.99 / year below it.The same paywall in the dark theme, in Ukrainian: the benefits, the plans Щороку with the badge Найвигідніше and Щомісяця, and the light button Спробувати безкоштовно.

When the plans do not load, the paywall says that they are not available right now and offers Try again, and a debug build shows the hint of the provider in its note. A call that fails shows a text of the app for its reason above the button, such as "No connection. Check the internet and try again.", with the developerHint below it in a debug build. A payment that waits for an approval, such as one that a parent approves, says so, and the access arrives later without another call. A restore that finds nothing says "No purchases to restore."

The screen of the subscription shows the same card with the badge Unlocked, a thank you, the plan and the date on which it renews, ends or ends its free trial, or Lifetime access for a plan paid once. Its button, Manage subscription, opens manageUrl, the page of the store where the user manages or cancels the plan, with "Cancel anytime in the store where you subscribed." below it. It has no restore: the paywall and the row of the purchases role on the settings screen restore.

The screen of the subscription after the purchase of the yearly plan: a back button, the title Subscription, Thank you for your support. Premium is unlocked., the card with the badge Unlocked, the plan Yearly and the date on which its trial ends, and the button Manage subscription with Cancel anytime in the store where you subscribed. below it.

Nothing navigates after a purchase​

The route of the screen of the subscription asks for the condition PurchasesRole.premium. The module declares the guard paywall.hasPremium, which stands for that condition: its function, paywallHasPremium() of lib/features/paywall/paywall_guards.dart, returns appPurchases.hasPremium, and its target is the paywall. See Conditions of routes.

So no screen of the paywall closes itself or navigates after a purchase or a restore:

  1. A request for a route that needs premium access, from a user without it, opens the paywall over the page that the user is on. The request waits.
  2. The user buys a plan, or restores one. The access changes.
  3. The router closes the paywall and shows what was asked for: a push() shows the route over the page and completes with the value of its page.

The close button and back drop the request, and its push() completes with null. When the access ends, as when a subscription expires, the router closes a page that needs it.

To sell from a button of your own, navigate to a route that needs premium access:

await context.nav.paywall.subscription().push<void>();

To keep a screen of your own for users with premium access, list PurchasesRole.premium in the conditions of its route in a module of yours, or add the full name of its route to the routes of paywall.hasPremium in routeGuards of lib/core/router/app_router.dart. The routes below such a route need the access too.

Code may also push /paywall itself for a user without the access, and the router closes it once the user has it. Do not push /paywall for a user with premium access: the flow of the guard is over then, so the router shows the screen that the app starts on.

The guard has the stage identity, since premium access depends on who the user is, and it brings the user back once it allows again. In an app with onboarding, the onboarding comes first. In an app with sign_in, the router asks the gate of the sign-in before this guard; see Guards of routes.

The row of the settings screen​

In an app with a settings screen, the module gives it SubscriptionSetting: the row Subscription, with Active or Not active below its title. A tap asks for the screen of the subscription, so for a user without premium access the router opens the paywall over the settings screen first. The purchases role gives the same screen the row Restore purchases.

In an app without a settings screen, open the screen of the subscription from a screen of yours with context.nav.paywall.subscription().push<void>(), so that the user finds where to cancel.

The step of the onboarding​

In an app with an onboarding, such as onboarding, the module gives the onboarding screen role the step paywall.offer. It has the stage offer, so the onboarding shows it after the steps that ask for permissions, such as the one of the notifications. The onboarding shows the step while the user has no premium access, and a new onboarding leaves it out once the user has it.

IconIcons.workspace_premium_outlined
TitleUnlock premium
TextGet every feature of the app, and the new ones as they arrive.
ButtonSee plans
Second buttonNot now, which goes on without the paywall

The page shows no price: the prices are on the paywall. See Plans calls showPaywall() of lib/features/paywall/paywall_step.dart, which pushes PaywallScreen on the navigator at the root of the app, over the onboarding. While the onboarding is on, a guard of the routes may keep the router from showing /paywall, so the paywall there is a route of its own, without a page of the router. The router does not close such a route, so showPaywall() closes it itself once the user has premium access, also from below a dialog over it. The onboarding goes on to the next page once the paywall is closed, whether by the close button, by back or after a purchase.

This is the one place where code of the module, not the router, closes the paywall.

Whose purchase it is​

In an app with sign-in, a user may buy before signing in, such as from the step of the onboarding in an app that nobody uses without an account. A sign-up keeps the purchase in the new account. A sign-in to an account that exists already leaves the purchase with the user of the device until the user restores it. See Purchases.

What the stores ask of a paywall​

The App Store and Google Play ask a screen that sells a subscription to show certain things. The paywall shows them:

The stores ask forThe paywall shows
The name of the plan, its period and what the user getsThe name of each plan, its period, and the three benefits
The full price of each period, shown more clearly than any other priceThe price that the store gives, the largest text of its card. A yearly plan is never shown by its price per month.
For a free trial or a lower price at first, how long it lasts and the price after itThe intro offer on the card, and what the plan costs after it right below the button
That the plan renews until the user cancels, and how to cancelThe terms of the store below the button: on iOS that the payment is charged to the Apple Account and that the user cancels at least 24 hours before the period ends, in the settings of the App Store; on Android that the user cancels anytime in Google Play, under Payments & subscriptions
A way to restore purchasesThe link Restore purchases
Links to the terms of use and the privacy policyThe links Terms of Use and Privacy Policy, from .env
A visible way to close the screen without buyingThe close button, which stays in sight at the top whenever a page is below the paywall. A paywall that go() shows alone has none, so push /paywall rather than go to it.

A plan paid once says "One-time payment. No subscription." in place of the terms of renewal.

Values in .env​

The module gives the role of the values in .env two keys:

KeyGetterWhat goes there
PAYWALL_TERMS_URLAppEnv.paywallTermsUrlThe address of your terms of use
PAYWALL_PRIVACY_URLAppEnv.paywallPrivacyUrlThe address of your privacy policy

The App Store asks a paywall to link to both. While a value is empty, or is no web address with https or http and a host, the paywall leaves its link out. The exception is the terms of use on iOS, which then lead to the standard license agreement of Apple. A debug build names the empty keys in its note for you. Publishing the pages and filling in the keys is a step by hand of the app, with a click-by-click guide.

An address with an anchor, such as https://example.com/terms#refunds, goes into double quotes, since # after a plain value starts a comment; see Values in .env. The links open outside the app, in the browser, through url_launcher.

What it adds to the app​

The look of the screens is the same in every app, in lib/features/paywall/:

FileWhat it holds
paywall_view.dartPaywallView, the look of the paywall.
subscription_view.dartSubscriptionView, the look of the screen of the subscription.
paywall_widgets.dart, paywall_card.dartThe widgets of both, among them PaywallWords, a text that keeps its words whole, and the card in the colours of Say My Frame.
paywall_state.dartPaywallState, the state of the paywall: the plans or why they did not load, the selected plan, the call on its way and what the last call ended with. Also which plan is selected first and which is the best value.
paywall_texts.dartThe words that depend on a plan: its name, the period after its price, its intro offer, the label of the button, the terms of the store, and the text of each failure.
paywall_links.dartThe addresses of the terms of use and of the privacy policy, from .env, and openPaywallLink().
paywall_guards.dartpaywallHasPremium(), the function of the guard.
subscription_setting_row.dart, in an app with a settings screenThe look of the row Subscription.
paywall_step.dart, in an app with an onboardingpaywallStepShows() and showPaywall(), the functions of the step.

The views are plain Flutter widgets: a view gets the state and callbacks, and imports nothing that manages state. What keeps the state is a variant for each state manager:

With blocWith riverpod
The paywallPaywallCubit in paywall_cubit.dart, which PaywallScreen providespaywallProvider of paywall_notifier.dart, which lasts as long as the paywall
The screen of the subscriptionSubscriptionCubit in subscription_cubit.dartpremiumProvider
The access of the user, for any widgetPremiumCubit of premium_cubit.dart, around the root of the app: context.watch<PremiumCubit>().statepremiumProvider of purchases_provider.dart: ref.watch(premiumProvider)
The purchases of the apppaywall_composition.dart creates the cubits with appPurchasesappPurchasesProvider of purchases_provider.dart, which a test overrides, and paywallLinkOpenerProvider there
The row of the settings screensubscription_setting.dart, with PremiumCubitsubscription_setting.dart, with premiumProvider

The module adds url_launcher: ^6.3.2 to the dependencies, and the package of the state manager with the constraint any, whose version the module of the state manager gives. It adds no asset: the card is drawn in code.

The module gives the roles of the app what they need:

RoleWhat the module gives it
RouterThe routes paywall.paywall and paywall.subscription, and the guard paywall.hasPremium of the stage identity, which stands for PurchasesRole.premium.
Values in .envPAYWALL_TERMS_URL and PAYWALL_PRIVACY_URL.
Settings screenSubscriptionSetting, in an app with a settings screen.
Onboarding screenThe step paywall.offer, in an app with an onboarding.
LocalizationIts 65 texts of the screens, the three of the row in an app with a settings screen, and the four of the step in an app with an onboarding, in English and in Ukrainian.

In the guide for coding agents of the app, the module writes the section "Paywall": where its screens are and what shows and closes them, how to sell from a button, which files are the look and which the state, where the plans get their words and a failure its text, what the links need in .env, whose a purchase before a sign-in is, and what a widget test minds while a call is on its way. By the state manager of the app, it adds where the state of a screen is and how a widget reads the access, and in an app with an onboarding, how the step shows the paywall and who closes it. The README of the app gets the section "Paywall".

The texts​

The texts of the screens are in English and in Ukrainian. In an app with a module that provides the localization, such as gen_l10n, they are texts of the app and follow its language. Their names start with paywall, such as paywallBenefitAll. An app without such a module shows them in English.

A text of the app has no parameters, so the paywall puts the words of a plan together from closed sets, with the price that the store gives:

The paywall has words forAnything else
The period of a planevery week, every 1, 2, 3 or 6 months, every year, and a plan paid onceThe title of the product in the store as its name, and its price without a period
The length of an intro offer3 days, 1 or 2 weeks, 1, 2, 3 or 6 months, 1 yearFree trial or Introductory price, without its length

These are the periods and the free trials of the App Store. Google Play also has periods such as every 4 weeks, which get the title of the product.

To make the texts yours:

  • The benefits are the texts benefitAll, benefitNew and benefitSupport of the module. Change them to what premium access gives in your app. With gen_l10n, they are paywallBenefitAll, paywallBenefitNew and paywallBenefitSupport in lib/l10n/app_en.arb and in the file of each other language, and flutter gen-l10n generates the code after a change; see Languages. Without a module of the localization, they are English texts in paywall_view.dart.
  • The texts of the step, stepTitle, stepText, stepAction and stepLater, change the same way.
  • The words of a plan and the text of each failure are put together in paywall_texts.dart. failureTextOf() there has a text for every reason of a failure, so the app does not compile once the purchases role has a reason without one.

Large text, motion and screen readers​

On a small phone with a large text size:

  • The texts of the screens, such as the benefits, the plans, the label of the button and the lines below it, break between their words and never inside one. PaywallWords measures the longest word of a text, and where that word would not fit the width, as on a narrow phone with a large text size, the text is only as large as that word allows. A no-break space keeps two words together, as in a price and its period.
  • A price grows at most by half with the text size of the device, stays on one line with its period, and stays the largest text of its card. The name of the app on the card, the title of the screen of the subscription and the small labels, such as Premium and Best value, also grow at most by half.
  • The letters and the number in the cell of the card keep their size, since they are part of the picture.
  • On a phone of 320 by 480 points with the text at three times its size, the close button of a paywall over a page stays in sight at the top and at the end, and the paywall scrolls to its links, above the inset at the bottom of the screen.

On a device that asks for less motion, the parts of the screens and the cards of the plans are in their places in the first frame, and a button that is busy is disabled with its label rather than spinning. A screen reader reads the name of the app with Premium as a header, each plan with its name, its price, its intro offer and its badge, and a failure as it comes, since the failure is in a live region.

In tests​

A widget test of the screens gives appPurchases.start() a service of its own, which scripts the offer, what a call answers and the access; see Purchases. The button spins while a purchase is on its way and after one that gave premium access, until the router closes the paywall. So a test that waits with pumpAndSettle() returns only once the router closed it, and until then it shows frames with pump().

The guard keeps a user without premium access from the screen of the subscription. SMF's CI checks the module in every app with it, through the provider of purchases and its mocks: a request for the screen of the subscription opens the paywall over the page, the close button drops the request, and once the user bought a plan the router closes the paywall and shows the screen of the subscription. It checks the cards and the texts of the plans in each language of the app, the text of each failure, the links from .env, the screen of the subscription for each kind of plan, the row of the settings screen, and the paywall on a small phone with a large text size, in both themes and with less motion. In an app with an onboarding, it checks that the step shows the paywall over the onboarding, that the close button, back and a purchase close it, and that the onboarding then goes on. See Contributing.

The mocks of the module buy the first plan through appPurchases before the app starts, so the tests of the other modules of an app with the paywall start with premium access, and the guard allows.

What the module does not do​

  • It shows the plans of the current offer alone. Which plans that are, and in which order, is set at the provider of purchases, such as in the default offering of RevenueCat.
  • It changes no plan of a user who has one. The screen of the subscription leads to the page of the store, where the user changes or cancels the plan.
  • It shows no price per month for a plan of a longer period, which Google Play does not allow as the main price.
  • It asks for no sign-in: anyone may buy, and whose the purchase is follows the sign-in of the app.

Choosing it​

smf create asks which features the app has. To choose this one without the question, with BLoC:

smf create my_app -m paywall,bloc

The module requires the purchases role and the values of .env, and as a feature it requires a router. smf create adds each when only one module provides it:

Adding flutter_core: the only provider of the app entry role, which every app needs.
Adding revenuecat: the only provider of the purchases role, which paywall requires.
Adding dart_define: the only provider of the env role, which paywall requires.
Adding go_router: the only provider of the router role, which paywall requires.

The screens keep their state with the state manager of the app, and two modules provide one, so a run without a terminal stops until -m names one:

Several modules provide the state management role, which paywall requires: bloc, riverpod. Add one of them to -m.

With a start screen, tabs, a settings screen and an onboarding, the paywall has its row and its step:

smf create my_app -m home,onboarding,settings,bottom_tabs,paywall,bloc