How I structured 12 Flutter paywall screens to share the same purchase logic

Published: (May 28, 2026 at 11:28 AM EDT)
5 min read
Source: Dev.to

Source: Dev.to

Cover image for “How I structured 12 Flutter paywall screens to share the same purchase logic”

jay limbani

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:

  1. Render its own unique layout.
  2. Show a Buy button that calls somebody’s purchase API.
  3. Show a spinner while the purchase is in flight.
  4. Handle success, failure, and user cancellation.
  5. Wire up a Restore Purchases link (App Store requires it).

Two obvious ways to handle this, both bad:

  • Hard‑code the in_app_purchase plugin 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:

  1. An adapter interface for the purchase backend.
  2. 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:

AdapterDescription
PreviewAdapterReturns 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.
IapAdapterWraps 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:

VariantBest for
carouselOnboarding flows with swipeable feature highlights
comparisonMulti‑tier offers (Free / Pro / Lifetime)
trialToggleSubscriptions with a free trial toggle
lifetimeOne‑time purchase offering all features forever
promoTime‑limited promotional offers
featureListSimple list of features with a single CTA
gridGrid of feature cards
splitSplit‑screen layout with image on one side
heroLarge hero image with overlay text
minimalMinimalist design for low‑distraction UI
compactCompact layout for small screens
customFully 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

PatternDescription
lifetimeIndie‑style one‑time‑purchase apps
softNon‑blocking nudge with a “continue with limits” escape
hardOnboarding‑blocking, no skip
winbackLapsed‑subscriber re‑engagement with discount
familyFamily Sharing‑compatible multi‑seat tier
minimalPieter Levels aesthetic, single price, single CTA
storytellingLong‑scroll with testimonials and social proof
gamifiedReward‑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.

Paywall Kit illustration

0 views
Back to Blog

Related posts

Read more »