Table of Contents
After my previous article on how to add payment gateway capabilities to your next Flutter app, I’m back today to show you how to add in-app purchases (IAP) to your iOS or Android application. In this tutorial, you will learn:
- The difference between payment gateways and in-app purchases
- The types of in-app purchases available to you
- How to make a simple Flutter app with a recurring monthly subscription payment
There is so much you can do with IAP. In this article, I will show you how to use a few basic tools to quickly set up a demo app with IAP capabilities. This tutorial is not meant to explain everything about IAPs. Instead, think of it as a quick-start guide you can use to start experimenting with in-app purchases.
In this tutorial, you’ll create an Android and iOS app that allows users to purchase a monthly subscription to your app. We’ll set up a parental gate before the in-app payments screen and a few alert dialogs that require the user to confirm the purchase before finalizing payment.
In this tutorial, you’ll create an Android and iOS app that allows users to purchase a monthly subscription to your app. We’ll set up a parental gate before the in-app payments screen and a few alert dialogs that require the user to confirm the purchase before finalizing payment.
This article is organized into 3 sections:
- Part 1: What Are In-App Purchases?
- Part 2: How to Set Up In-App Purchases with Apple & Google
- Part 3: How to Add In-App Purchases to Your Flutter App
Why is it important to understand in-app purchases? The bottom line is that you and/or your sponsor probably want to make money with your app.
Did you know that revenue from mobile stores reached a whopping $83.5b in 2019?
$54.2b (65%) of the total comes from the App Store and $29.3b (35%) comes from the Play Store. Although the share between in-app purchases and paid apps is not publicly available, it is estimated that in-app purchases account for the majority of app revenues. Moreover, 75% of the total revenues are linked to mobile games.
So here’s my suggestion for your next project: a mobile game with in-app purchases in Flutter! Although it would be awesome to create the entire game in this tutorial, to keep the article short and focused, I will instead create a single-screen app which you can use as a framework to implement IAP capabilities in your app.
Part 1: What Are In-App Purchases?
In-app purchases can be used to sell a variety of content through your app, including subscriptions, new features, and services. Users can make in-app purchases on all sorts of devices and operating systems — not just their mobile phones. For example, Apple users can make purchases on iOS, iPadOS, macOS, watchOS, and tvOS.
There are 4 types of in-app purchases in the Apple world:
- Consumables: Users can purchase different types of consumables, such as extra lives or gems in a game, to further their progress through an app. Consumable in-app purchases are used once, are depleted, and can be purchased again.
- Non-Consumables: Users can purchase non-consumable, premium features within an app, such as additional filters in a photo app. Non-consumables are purchased once and do not expire.
- Auto-Renewable Subscriptions: Users can purchase access to services or periodically updated content, such as monthly access to cloud storage or a weekly subscription to a magazine. Users are charged on a recurring basis until they decide to cancel.
- Non-Renewing Subscriptions: Users can purchase access to services or content for a limited time, such as a season pass to streaming content. This type of subscription does not renew automatically, so users need to renew at the end of each subscription period.
Google offers the following types of in-app purchases:
- One-time Products: These are in-app products requiring a single, non-recurring charge to the user’s form of payment. Additional game levels, premium loot boxes, and media files are examples of one-time products. The Google Play Console refers to one-time products as managed products, and the Google Play Billing library calls them “INAPP”.
- Subscriptions: Subscriptions are in-app products that require a recurring charge to the user’s form of payment. Online magazines and music streaming services are examples of subscriptions. The Google Play Billing Library calls these “SUBS”.
As you can see, in-app purchases in Apple and Google are basically the same. Apple offers a few more choices, but both work the same way: you either charge a one-time fee or a fee on a recurring basis.
In-App Purchases vs. Payment Gateways
Generally speaking, there are two ways to pay for things through Apple and Google: payment gateways and in-app purchases. A payment gateway is similar to a credit card transaction; it takes a small percentage of the transaction, plus a flat fee. Stripe is a great option for this — they collect a fee of 1.4% plus £0.20p per transaction in the UK and 2.9% plus $0.30 in the US.) IAPs use Apple’s or Google’s payment infrastructure and take a 30% cut on all purchases, whether they’re one-time payments or recurring subscription fees. After a year of service, your revenue share increases to 85% of the subscription price, minus applicable taxes — so Apple and Google take 15% of your revenue.
Payment gateway fees: 1–3%
In-pp purchase fees: 30%-15%
Based on the fee structure alone, it sounds like you’d be a fool not to go with a payment gateway. Unfortunately, you can’t use a payment gateway everywhere. Apart from a few specific use cases, you generally can’t use an outside payment processor.
So when should you use which?
When to Use a Payment Gateway
You must use a payment gateway if you are selling physical goods and services that are consumable outside of the app.
Per the App Store Review Guidelines:
3.1.5 (a) Physical Goods and Services Outside of the App: If your app enables people to purchase goods or services that will be consumed outside of the app, you must use purchase methods other than in-app purchase to collect those payments, such as Apple Pay or traditional credit card entry.
Note that you can sell services through a payment gateway if those services are provided outside of the app. This opens up few options, since you could technically sell your services on your website and make the app available to your website subscribers. The limitation is that you cannot advertise anywhere in an app that you are selling something outside of the OS. This is a pretty tough decision to make, as it has implications for not only product development but also user acquisition, engagement, and retention.
When to Use In-App Purchases
Originally, Netflix did not allow you to sign up for their $10/month plan within their app. Instead, they forced every user to activate their account online on the Netflix website. They did that for a long time, opting to avoid the IAP fees with clunkier user experience. That is, until they determined that the number of signups they received by allowing users to activate their subscription in the app outweighed the 30% cut of the revenue. Now Netflix allows users to sign up in the app, and they pay the IAP fees.
You can think of the IAP fees as the cost of marketing your app to the massive audiences that Apple and Google attract. This is a good strategy if you plan to have a large number of paying customers. However, if your revenue comes from a small number of paying customers (for example, if your app is a B2B2C platform) and you want to save money, you can use your website.
I cannot stress enough that your app will be rejected if you link to a website that displays a payment form. Even if you link to your homepage, and it links to a payment page, you’ll be rejected.
Long story short, you will quickly realize that in-app purchases are the best way to monetize your app.
You must use IAP if you are selling services within your app.
According to Apple’s official guidelines:
If you want to enable users to unlock features or functionality within your app (subscriptions, in-game currencies, game levels, access to premium content, or unlocking a full version of the app), you must use in-app purchases. Apps may use in-app purchase currencies to enable customers to “tip” digital content providers in the app. Apps and their metadata may not include buttons, external links, or other calls to action that direct customers to purchasing mechanisms other than in-app purchases.
Here is how Apple and Google sell this service to you as a developer:
Now let’s get started setting up in-app purchases!
Part 2: Set Up In-App Purchases on iOS & Android
Regardless of what OS you plan to use, you must set up your in-app purchases through the app stores. This is a lengthy process, and it requires different steps on iOS and Android. To keep this article concise, I’ve created a stand-alone article (How to set up In App Purchases in Apple Connect and Google Play Console) on how to set up in-app purchases on iOS and Android. Before moving on to Part 3, head over to that post and follow all the steps.
Part 3: How to Add In-App Purchases to Your Flutter App
My preferred method of adding IAP functionality to an app is to use RevenueCat. I’ve used it several times before, and it works like a charm. They simplify the process of adding IAP capabilities to apps built in Flutter (or any other platform, but who cares about Swift and Java now that we have Flutter?!).
Here are a few reasons to use their platform:
- Free to use. RevenueCat is completely free until you have over $10,000 USD in monthly revenue — which means it’s free for most full-stack developers. If you are lucky enough to have over $10k in monthly revenue, it shouldn’t be a problem to pay approximately 1% to them. Check out their pricing below.
- Documentation. For me, this is the main reason to use RevenueCat. They have impressive documentation that guides you through every step of the process in detail. Kudos to them for making it available and for including Flutter!
- Serverless. With RevenueCat, you don’t need to set up a server. Their subscription infrastructure is scalable and can be established in minutes.
- Dashboard. You get a page with helpful insights on your customer metrics (monthly revenue, active installs, trials, etc.).
- Charts. On paid plans, you get access to more detailed metrics, like daily revenue, conversion rate, churn rate, etc. This is cool, but you have to upgrade to a paid plan to get it. Go for it if you think it’s worth it!
There is also technical support and an internal discussion board where you can post questions. However, I have not needed to use either one, since everything was so straightforward!
RevenueCat has created a Flutter plugin (purchases_flutter) that we will use in our project.
Here’s what we are going to do:
- Step 1: Create a service credential for Android
- Step 2: Create in-app products in RevenueCat
- Step 3: Add the code to our Flutter app
Step 1: Create a service credential for Android
This is a tricky step, so make sure you follow the instructions carefully. You need to allow RevenueCat to see the in-app data of your Android app. (On iOS, you simply need to create a secret code, so this step isn’t necessary.)
First, navigate to the Play Console and click Settings > Developer account > API access.
I already have a service credential, but you’ll most likely see an empty page. If you have never used API access before, click Create New Project.
If you already have a project, you can link your account to your existing project by pressing the Link button.
Next, click Create Service Account to create a new service account associated with your project.
Follow the link to the Google API Console.
Click on the Google API Console link in the first bullet point. You will be directed to the Google Cloud Platform. Then click Create Service Account.
Add a name and a description and click Create.
For the role, select “Owner” and click Continue.
Click on Create Key.
Select JSON and click Create.
A file will be downloaded to your computer.
Close the message and click Done.
You will be redirected to the Service Account page and your new service account will show up in the list.
You can now close the Google Cloud Platform page and go back to the Google Play Console. Click Done to dismiss the dialog message.
Click Grant access for the service account you just created.
Choose “Finance” in the Role dropdown and make sure the “View app information” and “View financial data” options are selected. Click Add User.
You will be directed to the Users & Permissions screen, where you can see a list of all the users you have created (including the one we just created).
You’re done with Step 1! Remember where you saved your JSON file — you’ll need it in the next step.
Step 2: Create in-app products in RevenueCat
Go to the RevenueCat website and sign up for a free account.
Enter your name, email, and password, then click Sign Up. Don’t forget to verify your account by opening the email you receive from RevenueCat..
Next, add the details of your app. If you have followed all the instructions in this guide, all of this information should be easy to find. You need to add the bundle ID of your app, along with the app-specific shared secret (for Apple) and the Google service account. For the JSON file, open the file with a text editor like Atom and copy and paste all the text. Next click Add.
Your app will be created in the RevenueCat console.
Next, you will need to add a few attributes specific to the RevenueCat platform. First, let me explain a little bit about these attributes:
- Entitlements: An entitlement is something a user is entitled to in your app — for example, VIP access (access to all features of the app) or Pro access (access to only some features of the app). Entitlements include products.
- Products: This refers to the app subscription itself. You need to have a product for each platform (one for iOS and one for Android).
- Offerings: This is a selection of products that are offered to a user. Offerings allow you to choose which combination of products are shown to a user on your paywall screen.
Let’s create our first entitlement by selecting Entitlements and clicking New.
Next, add an identifier and a description.
Now let’s create an offering. Select Offerings and click New.
Next, add an identifier and a description.
Click on 0 packages.
Create a new monthly package and click Add.
You should now see the package added to your offering.
Finally, let’s create a product. Select Products and click New.
We need to create as many products as our subscription, times 2 (one product per platform). So we will create one product for iOS and one for Android. The name of each product must match the name of the subscriptions you created in Apple Connect and the Play Store. In my case, it’s “TestInApp” for iOS and “test_in_app” for Android.
Create a product for each subscription.
By the end of this step, you should have 2 products in your console.
Now we need to link the products to the entitlements and offerings. Select Entitlements and click on 0 products.
Click Attach.
Attach both products you just created.
Your products should now be listed under your associated products.
Next, go to the Offerings section and click on 0 packages.
Next, click Attach.
Finally, select the product in the dropdown and click Attach.
You have now listed your products in your offering.
This completes your RevenueCat setup! Well done!
Step 3: Add code to your Flutter app
This is the moment you’ve been waiting for: the very last step! At this point, it’s a simple process to add in-app purchases to your Flutter app.
First things first, import the following plugins to your pubspec.yaml file:
url_launcher: ^5.4.10
rflutter_alert: ^1.0.3
Open the iOS simulator and run flutter pub get. This does a few things for us, including creating a Pod file.
Next, launch Xcode. Open your project, then open the Pod file and uncomment the second line. This task defines a global platform for your project.
While you are in Xcode, make sure to add “In-App Purchase” to the project capabilities section: project target -> Capabilities -> In-App Purchase
Next, go back to the pubspec.yaml file and add the RevenueCat plugin to it:
purchases_flutter: ^1.1.1
Now let’s make sure everything works in iOS and Android before we start coding our app.
- iOS. Run flutter pub get and flutter run to compile your project in the iOS simulator. If you have followed the steps, everything should work and your app should be up and running!
- Android. Open Android Studio and run the project. You’ll probably see some red notes in the console about url_launcher using a deprecated API. Don’t worry about that. The app should properly launch in your Android simulator.
In this tutorial, we won’t worry too much about the design because we are focused on building the IAP functionality. However, we will add a few components to make our main code simple to read.
Create the following 3 empty dart files in the /lib folder:
- components.dart: Here we’ll add some code to call from other files and make our main code look simpler.
- parental_gate.dart: This will be our second screen, where we will block access to certain users. This is a requirement on iOS if you develop apps for kids.
- upgrade.dart: This is the final screen, where we will handle in-app purchase transactions.
components.dart
We are going to add the following classes (in your real app, you may have these in different files):
- Singleton
- Color
- Style
- Widget
First, let’s create a singleton class to keep track of variables. This isn’t strictly necessary, but you may want to reuse your code for other apps, so let’s do it since it’s super easy. We will create a variable called “isPro”, where we will keep track of whether or not the user has already made an in-app purchase.
class AppData {
static final AppData _appData = new AppData._internal();
bool isPro;
factory AppData() {
return _appData;
}
AppData._internal();
}
final appData = AppData();
Then add the following colors (you can change these if you want):
const kColorPrimary = Color(0xff283149);
const kColorPrimaryLight = Color(0xff424B67);
const kColorPrimaryDark = Color(0xff21293E);
const kColorAccent = Colors.blue;
const kColorText = Color(0xffDBEDF3);
Next, add the following styles:
var kWelcomeAlertStyle = AlertStyle(
animationType: AnimationType.grow,
isCloseButton: false,
isOverlayTapDismiss: false,
animationDuration: Duration(milliseconds: 450),
backgroundColor: kColorPrimaryLight,
alertBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
titleStyle: TextStyle(
color: kColorText,
fontWeight: FontWeight.bold,
fontSize: 30.0,
letterSpacing: 1.5,
),
);
TextStyle kSendButtonTextStyle = TextStyle(
color: kColorText,
fontWeight: FontWeight.bold,
fontSize: 20,
);
And finally, create a widget for the top navigation bar (I have added different transition effects depending on the operating system):
class TopBarAgnosticNoIcon extends StatelessWidget {
final String text;
final TextStyle style;
final String uniqueHeroTag;
final Widget child;
TopBarAgnosticNoIcon({
this.text,
this.style,
this.uniqueHeroTag,
this.child,
});
@override
Widget build(BuildContext context) {
if (!Platform.isIOS) {
return Scaffold(
backgroundColor: kColorPrimary,
appBar: AppBar(
iconTheme: IconThemeData(
color: kColorText, //change your color here
),
backgroundColor: kColorPrimaryLight,
title: Text(
text,
style: style,
),
),
body: child,
);
} else {
return CupertinoPageScaffold(
backgroundColor: kColorPrimary,
navigationBar: CupertinoNavigationBar(
backgroundColor: kColorPrimaryLight,
heroTag: uniqueHeroTag,
transitionBetweenRoutes: false,
middle: Text(
text,
style: style,
),
),
child: child,
);
}
}
}
When you’re finished, your component dart file should look like this. Don’t forget to import the relevant packages!
main.dart
Let’s modify our main file so that it includes a text widget and a button.
@override
Widget build(BuildContext context) {
return TopBarAgnosticNoIcon(
text: widget.title,
style: kSendButtonTextStyle,
uniqueHeroTag: 'main',
child: Scaffold(
backgroundColor: kColorPrimary,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: 18.0),
child: Text(
'Welcome',
style: kSendButtonTextStyle.copyWith(fontSize: 40),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: RaisedButton(
color: kColorAccent,
textColor: kColorText,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
'Purchase a subscription',
style: kSendButtonTextStyle,
),
),
onPressed: () {
if (appData.isPro) {
Navigator.push(context, MaterialPageRoute(builder: (context) => UpgradeScreen(), settings: RouteSettings(name: 'Upgrade screen')));
} else {
Navigator.push(context, MaterialPageRoute(builder: (context) => ParentalGate(), settings: RouteSettings(name: 'Parental Gate')));
}
}),
),
],
),
),
),
);
}
When you run your app, this is what you should see:
The first thing we need to do when our app loads is to set up in-app purchases and fetch in-app purchase information about the device. We only need to set up in-app purchases once, but you will fetch device information multiple times during the lifecycle of your app.
You can use the function Purchases.setup(“your_public_revenuecat_API_key”) to set up in-app purchases. Before you run it, you’ll need to add your RevenueCat API key, which you can find in the RevenueCat dashboard. (Please note this key is specific to your app.)
The second function we’ll use is Purchases.getPurchaserInfo()
. This function fetches in-app purchase information about the device/user that is stored on Apple or Google servers.
These two functions need to be run asynchronously, so let’s create a future state and handle the output accordingly. Let’s also enable debugging with Purchases.setDebugLogsEnabled(true).
import 'package:flutter/material.dart'; import 'package:test_in_app/parental_gate.dart'; import 'package:test_in_app/upgrade.dart'; import 'components.dart'; import 'package:purchases_flutter/purchases_flutter.dart'; import 'package:flutter/services.dart';@override void initState() { super.initState(); initPlatformState(); } Future<void> initPlatformState() async { appData.isPro = false; await Purchases.setDebugLogsEnabled(true): await Purchases.setup("your_api_key"); PurchaserInfo purchaserInfo; try { purchaserInfo = await Purchases.getPurchaserInfo(); print(purchaserInfo.toString()); if (purchaserInfo.entitlements.all['all_features'] != null) { appData.isPro = purchaserInfo.entitlements.all['all_features'].isActive; } else { appData.isPro = false; } } on PlatformException catch (e) { print(e); } print('#### is user pro? ${appData.isPro}'); }
Let’s run this and see what happens. You can see all the relevant entitlements and product information highlighted with the red/yellow arrows.
We’re done with the main file! Now let’s move on to the parental gate.
parental_gate.dart
This is optional but recommended since Apple requires parental gates in apps designed for kids.
A parental gate presents an adult-level challenge that must be completed in order to continue. The App Store Review Guidelines require all apps in the Kids category to use parental gates — this is to prevent kids from engaging in commerce or following links to websites, social networks, or other apps without the knowledge of their parent or guardian.
It’s a good idea to randomize the combination of questions and answers each time the gate is presented to prevent kids from memorizing the correct responses. For more details on Apple’s requirements, check out the relevant sections of the review guidelines:
• App Store Review Guidelines Section 1.3
• App Store Review Guidelines Section 5.1.4
You can make the parental gate as complex as you want. In this example, we are simply going to present a random math question and allow the user to move on to the next screen if the answer is correct. Let’s first create the relevant variables and methods to generate random numbers:
String answer;
int firstNumber = 0;
int secondNumber = 0;
String solution;
final myController = TextEditingController();
void solvePuzzle() {
firstNumber = generateRandomNumbers();
secondNumber = generateRandomNumbers();
solution = (firstNumber + secondNumber).toString();
setState(() {});
}
generateRandomNumbers() {
int min = 11;
int max = 95;
print('max is ' + max.toString());
int randomNumber = min + (Random().nextInt(max - min));
return randomNumber;
}
@override
void initState() {
firstNumber = generateRandomNumbers();
secondNumber = generateRandomNumbers();
solution = (firstNumber + secondNumber).toString();
super.initState();
}
Then let’s create a few text widgets with the instructions, as well as an alert that will display if the answer is not correct. I also added an image as a bonus.
@override
Widget build(BuildContext context) {
return TopBarAgnosticNoIcon(
text: 'Parental Gate',
style: kSendButtonTextStyle,
uniqueHeroTag: 'parental_gate',
child: Scaffold(
backgroundColor: kColorPrimary,
body: Center(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, bottom: 20.0),
child: Hero(
tag: 'logo',
child: CircleAvatar(
backgroundColor: kColorPrimary,
radius: 50.0,
backgroundImage: AssetImage("assets/images/avatar_demo.png"),
),
),
),
Text(
'Ask parent/guarding for help',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
SizedBox(
height: 40.0,
),
Text(
'Please hand over the device to a parent or a guardian to continue.',
style: kSendButtonTextStyle,
textAlign: TextAlign.center,
),
SizedBox(
height: 20.0,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'How much is ${firstNumber.toString()} plus ${secondNumber.toString()}?',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
),
),
),
TextField(
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
style: kSendButtonTextStyle,
controller: myController,
autofocus: true,
onChanged: (value) {
answer = value;
},
),
SizedBox(
height: 30.0,
),
Padding(
padding: const EdgeInsets.all(12.0),
child: RaisedButton(
color: kColorAccent,
textColor: kColorText,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
'Confirm',
style: kSendButtonTextStyle,
),
),
onPressed: () {
setState(() {
myController.text = '';
});
if (answer == solution) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UpgradeScreen(),
settings: RouteSettings(name: 'Upgrade screen'),
));
} else {
//try again
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Error",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'This is not correct. Please try again.',
textAlign: TextAlign.center,
style: kSendButtonTextStyle.copyWith(fontSize: 19, color: kColorText),
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
solvePuzzle();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
}
}),
),
],
),
),
),
),
),
);
}
Here is the result:
upgrade.dart
This last one is where all the IAP action will happen. We want the screen to look like this:
You can see we have a button for purchasing a subscription, a button for restoring purchases, and some links to the privacy policy and terms of use. Here’s how we are going to do it:
Step 1: Create an UpgradeScreen class
We’ll use this class to fetch IAP data and send the user to the upsell screen or a pro screen.
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'components.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:rflutter_alert/rflutter_alert.dart';
PurchaserInfo _purchaserInfo;
class UpgradeScreen extends StatefulWidget {
@override
State<StatefulWidget> createState() => _UpgradeScreenState();
}
class _UpgradeScreenState extends State<UpgradeScreen> {
Offerings _offerings;
@override
void initState() {
super.initState();
fetchData();
}
Future<void> fetchData() async {
PurchaserInfo purchaserInfo;
try {
purchaserInfo = await Purchases.getPurchaserInfo();
} on PlatformException catch (e) {
print(e);
}
Offerings offerings;
try {
offerings = await Purchases.getOfferings();
} on PlatformException catch (e) {
print(e);
}
if (!mounted) return;
setState(() {
_purchaserInfo = purchaserInfo;
_offerings = offerings;
});
}
@override
Widget build(BuildContext context) {
if (_purchaserInfo == null) {
return TopBarAgnosticNoIcon(
text: "Upgrade Screen",
style: kSendButtonTextStyle,
uniqueHeroTag: 'upgrade_screen',
child: Scaffold(
backgroundColor: kColorPrimary,
body: Center(
child: Text(
"Loading...",
))),
);
} else {
if (_purchaserInfo.entitlements.all.isNotEmpty && _purchaserInfo.entitlements.all['all_features'].isActive != null) {
appData.isPro = _purchaserInfo.entitlements.all['all_features'].isActive;
} else {
appData.isPro = false;
}
if (appData.isPro) {
return ProScreen();
} else {
return UpsellScreen(
offerings: _offerings,
);
}
}
}
}
Step 2: Create the upsell screen
This screen will show the user some text and buttons. If the purchase is successful, it will display a confirmation message and send the user back to the home page. I kept the restore button in this class and refactored the purchase button into another class, which makes the code cleaner.
class UpsellScreen extends StatefulWidget {
final Offerings offerings;
UpsellScreen({Key key, @required this.offerings}) : super(key: key);
@override
_UpsellScreenState createState() => _UpsellScreenState();
}
class _UpsellScreenState extends State<UpsellScreen> {
_launchURLWebsite(String zz) async {
if (await canLaunch(zz)) {
await launch(zz);
} else {
throw 'Could not launch $zz';
}
}
@override
Widget build(BuildContext context) {
if (widget.offerings != null) {
print('offeringS is not null');
print(widget.offerings.current.toString());
print('--');
print(widget.offerings.toString());
final offering = widget.offerings.current;
if (offering != null) {
final monthly = offering.monthly;
if (monthly != null) {
return TopBarAgnosticNoIcon(
text: "Upgrade Screen",
style: kSendButtonTextStyle,
uniqueHeroTag: 'purchase_screen',
child: Scaffold(
backgroundColor: kColorPrimary,
body: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
'Thanks for your interest in our app!',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
Padding(
padding: const EdgeInsets.all(18.0),
child: CircleAvatar(
backgroundColor: kColorPrimary,
radius: 45.0,
backgroundImage: AssetImage("assets/images/avatar_demo.png"),
),
),
Text(
'Choose one of the plan to continue to get access to all the app content.\n',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: PurchaseButton(package: monthly),
),
Padding(
padding: const EdgeInsets.all(18.0),
child: GestureDetector(
child: Container(
decoration: new BoxDecoration(
color: kColorPrimaryDark,
borderRadius: new BorderRadius.all(Radius.circular(10)),
),
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Text(
'Restore Purchase',
style: kSendButtonTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.normal,
),
),
),
),
onTap: () async {
try {
print('now trying to restore');
PurchaserInfo restoredInfo = await Purchases.restoreTransactions();
print('restore completed');
print(restoredInfo.toString());
appData.isPro = restoredInfo.entitlements.all["all_features"].isActive;
print('is user pro? ${appData.isPro}');
if (appData.isPro) {
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Congratulation",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'Your purchase has been restored!',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context, rootNavigator: true).pop();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
} else {
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Error",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'There was an error. Please try again later',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
}
} on PlatformException catch (e) {
print('----xx-----');
var errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode == PurchasesErrorCode.purchaseCancelledError) {
print("User cancelled");
} else if (errorCode == PurchasesErrorCode.purchaseNotAllowedError) {
print("User not allowed to purchase");
}
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Error",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'There was an error. Please try again later',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
}
return UpgradeScreen();
},
),
),
SizedBox(
height: 20.0,
),
Padding(
padding: const EdgeInsets.all(18.0),
child: GestureDetector(
onTap: () {
_launchURLWebsite('https://google.com');
},
child: Text(
'Privacy Policy (click to read)',
style: kSendButtonTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.normal,
),
),
),
),
Padding(
padding: const EdgeInsets.all(18.0),
child: GestureDetector(
onTap: () {
_launchURLWebsite('https://yahoo.com');
},
child: Text(
'Term of Use (click to read)',
style: kSendButtonTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.normal,
),
),
),
),
],
)),
)),
);
}
}
}
return TopBarAgnosticNoIcon(
text: "Upgrade Screen",
style: kSendButtonTextStyle,
uniqueHeroTag: 'upgrade_screen1',
child: Scaffold(
backgroundColor: kColorPrimary,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(18.0),
child: Icon(
Icons.error,
color: kColorText,
size: 44.0,
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
"There was an error. Please check that your device is allowed to make purchases and try again. Please contact us at [email protected] if the problem persists.",
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
),
],
),
)),
);
}
}
Step 3: Create a PurchaseButton class
This is where you will allow the user to purchase a subscription.
class PurchaseButton extends StatefulWidget {
final Package package;
PurchaseButton({Key key, @required this.package}) : super(key: key);
@override
_PurchaseButtonState createState() => _PurchaseButtonState();
}
class _PurchaseButtonState extends State<PurchaseButton> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 20.0, right: 20.0),
child: Container(
color: kColorPrimaryLight,
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 18.0),
child: RaisedButton(
onPressed: () async {
try {
print('now trying to purchase');
_purchaserInfo = await Purchases.purchasePackage(widget.package);
print('purchase completed');
appData.isPro = _purchaserInfo.entitlements.all["all_features"].isActive;
print('is user pro? ${appData.isPro}');
if (appData.isPro) {
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Congratulation",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'Well done, you now have full access to the app',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context, rootNavigator: true).pop();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
} else {
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Error",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'There was an error. Please try again later',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
}
} on PlatformException catch (e) {
print('----xx-----');
var errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode == PurchasesErrorCode.purchaseCancelledError) {
print("User cancelled");
} else if (errorCode == PurchasesErrorCode.purchaseNotAllowedError) {
print("User not allowed to purchase");
}
Alert(
context: context,
style: kWelcomeAlertStyle,
image: Image.asset(
"assets/images/avatar_demo.png",
height: 150,
),
title: "Error",
content: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20.0, right: 8.0, left: 8.0, bottom: 20.0),
child: Text(
'There was an error. Please try again later',
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
),
)
],
),
buttons: [
DialogButton(
radius: BorderRadius.circular(10),
child: Text(
"COOL",
style: kSendButtonTextStyle,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
},
width: 127,
color: kColorAccent,
height: 52,
),
],
).show();
}
return UpgradeScreen();
},
textColor: kColorText,
padding: const EdgeInsets.all(0.0),
child: Container(
width: MediaQuery.of(context).size.width / 1.5,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
Color(0xFF0D47A1),
Color(0xFF1976D2),
Color(0xFF42A5F5),
],
),
),
padding: const EdgeInsets.all(10.0),
child: Text(
'Buy ${widget.package.product.title}\n${widget.package.product.priceString}',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 18.0),
child: Text(
'${widget.package.product.description}',
textAlign: TextAlign.center,
style: kSendButtonTextStyle.copyWith(fontSize: 16, fontWeight: FontWeight.normal),
),
)
],
),
),
);
}
}
Step 4: Create a ProScreen class
We’ll use this class to confirm that the user has already purchased a subscription.
class ProScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TopBarAgnosticNoIcon(
text: "Upgrade Screen",
style: kSendButtonTextStyle,
uniqueHeroTag: 'pro_screen',
child: Scaffold(
backgroundColor: kColorPrimary,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(18.0),
child: Icon(
Icons.star,
color: kColorText,
size: 44.0,
),
),
Padding(
padding: const EdgeInsets.all(18.0),
child: Text(
"You are a Pro user.\n\nYou can use the app in all its functionality.\nPlease contact us at [email protected] if you have any problem",
textAlign: TextAlign.center,
style: kSendButtonTextStyle,
)),
],
),
)),
);
}
}
Now run the app so we can see it all come together!
If you’ve reached this point, congratulations! You worked really hard and you tied a lot of different components of the IAP world together.
Consider that time in a sandbox environment! Every transaction lasts 5 minutes and then renews up to 6 times. Also note that you will receive emails from Apple and Google about your subscription.
Before we finish up, let me show you the RevenueCat dashboard. You can easily view all of your app’s IAP transactions in your dashboard and drill down into the activity of individual users.
Troubleshooting
When I run into problems, it’s usually because of the iOS sandbox account. It can be really frustrating because there are no details of the error in Xcode or Android. I have built a few apps, and for some strange reason, the sandbox account doesn’t work. Sometimes it gets invalidated if you log in to the App Store, but not always, and it depends on which OS version you have. Below I’ve listed a few of the errors you may get if you don’t have the sandbox account set up properly. (The part marked in red is where you can see that there is no data in the PurchaserInfo object.)
If you are unable to fetch the data from RevenueCat with iOS, check out the Apple docs for more information on how to set up a sandbox account. There are also a lot of good questions in Stack Overflow, like this one for example. RevenueCat also has a nice article on the most frequent reasons for a failure to connect to Apple’s servers.
Conclusion
You’ve probably realized by now that while adding IAP capabilities to an app is definitely possible, it requires a lot of upfront investment to set up the environment. Writing the code is easy — the time-consuming part is all the admin work you have to do in Apple Connect and the Google Play Console.
I hope you liked this article (if you did, clap a few times!). And feel free to ask questions below if you get stuck on one of the steps of the tutorial. Stay safe, and happy coding!