DEV Community

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

Posted on

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

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:

  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:

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();
}
Enter fullscreen mode Exit fullscreen mode


{data-source-line="380"}

Two implementations ship with the package:

  • PreviewAdapter returns a successful result instantly. Useful when you're designing your 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 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>()!;
}
Enter fullscreen mode Exit fullscreen mode


{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);
}
Enter fullscreen mode Exit fullscreen mode


{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
}
Enter fullscreen mode Exit fullscreen mode


{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);
}
Enter fullscreen mode Exit fullscreen mode


{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
Enter fullscreen mode Exit fullscreen mode


{data-source-line="495"}

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)