In-app purchases and subscription management for Flutter

This document provides a comprehensive overview of the LinkFive Purchases Flutter library, allowing you to integrate in-app purchases and subscription management functionalities into your Flutter application.

What is LinkFive Purchases?

LinkFive Purchases empowers developers to seamlessly implement and manage in-app purchases and subscriptions within their Flutter applications. It simplifies the integration process, taking care of the complexities and streamlining the experience for both developers and users.

Getting Started

  1. Sign Up and Obtain API Key: Visit the LinkFive website (https://app.linkfive.io/sign-up) to register and acquire your unique API key (it's free!). This key is essential for initializing the LinkFive Purchases plugin within your application.
  2. Installation: Add the linkfive_purchases package to your pubspec.yaml file and run pub get to download and install the necessary dependencies.

Core Functionalities

Supported Purchase Types:

  • Subscriptions: Offer recurring billing plans that provide users with ongoing access to premium features or content within your app.
  • One-Time Purchases: Enable users to purchase a product or service permanently with a single payment.

Initialization:

  • Employ the init method to initialize the LinkFive Purchases plugin, providing your API key as an argument. You can optionally set the logging level using the logLevel parameter.
await LinkFivePurchases.init("API_KEY");

Fetching Products:

  • Utilize the fetchProducts method to retrieve a list of available products from LinkFive. This method is crucial for populating your paywall or displaying relevant subscription options to users.
await LinkFivePurchases.fetchProducts();

Purchasing Products:

  • Trigger the purchase flow for a specific product using the purchase method. This method takes a ProductDetails object as input, representing the product the user intends to purchase.
await LinkFivePurchases.purchase(productDetails);
  • BETA-function: In version 4.x we also added purchaseFuture which will wait for the purchase to finish and return the ActiveProducts.
await LinkFivePurchases.purchaseFuture(productDetails);

Restoring Purchases:

  • The restore method enables users to restore previously purchased products. This ensures continued access to subscribed features upon app reinstallation or device change.
await LinkFivePurchases.restore();
  • BETA-function: In version 4.x we also added restoreFuture which will wait for the restore to finish and return the ActiveProducts.
await LinkFivePurchases.restoreFuture();

Products Stream:

  • The products stream continuously delivers information about the user's offering. Whenever the user opens the paywall, you want to show the data that is included in this stream.
stream = LinkFivePurchases.products.listen((products) {
  // Handle products here
});

Active Products Stream:

  • The activeProducts stream continuously delivers information about the user's active and verified products. This stream proves valuable for managing access to subscription-based features or One-time purchases within your application.
stream = LinkFivePurchases.activeProducts.listen((activeProducts) {
  // Handle active products here
});

Purchase in Progress Stream

The purchaseInProgressStream provides real-time updates on the purchase process. This stream is helpful for displaying loading indicators or disabling purchase buttons while a purchase is underway.

An example riverpod Notifier would be:

/// true -> show loading indicator / disable purchase button
/// false -> disable loading indicator / enable purchase Button  
class PremiumPurchaseInProgressNotifier extends Notifier<bool> {
  @override
  bool build() {
    final streamSub =
    ref.read(billingRepositoryProvider).purchaseInProgressStream().listen((bool isPurchaseInProgress) {
      state = isPurchaseInProgress;
    });
    ref.onDispose(() {
      streamSub.cancel();
    });
    return false;
  }
}

Subscription and One-Time Purchase Offerings

A typical riverpod notifier implementation would be:

class PremiumOfferNotifier extends Notifier<LinkFiveProducts?> {
  /// fetched once whenever the user enters the paywall  
  Future<void> fetchOffering() async {
    state = await ref.read(billingRepositoryProvider).fetchOffering();
  }

  void purchase(LinkFiveProductDetails productDetails) {
    ref.read(billingRepositoryProvider).purchase(productDetails.productDetails);
  }

  void restore() {
    ref.read(billingRepositoryProvider).restore();
  }

  @override
  LinkFiveProducts? build() {
    return null;
  }
}

And the Widget Implementation:

class _PurchasePaywall extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final premiumOffer = ref.watch(premiumOfferProvider);
    if (premiumOffer == null) {
      // return Page Loading Widget
    }
    
    return ListView(children: [
        for (final offer in premiumOffer.productDetailList)
          switch (offer.productType) {
              LinkFiveProductType.OneTimePurchase => LayoutBuilder(builder: (_, _) {
                // build your One Time Purchase Widget
                // e.g:
                // Text(offer.oneTimePurchasePrice.formattedPrice)
                
                // and later when pressed:
                // onPressed: () {
                //   ref.read(premiumOfferProvider.notifier).purchase(offer);
                // }
              }), 
              LinkFiveProductType.Subscription => LayoutBuilder(builder: (_, _) {
                // build your Subscription Purchase Widget
                // use the pricing Phases:
                // for (var pricingPhase in offer.pricingPhases) {
                //   Text(pricingPhase.formattedPrice);
                //   Text(pricingPhase.billingPeriod.iso8601); // e.g.: P6M
                // }

                // and later when pressed:
                // onPressed: () {
                //   ref.read(premiumOfferProvider.notifier).purchase(offer);
                // }
              }), 
          }
        ]
    );
  }
}

Active & Purchased Products

A typical riverpod notifier implementation would look like this:

class PremiumPurchaseNotifier extends Notifier<bool?> {
  Future<void> _initBilling() async {
    final activeProducts = await ref.read(billingRepositoryProvider).load();
    state = activeProducts.isNotEmpty;
    print("Billing initialized $state");
  }

  @override
  bool? build() {
    _initBilling();

    final purchaseStream = ref.read(billingRepositoryProvider).purchaseStream().listen((LinkFiveActiveProducts event) {
      print("Purchase Update $event");
      state = event.isNotEmpty;
    });
    ref.onDispose(() {
      purchaseStream.cancel();
    });
    return null;
  }
}

LinkFiveActiveProducts holds all active purchases, Subscriptions and One-Time Purchases.

// LinkFiveActiveProducts
activeProducts.planList;
activeProducts.oneTimePurchaseList;

Wrap LinkFive into a repository for testing

You can wrap the Purchases implementation inside a Repository pattern or use it directly.


final billingRepositoryProvider = Provider<BillingRepository>((ref) => LinkFiveBillingRepository());

class LinkFiveBillingRepository extends BillingRepository {
  @override
  Future<LinkFiveActiveProducts> load() async {
    return LinkFivePurchases.init(
      LinkFiveKey().apiKey,
      logLevel: LinkFiveLogLevel.DEBUG,
    );
  }

  @override
  Future<LinkFiveProducts?> fetchOffering() {
    return LinkFivePurchases.fetchProducts();
  }

  @override
  Future<LinkFiveActiveProducts> loadActiveProducts() async {
    return LinkFivePurchases.reloadActivePlans();
  }

  @override
  void purchase(ProductDetails productDetails) async {
    await LinkFivePurchases.purchase(productDetails);
  }

  @override
  Future<void> restore() async {
    await LinkFivePurchases.restore();
  }

  Stream<LinkFiveActiveProducts> listenToPurchases() {
    return LinkFivePurchases.activeProducts;
  }

  @override
  Stream<LinkFiveActiveProducts> purchaseStream() {
    return LinkFivePurchases.activeProducts;
  }

  @override
  Stream<bool> purchaseInProgressStream() {
    return LinkFivePurchases.purchaseInProgressStream;
  }
}

ProductDetails, Pricing Phase & Google‘s new Base Plans approach

Google changed how they handle subscriptions and added Base Plans & PricingPhases to it's new data model. Unfortunately, the in_app_purchase library exposes different models depending on the platform.

We decided to combine the ProductDetails class into a simple to use class called LinkFiveProductDetails which holds pricingPhases for both platforms, Android & iOS.

class LinkFiveProductDetails {

  /// Platform dependent Product Details such as GooglePlayProductDetails or AppStoreProductDetails
  final ProductDetails productDetails;

  /// Base64 encoded attributes which you can define on LinkFive
  final String? attributes;

  /// Converts the new Google Play & AppStore Model to a known list of pricing phases
  List<PricingPhase> get pricingPhases;
}

Pricing Phase

The PricingPhase class now holds all information about the product and it's phases. An example would be a FreeTrial phase and a yearly subscription as 2 elements in the PricingPhase list.

Here are all interesting parts of the class:

class PricingPhase {
  
  /// Represents a pricing phase, describing how a user pays at a point in time.
  int get billingCycleCount;

  /// Billing period for which the given price applies, specified in ISO 8601 format.
  Period get billingPeriod;

  /// Returns formatted price for the payment cycle, including its currency sign.
  String get formattedPrice;

  /// Returns the price for the payment cycle in micro-units, where 1,000,000
  /// micro-units equal one unit of the currency.
  int get priceAmountMicros;

  /// ISO 4217 e.g. EUR, USD
  String get priceCurrencyCode;

  /// Recurrence of the phase
  Recurrence get recurrence;
}

Period & PeriodUnit class

The Period class now holds the length of a subscription.

class Period {
  final int amount;
  final PeriodUnit periodUnit;
}

enum PeriodUnit {
  DAYS('D'),
  WEEKS('W'),
  MONTH('M'),
  YEARS('Y');
}

A Period of 3 months would be Period(amount: 3, periodUnit: MONTH) and a year would be Period(amount: 1, periodUnit: YEAR),

From the Period class to a readable user-friendly text

We added a intl-localization-package which uses the intl package that can help you translate the Period into a readable text.

Here is the package on pub.dev

You can use the isoCode from the billingPeriod to get a readable String:

final translationClass = pricingPhase.billingPeriod.iso8601.fromIso8601(PaywallL10NHelper.of(context));

The translation class will output:

  • 7 days from P7D
  • 1 month from P1M
  • 3 months from P3M (or also quarterly)
  • 1 year from P1Y (or also yearly)

You can submit your own translation to it's github Repository.

Easy Integration with the Paywall UI package

Integrate linkfive_purchases with package in_app_purchases_paywall_ui.

  • it's working with just passing the LinkFive client to the UI library
  • Automatic purchase state management
  • The UI is fully customizable
  • You can control the UI on our Website

Purchase Page

Simple Paywall

Success Page

Simple Success page Paywall

Page State Management

Simple Paywall Success state

That‘s it. Now the page will automatically offer the subscriptions to the user or if the user already bought the subscription, the paywall will show the success page.