mirror of
https://github.com/feeddeck/feeddeck.git
synced 2026-04-30 11:28:45 -05:00
[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:
2
.github/workflows/continuous-delivery.yaml
vendored
2
.github/workflows/continuous-delivery.yaml
vendored
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
207
app/lib/widgets/settings/premium/settings_premium.dart
Normal file
207
app/lib/widgets/settings/premium/settings_premium.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
250
app/lib/widgets/settings/premium/settings_premium_inapp.dart
Normal file
250
app/lib/widgets/settings/premium/settings_premium_inapp.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface IProfile {
|
||||
id: string;
|
||||
tier: "free" | "premium";
|
||||
subscriptionProvider?: "stripe" | "revenuecat";
|
||||
accountGithub?: {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
174
supabase/functions/revenuecat-webhooks-v1/index.ts
Normal file
174
supabase/functions/revenuecat-webhooks-v1/index.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user