DEV Community

Cover image for Adding a One-Time "Remove Ads" Purchase to a Local-First Flutter App
Ayesha Iftikhar
Ayesha Iftikhar

Posted on Originally published at aishayy.com

Adding a One-Time "Remove Ads" Purchase to a Local-First Flutter App

Short answer: Mindleau sells "Remove Ads" as a single non-consumable purchase through RevenueCat. A PurchaseService owns the entitlement and caches it on the device. A single ValueNotifier<bool> on the AdService gates every banner, interstitial and app-open ad. If the user has already paid, the Google Mobile Ads SDK is never started. The RevenueCat customer is tied to the app's Firebase user ID, so a purchase follows the person, not the install.

Mindleau is a free brain dump journal and mood tracker for iOS and Android. Every feature is free and there is no subscription. It's paid for by a small number of ads, kept out of writing and reflection moments. Some people would rather pay once than see any ads at all, so I added one in-app purchase: Remove Ads.

It sounds like a small feature. The purchase itself is one API call. The architecture around it is where the decisions are, and those decisions are what this post is about.

The requirements

I wrote these down before choosing anything:

  1. One-time, not a subscription. Pay once, never see an ad again, nothing to cancel.
  2. No account required to buy. Mindleau doesn't have a login wall, and the purchase shouldn't add one.
  3. A paying user must never see an ad. Not on a cold start, not while offline, not for the half-second before the store SDK answers.
  4. Restorable on a new phone, using the store's own restore flow.
  5. The app must work if purchases are broken. A missing key, an unsupported platform or a store outage means "show ads as before", not a crash.
  6. Deleting an account must remove the person from the purchase backend too.

Why RevenueCat instead of in_app_purchase

Flutter's official in_app_purchase plugin can do a non-consumable purchase. What it doesn't do is answer the question "does this user own it?" reliably. You either trust the device's purchase history or build receipt validation on a server.

RevenueCat validates receipts on its servers and gives you an entitlement: a named yes or no (ads_free) that you can check from any platform. Its free tier covers an app at Mindleau's scale, and it saves me from running a validation backend for an app that otherwise doesn't need one.

The cost is another third-party SDK that receives an identifier for the user. That has to be listed in the privacy policy and in the store privacy labels.

The shape of it

            ┌───────────────────┐
            │  PurchaseService  │  owns: adsRemoved (cached), the package, identity
            └─────────┬─────────┘
                      │ addListener
                      ▼
            ┌───────────────────┐
            │     AdService     │  adsRemoved: ValueNotifier<bool>
            └─────────┬─────────┘
          ┌───────────┼─────────────┐
          ▼           ▼             ▼
       banners   interstitials   app-open
Enter fullscreen mode Exit fullscreen mode

Two services, one direction. PurchaseService knows about RevenueCat and nothing about ads. AdService knows about AdMob and nothing about purchases. main.dart connects them with one line:

final purchases = await PurchaseService.initialize(
  screenshotMode: screenshotMode,
  analytics: analytics,
);
final ads = await AdService.initialize(adsRemoved: purchases.adsRemoved);
purchases.addListener(() => ads.adsRemoved.value = purchases.adsRemoved);
Enter fullscreen mode Exit fullscreen mode

The order matters. Purchases are initialized before ads, so AdService already knows on its first line whether this user has paid.

Requirement 3: no ad flash on a cold start

RevenueCat answers over the network. Ask it "does this user own ads_free?" at launch and you'll wait somewhere between 100 ms and forever, depending on the connection. If you show ads while you wait, paying users see ads, which is the one thing they paid to avoid.

So the last known answer is cached in SharedPreferences and read synchronously at startup:

final service = PurchaseService._(
  entitlementId: entitlementId,
  prefs: prefs,
  analytics: analytics,
  adsRemoved:
      !screenshotMode && (prefs?.getBool(_adsRemovedPrefsKey) ?? false),
);
Enter fullscreen mode Exit fullscreen mode

RevenueCat is then configured, and the real answer is fetched in the background with unawaited(service.refresh()). Every time RevenueCat reports customer info, whether from a purchase, a restore, a refresh or its own update listener, it goes through one function:

void _applyCustomerInfo(CustomerInfo info) {
  final adsRemoved = info.entitlements.active.containsKey(entitlementId);
  if (adsRemoved == _adsRemoved) return;
  _adsRemoved = adsRemoved;
  unawaited(_prefs?.setBool(_adsRemovedPrefsKey, adsRemoved));
  notifyListeners();
}
Enter fullscreen mode Exit fullscreen mode

There's one writer for the cached value and one place that notifies. The cache can't drift from what the UI shows.

The trade-off: the cache is trusted until RevenueCat says otherwise. If someone is refunded while offline, they keep an ad-free app until the device reconnects. For a "remove ads" product that's the right side to err on: the cost of a wrong "yes" is a few ad impressions, and the cost of a wrong "no" is a paying customer seeing ads.

Don't even start the ad SDK for paying users

Starting the Google Mobile Ads SDK isn't free. It spins up its own WebView-backed renderer, which competes with Flutter for the GPU during the first frame. If the user has paid, there's no reason to pay that cost:

if (!service.enabled || adsRemoved) {
  service._sdkReady.complete();
  return service;
}
Enter fullscreen mode Exit fullscreen mode

_sdkReady is completed anyway, so any code awaiting it moves on. It just never gets an ad.

One gate for every ad format

Mindleau has three ad formats, and each one has its own load and show path. Every one of those paths checks the same notifier:

if (!enabled || !interstitialEnabled || adsRemoved.value) return;
Enter fullscreen mode Exit fullscreen mode

When a purchase completes mid-session, ads that are already loaded have to go too, not only future ones:

void _onAdsRemovedChanged() {
  if (!adsRemoved.value) return;
  _pendingLaunchAppOpenShow = false;
  _interstitialAd?.dispose();
  _interstitialAd = null;
  _appOpenAd?.dispose();
  _appOpenAd = null;
}
Enter fullscreen mode Exit fullscreen mode

Banners are widgets, so they listen to the same ValueNotifier and collapse when it flips. The user taps "Remove Ads", the store sheet closes, and the banner under their journal is gone, with no restart needed.

Note the asymmetry: removing ads takes effect immediately, while restoring ads after a refund only happens on the next cold start, because the SDK was never started. That's deliberate. It keeps the "never show a paying user an ad" path simple.

Requirement 2 and 4: identity without a login wall

Mindleau already has a user ID for everyone: the Firebase Auth UID, anonymous until the user links an email. RevenueCat lets you log in a customer with your own ID, so the purchase is attached to that UID.

The tricky part is when. The Firebase user can appear after RevenueCat is configured (first launch), change (email restore on a new phone) or disappear (account deletion). So PurchaseService doesn't take an ID. It takes a function that reads the current one, plus a stream that says "it might have changed":

purchases.bindIdentity(
  read: () async => (
    userId: repository.userId,
    displayName: await repository.displayName(),
  ),
  changes: repository.syncStateChanges,
);
Enter fullscreen mode Exit fullscreen mode

syncIdentity() is idempotent and cheap. It remembers what it last sent and skips anything already in sync. That makes it safe to call often, and it's called in three places: at startup, on every identity change, and right before every purchase or restore. The last one matters. If the identity sync failed earlier because the user was offline, the purchase still lands on the right customer.

Future<PurchaseOutcome> purchaseRemoveAds() async {
  final package = _removeAdsPackage;
  if (!enabled || package == null) return PurchaseOutcome.unavailable;
  await syncIdentity();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Restore uses the store's own mechanism (Purchases.restorePurchases()), which works from the App Store or Google Play account, so a user who never linked an email can still restore on a new phone.

Requirement 6: account deletion reaches the purchase backend

When someone deletes their Mindleau account, the RevenueCat customer shouldn't keep their Firebase ID and display name. After the Firebase account is deleted:

Future<void> clearIdentity() async {
  _syncedUserId = null;
  _syncedDisplayName = null;
  if (!enabled) return;
  try {
    if (await Purchases.isAnonymous) return;
    // An empty value deletes the reserved $displayName attribute.
    await Purchases.setDisplayName('');
    _applyCustomerInfo(await Purchases.logOut());
  } catch (error) {
    log('Identity reset failed: $error', name: 'mindleau.purchases');
  }
}
Enter fullscreen mode Exit fullscreen mode

syncIdentity() also calls this on its own if it sees the Firebase UID go from a value to null, so deletion paths I haven't thought of are still covered.

One consequence to be honest about: after deleting their account, a paying user is a fresh anonymous customer with no entitlement, so ads come back until they tap Restore Purchases. The store account still owns the purchase, so nothing is lost.

Requirement 5: purchases must fail closed, to "ads as before"

PurchaseService.initialize never throws. A missing API key, an unsupported platform (web, desktop) or an SDK error produces a disabled service:

/// Never throws: a missing API key, unsupported platform or SDK failure
/// yields a disabled service, and the app keeps showing ads as before.
Enter fullscreen mode Exit fullscreen mode

The settings tiles for Remove Ads and Restore Purchases are hidden when enabled is false, so nobody taps a button that can't work.

Purchase results are an enum, not a boolean or an exception:

enum PurchaseOutcome { success, cancelled, nothingToRestore, failed, unavailable }
Enter fullscreen mode Exit fullscreen mode

A boolean can't tell "the user changed their mind" from "the store is down", and the UI should respond very differently to each: silence for a cancel, a gentle message for "nothing to restore", an error for a failure. The enum makes the UI handle every case, and the compiler checks the switch.

Measuring it

Purchase analytics are logged inside PurchaseService, not in the UI, so every path is counted the same way:

  • remove_ads_purchase_started, remove_ads_purchased (with price and currency), remove_ads_purchase_cancelled, remove_ads_purchase_failed (with error code)
  • purchases_restore_started, purchases_restored, purchases_restore_failed

One case is worth calling out: the store reports success but the entitlement isn't active (for example a misconfigured product in the RevenueCat dashboard). That's logged as a failure with entitlement_inactive, because from the user's point of view it is one.

What I'd do the same way again

  • Cache the entitlement and trust it at launch. It's the only way to guarantee no ad flash.
  • One notifier, checked by every ad path. Ad formats get added over time; a single gate keeps them honest.
  • Identity as a function plus a change stream, re-synced before every purchase.
  • Never-throwing initializers for every optional SDK. Monetization breaking should never break journaling.

If you're adding monetization to a Flutter app and want a second pair of eyes on the architecture, you can read the Mindleau case study or see how I help with technical product consulting.

Top comments (2)

Collapse
 
saqrelfirgany profile image
Ahmed ElFirgany •

Sending every entitlement change through _applyCustomerInfo is the part I would copy. One case the PurchaseOutcome enum does not name is a pending payment. Ask to Buy on iOS returns paymentPendingError, not a success. Cash payments on Google Play do the same. If that maps to failed, the user sees an error for a valid purchase. A separate pending outcome, plus the customer info listener, covers the late approval.

Collapse
 
ahmedznouda profile image
Ahmed ZNOUDA •

Requirement 6 is the one most posts skip, and it's nice to see clearIdentity() clear the display name too, not only call logOut().

One setting I'd check with anonymous Firebase UIDs: RevenueCat's restore behavior in the project settings. On a new phone the user gets a fresh anonymous UID, so when they tap Restore, the purchase is transferred to that new UID (the default, if I remember right). That's what you want, but if they later restore their email and the app logs back in with the original UID, it's worth checking where ads_free ends up. Trying that path once (new phone, restore, then email restore) would tell you.

Same spirit as your requirement 3: if you show the UMP consent form to EEA users, a paying user shouldn't see it either, since there's no ad to consent to. Checking adsRemoved before requesting consent info keeps that clean.