惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
Vercel News
Vercel News
博客园_首页
Y
Y Combinator Blog
美团技术团队
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
阮一峰的网络日志
阮一峰的网络日志
aimingoo的专栏
aimingoo的专栏
H
Hackread – Cybersecurity News, Data Breaches, AI and More
MyScale Blog
MyScale Blog
GbyAI
GbyAI
人人都是产品经理
人人都是产品经理
T
Tailwind CSS Blog
MongoDB | Blog
MongoDB | Blog
D
DataBreaches.Net
博客园 - Franky
Engineering at Meta
Engineering at Meta
量子位
The GitHub Blog
The GitHub Blog
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
酷 壳 – CoolShell
酷 壳 – CoolShell
N
Netflix TechBlog - Medium

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
How I structured 12 Flutter paywall screens to share the ...
jay limbani · 2026-05-28 · via DEV Community

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.