From 4708c0f779fc1b11049b477e5d746054cd29100d Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:08:55 +0200 Subject: [PATCH] feat: plan+billing (#5518) * feat: billing client * feat: subscribe workspace default impl * feat: added create subscription * feat: add get workspace subs * feat: added subscription cancellation * feat: add workspace limits api * fix: update client api * feat: user billing portal * feat: billing UI (#5455) * feat: plan ui * feat: billing ui * feat: settings plan comparison dialog * feat: complete plan+billing ui * feat: backend integration * chore: cleaning * chore: fixes after merge * fix: dependency issue * feat: added subscription plan cancellation information * feat: subscription callback + canceled date * feat: put behind feature flag * feat: downgrade/upgrade dialogs * feat: update limit error codes * fix: billing refresh + downgrade dialog * fix: some minor improvements to settings * chore: use patch for client-api in tauri * fix: add shared-entity to patch * fix: compile * ci: try to add back maximize build space step * test: increase timeout in failing test --------- Co-authored-by: Zack Fu Zi Xiang --- .github/workflows/rust_ci.yaml | 16 +- .../lib/core/helpers/url_launcher.dart | 5 +- .../lib/shared/feature_flags.dart | 6 + .../lib/startup/deps_resolver.dart | 4 + .../startup/tasks/appflowy_cloud_task.dart | 12 +- .../lib/user/application/user_service.dart | 31 +- .../billing/settings_billing_bloc.dart | 112 +++ .../settings/plan/settings_plan_bloc.dart | 174 +++++ .../plan/workspace_subscription_ext.dart | 16 + .../settings/plan/workspace_usage_ext.dart | 8 + .../settings/settings_dialog_bloc.dart | 4 +- .../subscription_success_listenable.dart | 7 + .../workspace/workspace_service.dart | 10 + .../menu/sidebar/shared/sidebar_setting.dart | 5 + .../settings/pages/settings_account_view.dart | 4 +- .../settings/pages/settings_billing_view.dart | 138 ++++ .../pages/settings_manage_data_view.dart | 7 +- .../settings_plan_comparison_dialog.dart | 594 ++++++++++++++ .../settings/pages/settings_plan_view.dart | 732 ++++++++++++++++++ .../pages/settings_workspace_view.dart | 12 +- .../settings/settings_dialog.dart | 9 + .../shared/flowy_gradient_button.dart | 88 +++ .../settings/shared/setting_list_tile.dart | 1 + .../shared/settings_alert_dialog.dart | 9 + .../settings/shared/settings_body.dart | 6 +- .../settings/shared/settings_input_field.dart | 3 +- .../shared/single_setting_action.dart | 2 + .../feature_flags/feature_flag_page.dart | 9 +- .../settings/widgets/settings_menu.dart | 16 + .../lib/src/flowy_overlay/flowy_dialog.dart | 2 +- .../lib/style_widget/hover.dart | 8 +- .../flowy_infra_ui/lib/widget/error_page.dart | 112 ++- frontend/appflowy_tauri/src-tauri/Cargo.toml | 5 +- frontend/appflowy_web/wasm-libs/Cargo.toml | 7 +- .../appflowy_web_app/src-tauri/Cargo.toml | 5 +- .../resources/flowy_icons/16x/ai_star.svg | 3 + .../flowy_icons/16x/check_circle.svg | 3 + .../flowy_icons/24x/settings_billing.svg | 8 + .../flowy_icons/24x/settings_plan.svg | 6 +- frontend/resources/translations/en.json | 143 +++- frontend/rust-lib/Cargo.lock | 23 +- frontend/rust-lib/Cargo.toml | 8 +- .../folder/local_test/subscription_test.rs | 2 +- .../flowy-error/src/impl_from/cloud.rs | 8 +- frontend/rust-lib/flowy-server/Cargo.toml | 3 +- .../af_cloud/impls/user/cloud_service_impl.rs | 125 +++ frontend/rust-lib/flowy-user-pub/src/cloud.rs | 31 +- .../rust-lib/flowy-user-pub/src/entities.rs | 26 + .../flowy-user/src/entities/workspace.rs | 138 +++- .../rust-lib/flowy-user/src/event_handler.rs | 61 ++ frontend/rust-lib/flowy-user/src/event_map.rs | 21 + .../user_manager/manager_user_workspace.rs | 64 +- 52 files changed, 2769 insertions(+), 83 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart create mode 100644 frontend/resources/flowy_icons/16x/ai_star.svg create mode 100644 frontend/resources/flowy_icons/16x/check_circle.svg create mode 100644 frontend/resources/flowy_icons/24x/settings_billing.svg diff --git a/.github/workflows/rust_ci.yaml b/.github/workflows/rust_ci.yaml index c59d45c58c..f61925f9a6 100644 --- a/.github/workflows/rust_ci.yaml +++ b/.github/workflows/rust_ci.yaml @@ -32,14 +32,14 @@ jobs: # swap-size-mb: 1024 # remove-dotnet: 'true' - # # the following step is required to avoid running out of space - # - name: Maximize build space - # run: | - # sudo rm -rf /usr/share/dotnet - # sudo rm -rf /opt/ghc - # sudo rm -rf "/usr/local/share/boost" - # sudo rm -rf "$AGENT_TOOLSDIRECTORY" - # sudo docker image prune --all --force + # the following step is required to avoid running out of space + - name: Maximize build space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force - name: Checkout source code uses: actions/checkout@v4 diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index e8c9be51d5..94d2074c6b 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -1,9 +1,10 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 41d9d4d94d..31e61ebb08 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -32,6 +32,9 @@ enum FeatureFlag { // used for the search feature search, + // used for controlling whether to show plan+billing options in settings + planBilling, + // used for ignore the conflicted feature flag unknown; @@ -104,6 +107,7 @@ enum FeatureFlag { switch (this) { case FeatureFlag.collaborativeWorkspace: case FeatureFlag.membersSettings: + case FeatureFlag.planBilling: case FeatureFlag.unknown: return false; case FeatureFlag.search: @@ -125,6 +129,8 @@ enum FeatureFlag { return 'if it\'s on, the collaborators will show in the database'; case FeatureFlag.search: return 'if it\'s on, the command palette and search button will be available'; + case FeatureFlag.planBilling: + return 'if it\'s on, plan and billing pages will be available in Settings'; case FeatureFlag.unknown: return ''; } 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 b8da2cd1ad..bd5fedf526 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:app_links/app_links.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; @@ -10,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'; @@ -17,7 +20,6 @@ import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter/material.dart'; import 'package:url_protocol/url_protocol.dart'; class AppFlowyCloudDeepLink { @@ -92,6 +94,10 @@ class AppFlowyCloudDeepLink { return; } + if (_isPaymentSuccessUri(uri)) { + return getIt().onPaymentSuccess(); + } + return _isAuthCallbackDeepLink(uri).fold( (_) async { final deviceId = await getDeviceId(); @@ -160,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 5eeaacdc77..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'; @@ -8,9 +10,7 @@ import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; class UserBackendService { - UserBackendService({ - required this.userId, - }); + UserBackendService({required this.userId}); final Int64 userId; @@ -219,4 +219,29 @@ class UserBackendService { final data = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventLeaveWorkspace(data).send(); } + + static Future> + getWorkspaceSubscriptions() { + return UserEventGetWorkspaceSubscriptions().send(); + } + + static Future> createSubscription( + String workspaceId, + SubscriptionPlanPB plan, + ) { + final request = SubscribeWorkspacePB() + ..workspaceId = workspaceId + ..recurringInterval = RecurringIntervalPB.Month + ..workspaceSubscriptionPlan = plan + ..successUrl = + '${getIt().appflowyCloudConfig.base_url}/web/payment-success'; + return UserEventSubscribeWorkspace(request).send(); + } + + static Future> cancelSubscription( + String workspaceId, + ) { + final request = UserWorkspaceIdPB()..workspaceId = workspaceId; + return UserEventCancelWorkspaceSubscription(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 new file mode 100644 index 0000000000..49b6cb246e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -0,0 +1,174 @@ +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'; +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_plan_bloc.freezed.dart'; + +class SettingsPlanBloc extends Bloc { + SettingsPlanBloc({ + required this.workspaceId, + }) : super(const _Initial()) { + _service = WorkspaceService(workspaceId: workspaceId); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); + + on((event, emit) async { + await event.when( + started: (withShowSuccessful) async { + emit(const SettingsPlanState.loading()); + + final snapshots = await Future.wait([ + _service.getWorkspaceUsage(), + UserBackendService.getWorkspaceSubscriptions(), + _service.getBillingPortal(), + ]); + + FlowyError? error; + + final usageResult = snapshots.first.fold( + (s) => s as WorkspaceUsagePB, + (f) { + error = f; + return null; + }, + ); + + final subscription = snapshots[1].fold( + (s) => + (s as RepeatedWorkspaceSubscriptionPB) + .items + .firstWhereOrNull((i) => i.workspaceId == workspaceId) ?? + WorkspaceSubscriptionPB( + workspaceId: workspaceId, + subscriptionPlan: SubscriptionPlanPB.None, + isActive: true, + ), + (f) { + error = f; + 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 (usageResult == null || + subscription == null || + billingPortal == null || + error != null) { + return emit(SettingsPlanState.error(error: error)); + } + + emit( + SettingsPlanState.ready( + workspaceUsage: usageResult, + subscription: subscription, + billingPortal: billingPortal, + showSuccessDialog: withShowSuccessful, + ), + ); + + if (withShowSuccessful) { + emit( + SettingsPlanState.ready( + workspaceUsage: usageResult, + subscription: subscription, + billingPortal: billingPortal, + ), + ); + } + }, + addSubscription: (plan) async { + final result = await UserBackendService.createSubscription( + workspaceId, + SubscriptionPlanPB.Pro, + ); + + result.fold( + (pl) => afLaunchUrlString(pl.paymentLink), + (f) => Log.error(f.msg, f), + ); + }, + cancelSubscription: () async { + await UserBackendService.cancelSubscription(workspaceId); + add(const SettingsPlanEvent.started()); + }, + paymentSuccessful: () { + final readyState = state.mapOrNull(ready: (state) => state); + if (readyState == null) { + return; + } + + add(const SettingsPlanEvent.started(withShowSuccessful: true)); + }, + ); + }); + } + + 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 +class SettingsPlanEvent with _$SettingsPlanEvent { + const factory SettingsPlanEvent.started({ + @Default(false) bool withShowSuccessful, + }) = _Started; + const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = + _AddSubscription; + const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; + const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful; +} + +@freezed +class SettingsPlanState with _$SettingsPlanState { + const factory SettingsPlanState.initial() = _Initial; + + const factory SettingsPlanState.loading() = _Loading; + + const factory SettingsPlanState.error({ + @Default(null) FlowyError? error, + }) = _Error; + + const factory SettingsPlanState.ready({ + required WorkspaceUsagePB workspaceUsage, + required WorkspaceSubscriptionPB subscription, + required BillingPortalPB? billingPortal, + @Default(false) bool showSuccessDialog, + }) = _Ready; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart new file mode 100644 index 0000000000..fe6cb896bd --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart @@ -0,0 +1,16 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension SubscriptionLabels on WorkspaceSubscriptionPB { + String get label => switch (subscriptionPlan) { + SubscriptionPlanPB.None => + LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), + SubscriptionPlanPB.Pro => + LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), + SubscriptionPlanPB.Team => + LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), + _ => 'N/A', + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart new file mode 100644 index 0000000000..bc309b60c5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart @@ -0,0 +1,8 @@ +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; + +extension PresentableUsage on WorkspaceUsagePB { + String get totalBlobInGb => + (totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString(); + String get currentBlobInGb => + (totalBlobBytes.toInt() / 1024 / 1024 / 1024).round().toString(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 02aabc6c57..7395dce731 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -13,6 +13,8 @@ enum SettingsPage { account, workspace, manageData, + plan, + billing, // OLD notifications, cloud, @@ -81,14 +83,12 @@ class SettingsDialogEvent with _$SettingsDialogEvent { class SettingsDialogState with _$SettingsDialogState { const factory SettingsDialogState({ required UserProfilePB userProfile, - required FlowyResult successOrFailure, required SettingsPage page, }) = _SettingsDialogState; factory SettingsDialogState.initial(UserProfilePB userProfile) => SettingsDialogState( userProfile: userProfile, - successOrFailure: FlowyResult.success(null), page: SettingsPage.account, ); } 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/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index 37ff786fab..d95f90b362 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class WorkspaceService { @@ -70,4 +71,13 @@ class WorkspaceService { return FolderEventMoveView(payload).send(); } + + Future> getWorkspaceUsage() { + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); + return UserEventGetWorkspaceUsage(payload).send(); + } + + Future> getBillingPortal() { + return UserEventGetBillingPortal().send(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart index 3e3610a653..feffb61cc1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart @@ -79,6 +79,11 @@ void showSettingsDialog(BuildContext context, UserProfilePB userProfile) { ], child: SettingsDialog( userProfile, + workspaceId: context + .read() + .state + .currentWorkspace! + .workspaceId, didLogout: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index 2964e86e45..d77c877601 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -436,7 +436,7 @@ class _UserProfileSettingState extends State { iconUrl: widget.iconUrl, name: widget.name, size: 48, - fontSize: 24, + fontSize: 20, isHovering: isHovering, ), ), @@ -445,7 +445,7 @@ class _UserProfileSettingState extends State { const HSpace(16), if (!isEditing) ...[ Padding( - padding: const EdgeInsets.only(top: 20), + padding: const EdgeInsets.only(top: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ 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 new file mode 100644 index 0000000000..53caaaee0e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -0,0 +1,138 @@ +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/protobuf.dart'; +import 'package:easy_localization/easy_localization.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, required this.workspaceId}); + + final String workspaceId; + + @override + Widget build(BuildContext context) { + 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), + ), + ), + 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) { + final billingPortalEnabled = state.billingPortal != null && + state.billingPortal!.url.isNotEmpty; + + 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, + ), + fontWeight: FontWeight.w500, + label: state.subscription.label, + buttonLabel: LocaleKeys + .settings_billingPage_plan_planButtonLabel + .tr(), + ), + if (billingPortalEnabled) + SingleSettingAction( + onPressed: () => + afLaunchUrlString(state.billingPortal!.url), + label: LocaleKeys + .settings_billingPage_plan_billingPeriod + .tr(), + fontWeight: FontWeight.w500, + buttonLabel: LocaleKeys + .settings_billingPage_plan_periodButtonLabel + .tr(), + ), + ], + ), + if (billingPortalEnabled) + SettingsCategory( + title: LocaleKeys + .settings_billingPage_paymentDetails_title + .tr(), + children: [ + SingleSettingAction( + onPressed: () => + afLaunchUrlString(state.billingPortal!.url), + label: LocaleKeys + .settings_billingPage_paymentDetails_methodLabel + .tr(), + fontWeight: FontWeight.w500, + buttonLabel: LocaleKeys + .settings_billingPage_paymentDetails_methodButtonLabel + .tr(), + ), + ], + ), + ], + ); + }, + ); + }, + ), + ); + } + + void _openPricingDialog( + BuildContext context, + String workspaceId, + WorkspaceSubscriptionPB subscription, + ) => + showDialog( + context: context, + builder: (_) => BlocProvider( + create: (_) => SettingsPlanBloc(workspaceId: workspaceId) + ..add(const SettingsPlanEvent.started()), + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + subscription: subscription, + ), + ), + ).then((didChangePlan) { + if (didChangePlan == true) { + context + .read() + .add(const SettingsBillingEvent.started()); + } + }); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 4ad5d00e1f..bde6c5bf31 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -59,7 +59,10 @@ class SettingsManageDataView extends StatelessWidget { tooltip: LocaleKeys .settings_manageDataPage_dataStorage_actions_resetTooltip .tr(), - icon: const FlowySvg(FlowySvgs.restore_s), + icon: const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), label: LocaleKeys.settings_common_reset.tr(), onPressed: () => SettingsAlertDialog( title: LocaleKeys @@ -492,7 +495,7 @@ class _DataPathActions extends StatelessWidget { .tr(), label: LocaleKeys.settings_manageDataPage_dataStorage_actions_open.tr(), - icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(16)), + icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), onPressed: () => afLaunchUrlString('file://$currentPath'), ), ], 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 new file mode 100644 index 0000000000..05a6c5c033 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -0,0 +1,594 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.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/shared/settings_alert_dialog.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsPlanComparisonDialog extends StatefulWidget { + const SettingsPlanComparisonDialog({ + super.key, + required this.workspaceId, + required this.subscription, + }); + + final String workspaceId; + final WorkspaceSubscriptionPB subscription; + + @override + State createState() => + _SettingsPlanComparisonDialogState(); +} + +class _SettingsPlanComparisonDialogState + extends State { + final horizontalController = ScrollController(); + final verticalController = ScrollController(); + + late WorkspaceSubscriptionPB currentSubscription = widget.subscription; + + @override + void dispose() { + horizontalController.dispose(); + verticalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + final readyState = state.mapOrNull(ready: (state) => state); + + if (readyState == null) { + return; + } + + if (readyState.showSuccessDialog) { + SettingsAlertDialog( + icon: Center( + child: SizedBox( + height: 90, + width: 90, + child: FlowySvg( + FlowySvgs.check_circle_s, + color: AFThemeExtension.of(context).success, + ), + ), + ), + title: + LocaleKeys.settings_comparePlanDialog_paymentSuccess_title.tr( + args: [readyState.subscription.label], + ), + subtitle: LocaleKeys + .settings_comparePlanDialog_paymentSuccess_description + .tr( + args: [readyState.subscription.label], + ), + hideCancelButton: true, + confirm: Navigator.of(context).pop, + confirmLabel: LocaleKeys.button_close.tr(), + ).show(context); + } + + setState(() { + currentSubscription = readyState.subscription; + }); + }, + child: 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: [ + FlowyText.semibold( + LocaleKeys.settings_comparePlanDialog_title.tr(), + fontSize: 24, + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.of(context).pop( + currentSubscription.subscriptionPlan != + widget.subscription.subscriptionPlan, + ), + 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( + 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: currentSubscription.subscriptionPlan == + SubscriptionPlanPB.None, + canDowngrade: + currentSubscription.subscriptionPlan != + SubscriptionPlanPB.None, + onSelected: () async { + if (currentSubscription.subscriptionPlan == + SubscriptionPlanPB.None || + currentSubscription.hasCanceled) { + return; + } + + await SettingsAlertDialog( + title: LocaleKeys + .settings_comparePlanDialog_downgradeDialog_title + .tr(args: [currentSubscription.label]), + subtitle: LocaleKeys + .settings_comparePlanDialog_downgradeDialog_description + .tr(), + isDangerous: true, + confirm: () { + context.read().add( + const SettingsPlanEvent + .cancelSubscription(), + ); + + Navigator.of(context).pop(); + }, + confirmLabel: LocaleKeys + .settings_comparePlanDialog_downgradeDialog_downgradeLabel + .tr(), + ).show(context); + }, + ), + _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: currentSubscription.subscriptionPlan == + SubscriptionPlanPB.Pro, + canUpgrade: currentSubscription.subscriptionPlan == + SubscriptionPlanPB.None, + onSelected: () => + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.Pro, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PlanTable extends StatelessWidget { + const _PlanTable({ + required this.title, + required this.description, + required this.price, + required this.priceInfo, + required this.cells, + required this.isCurrent, + required this.onSelected, + this.canUpgrade = false, + this.canDowngrade = false, + }); + + final String title; + final String description; + final String price; + final String priceInfo; + + final List cells; + final bool isCurrent; + final VoidCallback onSelected; + final bool canUpgrade; + final bool canDowngrade; + + @override + Widget build(BuildContext context) { + final highlightPlan = !isCurrent && !canDowngrade && canUpgrade; + + return Container( + width: 210, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: !highlightPlan + ? null + : const LinearGradient( + colors: [ + Color(0xFF251D37), + Color(0xFF7547C0), + ], + ), + ), + padding: !highlightPlan + ? const EdgeInsets.only(top: 4) + : const EdgeInsets.all(4), + child: Container( + clipBehavior: Clip.antiAlias, + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + color: Theme.of(context).cardColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Heading( + title: title, + description: description, + isPrimary: !highlightPlan, + horizontalInset: 12, + ), + _Heading( + title: price, + description: priceInfo, + isPrimary: !highlightPlan, + height: 64, + horizontalInset: 12, + ), + if (canUpgrade || canDowngrade) ...[ + 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), + ], + ...cells.map((e) => _ComparisonCell(label: e)), + ], + ), + ), + ); + } +} + +class _ComparisonCell extends StatelessWidget { + const _ComparisonCell({required this.label, this.tooltip}); + + final String label; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + height: 36, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: FlowyText.medium(label)), + if (tooltip != null) + FlowyTooltip( + message: tooltip, + child: const FlowySvg(FlowySvgs.information_s), + ), + ], + ), + ); + } +} + +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; + + @override + Widget build(BuildContext context) { + final isLM = Theme.of(context).brightness == Brightness.light; + + final gradientBorder = useGradientBorder && isLM; + return SizedBox( + height: 56, + child: Row( + children: [ + GestureDetector( + onTap: onPressed, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _drawGradientBorder( + isLM: isLM, + child: Container( + height: gradientBorder ? 36 : 40, + width: gradientBorder ? 148 : 152, + decoration: BoxDecoration( + color: gradientBorder + ? Theme.of(context).cardColor + : Colors.transparent, + border: Border.all( + color: gradientBorder + ? Colors.transparent + : AFThemeExtension.of(context).textColor, + ), + borderRadius: + BorderRadius.circular(gradientBorder ? 14 : 16), + ), + child: Center( + child: _drawText( + label, + isLM, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _drawText(String text, bool isLM) { + final child = FlowyText( + text, + fontSize: 14, + fontWeight: useGradientBorder ? FontWeight.w600 : FontWeight.w500, + ); + + if (!useGradientBorder || !isLM) { + return child; + } + + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => const LinearGradient( + transform: GradientRotation(-1.55), + stops: [0.4, 1], + colors: [ + Color(0xFF251D37), + Color(0xFF7547C0), + ], + ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), + child: child, + ); + } + + Widget _drawGradientBorder({required bool isLM, required Widget child}) { + if (!useGradientBorder || !isLM) { + return child; + } + + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + gradient: const LinearGradient( + transform: GradientRotation(-1.2), + stops: [0.4, 1], + colors: [ + Color(0xFF251D37), + Color(0xFF7547C0), + ], + ), + borderRadius: BorderRadius.circular(16), + ), + child: child, + ); + } +} + +class _Heading extends StatelessWidget { + const _Heading({ + required this.title, + this.description, + this.isPrimary = true, + this.height = 100, + this.horizontalInset = 0, + }); + + final String title; + final String? description; + final bool isPrimary; + final double height; + final double horizontalInset; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 165, + height: height, + child: Padding( + padding: EdgeInsets.only(left: horizontalInset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + title, + fontSize: 24, + color: isPrimary ? null : const Color(0xFF5C3699), + ), + if (description != null && description!.isNotEmpty) ...[ + const VSpace(4), + FlowyText.regular( + description!, + fontSize: 12, + maxLines: 3, + ), + ], + ], + ), + ), + ); + } +} + +class _PlanItem { + const _PlanItem({required this.label, this.tooltip}); + + final String label; + final String? tooltip; +} + +final _planLabels = [ + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemOne.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemTwo.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemThree.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipThree.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFour.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipFour.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFive.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemEight.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipEight.tr(), + ), +]; + +final _freeLabels = [ + LocaleKeys.settings_comparePlanDialog_freeLabels_itemOne.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemTwo.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemThree.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(), + LocaleKeys.settings_comparePlanDialog_freeLabels_itemEight.tr(), +]; + +final _proLabels = [ + LocaleKeys.settings_comparePlanDialog_proLabels_itemOne.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemTwo.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemThree.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(), + LocaleKeys.settings_comparePlanDialog_proLabels_itemEight.tr(), +]; 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 new file mode 100644 index 0000000000..091730ab81 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -0,0 +1,732 @@ +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'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/flowy_gradient_button.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsPlanView extends StatelessWidget { + const SettingsPlanView({super.key, required this.workspaceId}); + + final String workspaceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SettingsPlanBloc(workspaceId: workspaceId) + ..add(const SettingsPlanEvent.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), + ), + ), + 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( + autoSeparate: false, + title: LocaleKeys.settings_planPage_title.tr(), + children: [ + _PlanUsageSummary( + usage: state.workspaceUsage, + subscription: state.subscription, + ), + _CurrentPlanBox(subscription: state.subscription), + ], + ); + }, + ); + }, + ), + ); + } +} + +class _CurrentPlanBox extends StatelessWidget { + const _CurrentPlanBox({required this.subscription}); + + final WorkspaceSubscriptionPB subscription; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFBDBDBD)), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + subscription.label, + fontSize: 24, + ), + const VSpace(4), + FlowyText.regular( + LocaleKeys + .settings_planPage_planUsage_currentPlan_freeInfo + .tr(), + fontSize: 16, + maxLines: 3, + ), + const VSpace(16), + FlowyGradientButton( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_upgrade + .tr(), + onPressed: () => _openPricingDialog( + context, + context.read().workspaceId, + subscription, + ), + ), + 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, + ), + ], + ], + ), + ), + const HSpace(16), + Expanded( + child: SeparatedColumn( + separatorBuilder: () => const VSpace(4), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._getPros(subscription.subscriptionPlan).map( + (s) => _ProConItem(label: s), + ), + ..._getCons(subscription.subscriptionPlan).map( + (s) => _ProConItem(label: s, isPro: false), + ), + ], + ), + ), + ], + ), + ), + Positioned( + top: 0, + left: 0, + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration(color: Color(0xFF4F3F5F)), + child: Center( + child: FlowyText.semibold( + LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel + .tr(), + fontSize: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ); + } + + 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, + WorkspaceSubscriptionPB subscription, + ) => + showDialog( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + subscription: subscription, + ), + ), + ); + + List _getPros(SubscriptionPlanPB plan) => switch (plan) { + SubscriptionPlanPB.Pro => _proPros(), + _ => _freePros(), + }; + + List _getCons(SubscriptionPlanPB plan) => switch (plan) { + SubscriptionPlanPB.Pro => _proCons(), + _ => _freeCons(), + }; + + List _freePros() => [ + LocaleKeys.settings_planPage_planUsage_currentPlan_freeProOne.tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_freeProTwo.tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_freeProThree.tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFour.tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_freeProFive.tr(), + ]; + List _freeCons() => [ + LocaleKeys.settings_planPage_planUsage_currentPlan_freeConOne.tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_freeConTwo.tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_freeConThree.tr(), + ]; + + List _proPros() => [ + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProOne + .tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProTwo + .tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProThree + .tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFour + .tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalProFive + .tr(), + ]; + List _proCons() => [ + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConOne + .tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConTwo + .tr(), + LocaleKeys.settings_planPage_planUsage_currentPlan_professionalConThree + .tr(), + ]; +} + +class _ProConItem extends StatelessWidget { + const _ProConItem({ + required this.label, + this.isPro = true, + }); + + final String label; + final bool isPro; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + height: 24, + width: 24, + child: FlowySvg( + isPro ? FlowySvgs.check_s : FlowySvgs.close_s, + color: isPro ? null : const Color(0xFF900000), + ), + ), + const HSpace(4), + Flexible( + child: FlowyText.regular( + label, + fontSize: 12, + maxLines: 2, + ), + ), + ], + ); + } +} + +class _PlanUsageSummary extends StatelessWidget { + const _PlanUsageSummary({required this.usage, required this.subscription}); + + final WorkspaceUsagePB usage; + final WorkspaceSubscriptionPB subscription; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + LocaleKeys.settings_planPage_planUsage_title.tr(), + maxLines: 2, + fontSize: 16, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + const VSpace(16), + Row( + children: [ + Expanded( + child: _UsageBox( + title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(), + label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr( + args: [ + usage.currentBlobInGb, + usage.totalBlobInGb, + ], + ), + value: usage.totalBlobBytes.toInt() / + usage.totalBlobBytesLimit.toInt(), + ), + ), + Expanded( + child: _UsageBox( + title: LocaleKeys.settings_planPage_planUsage_collaboratorsLabel + .tr(), + label: LocaleKeys.settings_planPage_planUsage_collaboratorsUsage + .tr( + args: [ + usage.memberCount.toString(), + usage.memberCountLimit.toString(), + ], + ), + value: + usage.memberCount.toInt() / usage.memberCountLimit.toInt(), + ), + ), + ], + ), + const VSpace(16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ToggleMore( + value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro, + label: + LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), + subscription: subscription, + badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(), + ), + const VSpace(8), + _ToggleMore( + value: subscription.subscriptionPlan == SubscriptionPlanPB.Pro, + label: + LocaleKeys.settings_planPage_planUsage_guestCollabToggle.tr(), + subscription: subscription, + badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(), + ), + ], + ), + ], + ); + } +} + +class _UsageBox extends StatelessWidget { + const _UsageBox({ + required this.title, + required this.label, + required this.value, + }); + + final String title; + final String label; + final double value; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + title, + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + _PlanProgressIndicator(label: label, progress: value), + ], + ); + } +} + +class _ToggleMore extends StatefulWidget { + const _ToggleMore({ + required this.value, + required this.label, + required this.subscription, + this.badgeLabel, + }); + + final bool value; + final String label; + final WorkspaceSubscriptionPB subscription; + final String? badgeLabel; + + @override + State<_ToggleMore> createState() => _ToggleMoreState(); +} + +class _ToggleMoreState extends State<_ToggleMore> { + late bool toggleValue = widget.value; + + @override + Widget build(BuildContext context) { + final isLM = Brightness.light == Theme.of(context).brightness; + final primaryColor = + isLM ? const Color(0xFF653E8C) : const Color(0xFFE8E2EE); + final secondaryColor = + isLM ? const Color(0xFFE8E2EE) : const Color(0xFF653E8C); + + return Row( + children: [ + Toggle( + value: toggleValue, + padding: EdgeInsets.zero, + style: ToggleStyle.big, + onChanged: (_) { + setState(() => toggleValue = !toggleValue); + + Future.delayed(const Duration(milliseconds: 150), () { + if (mounted) { + showDialog( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: SettingsPlanComparisonDialog( + workspaceId: context.read().workspaceId, + subscription: widget.subscription, + ), + ), + ).then((_) { + Future.delayed(const Duration(milliseconds: 150), () { + if (mounted) { + setState(() => toggleValue = !toggleValue); + } + }); + }); + } + }); + }, + ), + const HSpace(10), + FlowyText.regular(widget.label, fontSize: 14), + if (widget.badgeLabel != null && widget.badgeLabel!.isNotEmpty) ...[ + const HSpace(10), + SizedBox( + height: 26, + child: Badge( + padding: const EdgeInsets.symmetric(horizontal: 10), + backgroundColor: secondaryColor, + label: FlowyText.semibold( + widget.badgeLabel!, + fontSize: 12, + color: primaryColor, + ), + ), + ), + ], + ], + ); + } +} + +class _PlanProgressIndicator extends StatelessWidget { + const _PlanProgressIndicator({required this.label, required this.progress}); + + final String label; + final double progress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Container( + 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, + ), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + children: [ + FractionallySizedBox( + widthFactor: progress, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ), + ), + const HSpace(8), + FlowyText.medium( + label, + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + const HSpace(16), + ], + ); + } +} + +/// Uncomment if we need it in the future +// class _DealBox extends StatelessWidget { +// const _DealBox(); + +// @override +// Widget build(BuildContext context) { +// final isLM = Theme.of(context).brightness == Brightness.light; + +// return Container( +// clipBehavior: Clip.antiAlias, +// decoration: BoxDecoration( +// gradient: LinearGradient( +// stops: isLM ? null : [.2, .3, .6], +// transform: isLM ? null : const GradientRotation(-.9), +// begin: isLM ? Alignment.centerLeft : Alignment.topRight, +// end: isLM ? Alignment.centerRight : Alignment.bottomLeft, +// colors: [ +// isLM +// ? const Color(0xFF7547C0).withAlpha(60) +// : const Color(0xFF7547C0), +// if (!isLM) const Color.fromARGB(255, 94, 57, 153), +// isLM +// ? const Color(0xFF251D37).withAlpha(60) +// : const Color(0xFF251D37), +// ], +// ), +// borderRadius: BorderRadius.circular(16), +// ), +// child: Stack( +// children: [ +// Padding( +// padding: const EdgeInsets.all(16), +// child: Row( +// children: [ +// Expanded( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// const VSpace(18), +// FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_deal_title.tr(), +// fontSize: 24, +// color: Theme.of(context).colorScheme.tertiary, +// ), +// const VSpace(8), +// FlowyText.medium( +// LocaleKeys.settings_planPage_planUsage_deal_info.tr(), +// maxLines: 6, +// color: Theme.of(context).colorScheme.tertiary, +// ), +// const VSpace(8), +// FlowyGradientButton( +// label: LocaleKeys +// .settings_planPage_planUsage_deal_viewPlans +// .tr(), +// fontWeight: FontWeight.w500, +// backgroundColor: isLM ? null : Colors.white, +// textColor: isLM +// ? Colors.white +// : Theme.of(context).colorScheme.onPrimary, +// ), +// ], +// ), +// ), +// ], +// ), +// ), +// Positioned( +// right: 0, +// top: 9, +// child: Container( +// height: 32, +// padding: const EdgeInsets.symmetric(horizontal: 16), +// decoration: BoxDecoration( +// gradient: LinearGradient( +// transform: const GradientRotation(.7), +// colors: [ +// if (isLM) const Color(0xFF7156DF), +// isLM +// ? const Color(0xFF3B2E8A) +// : const Color(0xFFCE006F).withAlpha(150), +// isLM ? const Color(0xFF261A48) : const Color(0xFF431459), +// ], +// ), +// ), +// child: Center( +// child: FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_deal_bannerLabel.tr(), +// fontSize: 16, +// color: Colors.white, +// ), +// ), +// ), +// ), +// ], +// ), +// ); +// } +// } + +/// Uncomment if we need it in the future +// class _AddAICreditBox extends StatelessWidget { +// const _AddAICreditBox(); + +// @override +// Widget build(BuildContext context) { +// return DecoratedBox( +// decoration: BoxDecoration( +// border: Border.all(color: const Color(0xFFBDBDBD)), +// borderRadius: BorderRadius.circular(16), +// ), +// child: Padding( +// padding: const EdgeInsets.all(16), +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_aiCredit_title.tr(), +// fontSize: 18, +// color: AFThemeExtension.of(context).secondaryTextColor, +// ), +// const VSpace(8), +// Row( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Flexible( +// flex: 5, +// child: ConstrainedBox( +// constraints: const BoxConstraints(maxWidth: 180), +// child: Column( +// mainAxisSize: MainAxisSize.min, +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_aiCredit_price +// .tr(), +// fontSize: 24, +// ), +// FlowyText.medium( +// LocaleKeys +// .settings_planPage_planUsage_aiCredit_priceDescription +// .tr(), +// fontSize: 14, +// color: +// AFThemeExtension.of(context).secondaryTextColor, +// ), +// const VSpace(8), +// FlowyGradientButton( +// label: LocaleKeys +// .settings_planPage_planUsage_aiCredit_purchase +// .tr(), +// ), +// ], +// ), +// ), +// ), +// const HSpace(16), +// Flexible( +// flex: 6, +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// mainAxisSize: MainAxisSize.min, +// children: [ +// FlowyText.regular( +// LocaleKeys.settings_planPage_planUsage_aiCredit_info +// .tr(), +// overflow: TextOverflow.ellipsis, +// maxLines: 5, +// ), +// const VSpace(8), +// SeparatedColumn( +// separatorBuilder: () => const VSpace(4), +// children: [ +// _AIStarItem( +// label: LocaleKeys +// .settings_planPage_planUsage_aiCredit_infoItemOne +// .tr(), +// ), +// _AIStarItem( +// label: LocaleKeys +// .settings_planPage_planUsage_aiCredit_infoItemTwo +// .tr(), +// ), +// ], +// ), +// ], +// ), +// ), +// ], +// ), +// ], +// ), +// ), +// ); +// } +// } + +/// Uncomment if we need it in the future +// class _AIStarItem extends StatelessWidget { +// const _AIStarItem({required this.label}); + +// final String label; + +// @override +// Widget build(BuildContext context) { +// return Row( +// children: [ +// const FlowySvg(FlowySvgs.ai_star_s, color: Color(0xFF750D7E)), +// const HSpace(4), +// Expanded(child: FlowyText(label, maxLines: 2)), +// ], +// ); +// } +// } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart index d2a2788250..4432618eaa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -637,7 +637,7 @@ class _ThemeDropdown extends StatelessWidget { actions: [ SettingAction( tooltip: 'Upload a custom theme', - icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(16)), + icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), onPressed: () => Dialogs.show( context, child: BlocProvider.value( @@ -658,7 +658,10 @@ class _ThemeDropdown extends StatelessWidget { }), ), SettingAction( - icon: const FlowySvg(FlowySvgs.restore_s), + icon: const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), label: LocaleKeys.settings_common_reset.tr(), onPressed: () => context .read() @@ -1018,7 +1021,10 @@ class _FontSelectorDropdownState extends State<_FontSelectorDropdown> { const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( children: [ - const FlowySvg(FlowySvgs.restore_s), + const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), const HSpace(4), FlowyText.regular( LocaleKeys.settings_common_reset.tr(), 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 9153215c79..4ee2ad9203 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart'; @@ -22,12 +24,14 @@ class SettingsDialog extends StatelessWidget { required this.dismissDialog, required this.didLogout, required this.restartApp, + required this.workspaceId, }) : super(key: ValueKey(user.id)); final VoidCallback dismissDialog; final VoidCallback didLogout; final VoidCallback restartApp; final UserProfilePB user; + final String workspaceId; @override Widget build(BuildContext context) { @@ -37,6 +41,7 @@ class SettingsDialog extends StatelessWidget { child: BlocBuilder( builder: (context, state) => FlowyDialog( width: MediaQuery.of(context).size.width * 0.7, + constraints: const BoxConstraints(maxWidth: 784, minWidth: 564), child: ScaffoldMessenger( child: Scaffold( backgroundColor: Colors.transparent, @@ -89,6 +94,10 @@ class SettingsDialog extends StatelessWidget { return const SettingsShortcutsView(); case SettingsPage.member: return WorkspaceMembersPage(userProfile: user); + case SettingsPage.plan: + return SettingsPlanView(workspaceId: workspaceId); + case SettingsPage.billing: + return SettingsBillingView(workspaceId: workspaceId); case SettingsPage.featureFlags: return const FeatureFlagsPage(); default: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart new file mode 100644 index 0000000000..41e8733cc5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/style_widget/text.dart'; + +class FlowyGradientButton extends StatefulWidget { + const FlowyGradientButton({ + super.key, + required this.label, + this.onPressed, + this.fontWeight = FontWeight.w600, + this.textColor = Colors.white, + this.backgroundColor, + }); + + final String label; + final VoidCallback? onPressed; + final FontWeight fontWeight; + + /// Used to provide a custom foreground color for the button, used in cases + /// where a custom [backgroundColor] is provided and the default text color + /// does not have enough contrast. + /// + final Color textColor; + + /// Used to provide a custom background color for the button, this will + /// override the gradient behavior, and is mostly used in rare cases + /// where the gradient doesn't have contrast with the background. + /// + final Color? backgroundColor; + + @override + State createState() => _FlowyGradientButtonState(); +} + +class _FlowyGradientButtonState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: (_) => widget.onPressed?.call(), + child: MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + cursor: SystemMouseCursors.click, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: 4, + color: Colors.black.withOpacity(0.25), + offset: const Offset(0, 2), + ), + ], + borderRadius: BorderRadius.circular(16), + color: widget.backgroundColor, + gradient: widget.backgroundColor != null + ? null + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + isHovering + ? const Color.fromARGB(255, 57, 40, 92) + : const Color(0xFF44326B), + isHovering + ? const Color.fromARGB(255, 96, 53, 164) + : const Color(0xFF7547C0), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: FlowyText( + widget.label, + fontSize: 16, + fontWeight: widget.fontWeight, + color: widget.textColor, + maxLines: 2, + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart index c2d049733a..78d8d5223a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart @@ -57,6 +57,7 @@ class SettingListTile extends StatelessWidget { icon: FlowySvg( FlowySvgs.restore_s, color: Theme.of(context).iconTheme.color, + size: const Size.square(20), ), iconColorOnHover: Theme.of(context).colorScheme.onPrimary, tooltipText: resetTooltipText ?? diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart index 17d60359cd..4efc31b06d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; @@ -10,6 +11,7 @@ import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; class SettingsAlertDialog extends StatefulWidget { const SettingsAlertDialog({ super.key, + this.icon, required this.title, this.subtitle, this.children, @@ -21,6 +23,7 @@ class SettingsAlertDialog extends StatefulWidget { this.implyLeading = false, }); + final Widget? icon; final String title; final String? subtitle; final List? children; @@ -86,6 +89,10 @@ class _SettingsAlertDialogState extends State { ), ], ), + if (widget.icon != null) ...[ + widget.icon!, + const VSpace(16), + ], Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -168,6 +175,7 @@ class _Actions extends StatelessWidget { fontColor: AFThemeExtension.of(context).textColor, fillColor: Colors.transparent, hoverColor: Colors.transparent, + radius: Corners.s12Border, onPressed: () { cancel?.call(); Navigator.of(context).pop(); @@ -187,6 +195,7 @@ class _Actions extends StatelessWidget { horizontal: 24, vertical: 12, ), + radius: Corners.s12Border, fontColor: isDangerous ? Colors.white : null, fontHoverColor: Colors.white, fillColor: isDangerous diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart index 54593e1bd0..3c838bd6fa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart @@ -9,11 +9,13 @@ class SettingsBody extends StatelessWidget { super.key, required this.title, this.description, + this.autoSeparate = true, required this.children, }); final String title; final String? description; + final bool autoSeparate; final List children; @override @@ -28,7 +30,9 @@ class SettingsBody extends StatelessWidget { SettingsHeader(title: title, description: description), Flexible( child: SeparatedColumn( - separatorBuilder: () => const SettingsCategorySpacer(), + separatorBuilder: () => autoSeparate + ? const SettingsCategorySpacer() + : const VSpace(16), crossAxisAlignment: CrossAxisAlignment.start, children: children, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart index de0526b717..4ca3f8f508 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart @@ -100,7 +100,8 @@ class _SettingsInputFieldState extends State { ], ], ), - const VSpace(8), + if (widget.label?.isNotEmpty ?? false || widget.tooltip != null) + const VSpace(8), SizedBox( height: 48, child: FlowyTextField( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart index 7d51ea370c..8fc8e33280 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -65,6 +66,7 @@ class SingleSettingAction extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), fillColor: isDangerous ? null : Theme.of(context).colorScheme.primary, + radius: Corners.s12Border, hoverColor: isDangerous ? null : const Color(0xFF005483), fontColor: isDangerous ? Theme.of(context).colorScheme.error : null, fontHoverColor: Colors.white, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart index e4e15da75d..90ce4d6f9b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; class FeatureFlagsPage extends StatelessWidget { const FeatureFlagsPage({ @@ -49,8 +50,10 @@ class _FeatureFlagItemState extends State<_FeatureFlagItem> { subtitle: FlowyText.small(widget.featureFlag.description, maxLines: 3), trailing: Switch.adaptive( value: widget.featureFlag.isOn, - onChanged: (value) => - setState(() async => widget.featureFlag.update(value)), + onChanged: (value) async { + await widget.featureFlag.update(value); + setState(() {}); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index e44af72edd..e86706a999 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -99,6 +99,22 @@ class SettingsMenu extends StatelessWidget { icon: const Icon(Icons.cut), changeSelectedPage: changeSelectedPage, ), + if (FeatureFlag.planBilling.isOn) ...[ + SettingsMenuElement( + page: SettingsPage.plan, + selectedPage: currentPage, + label: LocaleKeys.settings_planPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_plan_m), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.billing, + selectedPage: currentPage, + label: LocaleKeys.settings_billingPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_billing_m), + changeSelectedPage: changeSelectedPage, + ), + ], if (kDebugMode) SettingsMenuElement( // no need to translate this page diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart index 8a2c504bc5..78d89f0297 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart @@ -52,7 +52,7 @@ class FlowyDialog extends StatelessWidget { title: title, shape: shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - clipBehavior: Clip.hardEdge, + clipBehavior: Clip.antiAliasWithSaveLayer, children: [ Material( type: MaterialType.transparency, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart index 299eb76015..27bff59045 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart @@ -97,7 +97,13 @@ class _FlowyHoverState extends State { child: child, ); } else { - return Container(color: style.backgroundColor, child: child); + return Container( + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: style.borderRadius, + ), + child: child, + ); } } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart index c79f430942..fd6b734715 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart @@ -1,8 +1,14 @@ -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_svg/flowy_svg.dart'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_svg/flowy_svg.dart'; import 'package:url_launcher/url_launcher.dart'; class FlowyErrorPage extends StatelessWidget { @@ -71,34 +77,56 @@ class FlowyErrorPage extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - // mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ const FlowyText.medium( "AppFlowy Error", fontSize: _titleFontSize, ), - const SizedBox( - height: _titleToMessagePadding, + const SizedBox(height: _titleToMessagePadding), + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) async { + await Clipboard.setData(ClipboardData(text: message)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + content: FlowyText( + 'Message copied to clipboard', + fontSize: kIsWeb || !Platform.isIOS && !Platform.isAndroid + ? 14 + : 12, + ), + ), + ); + } + }, + child: FlowyHover( + style: HoverStyle( + backgroundColor: + Theme.of(context).colorScheme.tertiaryContainer, + ), + cursor: SystemMouseCursors.click, + child: FlowyTooltip( + message: 'Click to copy message', + child: Padding( + padding: const EdgeInsets.all(4), + child: FlowyText.semibold(message, maxLines: 10), + ), + ), + ), ), - FlowyText.semibold( - message, - maxLines: 10, - ), - const SizedBox( - height: _titleToMessagePadding, - ), - FlowyText.regular( - howToFix, - maxLines: 10, - ), - const SizedBox( - height: _titleToMessagePadding, - ), - const GitHubRedirectButton(), - const SizedBox( - height: _titleToMessagePadding, + const SizedBox(height: _titleToMessagePadding), + FlowyText.regular(howToFix, maxLines: 10), + const SizedBox(height: _titleToMessagePadding), + GitHubRedirectButton( + title: 'Unexpected error', + message: message, + stackTrace: stackTrace, ), + const SizedBox(height: _titleToMessagePadding), if (stackTrace != null) StackTracePreview(stackTrace!), if (actions != null) Row( @@ -175,7 +203,16 @@ class StackTracePreview extends StatelessWidget { } class GitHubRedirectButton extends StatelessWidget { - const GitHubRedirectButton({super.key}); + const GitHubRedirectButton({ + super.key, + this.title, + this.message, + this.stackTrace, + }); + + final String? title; + final String? message; + final String? stackTrace; static const _height = 32.0; @@ -184,9 +221,34 @@ class GitHubRedirectButton extends StatelessWidget { host: 'github.com', path: '/AppFlowy-IO/AppFlowy/issues/new', query: - 'assignees=&labels=&projects=&template=bug_report.yaml&title=%5BBug%5D+', + 'assignees=&labels=&projects=&template=bug_report.yaml&os=$_platform&title=%5BBug%5D+$title&context=$_contextString', ); + String get _contextString { + if (message == null && stackTrace == null) { + return ''; + } + + String msg = ""; + if (message != null) { + msg += 'Error message:%0A```%0A$message%0A```%0A'; + } + + if (stackTrace != null) { + msg += 'StackTrace:%0A```%0A$stackTrace%0A```%0A'; + } + + return msg; + } + + String get _platform { + if (kIsWeb) { + return 'Web'; + } + + return Platform.operatingSystem; + } + @override Widget build(BuildContext context) { return FlowyButton( diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index a3f6e2f4e1..c21aa283d9 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +client-api = { version = "0.2" } [dependencies] serde_json.workspace = true @@ -113,3 +113,6 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } + +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml index a7b70b144f..6f3221b392 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ b/frontend/appflowy_web/wasm-libs/Cargo.toml @@ -55,9 +55,7 @@ yrs = "0.18.8" # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } - - +client-api = { version = "0.2" } [profile.dev] opt-level = 0 @@ -77,3 +75,6 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" } + +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 721c2dccee..54e0f74644 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +client-api = { version = "0.2" } [dependencies] serde_json.workspace = true @@ -114,3 +114,6 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } + +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } diff --git a/frontend/resources/flowy_icons/16x/ai_star.svg b/frontend/resources/flowy_icons/16x/ai_star.svg new file mode 100644 index 0000000000..b98634fda1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_star.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/check_circle.svg b/frontend/resources/flowy_icons/16x/check_circle.svg new file mode 100644 index 0000000000..6703344199 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/check_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/settings_billing.svg b/frontend/resources/flowy_icons/24x/settings_billing.svg new file mode 100644 index 0000000000..a9f45f7123 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/settings_billing.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/settings_plan.svg b/frontend/resources/flowy_icons/24x/settings_plan.svg index 5c6f53f836..3c6f05400f 100644 --- a/frontend/resources/flowy_icons/24x/settings_plan.svg +++ b/frontend/resources/flowy_icons/24x/settings_plan.svg @@ -1,8 +1,8 @@ - + - - + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 0c716a5e44..b8bc83103a 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -330,7 +330,8 @@ "signInGoogle": "Sign in with Google", "signInGithub": "Sign in with Github", "signInDiscord": "Sign in with Discord", - "more": "More" + "more": "More", + "close": "Close" }, "label": { "welcome": "Welcome!", @@ -371,10 +372,10 @@ "keys": { "title": "AI API Keys", "openAILabel": "OpenAI API key", - "openAITooltip": "The OpenAI API key to use for the AI models", + "openAITooltip": "You can find your Secret API key on the API key page", "openAIHint": "Input your OpenAI API Key", "stabilityAILabel": "Stability API key", - "stabilityAITooltip": "The Stability API key to use for the AI models", + "stabilityAITooltip": "Your Stability API key, used to authenticate your requests", "stabilityAIHint": "Input your Stability API Key" }, "login": { @@ -501,6 +502,142 @@ } } }, + "planPage": { + "menuLabel": "Plan", + "title": "Pricing plan", + "planUsage": { + "title": "Plan usage summary", + "storageLabel": "Storage", + "storageUsage": "{} of {} GB", + "collaboratorsLabel": "Collaborators", + "collaboratorsUsage": "{} of {}", + "aiResponseLabel": "AI Responses", + "aiResponseUsage": "{} of {}", + "proBadge": "Pro", + "memberProToggle": "Unlimited members", + "guestCollabToggle": "10 guest collaborators", + "aiCredit": { + "title": "Add AppFlowy AI Credit", + "price": "5$", + "priceDescription": "for 1,000 credits", + "purchase": "Purchase AI", + "info": "Add 1,000 Ai credits per workspace and seamlessly integrate customizable AI into your workflow for smarter, faster results with up to:", + "infoItemOne": "10,000 responses per database", + "infoItemTwo": "1,000 responses per workspace" + }, + "currentPlan": { + "bannerLabel": "Current plan", + "freeTitle": "Free", + "proTitle": "Pro", + "teamTitle": "Team", + "freeInfo": "Perfect for individuals or small teams up to 3.", + "upgrade": "Compare &\n Upgrade", + "freeProOne": "Collaborative workspace", + "freeProTwo": "Up to 3 members (incl. owner)", + "freeProThree": "Unlimited guests (view-only)", + "freeProFour": "Storage 5gb", + "freeProFive": "30 day revision history", + "freeConOne": "Guest collaborators (edit access)", + "freeConTwo": "Unlimited storage", + "freeConThree": "6 month revision history", + "professionalProOne": "Collaborative workspace", + "professionalProTwo": "Unlimited members", + "professionalProThree": "Unlimited guests (view-only)", + "professionalProFour": "Unlimited storage", + "professionalProFive": "6 month revision history", + "professionalConOne": "Unlimited guest collaborators (edit access)", + "professionalConTwo": "Unlimited AI responses", + "professionalConThree": "1 year revision history", + "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." + }, + "deal": { + "bannerLabel": "New year deal!", + "title": "Grow your team!", + "info": "Upgrade and save 10% off Pro and Team plans! Boost your workspace productivity with powerful new features including Appflowy Ai.", + "viewPlans": "View plans" + } + } + }, + "billingPage": { + "menuLabel": "Billing", + "title": "Billing", + "plan": { + "title": "Plan", + "freeLabel": "Free", + "proLabel": "Pro", + "planButtonLabel": "Change plan", + "billingPeriod": "Billing period", + "periodButtonLabel": "Edit period" + }, + "paymentDetails": { + "title": "Payment details", + "methodLabel": "Payment method", + "methodButtonLabel": "Edit method" + } + }, + "comparePlanDialog": { + "title": "Compare & select plan", + "planFeatures": "Plan\nFeatures", + "actions": { + "upgrade": "Upgrade", + "downgrade": "Downgrade", + "current": "Current" + }, + "freePlan": { + "title": "Free", + "description": "For organizing every corner of your work & life.", + "price": "$0", + "priceInfo": "free forever" + }, + "proPlan": { + "title": "Professional", + "description": "A palce for small groups to plan & get organized.", + "price": "$10 /month", + "priceInfo": "billed annually" + }, + "planLabels": { + "itemOne": "Workspaces", + "itemTwo": "Members", + "itemThree": "Guests", + "tooltipThree": "Guests have read-only permission to the specifically shared content", + "itemFour": "Guest collaborators", + "tooltipFour": "Guest collaborators are billed as one seat", + "itemFive": "Storage", + "itemSix": "Real-time collaboration", + "itemSeven": "Mobile app", + "itemEight": "AI Responses", + "tooltipEight": "Lifetime means the number of responses never reset" + }, + "freeLabels": { + "itemOne": "charged per workspace", + "itemTwo": "3", + "itemThree": "", + "itemFour": "0", + "itemFive": "5 GB", + "itemSix": "yes", + "itemSeven": "yes", + "itemEight": "1,000 lifetime" + }, + "proLabels": { + "itemOne": "charged per workspace", + "itemTwo": "up to 10", + "itemThree": "", + "itemFour": "10 guests billed as one seat", + "itemFive": "unlimited", + "itemSix": "yes", + "itemSeven": "yes", + "itemEight": "10,000 monthly" + }, + "paymentSuccess": { + "title": "You are now on the {} plan!", + "description": "Your payment has been successfully processed and your plan is upgraded to AppFlowy {}. You can view your plan details on the Plan page" + }, + "downgradeDialog": { + "title": "Are you sure you want to downgrade your plan?", + "description": "Downgrading your plan will revert you back to the Free plan. Members may lose access to workspaces and you may need to free up space to meet the storage limits of the Free plan.", + "downgradeLabel": "Downgrade plan" + } + }, "common": { "reset": "Reset" }, diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 26c616c416..d90ee2a7b3 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -194,6 +194,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "appflowy-cloud-billing-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud-Billing-Client?rev=9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6#9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6" +dependencies = [ + "client-api", + "reqwest", + "serde", + "serde_json", + "shared-entity", + "tokio", + "yrs", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -2066,6 +2080,7 @@ name = "flowy-server" version = "0.1.0" dependencies = [ "anyhow", + "appflowy-cloud-billing-client", "assert-json-diff", "bytes", "chrono", @@ -5590,9 +5605,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.36.0" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -5620,9 +5635,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index c1363948f0..039f280eef 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -76,7 +76,7 @@ uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" parking_lot = "0.12" futures = "0.3.29" -tokio = "1.34.0" +tokio = "1.38.0" tokio-stream = "0.1.14" async-trait = "0.1.74" chrono = { version = "0.4.31", default-features = false, features = ["clock"] } @@ -94,7 +94,8 @@ yrs = "0.18.8" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +client-api = { version = "0.2" } +appflowy-cloud-billing-client = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud-Billing-Client", rev = "9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6" } [profile.dev] opt-level = 1 @@ -121,6 +122,9 @@ lto = false incremental = false [patch.crates-io] +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } +shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } + # TODO(Lucas.Xu) Upgrade to the latest version of RocksDB once PR(https://github.com/rust-rocksdb/rust-rocksdb/pull/869) is merged. # Currently, using the following revision id. This commit is patched to fix the 32-bit build issue and it's checked out from 0.21.0, not 0.22.0. rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec131b9d82dc94e178fe8efc0c147b09" } diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs index 88636febcd..089bbae7ba 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs @@ -93,7 +93,7 @@ async fn delete_view_subscription_test() { let update = test .appflowy_core .dispatcher() - .run_until(receive_with_timeout(rx, Duration::from_secs(30))) + .run_until(receive_with_timeout(rx, Duration::from_secs(60))) .await .unwrap(); diff --git a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs index 3c38bc4005..b7af102c26 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs @@ -21,11 +21,9 @@ impl From for FlowyError { AppErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, AppErrorCode::NetworkError => ErrorCode::HttpError, AppErrorCode::PayloadTooLarge => ErrorCode::CloudRequestPayloadTooLarge, - AppErrorCode::UserUnAuthorized => match &*error.message { - "Workspace Limit Exceeded" => ErrorCode::WorkspaceLimitExceeded, - "Workspace Member Limit Exceeded" => ErrorCode::WorkspaceMemberLimitExceeded, - _ => ErrorCode::UserUnauthorized, - }, + AppErrorCode::UserUnAuthorized => ErrorCode::UserUnauthorized, + AppErrorCode::WorkspaceLimitExceeded => ErrorCode::WorkspaceLimitExceeded, + AppErrorCode::WorkspaceMemberLimitExceeded => ErrorCode::WorkspaceMemberLimitExceeded, _ => ErrorCode::Internal, }; diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index a92b0730ee..bb16b35cdb 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -37,6 +37,7 @@ flowy-user-pub = { workspace = true } flowy-folder-pub = { workspace = true } flowy-database-pub = { workspace = true } flowy-document-pub = { workspace = true } +appflowy-cloud-billing-client = { workspace = true } flowy-error = { workspace = true, features = ["impl_from_serde", "impl_from_reqwest", "impl_from_url", "impl_from_appflowy_cloud"] } flowy-server-pub = { workspace = true } flowy-encrypt = { workspace = true } @@ -69,4 +70,4 @@ assert-json-diff = "2.0.2" serde_json.workspace = true [features] -enable_supabase = ["collab-plugins/postgres_plugin"] \ No newline at end of file +enable_supabase = ["collab-plugins/postgres_plugin"] diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 858e74f501..7fc3e2bf1c 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -1,7 +1,11 @@ +use appflowy_cloud_billing_client::entities::{ + RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus, +}; use std::collections::HashMap; use std::sync::Arc; use anyhow::anyhow; +use appflowy_cloud_billing_client::BillingClient; use client_api::entity::workspace_dto::{ CreateWorkspaceMember, CreateWorkspaceParam, PatchWorkspaceParam, WorkspaceMemberChangeset, WorkspaceMemberInvitation, @@ -20,6 +24,7 @@ use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, User use flowy_user_pub::entities::{ AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserCredentials, UserProfile, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + WorkspaceSubscription, WorkspaceUsage, }; use lib_infra::box_any::BoxAny; use lib_infra::future::FutureResult; @@ -473,6 +478,82 @@ where Ok(()) }) } + + fn subscribe_workspace( + &self, + workspace_id: String, + recurring_interval: flowy_user_pub::entities::RecurringInterval, + workspace_subscription_plan: flowy_user_pub::entities::SubscriptionPlan, + success_url: String, + ) -> FutureResult { + let try_get_client = self.server.try_get_client(); + let workspace_id = workspace_id.to_string(); + FutureResult::new(async move { + let subscription_plan = to_workspace_subscription_plan(workspace_subscription_plan)?; + let client = try_get_client?; + let payment_link = BillingClient::from(client.as_ref()) + .create_subscription( + &workspace_id, + to_recurring_interval(recurring_interval), + subscription_plan, + &success_url, + ) + .await?; + Ok(payment_link) + }) + } + + fn get_workspace_subscriptions(&self) -> FutureResult, FlowyError> { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let workspace_subscriptions = BillingClient::from(client.as_ref()) + .list_subscription() + .await? + .into_iter() + .map(to_workspace_subscription) + .collect(); + Ok(workspace_subscriptions) + }) + } + + fn cancel_workspace_subscription(&self, workspace_id: String) -> FutureResult<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + BillingClient::from(client.as_ref()) + .cancel_subscription(&workspace_id) + .await?; + Ok(()) + }) + } + + fn get_workspace_usage(&self, workspace_id: String) -> FutureResult { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let usage = BillingClient::from(client.as_ref()) + .get_workspace_usage(&workspace_id) + .await?; + Ok(WorkspaceUsage { + member_count: usage.member_count, + member_count_limit: usage.member_count_limit, + total_blob_bytes: usage.total_blob_bytes, + total_blob_bytes_limit: usage.total_blob_bytes_limit, + }) + }) + } + + fn get_billing_portal_url(&self) -> FutureResult { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let url = BillingClient::from(client.as_ref()) + .get_portal_session_link() + .await?; + Ok(url) + }) + } } async fn get_admin_client(client: &Arc) -> FlowyResult { @@ -569,3 +650,47 @@ fn oauth_params_from_box_any(any: BoxAny) -> Result RecurringInterval { + match r { + flowy_user_pub::entities::RecurringInterval::Month => RecurringInterval::Month, + flowy_user_pub::entities::RecurringInterval::Year => RecurringInterval::Year, + } +} + +fn to_workspace_subscription_plan( + s: flowy_user_pub::entities::SubscriptionPlan, +) -> Result { + match s { + flowy_user_pub::entities::SubscriptionPlan::Pro => Ok(SubscriptionPlan::Pro), + flowy_user_pub::entities::SubscriptionPlan::Team => Ok(SubscriptionPlan::Team), + flowy_user_pub::entities::SubscriptionPlan::None => Err(FlowyError::new( + ErrorCode::InvalidParams, + "Invalid subscription plan", + )), + } +} + +fn to_workspace_subscription(s: WorkspaceSubscriptionStatus) -> WorkspaceSubscription { + WorkspaceSubscription { + workspace_id: s.workspace_id, + subscription_plan: match s.workspace_plan { + appflowy_cloud_billing_client::entities::WorkspaceSubscriptionPlan::Pro => { + flowy_user_pub::entities::SubscriptionPlan::Pro + }, + appflowy_cloud_billing_client::entities::WorkspaceSubscriptionPlan::Team => { + flowy_user_pub::entities::SubscriptionPlan::Team + }, + _ => flowy_user_pub::entities::SubscriptionPlan::None, + }, + recurring_interval: match s.recurring_interval { + RecurringInterval::Month => flowy_user_pub::entities::RecurringInterval::Month, + RecurringInterval::Year => flowy_user_pub::entities::RecurringInterval::Year, + }, + is_active: matches!( + s.subscription_status, + appflowy_cloud_billing_client::entities::SubscriptionStatus::Active + ), + canceled_at: s.canceled_at, + } +} diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index e789dd8c1a..0400c8ba51 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -13,8 +13,9 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::entities::{ - AuthResponse, Authenticator, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + AuthResponse, Authenticator, RecurringInterval, Role, SubscriptionPlan, UpdateUserProfileParams, + UserCredentials, UserProfile, UserTokenState, UserWorkspace, WorkspaceInvitation, + WorkspaceInvitationStatus, WorkspaceMember, WorkspaceSubscription, WorkspaceUsage, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -266,6 +267,32 @@ pub trait UserCloudService: Send + Sync + 'static { fn leave_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } + + fn subscribe_workspace( + &self, + workspace_id: String, + recurring_interval: RecurringInterval, + workspace_subscription_plan: SubscriptionPlan, + success_url: String, + ) -> FutureResult { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + + fn get_workspace_subscriptions(&self) -> FutureResult, FlowyError> { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + + fn cancel_workspace_subscription(&self, workspace_id: String) -> FutureResult<(), FlowyError> { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + + fn get_workspace_usage(&self, workspace_id: String) -> FutureResult { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + + fn get_billing_portal_url(&self) -> FutureResult { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } } pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver; diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 6d579c70dc..ae4f85d71a 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -422,3 +422,29 @@ pub struct WorkspaceInvitation { pub status: WorkspaceInvitationStatus, pub updated_at: DateTime, } + +pub enum RecurringInterval { + Month, + Year, +} + +pub enum SubscriptionPlan { + None, + Pro, + Team, +} + +pub struct WorkspaceSubscription { + pub workspace_id: String, + pub subscription_plan: SubscriptionPlan, + pub recurring_interval: RecurringInterval, + pub is_active: bool, + pub canceled_at: Option, +} + +pub struct WorkspaceUsage { + pub member_count: usize, + pub member_count_limit: usize, + pub total_blob_bytes: usize, + pub total_blob_bytes_limit: usize, +} diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index bef8151d52..1af4f2b020 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -1,7 +1,10 @@ use validator::Validate; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::entities::{ + RecurringInterval, Role, SubscriptionPlan, WorkspaceInvitation, WorkspaceMember, + WorkspaceSubscription, +}; use lib_infra::validator_fn::required_not_empty_str; #[derive(ProtoBuf, Default, Clone)] @@ -201,3 +204,136 @@ pub struct ChangeWorkspaceIconPB { #[pb(index = 2)] pub new_icon: String, } + +#[derive(ProtoBuf, Default, Clone, Validate, Debug)] +pub struct SubscribeWorkspacePB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, + + #[pb(index = 2)] + pub recurring_interval: RecurringIntervalPB, + + #[pb(index = 3)] + pub workspace_subscription_plan: SubscriptionPlanPB, + + #[pb(index = 4)] + pub success_url: String, +} + +#[derive(ProtoBuf_Enum, Clone, Default, Debug)] +pub enum RecurringIntervalPB { + #[default] + Month = 0, + Year = 1, +} + +impl From for RecurringInterval { + fn from(r: RecurringIntervalPB) -> Self { + match r { + RecurringIntervalPB::Month => RecurringInterval::Month, + RecurringIntervalPB::Year => RecurringInterval::Year, + } + } +} + +impl From for RecurringIntervalPB { + fn from(r: RecurringInterval) -> Self { + match r { + RecurringInterval::Month => RecurringIntervalPB::Month, + RecurringInterval::Year => RecurringIntervalPB::Year, + } + } +} + +#[derive(ProtoBuf_Enum, Clone, Default, Debug)] +pub enum SubscriptionPlanPB { + #[default] + None = 0, + Pro = 1, + Team = 2, +} + +impl From for SubscriptionPlan { + fn from(value: SubscriptionPlanPB) -> Self { + match value { + SubscriptionPlanPB::Pro => SubscriptionPlan::Pro, + SubscriptionPlanPB::Team => SubscriptionPlan::Team, + SubscriptionPlanPB::None => SubscriptionPlan::None, + } + } +} + +impl From for SubscriptionPlanPB { + fn from(value: SubscriptionPlan) -> Self { + match value { + SubscriptionPlan::Pro => SubscriptionPlanPB::Pro, + SubscriptionPlan::Team => SubscriptionPlanPB::Team, + SubscriptionPlan::None => SubscriptionPlanPB::None, + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct PaymentLinkPB { + #[pb(index = 1)] + pub payment_link: String, +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct RepeatedWorkspaceSubscriptionPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct WorkspaceSubscriptionPB { + #[pb(index = 1)] + pub workspace_id: String, + + #[pb(index = 2)] + pub subscription_plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub recurring_interval: RecurringIntervalPB, + + #[pb(index = 4)] + pub is_active: bool, + + #[pb(index = 5)] + pub has_canceled: bool, + + #[pb(index = 6)] + pub canceled_at: i64, // value is valid only if has_canceled is true +} + +impl From for WorkspaceSubscriptionPB { + fn from(s: WorkspaceSubscription) -> Self { + Self { + workspace_id: s.workspace_id, + subscription_plan: s.subscription_plan.into(), + recurring_interval: s.recurring_interval.into(), + is_active: s.is_active, + has_canceled: s.canceled_at.is_some(), + canceled_at: s.canceled_at.unwrap_or_default(), + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct WorkspaceUsagePB { + #[pb(index = 1)] + pub member_count: u64, + #[pb(index = 2)] + pub member_count_limit: u64, + #[pb(index = 3)] + pub total_blob_bytes: u64, + #[pb(index = 4)] + pub total_blob_bytes_limit: u64, +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct BillingPortalPB { + #[pb(index = 1)] + pub url: String, +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 34389b8793..0e225e6416 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -774,3 +774,64 @@ pub async fn leave_workspace_handler( manager.leave_workspace(&workspace_id).await?; Ok(()) } + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn subscribe_workspace_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let params = params.try_into_inner()?; + let manager = upgrade_manager(manager)?; + let payment_link = manager.subscribe_workspace(params).await?; + data_result_ok(PaymentLinkPB { payment_link }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_workspace_subscriptions_handler( + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let subs = manager + .get_workspace_subscriptions() + .await? + .into_iter() + .map(WorkspaceSubscriptionPB::from) + .collect::>(); + data_result_ok(RepeatedWorkspaceSubscriptionPB { items: subs }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn cancel_workspace_subscription_handler( + param: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let workspace_id = param.into_inner().workspace_id; + let manager = upgrade_manager(manager)?; + manager.cancel_workspace_subscription(workspace_id).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_workspace_usage_handler( + param: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let workspace_id = param.into_inner().workspace_id; + let manager = upgrade_manager(manager)?; + let workspace_usage = manager.get_workspace_usage(workspace_id).await?; + data_result_ok(WorkspaceUsagePB { + member_count: workspace_usage.member_count as u64, + member_count_limit: workspace_usage.member_count_limit as u64, + total_blob_bytes: workspace_usage.total_blob_bytes as u64, + total_blob_bytes_limit: workspace_usage.total_blob_bytes_limit as u64, + }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_billing_portal_handler( + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let url = manager.get_billing_portal_url().await?; + data_result_ok(BillingPortalPB { url }) +} diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 1c1530dbd5..1fa03d7604 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -71,6 +71,12 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::InviteWorkspaceMember, invite_workspace_member_handler) .event(UserEvent::ListWorkspaceInvitations, list_workspace_invitations_handler) .event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler) + // Billing + .event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler) + .event(UserEvent::GetWorkspaceSubscriptions, get_workspace_subscriptions_handler) + .event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler) + .event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler) + .event(UserEvent::GetBillingPortal, get_billing_portal_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -230,6 +236,21 @@ pub enum UserEvent { #[event(input = "MagicLinkSignInPB", output = "UserProfilePB")] MagicLinkSignIn = 50, + + #[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")] + SubscribeWorkspace = 51, + + #[event(output = "RepeatedWorkspaceSubscriptionPB")] + GetWorkspaceSubscriptions = 52, + + #[event(input = "UserWorkspaceIdPB")] + CancelWorkspaceSubscription = 53, + + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")] + GetWorkspaceUsage = 54, + + #[event(output = "BillingPortalPB")] + GetBillingPortal = 55, } pub trait UserStatusCallback: Send + Sync + 'static { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 0f74c563b1..f6f0f70720 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -11,10 +11,13 @@ use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::entities::{ Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + WorkspaceSubscription, WorkspaceUsage, }; use lib_dispatch::prelude::af_spawn; -use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB, UserWorkspacePB}; +use crate::entities::{ + RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, UserWorkspacePB, +}; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; use crate::services::data_import::{ @@ -417,6 +420,65 @@ impl UserManager { .await?; Ok(()) } + + #[instrument(level = "info", skip(self), err)] + pub async fn subscribe_workspace( + &self, + workspace_subscription: SubscribeWorkspacePB, + ) -> FlowyResult { + let payment_link = self + .cloud_services + .get_user_service()? + .subscribe_workspace( + workspace_subscription.workspace_id, + workspace_subscription.recurring_interval.into(), + workspace_subscription.workspace_subscription_plan.into(), + workspace_subscription.success_url, + ) + .await?; + + Ok(payment_link) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_workspace_subscriptions(&self) -> FlowyResult> { + let res = self + .cloud_services + .get_user_service()? + .get_workspace_subscriptions() + .await?; + Ok(res) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn cancel_workspace_subscription(&self, workspace_id: String) -> FlowyResult<()> { + self + .cloud_services + .get_user_service()? + .cancel_workspace_subscription(workspace_id) + .await?; + Ok(()) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_workspace_usage(&self, workspace_id: String) -> FlowyResult { + let workspace_usage = self + .cloud_services + .get_user_service()? + .get_workspace_usage(workspace_id) + .await?; + Ok(workspace_usage) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_billing_portal_url(&self) -> FlowyResult { + let url = self + .cloud_services + .get_user_service()? + .get_billing_portal_url() + .await?; + Ok(url) + } } /// This method is used to save one user workspace to the SQLite database