From fb07dc747a1fcde730511d6e3d5a018a614e2ba4 Mon Sep 17 00:00:00 2001 From: Gabby Gurdin Date: Mon, 19 Feb 2024 12:36:59 -0500 Subject: [PATCH] updates to subscription paywall flow --- assets/l10n/intl_en.arb | 7 +- .../controllers/choreographer.dart | 39 +-- .../controllers/error_service.dart | 9 +- .../widgets/has_error_button.dart | 2 - lib/pangea/constants/local.key.dart | 2 + lib/pangea/controllers/class_controller.dart | 9 - .../controllers/subscription_controller.dart | 235 +++++++++++------- .../change_subscription.dart | 74 ++---- .../settings_subscription.dart | 80 ++++-- .../settings_subscription_view.dart | 44 +--- .../widgets/igc/pangea_text_controller.dart | 31 ++- lib/pangea/widgets/igc/paywall_card.dart | 123 +++++++++ .../subscription/subscription_buttons.dart | 69 +++-- .../subscription/subscription_options.dart | 121 ++++++--- .../subscription/subscription_paywall.dart | 98 ++------ pubspec.lock | 8 + pubspec.yaml | 1 + 17 files changed, 562 insertions(+), 390 deletions(-) create mode 100644 lib/pangea/widgets/igc/paywall_card.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index d433179ec..6fd305baf 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3927,5 +3927,10 @@ "placeholders": { "sender": {} } - } + }, + "subscriptionPopupTitle": "This sentence could have a grammar mistake...", + "subscriptionPopupDesc": "Subscribe today to unlock translation and grammar correction!", + "seeOptions": "See options", + "continuedWithoutSubscription": "Continue without subscribing", + "trialPeriodExpired": "Your trial period has expired" } \ No newline at end of file diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index c45a42ab0..b1f6aa0c0 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -1,11 +1,6 @@ import 'dart:async'; import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/choreographer/controllers/alternative_translator.dart'; import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart'; @@ -13,6 +8,7 @@ import 'package:fluffychat/pangea/choreographer/controllers/message_options.dart import 'package:fluffychat/pangea/constants/language_keys.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/enum/edit_type.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/class_model.dart'; @@ -21,6 +17,12 @@ import 'package:fluffychat/pangea/models/message_data_models.dart'; import 'package:fluffychat/pangea/models/widget_measurement.dart'; import 'package:fluffychat/pangea/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/utils/overlay.dart'; +import 'package:fluffychat/pangea/widgets/igc/paywall_card.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + import '../../../widgets/matrix.dart'; import '../../enum/use_type.dart'; import '../../models/choreo_record.dart'; @@ -51,8 +53,6 @@ class Choreographer { ChoreoMode choreoMode = ChoreoMode.igc; final StreamController stateListener = StreamController(); - bool toldToPay = false; - Choreographer(this.pangeaController, this.chatController) { _initialize(); } @@ -71,9 +71,14 @@ class Choreographer { void send(BuildContext context) { if (isFetching) return; - if (!pangeaController.subscriptionController.isSubscribed && !toldToPay) { - toldToPay = true; - pangeaController.subscriptionController.showPaywall(context); + if (pangeaController.subscriptionController.canSendStatus == + CanSendStatus.showPaywall) { + OverlayUtil.showPositionedCard( + context: context, + cardToShow: const PaywallCard(), + cardSize: const Size(325, 375), + transformTargetId: inputTransformTargetKey, + ); return; } @@ -199,22 +204,20 @@ class Choreographer { Future getLanguageHelp([bool tokensOnly = false]) async { try { if (errorService.isError) return; - if (!pangeaController.subscriptionController.isSubscribed && - pangeaController.subscriptionController.initialized) { - debugPrint('setting not subscribed error'); - errorService.setErrorAndLock( - ChoreoError( - type: ChoreoErrorType.unsubscribed, - ), - ); + final CanSendStatus canSendStatus = + pangeaController.subscriptionController.canSendStatus; + + if (canSendStatus != CanSendStatus.subscribed) { return; } + startLoading(); if (choreoMode == ChoreoMode.it && itController.isTranslationDone && !tokensOnly) { debugger(when: kDebugMode); } + await (choreoMode == ChoreoMode.it && !itController.isTranslationDone ? itController.getTranslationData(_useCustomInput) : igc.getIGCTextData(tokensOnly: tokensOnly)); diff --git a/lib/pangea/choreographer/controllers/error_service.dart b/lib/pangea/choreographer/controllers/error_service.dart index 8ec044244..199b1de8c 100644 --- a/lib/pangea/choreographer/controllers/error_service.dart +++ b/lib/pangea/choreographer/controllers/error_service.dart @@ -1,13 +1,12 @@ +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import '../../utils/error_handler.dart'; enum ChoreoErrorType { unknown, classDisabled, userDisabled, - unsubscribed, } class ChoreoError { @@ -22,8 +21,6 @@ class ChoreoError { return "Class Disabled"; case ChoreoErrorType.userDisabled: return "User Disabled"; - case ChoreoErrorType.unsubscribed: - return "Unsubscribed"; default: return ErrorCopy(context, raw).title; } @@ -35,8 +32,6 @@ class ChoreoError { return "Class Disabled"; case ChoreoErrorType.userDisabled: return "User Disabled"; - case ChoreoErrorType.unsubscribed: - return "Unsubscribed"; default: return ErrorCopy(context, raw).body; } @@ -48,8 +43,6 @@ class ChoreoError { return Icons.history_edu_outlined; case ChoreoErrorType.userDisabled: return Icons.history_edu_outlined; - case ChoreoErrorType.unsubscribed: - return Icons.lock_outline; default: return Icons.error_outline; } diff --git a/lib/pangea/choreographer/widgets/has_error_button.dart b/lib/pangea/choreographer/widgets/has_error_button.dart index 47bf7ea7f..2cc6320f7 100644 --- a/lib/pangea/choreographer/widgets/has_error_button.dart +++ b/lib/pangea/choreographer/widgets/has_error_button.dart @@ -28,8 +28,6 @@ class ChoreographerHasErrorButton extends StatelessWidget { ), ), ); - } else if (error.type == ChoreoErrorType.unsubscribed) { - pangeaController.subscriptionController.showPaywall(context); } }, mini: true, diff --git a/lib/pangea/constants/local.key.dart b/lib/pangea/constants/local.key.dart index b5f118aab..a0fd78670 100644 --- a/lib/pangea/constants/local.key.dart +++ b/lib/pangea/constants/local.key.dart @@ -8,4 +8,6 @@ class PLocalKey { // making this a random string so that it's harder to guess static const String activatedTrialKey = '7C4EuKIsph'; + static const String dismissedPaywall = 'dismissedPaywall'; + static const String paywallBackoff = 'paywallBackoff'; } diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index 6d028fda2..a1284ec63 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -64,15 +64,6 @@ class ClassController extends BaseController { (error, stackTrace) => ClassCodeUtil.messageSnack(context, ErrorCopy(context, error).body), ); - } else { - try { - //question for gabby: why do we need this in two places? - if (!_pangeaController.subscriptionController.isSubscribed) { - await _pangeaController.subscriptionController.showPaywall(context); - } - } catch (err) { - debugger(when: kDebugMode); - } } } diff --git a/lib/pangea/controllers/subscription_controller.dart b/lib/pangea/controllers/subscription_controller.dart index 0d8d28e17..e2c181c4d 100644 --- a/lib/pangea/controllers/subscription_controller.dart +++ b/lib/pangea/controllers/subscription_controller.dart @@ -23,11 +23,16 @@ import 'package:http/http.dart'; import 'package:purchases_flutter/purchases_flutter.dart'; import 'package:url_launcher/url_launcher_string.dart'; +enum CanSendStatus { + subscribed, + dimissedPaywall, + showPaywall, +} + class SubscriptionController extends BaseController { late PangeaController _pangeaController; SubscriptionInfo? subscription; - //convert this logic to use completer bool initialized = false; final StreamController subscriptionStream = StreamController.broadcast(); @@ -40,12 +45,6 @@ class SubscriptionController extends BaseController { (subscription!.currentSubscriptionId != null || subscription!.currentSubscription != null); - bool get currentSubscriptionAvailable => - isSubscribed && subscription?.currentSubscription != null; - - bool get currentSubscriptionIsTrial => - subscription?.currentSubscription?.isTrial ?? false; - Future initialize() async { try { if (_pangeaController.matrixState.client.userID == null) { @@ -60,7 +59,7 @@ class SubscriptionController extends BaseController { : MobileSubscriptionInfo(pangeaController: _pangeaController); await subscription!.configure(); - if (activatedNewUserTrial) { + if (_activatedNewUserTrial) { setNewUserTrial(); } @@ -94,7 +93,72 @@ class SubscriptionController extends BaseController { } } - bool get activatedNewUserTrial => + void submitSubscriptionChange( + SubscriptionDetails? selectedSubscription, + BuildContext context, { + bool isPromo = false, + }) async { + if (selectedSubscription != null) { + if (selectedSubscription.isTrial) { + activateNewUserTrial(); + return; + } + + if (kIsWeb) { + if (selectedSubscription.duration == null) { + ErrorHandler.logError( + m: "Tried to subscribe to web SubscriptionDetails with Null duration", + s: StackTrace.current, + ); + return; + } + final String paymentLink = await getPaymentLink( + selectedSubscription.duration!, + isPromo: isPromo, + ); + _pangeaController.pStoreService.save( + PLocalKey.beganWebPayment, + true, + ); + setState(); + launchUrlString( + paymentLink, + webOnlyWindowName: "_self", + ); + return; + } + if (selectedSubscription.package == null) { + ErrorHandler.logError( + m: "Tried to subscribe to SubscriptionDetails with Null revenuecat Package", + s: StackTrace.current, + ); + return; + } + try { + GoogleAnalytics.beginPurchaseSubscription( + selectedSubscription, + context, + ); + await Purchases.purchasePackage(selectedSubscription.package!); + GoogleAnalytics.updateUserSubscriptionStatus(true); + } catch (err) { + final errCode = PurchasesErrorHelper.getErrorCode( + err as PlatformException, + ); + if (errCode == PurchasesErrorCode.purchaseCancelledError) { + debugPrint("User cancelled purchase"); + return; + } + ErrorHandler.logError( + m: "Failed to purchase revenuecat package for user ${_pangeaController.matrixState.client.userID} with error code $errCode", + s: StackTrace.current, + ); + return; + } + } + } + + bool get _activatedNewUserTrial => _pangeaController.userController.inTrialWindow && (_pangeaController.pStoreService.read(PLocalKey.activatedTrialKey) ?? false); @@ -123,14 +187,56 @@ class SubscriptionController extends BaseController { return; } await subscription!.setCustomerInfo(); - setState(); } - Future showPaywall( - BuildContext context, [ - bool forceShow = false, - ]) async { + CanSendStatus get canSendStatus => isSubscribed + ? CanSendStatus.subscribed + : _shouldShowPaywall + ? CanSendStatus.showPaywall + : CanSendStatus.dimissedPaywall; + + DateTime? get _lastDismissedPaywall { + final lastDismissed = _pangeaController.pStoreService.read( + PLocalKey.dismissedPaywall, + ); + if (lastDismissed == null) return null; + return DateTime.tryParse(lastDismissed); + } + + int? get _paywallBackoff { + final backoff = _pangeaController.pStoreService.read( + PLocalKey.paywallBackoff, + ); + if (backoff == null) return null; + return backoff; + } + + bool get _shouldShowPaywall { + return initialized && + !isSubscribed && + (_lastDismissedPaywall == null || + DateTime.now().difference(_lastDismissedPaywall!).inHours > + (24 * (_paywallBackoff ?? 1))); + } + + void dismissPaywall() { + _pangeaController.pStoreService.save( + PLocalKey.dismissedPaywall, + DateTime.now().toString(), + ); + + if (_paywallBackoff == null) { + _pangeaController.pStoreService.save(PLocalKey.paywallBackoff, 1); + } else { + _pangeaController.pStoreService.save( + PLocalKey.paywallBackoff, + _paywallBackoff! + 1, + ); + } + } + + Future showPaywall(BuildContext context) async { try { if (!initialized) { await initialize(); @@ -138,14 +244,16 @@ class SubscriptionController extends BaseController { if (subscription?.availableSubscriptions.isEmpty ?? true) { return; } - if (!forceShow && isSubscribed) return; - showModalBottomSheet( + if (isSubscribed) return; + await showModalBottomSheet( isScrollControlled: true, useRootNavigator: !PlatformInfos.isMobile, clipBehavior: Clip.hardEdge, context: context, constraints: BoxConstraints( - maxHeight: PlatformInfos.isMobile ? 600 : 450, + maxHeight: PlatformInfos.isMobile + ? MediaQuery.of(context).size.height - 50 + : 600, ), builder: (_) { return SubscriptionPaywall( @@ -153,11 +261,28 @@ class SubscriptionController extends BaseController { ); }, ); + dismissPaywall(); } catch (e, s) { ErrorHandler.logError(e: e, s: s); } } + Future getPaymentLink(String duration, {bool isPromo = false}) async { + final Requests req = Requests(baseUrl: PApiUrls.baseAPI); + final String reqUrl = Uri.encodeFull( + "${PApiUrls.paymentLink}?pangea_user_id=${_pangeaController.matrixState.client.userID}&duration=$duration&redeem=$isPromo", + ); + final Response res = await req.get(url: reqUrl); + final json = jsonDecode(res.body); + String paymentLink = json["link"]["url"]; + + final String? email = await _pangeaController.userController.userEmail; + if (email != null) { + paymentLink += "?prefilled_email=${Uri.encodeComponent(email)}"; + } + return paymentLink; + } + Future fetchSubscriptionStatus() async { final Requests req = Requests(baseUrl: PApiUrls.baseAPI); final String reqUrl = Uri.encodeFull( @@ -183,82 +308,6 @@ class SubscriptionController extends BaseController { return subscribed; } - Future getPaymentLink(String duration, {bool isPromo = false}) async { - final Requests req = Requests(baseUrl: PApiUrls.baseAPI); - final String reqUrl = Uri.encodeFull( - "${PApiUrls.paymentLink}?pangea_user_id=${_pangeaController.matrixState.client.userID}&duration=$duration&redeem=$isPromo", - ); - final Response res = await req.get(url: reqUrl); - final json = jsonDecode(res.body); - String paymentLink = json["link"]["url"]; - - final String? email = await _pangeaController.userController.userEmail; - if (email != null) { - paymentLink += "?prefilled_email=${Uri.encodeComponent(email)}"; - } - return paymentLink; - } - - void submitSubscriptionChange( - SubscriptionDetails? selectedSubscription, - BuildContext context, { - bool isPromo = false, - }) async { - if (selectedSubscription != null) { - if (kIsWeb) { - if (selectedSubscription.duration == null) { - ErrorHandler.logError( - m: "Tried to subscribe to web SubscriptionDetails with Null duration", - s: StackTrace.current, - ); - return; - } - final String paymentLink = await getPaymentLink( - selectedSubscription.duration!, - isPromo: isPromo, - ); - _pangeaController.pStoreService.save( - PLocalKey.beganWebPayment, - true, - ); - setState(); - launchUrlString( - paymentLink, - webOnlyWindowName: "_self", - ); - return; - } - if (selectedSubscription.package == null) { - ErrorHandler.logError( - m: "Tried to subscribe to SubscriptionDetails with Null revenuecat Package", - s: StackTrace.current, - ); - return; - } - try { - GoogleAnalytics.beginPurchaseSubscription( - selectedSubscription, - context, - ); - await Purchases.purchasePackage(selectedSubscription.package!); - GoogleAnalytics.updateUserSubscriptionStatus(true); - } catch (err) { - final errCode = PurchasesErrorHelper.getErrorCode( - err as PlatformException, - ); - if (errCode == PurchasesErrorCode.purchaseCancelledError) { - debugPrint("User cancelled purchase"); - return; - } - ErrorHandler.logError( - m: "Failed to purchase revenuecat package for user ${_pangeaController.matrixState.client.userID} with error code $errCode", - s: StackTrace.current, - ); - return; - } - } - } - Future redeemPromoCode(BuildContext context) async { final List? promoCode = await showTextInputDialog( useRootNavigator: false, diff --git a/lib/pangea/pages/settings_subscription/change_subscription.dart b/lib/pangea/pages/settings_subscription/change_subscription.dart index 8a57a317f..e9c5c5f1c 100644 --- a/lib/pangea/pages/settings_subscription/change_subscription.dart +++ b/lib/pangea/pages/settings_subscription/change_subscription.dart @@ -1,13 +1,9 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/pages/settings_subscription/settings_subscription.dart'; import 'package:fluffychat/pangea/widgets/subscription/subscription_buttons.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class ChangeSubscription extends StatelessWidget { final SubscriptionManagementController controller; @@ -20,26 +16,7 @@ class ChangeSubscription extends StatelessWidget { @override Widget build(BuildContext context) { - void submitChange({bool isPromo = false}) { - try { - pangeaController.subscriptionController.submitSubscriptionChange( - controller.selectedSubscription, - context, - isPromo: isPromo, - ); - } catch (err) { - showOkAlertDialog( - context: context, - title: L10n.of(context)!.oopsSomethingWentWrong, - message: L10n.of(context)!.errorPleaseRefresh, - okLabel: L10n.of(context)!.close, - ); - } - } - - return pangeaController.subscriptionController.subscription != null && - pangeaController.subscriptionController.subscription! - .availableSubscriptions.isNotEmpty + return controller.subscriptionsAvailable ? Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, @@ -59,44 +36,25 @@ class ChangeSubscription extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ OutlinedButton( - onPressed: () => submitChange(), - child: Text(L10n.of(context)!.pay), - ), - const SizedBox(height: 10), - if (kIsWeb) - OutlinedButton( - onPressed: () => submitChange(isPromo: true), - child: Text(L10n.of(context)!.redeemPromoCode), + onPressed: () => controller.submitChange(), + child: Text( + controller.selectedSubscription!.isTrial + ? L10n.of(context)!.activateTrial + : L10n.of(context)!.pay, ), + ), + const SizedBox(height: 20), ], ), ), - // if (controller.selectedSubscription != null && Platform.isIOS) - // TextButton( - // onPressed: () { - // try { - // pangeaController.subscriptionController - // .redeemPromoCode(context); - // } catch (err) { - // showOkAlertDialog( - // context: context, - // title: L10n.of(context)!.oopsSomethingWentWrong, - // message: L10n.of(context)!.errorPleaseRefresh, - // okLabel: L10n.of(context)!.close, - // ); - // } - // }, - // child: Text(L10n.of(context)!.redeemPromoCode), - // ) ], ) - : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(L10n.of(context)!.oopsSomethingWentWrong), - Text(L10n.of(context)!.errorPleaseRefresh), - ], + : const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), ), ); } diff --git a/lib/pangea/pages/settings_subscription/settings_subscription.dart b/lib/pangea/pages/settings_subscription/settings_subscription.dart index 8a395615f..4121f1897 100644 --- a/lib/pangea/pages/settings_subscription/settings_subscription.dart +++ b/lib/pangea/pages/settings_subscription/settings_subscription.dart @@ -1,14 +1,15 @@ import 'dart:async'; +import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/config/environment.dart'; -import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/pages/settings_subscription/settings_subscription_view.dart'; import 'package:fluffychat/pangea/utils/subscription_app_id.dart'; import 'package:fluffychat/pangea/widgets/subscription/subscription_snackbar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -21,27 +22,30 @@ class SubscriptionManagement extends StatefulWidget { } class SubscriptionManagementController extends State { - final PangeaController pangeaController = MatrixState.pangeaController; + final SubscriptionController subscriptionController = + MatrixState.pangeaController.subscriptionController; SubscriptionDetails? selectedSubscription; late StreamSubscription _settingsSubscription; StreamSubscription? _subscriptionStatusStream; @override void initState() { - _settingsSubscription = - pangeaController.subscriptionController.stateStream.listen((event) { + if (!subscriptionController.initialized) { + subscriptionController.initialize().then((_) => setState(() {})); + } + + _settingsSubscription = subscriptionController.stateStream.listen((event) { debugPrint("stateStream event in subscription settings"); setState(() {}); }); - _subscriptionStatusStream ??= pangeaController - .subscriptionController.subscriptionStream.stream - .listen((_) { + _subscriptionStatusStream ??= + subscriptionController.subscriptionStream.stream.listen((_) { showSubscribedSnackbar(context); context.go('/rooms'); }); - pangeaController.subscriptionController.updateCustomerInfo(); + subscriptionController.updateCustomerInfo(); super.initState(); } @@ -52,44 +56,76 @@ class SubscriptionManagementController extends State { _subscriptionStatusStream?.cancel(); } + bool get subscriptionsAvailable => + subscriptionController.subscription?.availableSubscriptions.isNotEmpty ?? + false; + bool get currentSubscriptionAvailable => - pangeaController.subscriptionController.currentSubscriptionAvailable; + subscriptionController.isSubscribed && + subscriptionController.subscription?.currentSubscription != null; - String? get purchasePlatformDisplayName => pangeaController - .subscriptionController.subscription?.purchasePlatformDisplayName; + String? get purchasePlatformDisplayName => + subscriptionController.subscription?.purchasePlatformDisplayName; bool get currentSubscriptionIsPromotional => - pangeaController.subscriptionController.subscription - ?.currentSubscriptionIsPromotional ?? + subscriptionController.subscription?.currentSubscriptionIsPromotional ?? false; bool get isNewUserTrial => - pangeaController.subscriptionController.subscription?.isNewUserTrial ?? - false; + subscriptionController.subscription?.isNewUserTrial ?? false; + + String get currentSubscriptionTitle => + subscriptionController.subscription?.currentSubscription + ?.displayName(context) ?? + ""; + + String get currentSubscriptionPrice => + subscriptionController.subscription?.currentSubscription + ?.displayPrice(context) ?? + ""; bool get showManagementOptions { if (!currentSubscriptionAvailable) { return false; } - if (pangeaController.subscriptionController.subscription!.purchasedOnWeb) { + if (subscriptionController.subscription!.purchasedOnWeb) { return true; } - return pangeaController.subscriptionController.subscription! - .currentPlatformMatchesPurchasePlatform; + return subscriptionController + .subscription!.currentPlatformMatchesPurchasePlatform; + } + + void submitChange({bool isPromo = false}) { + try { + subscriptionController.submitSubscriptionChange( + selectedSubscription, + context, + isPromo: isPromo, + ); + setState(() {}); + } catch (err) { + showOkAlertDialog( + context: context, + title: L10n.of(context)!.oopsSomethingWentWrong, + message: L10n.of(context)!.errorPleaseRefresh, + okLabel: L10n.of(context)!.close, + ); + } } Future launchMangementUrl(ManagementOption option) async { String managementUrl = Environment.stripeManagementUrl; - final String? email = await pangeaController.userController.userEmail; + final String? email = + await MatrixState.pangeaController.userController.userEmail; if (email != null) { managementUrl += "?prefilled_email=${Uri.encodeComponent(email)}"; } - final String? purchaseAppId = pangeaController - .subscriptionController.subscription?.currentSubscription?.appId!; + final String? purchaseAppId = + subscriptionController.subscription?.currentSubscription?.appId!; if (purchaseAppId == null) return; final SubscriptionAppIds? appIds = - pangeaController.subscriptionController.subscription!.appIds; + subscriptionController.subscription!.appIds; if (purchaseAppId == appIds?.stripeId) { launchUrlString(managementUrl); diff --git a/lib/pangea/pages/settings_subscription/settings_subscription_view.dart b/lib/pangea/pages/settings_subscription/settings_subscription_view.dart index 80d3f382c..5e0d1da3a 100644 --- a/lib/pangea/pages/settings_subscription/settings_subscription_view.dart +++ b/lib/pangea/pages/settings_subscription/settings_subscription_view.dart @@ -1,33 +1,18 @@ // Flutter imports: -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:intl/intl.dart'; - -import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/pages/settings_subscription/change_subscription.dart'; import 'package:fluffychat/pangea/pages/settings_subscription/settings_subscription.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:intl/intl.dart'; class SettingsSubscriptionView extends StatelessWidget { final SubscriptionManagementController controller; - final PangeaController pangeaController = MatrixState.pangeaController; - SettingsSubscriptionView(this.controller, {super.key}); + const SettingsSubscriptionView(this.controller, {super.key}); @override Widget build(BuildContext context) { - final String currentSubscriptionTitle = pangeaController - .subscriptionController.subscription?.currentSubscription - ?.displayName(context) ?? - ""; - final String currentSubscriptionPrice = pangeaController - .subscriptionController.subscription?.currentSubscription - ?.displayPrice(context) ?? - ""; - return Scaffold( appBar: AppBar( centerTitle: true, @@ -38,17 +23,15 @@ class SettingsSubscriptionView extends StatelessWidget { body: ListTileTheme( iconColor: Theme.of(context).textTheme.bodyLarge!.color, child: MaxWidthBody( - child: !(pangeaController.subscriptionController.isSubscribed) + child: !(controller.subscriptionController.isSubscribed) ? ChangeSubscription(controller: controller) : Column( children: [ - if (pangeaController.subscriptionController.subscription! - .currentSubscription != - null) + if (controller.currentSubscriptionAvailable) ListTile( title: Text(L10n.of(context)!.currentSubscription), - subtitle: Text(currentSubscriptionTitle), - trailing: Text(currentSubscriptionPrice), + subtitle: Text(controller.currentSubscriptionTitle), + trailing: Text(controller.currentSubscriptionPrice), ), Column( children: [ @@ -84,8 +67,6 @@ class SettingsSubscriptionView extends StatelessWidget { if (!(controller.showManagementOptions)) ManagementNotAvailableWarning( controller: controller, - subscriptionController: - pangeaController.subscriptionController, ), ], ), @@ -97,11 +78,9 @@ class SettingsSubscriptionView extends StatelessWidget { class ManagementNotAvailableWarning extends StatelessWidget { final SubscriptionManagementController controller; - final SubscriptionController subscriptionController; const ManagementNotAvailableWarning({ required this.controller, - required this.subscriptionController, super.key, }); @@ -112,7 +91,7 @@ class ManagementNotAvailableWarning extends StatelessWidget { if (controller.isNewUserTrial) { return L10n.of(context)!.trialExpiration( formatter.format( - subscriptionController.subscription!.expirationDate!, + controller.subscriptionController.subscription!.expirationDate!, ), ); } @@ -125,13 +104,14 @@ class ManagementNotAvailableWarning extends StatelessWidget { return warningText; } if (controller.currentSubscriptionIsPromotional) { - if (subscriptionController.subscription?.isLifetimeSubscription ?? + if (controller + .subscriptionController.subscription?.isLifetimeSubscription ?? false) { return L10n.of(context)!.promotionalSubscriptionDesc; } return L10n.of(context)!.promoSubscriptionExpirationDesc( formatter.format( - subscriptionController.subscription!.expirationDate!, + controller.subscriptionController.subscription!.expirationDate!, ), ); } diff --git a/lib/pangea/widgets/igc/pangea_text_controller.dart b/lib/pangea/widgets/igc/pangea_text_controller.dart index 886274316..dad55b670 100644 --- a/lib/pangea/widgets/igc/pangea_text_controller.dart +++ b/lib/pangea/widgets/igc/pangea_text_controller.dart @@ -1,5 +1,8 @@ import 'dart:developer'; +import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; +import 'package:fluffychat/pangea/models/igc_text_data_model.dart'; +import 'package:fluffychat/pangea/widgets/igc/paywall_card.dart'; import 'package:fluffychat/pangea/widgets/igc/span_card.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -40,6 +43,19 @@ class PangeaTextController extends TextEditingController { debugger(when: kDebugMode); return; } + final CanSendStatus canSendStatus = + choreographer.pangeaController.subscriptionController.canSendStatus; + if (canSendStatus == CanSendStatus.showPaywall && + !choreographer.isFetching && + text.isNotEmpty) { + OverlayUtil.showPositionedCard( + context: context, + cardToShow: const PaywallCard(), + cardSize: const Size(325, 375), + transformTargetId: choreographer.inputTransformTargetKey, + ); + } + if (choreographer.igc.igcTextData == null) return; // debugPrint( @@ -113,7 +129,20 @@ class PangeaTextController extends TextEditingController { // debugPrint("composing after ${value.composing.textAfter(value.text)}"); // } - if (choreographer.igc.igcTextData == null || text.isEmpty) { + final CanSendStatus canSendStatus = + choreographer.pangeaController.subscriptionController.canSendStatus; + if (canSendStatus == CanSendStatus.showPaywall && + !choreographer.isFetching && + text.isNotEmpty) { + return TextSpan( + text: text, + style: style?.merge( + IGCTextData.underlineStyle( + const Color.fromARGB(187, 132, 96, 224), + ), + ), + ); + } else if (choreographer.igc.igcTextData == null || text.isEmpty) { return TextSpan(text: text, style: style); } else { final parts = text.split(choreographer.igc.igcTextData!.originalInput); diff --git a/lib/pangea/widgets/igc/paywall_card.dart b/lib/pangea/widgets/igc/paywall_card.dart new file mode 100644 index 000000000..9b6cfd1b0 --- /dev/null +++ b/lib/pangea/widgets/igc/paywall_card.dart @@ -0,0 +1,123 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart'; +import 'package:fluffychat/pangea/widgets/igc/card_header.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:shimmer/shimmer.dart'; + +class PaywallCard extends StatelessWidget { + const PaywallCard({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CardHeader( + text: L10n.of(context)!.subscriptionPopupTitle, + botExpression: BotExpression.addled, + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const OptionsShimmer(), + const SizedBox(height: 15.0), + Text( + L10n.of(context)!.subscriptionPopupDesc, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 15.0), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + MatrixState.pangeaController.subscriptionController + .showPaywall(context); + MatrixState.pAnyState.closeOverlay(); + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + (AppConfig.primaryColor).withOpacity(0.1), + ), + ), + child: Text(L10n.of(context)!.seeOptions), + ), + ), + const SizedBox(height: 5.0), + SizedBox( + width: double.infinity, + child: TextButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + AppConfig.primaryColor.withOpacity(0.1), + ), + ), + onPressed: () { + MatrixState.pangeaController.subscriptionController + .dismissPaywall(); + MatrixState.pAnyState.closeOverlay(); + }, + child: Center( + child: Text(L10n.of(context)!.continuedWithoutSubscription), + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +class OptionsShimmer extends StatelessWidget { + const OptionsShimmer({super.key}); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), + highlightColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), + direction: ShimmerDirection.ltr, + child: Wrap( + alignment: WrapAlignment.center, + children: List.generate( + 3, + (_) => Container( + margin: const EdgeInsets.all(2), + padding: EdgeInsets.zero, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 7), + ), + backgroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary.withOpacity(0.1), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + onPressed: () {}, + child: Text( + "", + style: BotStyle.text(context), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/subscription/subscription_buttons.dart b/lib/pangea/widgets/subscription/subscription_buttons.dart index 7798b5b28..9292e41a4 100644 --- a/lib/pangea/widgets/subscription/subscription_buttons.dart +++ b/lib/pangea/widgets/subscription/subscription_buttons.dart @@ -1,11 +1,9 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/pages/settings_subscription/settings_subscription.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class SubscriptionButtons extends StatelessWidget { final SubscriptionManagementController controller; @@ -17,43 +15,40 @@ class SubscriptionButtons extends StatelessWidget { @override Widget build(BuildContext context) { + final bool inTrialWindow = pangeaController.userController.inTrialWindow; return ListView.builder( shrinkWrap: true, - itemCount: pangeaController + itemCount: controller .subscriptionController.subscription!.availableSubscriptions.length, - itemBuilder: (BuildContext context, int i) => Column( - children: [ - ListTile( - title: pangeaController.subscriptionController.subscription! - .availableSubscriptions[i].isTrial - ? Text(L10n.of(context)!.oneWeekTrial) - : Text( - pangeaController.subscriptionController.subscription! - .availableSubscriptions[i] - .displayName(context), - ), - subtitle: Text( - pangeaController.subscriptionController.subscription! - .availableSubscriptions[i] - .displayPrice(context), + itemBuilder: (BuildContext context, int i) { + final SubscriptionDetails subscription = pangeaController + .subscriptionController.subscription!.availableSubscriptions[i]; + return Column( + children: [ + ListTile( + title: subscription.isTrial + ? Text(L10n.of(context)!.oneWeekTrial) + : Text( + subscription.displayName(context), + ), + subtitle: Text( + subscription.isTrial && !inTrialWindow + ? L10n.of(context)!.trialPeriodExpired + : subscription.displayPrice(context), + ), + trailing: const Icon(Icons.keyboard_arrow_right_outlined), + selected: controller.selectedSubscription == subscription, + selectedTileColor: + Theme.of(context).colorScheme.secondary.withAlpha(16), + enabled: !subscription.isTrial || inTrialWindow, + onTap: () { + controller.selectSubscription(subscription); + }, ), - trailing: const Icon(Icons.keyboard_arrow_right_outlined), - selected: controller.selectedSubscription == - pangeaController.subscriptionController.subscription! - .availableSubscriptions[i], - selectedTileColor: - Theme.of(context).colorScheme.secondary.withAlpha(16), - onTap: () { - final SubscriptionDetails selected = pangeaController - .subscriptionController - .subscription! - .availableSubscriptions[i]; - controller.selectSubscription(selected); - }, - ), - const Divider(height: 1), - ], - ), + const Divider(height: 1), + ], + ); + }, ); } } diff --git a/lib/pangea/widgets/subscription/subscription_options.dart b/lib/pangea/widgets/subscription/subscription_options.dart index 7fb7c944e..41c8cbe4f 100644 --- a/lib/pangea/widgets/subscription/subscription_options.dart +++ b/lib/pangea/widgets/subscription/subscription_options.dart @@ -18,68 +18,121 @@ class SubscriptionOptions extends StatelessWidget { return Wrap( alignment: WrapAlignment.center, direction: Axis.horizontal, - children: pangeaController - .subscriptionController.subscription!.availableSubscriptions - .map( - (subscription) => SubscriptionCard( - subscription: subscription, - pangeaController: pangeaController, - ), - ) - .toList(), + spacing: 10, + children: pangeaController.userController.inTrialWindow + ? [ + SubscriptionCard( + onTap: () => pangeaController.subscriptionController + .submitSubscriptionChange( + SubscriptionDetails( + price: 0, + id: "", + periodType: 'trial', + ), + context, + ), + title: L10n.of(context)!.freeTrial, + description: L10n.of(context)!.freeTrialDesc, + buttonText: L10n.of(context)!.activateTrial, + ), + ] + : pangeaController + .subscriptionController.subscription!.availableSubscriptions + .map( + (subscription) => SubscriptionCard( + subscription: subscription, + onTap: () { + pangeaController.subscriptionController + .submitSubscriptionChange( + subscription, + context, + ); + }, + title: subscription.isTrial + ? L10n.of(context)!.oneWeekTrial + : subscription.displayName(context), + enabled: !subscription.isTrial, + description: subscription.isTrial + ? L10n.of(context)!.trialPeriodExpired + : null, + ), + ) + .toList(), ); } } class SubscriptionCard extends StatelessWidget { - final SubscriptionDetails subscription; - final PangeaController pangeaController; + final SubscriptionDetails? subscription; + final void Function()? onTap; + final String? title; + final String? description; + final String? buttonText; + final bool enabled; const SubscriptionCard({ super.key, - required this.subscription, - required this.pangeaController, + this.subscription, + required this.onTap, + this.title, + this.description, + this.buttonText, + this.enabled = true, }); @override Widget build(BuildContext context) { + final ButtonStyle buttonStyle = OutlinedButton.styleFrom( + side: enabled ? null : BorderSide(color: Colors.grey[600]!), + foregroundColor: Colors.white, + backgroundColor: AppConfig.primaryColor, + disabledForegroundColor: const Color.fromARGB(255, 200, 200, 200), + disabledBackgroundColor: Colors.grey[600], + ); + return Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: AppConfig.primaryColorLight.withAlpha(64), - ), - borderRadius: const BorderRadius.all(Radius.zero), + color: enabled ? null : const Color.fromARGB(255, 245, 244, 244), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), ), child: SizedBox( - height: 250, - width: AppConfig.columnWidth * 0.75, + width: AppConfig.columnWidth * 0.6, + height: 200, child: Padding( padding: const EdgeInsets.all(25), child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - subscription.isTrial - ? L10n.of(context)!.oneWeekTrial - : subscription.displayName(context), + title ?? subscription?.displayName(context) ?? '', textAlign: TextAlign.center, - style: const TextStyle(fontSize: 24), + style: TextStyle( + fontSize: 24, + color: + enabled ? null : const Color.fromARGB(255, 174, 174, 174), + ), ), Text( - subscription.displayPrice(context), + description ?? subscription?.displayPrice(context) ?? '', textAlign: TextAlign.center, + style: TextStyle( + color: + enabled ? null : const Color.fromARGB(255, 174, 174, 174), + ), ), OutlinedButton( - onPressed: () { - pangeaController.subscriptionController - .submitSubscriptionChange( - subscription, - context, - ); - Navigator.of(context).pop(); - }, - child: Text(L10n.of(context)!.subscribe), + onPressed: enabled + ? () { + if (onTap != null) onTap!(); + Navigator.of(context).pop(); + } + : null, + style: buttonStyle, + child: Text( + buttonText ?? L10n.of(context)!.subscribe, + ), ), ], ), diff --git a/lib/pangea/widgets/subscription/subscription_paywall.dart b/lib/pangea/widgets/subscription/subscription_paywall.dart index b6b628c08..fb2c53305 100644 --- a/lib/pangea/widgets/subscription/subscription_paywall.dart +++ b/lib/pangea/widgets/subscription/subscription_paywall.dart @@ -1,6 +1,5 @@ // Flutter imports: -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/widgets/subscription/subscription_options.dart'; import 'package:flutter/material.dart'; @@ -18,89 +17,38 @@ class SubscriptionPaywall extends StatelessWidget { return Scaffold( appBar: AppBar( centerTitle: true, - leading: CloseButton(onPressed: Navigator.of(context).pop), + leading: const CloseButton(), title: Text( L10n.of(context)!.getAccess, - style: const TextStyle( - fontSize: 20, - ), - textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20), ), ), - body: Padding( - padding: const EdgeInsets.all(20), - child: ListView( - children: [ - if (pangeaController.matrixState.client.rooms.length > 1) ...[ + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (pangeaController.matrixState.client.rooms.length > 1) ...[ + Text( + L10n.of(context)!.welcomeBack, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 20), + ], Text( - L10n.of(context)!.welcomeBack, + L10n.of(context)!.subscriptionDesc, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16), ), - const SizedBox(height: 20), - ], - Text( - L10n.of(context)!.subscriptionDesc, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), - ), - const SizedBox(height: 20), - pangeaController.userController.inTrialWindow - ? FreeTrialCard( - pangeaController: pangeaController, - ) - : SubscriptionOptions( - pangeaController: pangeaController, - ), - ], - ), - ), - ); - } -} - -class FreeTrialCard extends StatelessWidget { - final PangeaController pangeaController; - const FreeTrialCard({super.key, required this.pangeaController}); - - @override - Widget build(BuildContext context) { - return Align( - child: Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: AppConfig.primaryColorLight.withAlpha(64), - ), - borderRadius: const BorderRadius.all(Radius.zero), - ), - child: SizedBox( - height: 250, - width: AppConfig.columnWidth * 0.75, - child: Padding( - padding: const EdgeInsets.all(25), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - L10n.of(context)!.freeTrial, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 24), + const SizedBox(height: 40), + Center( + child: SubscriptionOptions( + pangeaController: pangeaController, ), - Text( - L10n.of(context)!.freeTrialDesc, - textAlign: TextAlign.center, - ), - OutlinedButton( - onPressed: () { - pangeaController.subscriptionController - .activateNewUserTrial(); - Navigator.of(context).pop(); - }, - child: Text(L10n.of(context)!.activateTrial), - ), - ], - ), + ), + ], ), ), ), diff --git a/pubspec.lock b/pubspec.lock index dd1ad52b2..0681c37de 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1964,6 +1964,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 4b6d18698..931fbfa1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -119,6 +119,7 @@ dependencies: open_file: ^3.3.2 purchases_flutter: ^5.6.0 sentry_flutter: ^7.4.0 + shimmer: ^3.0.0 syncfusion_flutter_datepicker: ^23.2.7 syncfusion_flutter_xlsio: ^23.2.7 # Pangea#