developmentOctober 16, 2026

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

    By Ayesha IftikharSenior Software Engineer & Product Engineer
    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
    

    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);
    

    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),
    );
    

    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();
    }
    

    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;
    }
    

    _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;
    

    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;
    }
    

    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,
    );
    

    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();
      // ...
    }
    

    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');
      }
    }
    

    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.
    

    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 }
    

    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.

    Share this article

    You Might Also Like

    Opt-In Analytics and Crashlytics in Flutter: Consent Done Properly

    Opt-In Analytics and Crashlytics in Flutter: Consent Done Properly

    How I made Firebase Analytics and Crashlytics opt-in in Eats AI: collection off at startup, one ConsentService that applies and persists the choice, separate health-data consent, an audit record, and the native flags that close the gap before Dart runs.

    Read More →
    Shipping an AI Health App: Disclosure, Sources and Consent

    Shipping an AI Health App: Disclosure, Sources and Consent

    What it took to get Eats AI, an AI nutrition tracker, ready for the App Store and Google Play: a clear AI disclosure, cited sources for health guidance, separate health-data consent, honest AI failures, and a paywall that can't be bypassed.

    Read More →
    Vibe Coding Cleanup: Refactoring AI-Generated Spaghetti

    Vibe Coding Cleanup: Refactoring AI-Generated Spaghetti

    Inherited a codebase built purely on 'vibes'? Here is a systematic guide to untangling, refactoring, and securing AI-generated spaghetti code.

    Read More →