diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index bd735aa4a7..cf95d8f8a0 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -27,6 +27,7 @@ import 'package:appflowy/workspace/application/settings/appearance/desktop_appea import 'package:appflowy/workspace/application/settings/appearance/mobile_appearance.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; @@ -168,6 +169,9 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { getIt.registerFactory(() => SplashBloc()); getIt.registerLazySingleton(() => NetworkListener()); getIt.registerLazySingleton(() => CachedRecentService()); + getIt.registerLazySingleton( + () => SubscriptionSuccessListenable(), + ); } void _resolveHomeDeps(GetIt getIt) { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 06445bd0d9..bd5fedf526 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -12,6 +12,7 @@ import 'package:appflowy/user/application/auth/auth_error.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy/user/application/user_auth_listener.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; @@ -93,6 +94,10 @@ class AppFlowyCloudDeepLink { return; } + if (_isPaymentSuccessUri(uri)) { + return getIt().onPaymentSuccess(); + } + return _isAuthCallbackDeepLink(uri).fold( (_) async { final deviceId = await getDeviceId(); @@ -161,6 +166,10 @@ class AppFlowyCloudDeepLink { ..msg = uri.path, ); } + + bool _isPaymentSuccessUri(Uri uri) { + return uri.host == 'payment-success'; + } } class InitAppFlowyCloudTask extends LaunchTask { diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 6f02908d9c..6475535843 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; @@ -7,8 +9,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; -const _deepLinkSubscriptionUrl = 'appflowy-flutter://subscription-callback'; - class UserBackendService { UserBackendService({required this.userId}); @@ -234,8 +234,7 @@ class UserBackendService { ..recurringInterval = RecurringIntervalPB.Month ..workspaceSubscriptionPlan = plan ..successUrl = - 'http://$_deepLinkSubscriptionUrl'; // TODO(Mathias): Change once Zack has resolved - + '${getIt().appflowyCloudConfig.base_url}/web/payment-success'; return UserEventSubscribeWorkspace(request).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart new file mode 100644 index 0000000000..81c96b3232 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_billing_bloc.freezed.dart'; + +class SettingsBillingBloc + extends Bloc { + SettingsBillingBloc({ + required this.workspaceId, + }) : super(const _Initial()) { + _service = WorkspaceService(workspaceId: workspaceId); + + on((event, emit) async { + await event.when( + started: () async { + emit(const SettingsBillingState.loading()); + + final snapshots = await Future.wait([ + UserBackendService.getWorkspaceSubscriptions(), + _service.getBillingPortal(), + ]); + + FlowyError? error; + + final subscription = snapshots.first.fold( + (s) => + (s as RepeatedWorkspaceSubscriptionPB) + .items + .firstWhereOrNull((i) => i.workspaceId == workspaceId) ?? + WorkspaceSubscriptionPB( + workspaceId: workspaceId, + subscriptionPlan: SubscriptionPlanPB.None, + isActive: true, + ), + (e) { + // Not a Cjstomer yet + if (e.code == ErrorCode.InvalidParams) { + return WorkspaceSubscriptionPB( + workspaceId: workspaceId, + subscriptionPlan: SubscriptionPlanPB.None, + isActive: true, + ); + } + + error = e; + return null; + }, + ); + + final billingPortalResult = snapshots.last; + final billingPortal = billingPortalResult.fold( + (s) => s as BillingPortalPB, + (e) { + // Not a customer yet + if (e.code == ErrorCode.InvalidParams) { + return BillingPortalPB(); + } + + error = e; + return null; + }, + ); + + if (subscription == null || billingPortal == null || error != null) { + return emit(SettingsBillingState.error(error: error)); + } + + emit( + SettingsBillingState.ready( + subscription: subscription, + billingPortal: billingPortal, + ), + ); + }, + ); + }); + } + + late final String workspaceId; + late final WorkspaceService _service; +} + +@freezed +class SettingsBillingEvent with _$SettingsBillingEvent { + const factory SettingsBillingEvent.started() = _Started; +} + +@freezed +class SettingsBillingState with _$SettingsBillingState { + const factory SettingsBillingState.initial() = _Initial; + + const factory SettingsBillingState.loading() = _Loading; + + const factory SettingsBillingState.error({ + @Default(null) FlowyError? error, + }) = _Error; + + const factory SettingsBillingState.ready({ + required WorkspaceSubscriptionPB subscription, + required BillingPortalPB? billingPortal, + }) = _Ready; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart index 99f866bc6e..122c1e070b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -1,7 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; @@ -20,6 +22,8 @@ class SettingsPlanBloc extends Bloc { required this.workspaceId, }) : super(const _Initial()) { _service = WorkspaceService(workspaceId: workspaceId); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); on((event, emit) async { await event.when( @@ -101,12 +105,32 @@ class SettingsPlanBloc extends Bloc { cancelSubscription: () async { await UserBackendService.cancelSubscription(workspaceId); }, + paymentSuccessful: () { + final readyState = state.mapOrNull(ready: (state) => state); + if (readyState == null) { + return; + } + + emit(readyState.copyWith(showSuccessDialog: true)); + emit(readyState.copyWith(showSuccessDialog: false)); + }, ); }); } late final String workspaceId; late final WorkspaceService _service; + late final SubscriptionSuccessListenable _successListenable; + + void _onPaymentSuccessful() { + add(const SettingsPlanEvent.paymentSuccessful()); + } + + @override + Future close() async { + _successListenable.removeListener(_onPaymentSuccessful); + return super.close(); + } } @freezed @@ -115,6 +139,7 @@ class SettingsPlanEvent with _$SettingsPlanEvent { const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; + const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful; } @freezed @@ -131,5 +156,6 @@ class SettingsPlanState with _$SettingsPlanState { required WorkspaceUsagePB workspaceUsage, required WorkspaceSubscriptionPB subscription, required BillingPortalPB? billingPortal, + @Default(false) bool showSuccessDialog, }) = _Ready; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart b/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart new file mode 100644 index 0000000000..b53c8237a6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart @@ -0,0 +1,7 @@ +import 'package:flutter/foundation.dart'; + +class SubscriptionSuccessListenable extends ChangeNotifier { + SubscriptionSuccessListenable(); + + void onPaymentSuccess() => notifyListeners(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart index 873cda0541..90f5ea522c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -1,48 +1,123 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart'; +import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../generated/locale_keys.g.dart'; class SettingsBillingView extends StatelessWidget { - const SettingsBillingView({super.key}); + const SettingsBillingView({super.key, required this.workspaceId}); + + final String workspaceId; @override Widget build(BuildContext context) { - return SettingsBody( - title: LocaleKeys.settings_billingPage_title.tr(), - description: LocaleKeys.settings_billingPage_description.tr(), - children: [ - SettingsCategory( - title: LocaleKeys.settings_billingPage_plan_title.tr(), - children: [ - SingleSettingAction( - label: LocaleKeys.settings_billingPage_plan_freeLabel.tr(), - buttonLabel: - LocaleKeys.settings_billingPage_plan_planButtonLabel.tr(), + return BlocProvider( + create: (context) => SettingsBillingBloc(workspaceId: workspaceId) + ..add(const SettingsBillingEvent.started()), + child: BlocBuilder( + builder: (context, state) { + return state.map( + initial: (_) => const SizedBox.shrink(), + loading: (_) => const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 3), + ), ), - SingleSettingAction( - label: LocaleKeys.settings_billingPage_plan_billingPeriod.tr(), - buttonLabel: - LocaleKeys.settings_billingPage_plan_periodButtonLabel.tr(), - ), - ], - ), - SettingsCategory( - title: LocaleKeys.settings_billingPage_paymentDetails_title.tr(), - children: [ - SingleSettingAction( - label: LocaleKeys.settings_billingPage_paymentDetails_methodLabel - .tr(), - buttonLabel: LocaleKeys - .settings_billingPage_paymentDetails_methodButtonLabel - .tr(), - ), - ], - ), - ], + error: (state) { + if (state.error != null) { + return Padding( + padding: const EdgeInsets.all(16), + child: FlowyErrorPage.message( + state.error!.msg, + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + ); + } + + return ErrorWidget.withDetails(message: 'Something went wrong!'); + }, + ready: (state) { + return SettingsBody( + title: LocaleKeys.settings_billingPage_title.tr(), + children: [ + SettingsCategory( + title: LocaleKeys.settings_billingPage_plan_title.tr(), + children: [ + SingleSettingAction( + onPressed: () => _openPricingDialog( + context, + workspaceId, + state.subscription.subscriptionPlan, + ), + label: state.subscription.label, + buttonLabel: LocaleKeys + .settings_billingPage_plan_planButtonLabel + .tr(), + ), + SingleSettingAction( + onPressed: () => + afLaunchUrlString(state.billingPortal!.url), + label: LocaleKeys + .settings_billingPage_plan_billingPeriod + .tr(), + buttonLabel: LocaleKeys + .settings_billingPage_plan_periodButtonLabel + .tr(), + ), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_billingPage_paymentDetails_title + .tr(), + children: [ + SingleSettingAction( + onPressed: () => + afLaunchUrlString(state.billingPortal!.url), + label: LocaleKeys + .settings_billingPage_paymentDetails_methodLabel + .tr(), + buttonLabel: LocaleKeys + .settings_billingPage_paymentDetails_methodButtonLabel + .tr(), + ), + ], + ), + ], + ); + }, + ); + }, + ), ); } + + void _openPricingDialog( + BuildContext context, + String workspaceId, + SubscriptionPlanPB plan, + ) => + showDialog( + context: context, + builder: (_) => BlocProvider( + create: (_) => SettingsPlanBloc(workspaceId: workspaceId) + ..add(const SettingsPlanEvent.started()), + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + currentPlan: plan, + ), + ), + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart index fb90c83ab0..849177eb05 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -39,139 +39,147 @@ class _SettingsPlanComparisonDialogState @override Widget build(BuildContext context) { - return FlowyDialog( - constraints: const BoxConstraints(maxWidth: 784, minWidth: 674), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 24, left: 24, right: 24), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const FlowyText.semibold( - 'Compare & select plan', - fontSize: 24, - ), - const Spacer(), - GestureDetector( - onTap: Navigator.of(context).pop, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: FlowySvg( - FlowySvgs.m_close_m, - size: const Size.square(20), - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - ], - ), - ), - Flexible( - child: SingleChildScrollView( - controller: horizontalController, - scrollDirection: Axis.horizontal, - child: SingleChildScrollView( - controller: verticalController, - padding: const EdgeInsets.only(left: 24, right: 24, bottom: 24), - child: Column( + return BlocBuilder( + builder: (context, state) { + return FlowyDialog( + constraints: const BoxConstraints(maxWidth: 784, minWidth: 674), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 24, left: 24, right: 24), + child: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - const VSpace(18), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 248, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const VSpace(22), - const SizedBox( - height: 100, - child: FlowyText.semibold( - 'Plan\nFeatures', - fontSize: 24, - maxLines: 2, - color: Color(0xFF5C3699), - ), - ), - const SizedBox(height: 64), - const SizedBox(height: 56), - ..._planLabels.map( - (e) => _ComparisonCell( - label: e.label, - tooltip: e.tooltip, - ), - ), - ], - ), + FlowyText.semibold( + LocaleKeys.settings_comparePlanDialog_title.tr(), + fontSize: 24, + ), + const Spacer(), + GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowySvg( + FlowySvgs.m_close_m, + size: const Size.square(20), + color: Theme.of(context).colorScheme.outline, ), - _PlanTable( - title: LocaleKeys - .settings_comparePlanDialog_freePlan_title - .tr(), - description: LocaleKeys - .settings_comparePlanDialog_freePlan_description - .tr(), - price: LocaleKeys - .settings_comparePlanDialog_freePlan_price - .tr(), - priceInfo: LocaleKeys - .settings_comparePlanDialog_freePlan_priceInfo - .tr(), - cells: _freeLabels, - isCurrent: - widget.currentPlan == SubscriptionPlanPB.None, - canDowngrade: - widget.currentPlan != SubscriptionPlanPB.None, - onSelected: () async { - if (widget.currentPlan == SubscriptionPlanPB.None) { - return; - } - - context.read().add( - const SettingsPlanEvent.cancelSubscription(), - ); - }, - ), - _PlanTable( - title: LocaleKeys - .settings_comparePlanDialog_proPlan_title - .tr(), - description: LocaleKeys - .settings_comparePlanDialog_proPlan_description - .tr(), - price: LocaleKeys - .settings_comparePlanDialog_proPlan_price - .tr(), - priceInfo: LocaleKeys - .settings_comparePlanDialog_proPlan_priceInfo - .tr(), - cells: _proLabels, - isCurrent: - widget.currentPlan == SubscriptionPlanPB.Pro, - canUpgrade: - widget.currentPlan == SubscriptionPlanPB.None, - onSelected: () => - context.read().add( - const SettingsPlanEvent.addSubscription( - SubscriptionPlanPB.Pro, - ), - ), - ), - ], + ), ), ], ), ), - ), + Flexible( + child: SingleChildScrollView( + controller: horizontalController, + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + controller: verticalController, + padding: + const EdgeInsets.only(left: 24, right: 24, bottom: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 250, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(22), + SizedBox( + height: 100, + child: FlowyText.semibold( + LocaleKeys + .settings_comparePlanDialog_planFeatures + .tr(), + fontSize: 24, + maxLines: 2, + color: const Color(0xFF5C3699), + ), + ), + const SizedBox(height: 64), + const SizedBox(height: 56), + ..._planLabels.map( + (e) => _ComparisonCell( + label: e.label, + tooltip: e.tooltip, + ), + ), + ], + ), + ), + _PlanTable( + title: LocaleKeys + .settings_comparePlanDialog_freePlan_title + .tr(), + description: LocaleKeys + .settings_comparePlanDialog_freePlan_description + .tr(), + price: LocaleKeys + .settings_comparePlanDialog_freePlan_price + .tr(), + priceInfo: LocaleKeys + .settings_comparePlanDialog_freePlan_priceInfo + .tr(), + cells: _freeLabels, + isCurrent: + widget.currentPlan == SubscriptionPlanPB.None, + canDowngrade: + widget.currentPlan != SubscriptionPlanPB.None, + onSelected: () async { + if (widget.currentPlan == + SubscriptionPlanPB.None) { + return; + } + + context.read().add( + const SettingsPlanEvent + .cancelSubscription(), + ); + }, + ), + _PlanTable( + title: LocaleKeys + .settings_comparePlanDialog_proPlan_title + .tr(), + description: LocaleKeys + .settings_comparePlanDialog_proPlan_description + .tr(), + price: LocaleKeys + .settings_comparePlanDialog_proPlan_price + .tr(), + priceInfo: LocaleKeys + .settings_comparePlanDialog_proPlan_priceInfo + .tr(), + cells: _proLabels, + isCurrent: + widget.currentPlan == SubscriptionPlanPB.Pro, + canUpgrade: + widget.currentPlan == SubscriptionPlanPB.None, + onSelected: () => + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.Pro, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], ), - ], - ), + ); + }, ); } } @@ -205,7 +213,7 @@ class _PlanTable extends StatelessWidget { final highlightPlan = !isCurrent && !canDowngrade && canUpgrade; return Container( - width: 200, + width: 210, decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), gradient: !highlightPlan @@ -248,11 +256,27 @@ class _PlanTable extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 12), child: _ActionButton( + label: canUpgrade && !canDowngrade + ? LocaleKeys.settings_comparePlanDialog_actions_upgrade + .tr() + : LocaleKeys.settings_comparePlanDialog_actions_downgrade + .tr(), onPressed: onSelected, isUpgrade: canUpgrade && !canDowngrade, useGradientBorder: !isCurrent && canUpgrade, ), ), + ] else if (isCurrent) ...[ + Padding( + padding: const EdgeInsets.only(left: 12), + child: _ActionButton( + label: LocaleKeys.settings_comparePlanDialog_actions_current + .tr(), + onPressed: () {}, + isUpgrade: canUpgrade && !canDowngrade, + useGradientBorder: !isCurrent && canUpgrade, + ), + ), ] else ...[ const SizedBox(height: 56), ], @@ -285,8 +309,7 @@ class _ComparisonCell extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - FlowyText.medium(label), - const Spacer(), + Expanded(child: FlowyText.medium(label)), if (tooltip != null) FlowyTooltip( message: tooltip, @@ -300,11 +323,13 @@ class _ComparisonCell extends StatelessWidget { class _ActionButton extends StatelessWidget { const _ActionButton({ + required this.label, required this.onPressed, required this.isUpgrade, this.useGradientBorder = false, }); + final String label; final VoidCallback onPressed; final bool isUpgrade; final bool useGradientBorder; @@ -341,13 +366,7 @@ class _ActionButton extends StatelessWidget { ), child: Center( child: _drawText( - isUpgrade - ? LocaleKeys - .settings_comparePlanDialog_actions_upgrade - .tr() - : LocaleKeys - .settings_comparePlanDialog_actions_downgrade - .tr(), + label, isLM, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart index 46402a3149..b334853e06 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_usage_ext.dart'; @@ -117,6 +120,19 @@ class _CurrentPlanBox extends StatelessWidget { subscription.subscriptionPlan, ), ), + if (subscription.hasCanceled) ...[ + const VSpace(12), + FlowyText( + LocaleKeys + .settings_planPage_planUsage_currentPlan_canceledInfo + .tr( + args: [_canceledDate(context)], + ), + maxLines: 5, + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ], ], ), ), @@ -197,6 +213,15 @@ class _CurrentPlanBox extends StatelessWidget { ); } + String _canceledDate(BuildContext context) { + final appearance = context.read().state; + return appearance.dateFormat.formatDate( + subscription.canceledAt.toDateTime(), + true, + appearance.timeFormat, + ); + } + void _openPricingDialog( BuildContext context, String workspaceId, @@ -282,16 +307,19 @@ class _PlanUsageSummary extends StatelessWidget { usage.totalBlobBytesLimit.toInt(), ), ), - // TODO(Mathias): Implement AI Usage once it's ready in backend Expanded( child: _UsageBox( - title: - LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(), - label: - LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr( - args: ['750', '1,000'], + title: LocaleKeys.settings_planPage_planUsage_collaboratorsLabel + .tr(), + label: LocaleKeys.settings_planPage_planUsage_collaboratorsUsage + .tr( + args: [ + usage.memberCount.toString(), + usage.memberCountLimit.toString(), + ], ), - value: .75, + value: usage.totalBlobBytes.toInt() / + usage.totalBlobBytesLimit.toInt(), ), ), ], @@ -301,7 +329,7 @@ class _PlanUsageSummary extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _ToggleMore( - value: false, + value: currentPlan == SubscriptionPlanPB.Pro, label: LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), currentPlan: currentPlan, @@ -309,7 +337,7 @@ class _PlanUsageSummary extends StatelessWidget { ), const VSpace(8), _ToggleMore( - value: false, + value: currentPlan == SubscriptionPlanPB.Pro, label: LocaleKeys.settings_planPage_planUsage_guestCollabToggle.tr(), currentPlan: currentPlan, @@ -446,12 +474,12 @@ class _PlanProgressIndicator extends StatelessWidget { height: 8, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), + color: AFThemeExtension.of(context).progressBarBGColor, border: Border.all( color: const Color(0xFFDDF1F7).withOpacity( theme.brightness == Brightness.light ? 1 : 0.1, ), ), - color: AFThemeExtension.of(context).progressBarBGColor, ), child: ClipRRect( borderRadius: BorderRadius.circular(8), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index e5c8559ff1..4ee2ad9203 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -97,7 +97,7 @@ class SettingsDialog extends StatelessWidget { case SettingsPage.plan: return SettingsPlanView(workspaceId: workspaceId); case SettingsPage.billing: - return const SettingsBillingView(); + return SettingsBillingView(workspaceId: workspaceId); case SettingsPage.featureFlags: return const FeatureFlagsPage(); default: diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 05edb263e6..62c17b1861 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -508,10 +508,12 @@ "title": "Plan usage summary", "storageLabel": "Storage", "storageUsage": "{} of {} GB", + "collaboratorsLabel": "Collaborators", + "collaboratorsUsage": "{} of {}", "aiResponseLabel": "AI Responses", "aiResponseUsage": "{} of {}", "proBadge": "Pro", - "memberProToggle": "Unlimited members", + "memberProToggle": "Up to 10 members", "guestCollabToggle": "10 guest collaborators", "aiCredit": { "title": "Add AppFlowy AI Credit", @@ -536,7 +538,8 @@ "freeConOne": "30 day revision history", "freeConTwo": "Guest collaborators (edit access)", "freeConThree": "unlimited storage", - "freeConFour": "6 month revision history" + "freeConFour": "6 month revision history", + "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." }, "deal": { "bannerLabel": "New year deal!", @@ -549,7 +552,6 @@ "billingPage": { "menuLabel": "Billing", "title": "Billing", - "description": "Customize your profile, manage account security, open AI keys, or login into your account.", "plan": { "title": "Plan", "freeLabel": "Free", @@ -565,9 +567,12 @@ } }, "comparePlanDialog": { + "title": "Compare & select plan", + "planFeatures": "Plan\nFeatures", "actions": { "upgrade": "Upgrade", - "downgrade": "Downgrade" + "downgrade": "Downgrade", + "current": "Current" }, "freePlan": { "title": "Free", @@ -585,7 +590,7 @@ "itemOne": "Workspaces", "itemTwo": "Members", "itemThree": "Guests", - "tooltipThree": "Guests have read-only permission to the specifically chared content", + "tooltipThree": "Guests have read-only permission to the specifically shared content", "itemFour": "Guest collaborators", "tooltipFour": "Guest collaborators are billed as one seat", "itemFive": "Storage", @@ -612,7 +617,7 @@ "itemFive": "unlimited", "itemSix": "yes", "itemSeven": "yes", - "itemEight": "100,000 monthly" + "itemEight": "10,000 monthly" } }, "common": {