[core] Add In-App Purchases (#24)

We have to add in-app purchases for the iOS, macOS and Android store, so
that users can also get the premium features of the app without using
Stripe for payments.

The in-app purchases are only enabled when a user uses the app with the
default Supabase environment or with the Supabase environment provided
during build time. If a user uses his own Supabase instance, he will not
be able to upgrade to the premium tier via in-app purchases.

We are using RevenueCat for in-app purchases, which automatically sends
all the events for a user to the "revenuecat-webhooks-v1" edge function.
Depending on the received event we can then upgrade / downgrade the
users profile. To be able to use RevenueCat as an additional provider to
Stripe we also had to add a new "subscriptionProvider" provider column
to the "profiles" table, which stores the information via which provider
a user upgraded his account.
This commit is contained in:
Rico Berger
2023-10-12 19:53:11 +02:00
committed by GitHub
parent 58e38a0a10
commit 0487dbdcde
27 changed files with 1071 additions and 190 deletions

View File

@@ -95,6 +95,7 @@ jobs:
supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
supabase functions deploy stripe-create-checkout-session-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
- name: Push Database Migration and Deploy Functions
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
@@ -115,6 +116,7 @@ jobs:
supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
supabase functions deploy stripe-create-checkout-session-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json
# The "Web" job builds the Flutter web app and publishes it to Cloudflare Pages. The job only runs when a commit is
# pushed to the main branch or a new tag is created.

View File

@@ -329,6 +329,7 @@ supabase functions deploy profile-v1 --project-ref <PROJECT-ID> --import-map sup
supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref <PROJECT-ID> --import-map supabase/functions/import_map.json
supabase functions deploy stripe-create-checkout-session-v1 --project-ref <PROJECT-ID> --import-map supabase/functions/import_map.json
supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref <PROJECT-ID> --import-map supabase/functions/import_map.json
supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref <PROJECT-ID> --import-map supabase/functions/import_map.json
```
Now we have to do some manual steps to finish the setup of our Supabase project:

View File

@@ -18,6 +18,12 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- purchases_flutter (5.7.0):
- Flutter
- PurchasesHybridCommon (= 6.2.0)
- PurchasesHybridCommon (6.2.0):
- RevenueCat (= 4.26.0)
- RevenueCat (4.26.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -40,6 +46,7 @@ DEPENDENCIES:
- just_audio (from `.symlinks/plugins/just_audio/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
@@ -49,6 +56,8 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- FMDB
- PurchasesHybridCommon
- RevenueCat
EXTERNAL SOURCES:
app_links:
@@ -67,6 +76,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
purchases_flutter:
:path: ".symlinks/plugins/purchases_flutter/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
sign_in_with_apple:
@@ -88,6 +99,9 @@ SPEC CHECKSUMS:
just_audio: baa7252489dbcf47a4c7cc9ca663e9661c99aafa
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
purchases_flutter: 9b875ca9540663d7cdaad9e574a65bd98a1e014c
PurchasesHybridCommon: 8ac2a833eb1a27c4d0d8a352418f1eaa5f4fe68d
RevenueCat: 1f3a5a1c3899cb27ef9279d77a01a807d489a240
shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472
sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904

View File

@@ -10,6 +10,7 @@
11A5CB70E48EE53811F041B1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4F63A76E5CF724D941DA24D /* Pods_Runner.framework */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
55F35B592ABF74D1007331B3 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55F35B582ABF74D1007331B3 /* StoreKit.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -34,6 +35,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
551175C42A39020E00A80299 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
55F35B582ABF74D1007331B3 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
5C1B3914BEEA60B960FC050D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -56,6 +58,7 @@
buildActionMask = 2147483647;
files = (
11A5CB70E48EE53811F041B1 /* Pods_Runner.framework in Frameworks */,
55F35B592ABF74D1007331B3 /* StoreKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -121,6 +124,7 @@
AC99711BA86FAD05300ADB7E /* Frameworks */ = {
isa = PBXGroup;
children = (
55F35B582ABF74D1007331B3 /* StoreKit.framework */,
E4F63A76E5CF724D941DA24D /* Pods_Runner.framework */,
);
name = Frameworks;

View File

@@ -29,6 +29,40 @@ FDProfileTier getProfileTierFromString(String state) {
return FDProfileTier.free;
}
/// [FDProfileSubscriptionProvider] is a enum value which defines the different
/// subscription providers for a profile. A user can use [stripe] or
/// [revenuecat] to get a premium account.
enum FDProfileSubscriptionProvider {
stripe,
revenuecat,
}
/// [FDProfileSubscriptionProviderExtension] defines all extensions which are
/// available for the [FDProfileSubscriptionProvider] enum type.
extension FDProfileSubscriptionProviderExtension
on FDProfileSubscriptionProvider {
/// [toShortString] returns a short string of the source type which can safely
/// be passed to our database.
String toShortString() {
return toString().split('.').last;
}
}
/// [getSourceTypeFromString] returns the [FDProfileSubscriptionProvider] from
/// his string representation. This is used to parse the JSON value returned by
/// our database into correct enum value in the [FDSource] model.
FDProfileSubscriptionProvider? getProfileSubscriptionProviderFromString(
String state,
) {
for (FDProfileSubscriptionProvider element
in FDProfileSubscriptionProvider.values) {
if (element.toShortString() == state) {
return element;
}
}
return null;
}
/// [FDProfile] is the model for a profile of a user in our app. The following
/// fields are required for a profile:
/// - An [id] to uniquely identify a column in the database
@@ -38,6 +72,7 @@ FDProfileTier getProfileTierFromString(String state) {
class FDProfile {
String id;
FDProfileTier tier;
FDProfileSubscriptionProvider? subscriptionProvider;
bool accountGithub;
int createdAt;
int updatedAt;
@@ -45,6 +80,7 @@ class FDProfile {
FDProfile({
required this.id,
required this.tier,
required this.subscriptionProvider,
required this.accountGithub,
required this.createdAt,
required this.updatedAt,
@@ -54,6 +90,11 @@ class FDProfile {
return FDProfile(
id: data['id'],
tier: getProfileTierFromString(data['tier']),
subscriptionProvider: data.containsKey('subscriptionProvider') &&
data['subscriptionProvider'] != null
? getProfileSubscriptionProviderFromString(
data['subscriptionProvider'])
: null,
accountGithub: data['accountGithub'],
createdAt: data['createdAt'],
updatedAt: data['updatedAt'],

View File

@@ -19,6 +19,8 @@ class ProfileRepository with ChangeNotifier {
FDProfileStatus get status => _status;
FDProfileTier get tier => _profile?.tier ?? FDProfileTier.free;
FDProfileSubscriptionProvider? get subscriptionProvider =>
_profile?.subscriptionProvider;
bool get accountGithub => _profile?.accountGithub ?? false;
Future<void> init(bool force) async {
@@ -45,6 +47,11 @@ class ProfileRepository with ChangeNotifier {
}
}
void setTier(FDProfileTier tier) {
_profile?.tier = tier;
notifyListeners();
}
Future<void> addGithubAccount(String token) async {
final result = await Supabase.instance.client.functions.invoke(
'profile-v1',

View File

@@ -19,6 +19,18 @@ class SettingsRepository {
String googleClientId =
'420185423235-9ehth1eodl4lt3cdns7kaf2e89eo6rkq.apps.googleusercontent.com';
/// By default the [subscriptionEnabled] variable is set to `true`, so that a
/// user can subscribe to FeedDeck Premium. If the variable is set to `false`
/// the user can not subscribe to FeedDeck Premium.
bool subscriptionEnabled = true;
/// The [revenueCatAppStoreKey] and [revenueCatGooglePlayKey] are used for the
/// in-app purchases. The [revenueCatAppStoreKey] is used for the Apple App
/// Store on iOS and macOS. The [revenueCatGooglePlayKey] is used for the
/// Google Play Store on Android.
final String revenueCatAppStoreKey = 'appl_kThbIaMkylDBtCEmsfczvgCBram';
final String revenueCatGooglePlayKey = 'goog_tBFPbLbygjioviXRIlGlmUOKZYA';
factory SettingsRepository() {
return _instance;
}
@@ -52,11 +64,16 @@ class SettingsRepository {
googleClientIdPrefs != null) {
/// Store the user provided values within the [SettingsRepository] and
/// use them to initialize the Supabase client.
///
/// Also set the [subscriptionEnabled] variabel to `false`, so that the
/// user can not subscribe to FeedDeck Premium.
supabaseUrl = supabaseUrlPrefs;
supabaseAnonKey = supabaseAnonKeyPrefs;
supabaseSiteUrl = supabaseSiteUrlPrefs;
googleClientId = googleClientIdPrefs;
subscriptionEnabled = false;
await Supabase.initialize(
url: supabaseUrlPrefs,
anonKey: supabaseAnonKeyPrefs,

View File

@@ -0,0 +1,207 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:feeddeck/models/profile.dart';
import 'package:feeddeck/repositories/profile_repository.dart';
import 'package:feeddeck/repositories/settings_repository.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/fd_icons.dart';
import 'package:feeddeck/widgets/general/logo.dart';
import 'package:feeddeck/widgets/settings/premium/settings_premium_inapp.dart';
import 'package:feeddeck/widgets/settings/premium/settings_premium_inapp_restore.dart';
import 'package:feeddeck/widgets/settings/premium/settings_premium_stripe.dart';
class SettingsPremium extends StatelessWidget {
const SettingsPremium({super.key});
/// [_showPaymentModal] show a modal to subscribe to FeedDeck Premium via
/// Stripe on the web, Linux and Windows. On macOS, Android and iOS the modal
/// to subscribe via in-app purchases is shown.
void _showPaymentModal(BuildContext context) {
if (kIsWeb || Platform.isLinux || Platform.isWindows) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Constants.spacingMiddle),
),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
constraints: const BoxConstraints(
maxWidth: Constants.centeredFormMaxWidth,
),
builder: (BuildContext context) {
return const SettingsPremiumStripe();
},
);
} else if (Platform.isMacOS || Platform.isAndroid || Platform.isIOS) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Constants.spacingMiddle),
),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
constraints: const BoxConstraints(
maxWidth: Constants.centeredFormMaxWidth,
),
builder: (BuildContext context) {
return const SettingsPremiumInApp();
},
);
}
}
@override
Widget build(BuildContext context) {
ProfileRepository profile = Provider.of<ProfileRepository>(
context,
listen: true,
);
/// If subscriptions are disabled, because the user uses a custom Supabase
/// instance or if the profile is not initialized yet or if the user is
/// already on the premium tier we do not show the option to subscribe to
/// FeedDeck Premium.
if (!SettingsRepository().subscriptionEnabled) {
return Container();
}
if (profile.status == FDProfileStatus.uninitialized) {
return Container();
}
/// In-App Purchases are disabled on Android for now, until we have the
/// first version of the app in the Play Store, so that we can properly test
/// the implementation.
///
/// TODO: Enable once the first Android version is in the Play Store.
if (!kIsWeb && Platform.isAndroid) {
return Container();
}
if (profile.tier != FDProfileTier.free) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Constants.primary,
margin: const EdgeInsets.only(
bottom: Constants.spacingSmall,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(
Constants.spacingMiddle,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Characters('You are using FeedDeck Premium')
.replaceAll(
Characters(''),
Characters('\u{200B}'),
)
.toString(),
maxLines: 1,
style: const TextStyle(
color: Constants.onPrimary,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const Icon(
FDIcons.feeddeck,
color: Constants.onPrimary,
),
],
),
),
],
),
),
const SizedBox(
height: Constants.spacingMiddle,
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _showPaymentModal(context),
child: Card(
color: Constants.primary,
margin: const EdgeInsets.only(
bottom: Constants.spacingSmall,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(
Constants.spacingMiddle,
),
child: Column(
children: const [
Logo(size: 64),
SizedBox(
height: Constants.spacingSmall,
),
Text(
'Subscribe to FeedDeck Premium',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Constants.onPrimary,
),
),
],
),
),
],
),
),
),
),
const SettingsPremiumInAppRestore(),
const SizedBox(
height: Constants.spacingMiddle,
),
],
);
}
}

View File

@@ -0,0 +1,250 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:feeddeck/models/profile.dart';
import 'package:feeddeck/repositories/profile_repository.dart';
import 'package:feeddeck/repositories/settings_repository.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/fd_icons.dart';
import 'package:feeddeck/widgets/general/elevated_button_progress_indicator.dart';
class SettingsPremiumInApp extends StatefulWidget {
const SettingsPremiumInApp({super.key});
@override
State<SettingsPremiumInApp> createState() => _SettingsPremiumInAppState();
}
class _SettingsPremiumInAppState extends State<SettingsPremiumInApp> {
late Future<Offering?> _futureFetchOfferings;
bool _isLoading = false;
/// [_fetchOfferings] is used to fetch the Stripe checkout session
/// link. For that we have to call the `stripe-create-checkout-session-v1`
/// Supabase edge function. If the link is generated successfully, the
/// function returns the url, which can then be opened by the user.
Future<Offering?> _fetchOfferings() async {
if (Platform.isAndroid) {
await Purchases.configure(
PurchasesConfiguration(
SettingsRepository().revenueCatAppStoreKey,
)..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id,
);
} else if (Platform.isMacOS || Platform.isIOS) {
await Purchases.configure(
PurchasesConfiguration(
SettingsRepository().revenueCatAppStoreKey,
)..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id,
);
}
Offerings offerings = await Purchases.getOfferings();
if (offerings.current != null) {
return offerings.current;
} else {
return null;
}
}
/// [_purchase] is used to purchase the provided [package]. If the purchase
/// was successful, the user is notified. If the purchase failed, the user is
/// notified as well.
Future<void> _purchase(Package package) async {
try {
setState(() {
_isLoading = true;
});
CustomerInfo customerInfo = await Purchases.purchasePackage(package);
setState(() {
_isLoading = false;
});
if (customerInfo.entitlements.all['FeedDeck Premium']!.isActive) {
if (!mounted) return;
Provider.of<ProfileRepository>(
context,
listen: false,
).setTier(FDProfileTier.premium);
}
if (!mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 10),
backgroundColor: Constants.primary,
showCloseIcon: true,
content: Text(
'FeedDeck Premium was successfully purchased.',
style: TextStyle(color: Constants.onPrimary),
),
),
);
} on PlatformException catch (err) {
final errorCode = PurchasesErrorHelper.getErrorCode(err);
if (errorCode == PurchasesErrorCode.purchaseCancelledError) {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop();
} else {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 10),
backgroundColor: Constants.error,
showCloseIcon: true,
content: Text(
'In-app purchase failed: ${err.message}',
style: const TextStyle(color: Constants.onError),
),
),
);
}
} catch (err) {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 10),
backgroundColor: Constants.error,
showCloseIcon: true,
content: Text(
'In-app purchase failed: ${err.toString()}',
style: const TextStyle(color: Constants.onError),
),
),
);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
setState(() {
_futureFetchOfferings = _fetchOfferings();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
shape: const Border(
bottom: BorderSide(
color: Constants.dividerColor,
width: 1,
),
),
title: const Text('FeedDeck Premium'),
actions: [
IconButton(
icon: const Icon(
Icons.close,
),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
body: FutureBuilder(
future: _futureFetchOfferings,
builder: (
BuildContext context,
AsyncSnapshot<Offering?> snapshot,
) {
return Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(Constants.spacingMiddle),
child: SingleChildScrollView(
child: snapshot.connectionState == ConnectionState.none ||
snapshot.connectionState ==
ConnectionState.waiting ||
snapshot.hasError ||
snapshot.data == null ||
snapshot.data?.monthly == null
? const Text('Loading ...')
: MarkdownBody(
selectable: true,
data: '''
You are currently using the free version of FeedDeck, which allows you to add up
to 10 sources for the first 7 days. After that trial period your sources will
not be updated anymore.
To use FeedDeck after the trial period with up to 1000 sources, you need to
upgrade to a premium account. The premium account costs
${snapshot.data?.monthly?.storeProduct.priceString} per month and can be
canceled at any time.
''',
),
),
),
),
const SizedBox(
height: Constants.spacingSmall,
),
const Divider(
color: Constants.dividerColor,
height: 1,
thickness: 1,
),
Padding(
padding: const EdgeInsets.all(Constants.spacingMiddle),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Constants.primary,
foregroundColor: Constants.onPrimary,
maximumSize: const Size.fromHeight(
Constants.elevatedButtonSize,
),
minimumSize: const Size.fromHeight(
Constants.elevatedButtonSize,
),
),
label: Text(
snapshot.data?.monthly?.storeProduct.priceString != null
? 'Subscribe to FeedDeck Premium for ${snapshot.data?.monthly?.storeProduct.priceString}'
: 'Subscribe to FeedDeck Premium',
),
onPressed: snapshot.connectionState == ConnectionState.none ||
snapshot.connectionState == ConnectionState.waiting ||
snapshot.hasError ||
snapshot.data == null ||
snapshot.data?.monthly == null ||
_isLoading
? null
: () => _purchase(snapshot.data!.monthly!),
icon: snapshot.connectionState == ConnectionState.none ||
snapshot.connectionState == ConnectionState.waiting ||
snapshot.hasError ||
snapshot.data == null ||
snapshot.data?.monthly == null ||
_isLoading
? const ElevatedButtonProgressIndicator()
: const Icon(FDIcons.feeddeck),
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,198 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:feeddeck/models/profile.dart';
import 'package:feeddeck/repositories/profile_repository.dart';
import 'package:feeddeck/repositories/settings_repository.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/widgets/general/elevated_button_progress_indicator.dart';
class SettingsPremiumInAppRestore extends StatefulWidget {
const SettingsPremiumInAppRestore({super.key});
@override
State<SettingsPremiumInAppRestore> createState() =>
_SettingsPremiumInAppRestoreState();
}
class _SettingsPremiumInAppRestoreState
extends State<SettingsPremiumInAppRestore> {
bool _isLoading = false;
Future<void> _restore() async {
try {
setState(() {
_isLoading = true;
});
if (Platform.isAndroid) {
await Purchases.configure(
PurchasesConfiguration(
SettingsRepository().revenueCatAppStoreKey,
)..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id,
);
} else if (Platform.isMacOS || Platform.isIOS) {
await Purchases.configure(
PurchasesConfiguration(
SettingsRepository().revenueCatAppStoreKey,
)..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id,
);
} else {
return;
}
CustomerInfo customerInfo = await Purchases.restorePurchases();
setState(() {
_isLoading = false;
});
if (customerInfo.entitlements.all['FeedDeck Premium']!.isActive) {
if (!mounted) return;
Provider.of<ProfileRepository>(
context,
listen: false,
).setTier(FDProfileTier.premium);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 10),
backgroundColor: Constants.primary,
showCloseIcon: true,
content: Text(
'FeedDeck Premium was restored.',
style: TextStyle(color: Constants.onPrimary),
),
),
);
} on PlatformException catch (err) {
setState(() {
_isLoading = false;
});
final errorCode = PurchasesErrorHelper.getErrorCode(err);
if (errorCode == PurchasesErrorCode.purchaseCancelledError) {
return;
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 10),
backgroundColor: Constants.error,
showCloseIcon: true,
content: Text(
'Restore purchase failed: ${err.message}',
style: const TextStyle(color: Constants.onError),
),
),
);
}
} catch (err) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 10),
backgroundColor: Constants.error,
showCloseIcon: true,
content: Text(
'Restore purchase failed: ${err.toString()}',
style: const TextStyle(color: Constants.onError),
),
),
);
}
}
/// [buildIcon] return the provided icon or when the [_isLoading] state is
/// `true` is returns a circular progress indicator.
Widget buildIcon() {
if (_isLoading) return const ElevatedButtonProgressIndicator();
return Container();
}
@override
Widget build(BuildContext context) {
ProfileRepository profile = Provider.of<ProfileRepository>(
context,
listen: true,
);
/// We do not display the restore button when subscriptions are disabled,
/// the profile isn't initalized yet or a user is already subscribed to
/// FeedDeck Premium.
if (!SettingsRepository().subscriptionEnabled) {
return Container();
}
if (profile.status == FDProfileStatus.uninitialized) {
return Container();
}
if (profile.tier != FDProfileTier.free) {
return Container();
}
if (!kIsWeb && (Platform.isMacOS || Platform.isAndroid || Platform.isIOS)) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _restore(),
child: Card(
color: Constants.secondary,
margin: const EdgeInsets.only(
bottom: Constants.spacingSmall,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(
Constants.spacingMiddle,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Characters('Restore Purchases')
.replaceAll(
Characters(''),
Characters('\u{200B}'),
)
.toString(),
maxLines: 1,
style: const TextStyle(
overflow: TextOverflow.ellipsis,
),
),
],
),
),
buildIcon(),
],
),
),
],
),
),
),
);
}
return Container();
}
}

View File

@@ -1,134 +1,32 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:feeddeck/models/profile.dart';
import 'package:feeddeck/repositories/profile_repository.dart';
import 'package:feeddeck/utils/api_exception.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/fd_icons.dart';
import 'package:feeddeck/utils/openurl.dart';
import 'package:feeddeck/widgets/general/elevated_button_progress_indicator.dart';
import 'package:feeddeck/widgets/general/logo.dart';
const _settingsPaymentBannerText = '''
const _settingsPremiumStripeText = '''
You are currently using the free version of FeedDeck, which allows you to add up
to 10 sources for the first 7 days. After that trial period your sources will
not be updated anymore.
To use FeedDeck after the trial period with up to 1000 sources you need to
To use FeedDeck after the trial period with up to 1000 sources, you need to
upgrade to a premium account. The premium account costs 5 per month and can be
canceled at any time.
''';
class SettingsPaymentBanner extends StatelessWidget {
const SettingsPaymentBanner({super.key});
class SettingsPremiumStripe extends StatefulWidget {
const SettingsPremiumStripe({super.key});
@override
Widget build(BuildContext context) {
ProfileRepository profile = Provider.of<ProfileRepository>(
context,
listen: true,
);
if (profile.status == FDProfileStatus.uninitialized) {
return Container();
}
if (!kIsWeb) {
return Container();
}
if (profile.tier != FDProfileTier.free) {
return Container();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Constants.spacingMiddle),
),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
constraints: const BoxConstraints(
maxWidth: Constants.centeredFormMaxWidth,
),
builder: (BuildContext context) {
return const SettingsPaymentBannerModal();
},
);
},
child: Card(
color: Constants.primary,
margin: const EdgeInsets.only(
bottom: Constants.spacingSmall,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(
Constants.spacingMiddle,
),
child: Column(
children: const [
Logo(size: 64),
SizedBox(
height: Constants.spacingSmall,
),
Text(
'Subscribe to FeedDeck Premium',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Constants.onPrimary,
),
),
],
),
),
],
),
),
),
),
const SizedBox(
height: Constants.spacingMiddle,
),
],
);
}
State<SettingsPremiumStripe> createState() => _SettingsPremiumStripeState();
}
class SettingsPaymentBannerModal extends StatefulWidget {
const SettingsPaymentBannerModal({super.key});
@override
State<SettingsPaymentBannerModal> createState() =>
_SettingsPaymentBannerModalState();
}
class _SettingsPaymentBannerModalState
extends State<SettingsPaymentBannerModal> {
class _SettingsPremiumStripeState extends State<SettingsPremiumStripe> {
late Future<String> _futureFetchCheckoutSessionLink;
/// [_fetchCheckoutSessionLink] is used to fetch the Stripe checkout session
@@ -177,7 +75,7 @@ class _SettingsPaymentBannerModalState
width: 1,
),
),
title: const Text('Subscribe to FeedDeck Premium'),
title: const Text('FeedDeck Premium'),
actions: [
IconButton(
icon: const Icon(
@@ -203,7 +101,7 @@ class _SettingsPaymentBannerModalState
child: SingleChildScrollView(
child: MarkdownBody(
selectable: true,
data: _settingsPaymentBannerText,
data: _settingsPremiumStripeText,
),
),
),

View File

@@ -1,9 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:feeddeck/models/profile.dart';
import 'package:feeddeck/repositories/profile_repository.dart';
import 'package:feeddeck/utils/api_exception.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/openurl.dart';
@@ -22,80 +24,86 @@ class SettingsProfileCustomerPortal extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!kIsWeb) {
return Container();
}
ProfileRepository profile = Provider.of<ProfileRepository>(
context,
listen: true,
);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Constants.spacingMiddle),
),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
constraints: const BoxConstraints(
maxWidth: Constants.centeredFormMaxWidth,
),
builder: (BuildContext context) {
return const SettingsProfileCustomerPortalModal();
},
);
},
child: Card(
color: Constants.secondary,
margin: const EdgeInsets.only(
bottom: Constants.spacingSmall,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(
Constants.spacingMiddle,
if (profile.tier == FDProfileTier.premium &&
profile.subscriptionProvider == FDProfileSubscriptionProvider.stripe) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Constants.spacingMiddle),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Characters('Customer Portal')
.replaceAll(
Characters(''),
Characters('\u{200B}'),
)
.toString(),
maxLines: 1,
style: const TextStyle(
overflow: TextOverflow.ellipsis,
),
clipBehavior: Clip.antiAliasWithSaveLayer,
constraints: const BoxConstraints(
maxWidth: Constants.centeredFormMaxWidth,
),
builder: (BuildContext context) {
return const SettingsProfileCustomerPortalModal();
},
);
},
child: Card(
color: Constants.secondary,
margin: const EdgeInsets.only(
bottom: Constants.spacingSmall,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(
Constants.spacingMiddle,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Characters('Customer Portal')
.replaceAll(
Characters(''),
Characters('\u{200B}'),
)
.toString(),
maxLines: 1,
style: const TextStyle(
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
),
),
const Icon(Icons.receipt),
],
const Icon(Icons.receipt),
],
),
),
),
],
],
),
),
),
),
);
);
}
return Container();
}
}
@@ -119,9 +127,9 @@ class _SettingsProfileCustomerPortalModalState
/// Supabase edge function. If the link is generated successfully, the
/// function returns the url, which can then be opened by the user.
Future<String> _fetchCustomerPortalLink() async {
final result = await Supabase.instance.client.functions.invoke(
final result = await supabase.Supabase.instance.client.functions.invoke(
'stripe-create-billing-portal-link-v1',
method: HttpMethod.get,
method: supabase.HttpMethod.get,
);
if (result.status != 200) {

View File

@@ -6,9 +6,9 @@ import 'package:feeddeck/repositories/profile_repository.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/widgets/settings/accounts/settings_accounts.dart';
import 'package:feeddeck/widgets/settings/decks/settings_decks.dart';
import 'package:feeddeck/widgets/settings/premium/settings_premium.dart';
import 'package:feeddeck/widgets/settings/profile/settings_profile.dart';
import 'package:feeddeck/widgets/settings/settings_info.dart';
import 'package:feeddeck/widgets/settings/settings_payment_banner.dart';
/// The [Settings] widget implements the settings page for the FeedDeck app. The
/// page is used to display all the users information and to provide a way where
@@ -58,7 +58,7 @@ class _SettingsState extends State<Settings> {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SettingsPaymentBanner(),
SettingsPremium(),
/// Display all decks. Here the user can switch the active
/// deck, he can delete a deck or update the name of a

View File

@@ -11,6 +11,7 @@ import audio_session
import just_audio
import package_info_plus
import path_provider_foundation
import purchases_flutter
import screen_retriever
import shared_preferences_foundation
import sign_in_with_apple
@@ -25,6 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))

View File

@@ -1,4 +1,4 @@
platform :osx, '10.14'
platform :osx, '11.00'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -16,6 +16,12 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- purchases_flutter (5.7.0):
- FlutterMacOS
- PurchasesHybridCommon (= 6.2.0)
- PurchasesHybridCommon (6.2.0):
- RevenueCat (= 4.26.0)
- RevenueCat (4.26.0)
- screen_retriever (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@@ -39,6 +45,7 @@ DEPENDENCIES:
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`)
- purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`)
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos`)
- sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`)
@@ -49,6 +56,8 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- FMDB
- PurchasesHybridCommon
- RevenueCat
EXTERNAL SOURCES:
app_links:
@@ -65,6 +74,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos
purchases_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos
screen_retriever:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
shared_preferences_foundation:
@@ -87,6 +98,9 @@ SPEC CHECKSUMS:
just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
purchases_flutter: a3e23d64646625d452ef542c3b6207f97a055c21
PurchasesHybridCommon: 8ac2a833eb1a27c4d0d8a352418f1eaa5f4fe68d
RevenueCat: 1f3a5a1c3899cb27ef9279d77a01a807d489a240
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472
sign_in_with_apple: a9e97e744e8edc36aefc2723111f652102a7a727
@@ -94,6 +108,6 @@ SPEC CHECKSUMS:
url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
PODFILE CHECKSUM: 086a133fa7149895148dfc1f080138c34e40ddbf
COCOAPODS: 1.12.1
COCOAPODS: 1.13.0

View File

@@ -26,6 +26,7 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
553BE2622AC0648A002EA0C0 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 553BE2612AC0648A002EA0C0 /* StoreKit.framework */; };
FBF50439F2C4D0BF615EFED2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27D60858A3CD81E41E2CDBF2 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
@@ -70,6 +71,7 @@
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
46A27EFECA16837D10C377EF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
553BE2612AC0648A002EA0C0 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
55A996D72AB9F2F200780025 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
@@ -82,6 +84,7 @@
buildActionMask = 2147483647;
files = (
FBF50439F2C4D0BF615EFED2 /* Pods_Runner.framework in Frameworks */,
553BE2622AC0648A002EA0C0 /* StoreKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -167,6 +170,7 @@
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
553BE2612AC0648A002EA0C0 /* StoreKit.framework */,
27D60858A3CD81E41E2CDBF2 /* Pods_Runner.framework */,
);
name = Frameworks;
@@ -431,6 +435,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
@@ -560,6 +565,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -583,6 +589,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};

View File

@@ -280,6 +280,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d
url: "https://pub.dev"
source: hosted
version: "2.4.1"
functions_client:
dependency: transitive
description:
@@ -680,6 +688,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
purchases_flutter:
dependency: "direct main"
description:
name: purchases_flutter
sha256: fd28e581d4508aecb83f82768169115870fb6639ddb163d044e60cc5584227c8
url: "https://pub.dev"
source: hosted
version: "5.7.0"
realtime_client:
dependency: transitive
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+3
version: 1.0.0+4
environment:
sdk: '>=2.19.6 <3.0.0'
@@ -51,6 +51,7 @@ dependencies:
just_audio_mpv: ^0.1.6
package_info_plus: ^4.1.0
provider: ^6.0.4
purchases_flutter: ^5.7.0
rxdart: ^0.27.7
scroll_to_index: ^3.0.1
shared_preferences: ^2.1.0

View File

@@ -65,8 +65,9 @@ export default function Pricing() {
<div className="flex justify-center items-baseline my-8">
<span className="mr-2 text-5xl font-extrabold">5</span>
<span className="text-gray-400">
/month
/ month{" "}
</span>
<span className="text-xl text-gray-400">*</span>
</div>
<ul role="list" className="mb-8 space-y-4 text-left">
@@ -129,6 +130,10 @@ export default function Pricing() {
</div>
</div>
</div>
<div className="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
* The price for a premium account buyed via in-app purchase on iOS,
macOS or Android can be different due to the fees of the app stores.
</div>
</main>
);
}
@@ -142,9 +147,9 @@ const Feature = ({ children }: { children: ReactNode }) => (
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
clipRule="evenodd"
>
</path>
</svg>

View File

@@ -18,6 +18,7 @@ FEEDDECK_REDIS_PASSWORD=
FEEDDECK_STRIPE_API_KEY=
FEEDDECK_STRIPE_PRICE_ID=
FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET=
FEEDDECK_REVENUECAT_WEBHOOK_HEADER=
# Source Configuration
FEEDDECK_SOURCE_NITTER_INSTANCE=

View File

@@ -1,6 +1,7 @@
export interface IProfile {
id: string;
tier: "free" | "premium";
subscriptionProvider?: "stripe" | "revenuecat";
accountGithub?: {
token?: string;
};

View File

@@ -168,6 +168,7 @@ export const manageSubscriptionStatusChange = async (
const { error: updateError } = await adminSupabaseClient.from("profiles")
.update({
tier: isCreated ? "premium" : "free",
subscriptionProvider: "stripe",
}).eq("id", profile[0].id);
if (updateError) {
log("error", "Failed to update user profile with new tier value", {

View File

@@ -53,6 +53,8 @@ export const FEEDDECK_REDIS_PASSWORD =
* - FEEDDECK_STRIPE_API_KEY is the API key to access the Stripe API.
* - FEEDDECK_STRIPE_PRICE_ID is the id of the price used for the subscription.
* - FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET is the signing secret used to verify the Stripe webhook calls.
* - FEEDDECK_REVENUECAT_ENVIRONMENT is the Store environment, this could be "SANDBOX" or "PRODUCTION".
* - FEEDDECK_REVENUECAT_WEBHOOK_HEADER is the value of the authorization header send by RevenueCat.
*/
export const FEEDDECK_STRIPE_API_KEY =
Deno.env.get("FEEDDECK_STRIPE_API_KEY") ?? "";
@@ -60,6 +62,8 @@ export const FEEDDECK_STRIPE_PRICE_ID =
Deno.env.get("FEEDDECK_STRIPE_PRICE_ID") ?? "";
export const FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET =
Deno.env.get("FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET") ?? "";
export const FEEDDECK_REVENUECAT_WEBHOOK_HEADER =
Deno.env.get("FEEDDECK_REVENUECAT_WEBHOOK_HEADER") ?? "";
/**
* Source Configuration

View File

@@ -98,6 +98,7 @@ serve(async (req) => {
JSON.stringify({
"id": (profile[0] as IProfile).id,
"tier": (profile[0] as IProfile).tier,
"subscriptionProvider": (profile[0] as IProfile).subscriptionProvider,
"accountGithub": (profile[0] as IProfile).accountGithub?.token
? true
: false,

View File

@@ -0,0 +1,174 @@
import { serve } from "std/server";
import { createClient } from "@supabase/supabase-js";
import { log } from "../_shared/utils/log.ts";
import {
FEEDDECK_REVENUECAT_WEBHOOK_HEADER,
FEEDDECK_SUPABASE_SERVICE_ROLE_KEY,
FEEDDECK_SUPABASE_URL,
} from "../_shared/utils/constants.ts";
/**
* The `IEventPayload` interface represents the payload of a RevenueCat webhook call.
*/
interface IEventPayload {
api_version: string;
event: IEvent;
}
interface IEvent {
app_id: string;
app_user_id: string;
environment: string;
type: string;
}
/**
* `isAuthorized` checks if the request is authorized. This is done by checking the authorization header of the request,
* which must match the configured header in RevenueCat.
*/
const isAuthorized = (req: Request): boolean => {
const authorizationHeader = req.headers.get("Authorization");
if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) {
return false;
}
const authToken = authorizationHeader.split("Bearer ")[1];
if (authToken !== FEEDDECK_REVENUECAT_WEBHOOK_HEADER) {
return false;
}
return true;
};
/**
* `manageSubscriptionStatusChange` changes the subscription status of a user in the database.
*/
export const manageSubscriptionStatusChange = async (
userId: string,
isCreated = false,
) => {
/**
* Create a new admin client for Supabase, which is used in the following steps to access the database. This client
* is required because the user client does not have the permissions to access the `profiles` table.
*/
const adminSupabaseClient = createClient(
FEEDDECK_SUPABASE_URL,
FEEDDECK_SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);
/**
* Get the user profile from the database. If there is no profile or more than one profile, we return an error.
*/
const { data: profile, error: profileError } = await adminSupabaseClient
.from(
"profiles",
)
.select("*").eq("id", userId);
if (profileError || profile?.length !== 1) {
log("error", "Failed to get user profile", {
"userId": userId,
"error": profileError,
});
throw new Error("Failed to get user profile");
}
/**
* If the user is already on the correct tier, we return early.
*/
if (
(profile[0].tier === "free" && !isCreated) ||
(profile[0].tier === "premium" && isCreated)
) {
return;
}
const { error: updateError } = await adminSupabaseClient.from("profiles")
.update({
tier: isCreated ? "premium" : "free",
subscriptionProvider: "revenuecat",
}).eq("id", profile[0].id);
if (updateError) {
log("error", "Failed to update user profile with new tier value", {
"userId": userId,
"error": updateError,
});
throw new Error("Failed to update user profile with new tier value");
}
};
/**
* The `revenuecat-webhooks-v1` edge function handles all incomming RevenueCat webhooks. When we a receive a new event,
* we have to change the users account tier to `premium` or to `free`, depending on the received event.
*/
serve(async (req) => {
try {
/**
* If the request method is not POST, we return a 403 Forbidden error. This is done because we only want to accept
* POST requests. If the request is not authorized, we return a 401 Unauthorized error.
*/
if (req.method !== "POST") {
return new Response(
"Forbidden",
{
status: 403,
},
);
}
if (!isAuthorized(req)) {
return new Response(
"Unauthorized",
{
status: 401,
},
);
}
/**
* Get the payload of the received webhook event.
*/
const payload = await req.json() as IEventPayload;
log("debug", "Received event", { "event": payload.event });
/**
* If the event type is `INITIAL_PURCHASE`, `RENEWAL` or `UNCANCELLATION`, we change the subscription status of the
* user to `premium`. If the event type is `EXPIRATION`, we change the subscription status of the user to `free`.
* All other event types are ignored.
*/
if (
payload.event.type === "INITIAL_PURCHASE" ||
payload.event.type === "RENEWAL" ||
payload.event.type === "UNCANCELLATION"
) {
await manageSubscriptionStatusChange(payload.event.app_user_id, true);
return new Response("ok", {
status: 200,
});
} else if (payload.event.type === "EXPIRATION") {
await manageSubscriptionStatusChange(payload.event.app_user_id, false);
return new Response("ok", {
status: 200,
});
} else {
return new Response("ok", {
status: 200,
});
}
} catch (err) {
log("error", "An unexpected error occured", { "error": err.toString() });
return new Response(
"An unexpected error occured",
{
status: 500,
},
);
}
});

View File

@@ -0,0 +1,7 @@
------------------------------------------------------------------------------------------------------------------------
-- Add a new "subscriptionProvider" column to the "profiles" table, which is used to store the provider for a users
-- subscription. When a user is on the "free" tier, this column will be NULL. If the user is on the "premium" tier, this
-- column can be "stripe" or "revenuecat".
------------------------------------------------------------------------------------------------------------------------
ALTER TABLE "profiles"
ADD COLUMN "subscriptionProvider" VARCHAR(255) DEFAULT NULL;