I shipped a Flutter package today called paywall_kit. It has 12 prebuilt paywall screens that you can drop into an app. This post is about the one architecture problem I spent the most time on while building it, because I think it's an interesting little case study even if you never use the package.
The problem
I had 12 different paywall layouts — a carousel one, a comparison table, a lifetime-offer one, etc. — and every single one needs to do the same five things:
- Render its own unique layout.
- Show a buy button that calls somebody's purchase API.
- Show a spinner while the purchase is in flight.
- Handle success, failure, and user cancellation.
- Wire up a Restore Purchases link (App Store requires it).
Two obvious ways to handle this, both bad:
If I hard-coded the in_app_purchase plugin into each variant, I'd lock everyone who uses the package into that one purchase backend. Nobody on RevenueCat could touch it.
If I made each variant take a callback for the buy action, then the spinner and the busy-state-management would get copy-pasted 12 times. Every time I needed to fix a bug in the loading state I'd be editing 12 files.
I wanted UI-only variants, with purchase logic happening somewhere else.
What I went with
Two pieces. An adapter interface for the purchase backend, and a stateful button that owns its own loading state.
The adapter is a two-method abstract class:
abstract class PaywallAdapter {
Future<PaywallResult> buy(PaywallProduct product);
Future<PaywallResult> restore();
}
{data-source-line="380"}
Two implementations ship with the package:
-
PreviewAdapterreturns a successful result instantly. Useful when you're designing your paywall, or when you want to handle the actual purchase yourself in anonCtaTapcallback. -
IapAdapterwrapspackage:in_app_purchaseand handles the purchase-stream lifecycle.
For RevenueCat, Stripe, or your own server, you write your own. The RC recipe lives in doc/ADAPTERS.md. It's about 30 lines.
To get the adapter to each variant without passing it through every constructor, I use an InheritedWidget:
class PaywallScope extends InheritedWidget {
final PaywallAdapter adapter;
// ...
static PaywallScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PaywallScope>()!;
}
{data-source-line="398"}
Inside a variant the call site looks like this:
Future<void> _onContinue() async {
final navigator = Navigator.of(context);
final adapter = PaywallScope.of(context).adapter;
final result = await adapter.buy(_selected);
if (!mounted) return;
navigator.pop(result);
}
{data-source-line="410"}
That's the variant's whole interaction with the purchase backend. It doesn't know whether adapter.buy is hitting Apple's StoreKit or RevenueCat or a Stripe webhook.
The button
The second piece is the primary CTA button. Every variant has one. Each one needs to disable itself and show a spinner while a purchase is pending. If I'd done that inside each variant, I'd have 12 copies of the same bool _busy = false boilerplate.
Instead the button takes a FutureOr<void> Function() and manages its own state:
class PaywallPrimaryButton extends StatefulWidget {
final FutureOr<void> Function() onPressed;
// ...
}
class _PaywallPrimaryButtonState extends State<PaywallPrimaryButton> {
bool _busy = false;
Future<void> _handleTap() async {
if (_busy) return;
setState(() => _busy = true);
try {
await widget.onPressed();
} finally {
if (mounted) setState(() => _busy = false);
}
}
// build() swaps the label for a CircularProgressIndicator when _busy is true
}
{data-source-line="441"}
Each variant just passes its CTA handler. The button takes care of the rest. Zero copies of the loading-state logic across the 12 variants.
What the consumer sees
After all that, calling the library is one line:
final result = await PaywallKit.show(
context,
variant: PaywallVariant.lifetime,
products: [monthly, annual, lifetime],
copy: PaywallCopy(
headline: 'Unlock everything',
features: ['No ads', 'Cloud sync', 'AI assistant'],
),
adapter: IapAdapter(),
);
switch (result) {
case PaywallPurchased(:final product): grant(product);
case PaywallRestored(:final products): restore(products);
case PaywallDismissed(): break;
case PaywallErrored(:final error): logError(error);
}
{data-source-line="467"}
PaywallResult is a sealed class so the switch is exhaustive (Dart 3). Swapping backends is one parameter.
The 12 variants
For reference, here's what I built and what each one is for:
| Variant | Best for |
|---|---|
carousel |
Onboarding flows with swipeable feature highlights |
comparison |
Multi-tier offers (Free / Pro / Lifetime) |
trialToggle |
Subscriptions with a free-trial conversion play |
lifetime |
Indie-style one-time-purchase apps |
soft |
Non-blocking nudge with a "continue with limits" escape |
hard |
Onboarding-blocking, no skip |
winback |
Lapsed-subscriber re-engagement with discount |
family |
Family Sharing-compatible multi-seat tier |
minimal |
Pieter Levels aesthetic, single price, single CTA |
storytelling |
Long-scroll with testimonials and social proof |
gamified |
Reward-unlock framing with a progress ring |
reverseTrial |
"You're on Pro for 7 days" post-onboarding pattern |
Try it
dependencies:
paywall_kit: ^0.1.0
{data-source-line="495"}
- pub.dev: https://pub.dev/packages/paywall_kit
- GitHub: https://github.com/jayu1023/paywall_kit
- Adapters recipe: https://github.com/jayu1023/paywall_kit/blob/main/doc/ADAPTERS.md
If you've shipped paid Flutter apps and have a paywall pattern that converts well for you and isn't in this list, I'd really like to hear which one.

Top comments (0)