How I structured 12 Flutter paywall screens to share the same purchase logic
Source: Dev.to

I shipped a Flutter package today called paywall_kit. It has 12 pre‑built 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:
- Hard‑code the
in_app_purchaseplugin into each variant. → Locks every consumer of the package into that one purchase backend. Nobody on RevenueCat could touch it. - Pass a callback for the buy action to each variant. → The spinner and busy‑state management would be copy‑pasted 12 times. Every bug‑fix to the loading state would require 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.
- A stateful button that owns its own loading state.
The adapter
abstract class PaywallAdapter {
Future buy(PaywallProduct product);
Future restore();
}
Two implementations ship with the package:
| Adapter | Description |
|---|---|
PreviewAdapter | Returns a successful result instantly. Useful when you’re designing the paywall, or when you want to handle the actual purchase yourself in an onCtaTap callback. |
IapAdapter | Wraps package:in_app_purchase and handles the purchase‑stream lifecycle. |
For RevenueCat, Stripe, or your own server you write your own. The RevenueCat recipe lives in doc/ADAPTERS.md and is 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()!;
}
Inside a variant the call site looks like this:
Future _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);
}
That’s the variant’s whole interaction with the purchase backend. It doesn’t know whether adapter.buy is hitting Apple’s StoreKit, RevenueCat, or a Stripe webhook.
The button
The second piece is the primary CTA button. Every variant has one, and each needs to disable itself and show a spinner while a purchase is pending. Instead of duplicating the same bool _busy = false boilerplate 12 times, the button manages its own state:
class PaywallPrimaryButton extends StatefulWidget {
final FutureOr Function() onPressed;
// ...
}
class _PaywallPrimaryButtonState extends State {
bool _busy = false;
Future _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
}
Each variant simply passes its CTA handler to the button; 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);
}
PaywallResult is a sealed class, so the switch is exhaustive (Dart 3). Swapping backends is just a different parameter.
The 12 variants
For reference, here’s what I built and what each one is best for:
| Variant | Best for |
|---|---|
carousel | Onboarding flows with swipeable feature highlights |
comparison | Multi‑tier offers (Free / Pro / Lifetime) |
trialToggle | Subscriptions with a free trial toggle |
lifetime | One‑time purchase offering all features forever |
promo | Time‑limited promotional offers |
featureList | Simple list of features with a single CTA |
grid | Grid of feature cards |
split | Split‑screen layout with image on one side |
hero | Large hero image with overlay text |
minimal | Minimalist design for low‑distraction UI |
compact | Compact layout for small screens |
custom | Fully custom layout (fallback) |
Feel free to explore the package and adapt the adapters or UI variants to fit your own app’s needs!
Free‑trial conversion play
| Pattern | Description |
|---|---|
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
Enter fullscreen mode
Exit fullscreen mode
Resources
- Pub.dev:
- GitHub:
- Adapters recipe:
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.

