diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart index 9765209a38..5118620d98 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart @@ -136,7 +136,7 @@ class DocumentService { }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); return workspace.fold((l) async { - final payload = UploadedFilePB( + final payload = DownloadFilePB( url: url, ); final result = await DocumentEventDownloadFile(payload).send(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart index 8918ccb21e..5935cf6eaf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart @@ -7,6 +7,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/file_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:path/path.dart' as p; @@ -63,6 +64,12 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage( ); return (s.url, null); }, - (e) => (null, e.msg), + (err) { + if (err.code == ErrorCode.FileStorageLimitExceeded) { + return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); + } else { + return (null, err.msg); + } + }, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart index 684b3e8264..0912f9bdcf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart @@ -1,14 +1,26 @@ import 'package:freezed_annotation/freezed_annotation.dart'; + part 'error.freezed.dart'; part 'error.g.dart'; @freezed class AIError with _$AIError { const factory AIError({ - String? code, required String message, + @Default(AIErrorCode.other) AIErrorCode code, }) = _AIError; factory AIError.fromJson(Map json) => _$AIErrorFromJson(json); } + +enum AIErrorCode { + @JsonValue('AIResponseLimitExceeded') + aiResponseLimitExceeded, + @JsonValue('Other') + other, +} + +extension AIErrorExtension on AIError { + bool get isLimitExceeded => code == AIErrorCode.aiResponseLimitExceeded; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart new file mode 100644 index 0000000000..218240ab63 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart @@ -0,0 +1,35 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class _AILimitDialog extends StatelessWidget { + const _AILimitDialog({ + required this.message, + required this.onOkPressed, + }); + final VoidCallback onOkPressed; + final String message; + + @override + Widget build(BuildContext context) { + return NavigatorOkCancelDialog( + message: message, + okTitle: LocaleKeys.button_ok.tr(), + onOkPressed: onOkPressed, + titleUpperCase: false, + ); + } +} + +void showAILimitDialog(BuildContext context, String message) { + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (dialogContext) => _AILimitDialog( + message: message, + onOkPressed: () {}, + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart index 41bfed3b45..5534ca94b8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart @@ -1,6 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; import 'package:appflowy/user/application/ai_service.dart'; @@ -17,6 +18,8 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'ai_limit_dialog.dart'; + class AutoCompletionBlockKeys { const AutoCompletionBlockKeys._(); @@ -225,11 +228,15 @@ class _AutoCompletionBlockComponentState onError: (error) async { barrierDialog?.dismiss(); if (mounted) { - showSnackBarMessage( - context, - error.message, - showCancel: true, - ); + if (error.isLimitExceeded) { + showAILimitDialog(context, error.message); + } else { + showSnackBarMessage( + context, + error.message, + showCancel: true, + ); + } } }, ); @@ -304,11 +311,15 @@ class _AutoCompletionBlockComponentState onEnd: () async {}, onError: (error) async { if (mounted) { - showSnackBarMessage( - context, - error.message, - showCancel: true, - ); + if (error.isLimitExceeded) { + showAILimitDialog(context, error.message); + } else { + showSnackBarMessage( + context, + error.message, + showCancel: true, + ); + } } }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart index ef1148608e..299aa3a6d1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; import 'package:appflowy/startup/startup.dart'; @@ -16,6 +17,8 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; +import 'ai_limit_dialog.dart'; + class SmartEditBlockKeys { const SmartEditBlockKeys._(); @@ -426,11 +429,15 @@ class _SmartEditInputWidgetState extends State { }); }, onError: (error) async { - showSnackBarMessage( - context, - error.message, - showCancel: true, - ); + if (error.isLimitExceeded) { + showAILimitDialog(context, error.message); + } else { + showSnackBarMessage( + context, + error.message, + showCancel: true, + ); + } await _onExit(); }, ); diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 2c727a8d46..bc87836659 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -91,6 +91,7 @@ enum FeatureFlag { bool get isOn { if ([ + FeatureFlag.planBilling, // release this feature in version 0.6.1 FeatureFlag.spaceDesign, // release this feature in version 0.5.9 @@ -110,6 +111,7 @@ enum FeatureFlag { } switch (this) { + case FeatureFlag.planBilling: case FeatureFlag.search: case FeatureFlag.syncDocument: case FeatureFlag.syncDatabase: @@ -117,7 +119,6 @@ enum FeatureFlag { return true; case FeatureFlag.collaborativeWorkspace: case FeatureFlag.membersSettings: - case FeatureFlag.planBilling: case FeatureFlag.unknown: return false; } 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 bd5fedf526..542e8b75a2 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -95,7 +95,9 @@ class AppFlowyCloudDeepLink { } if (_isPaymentSuccessUri(uri)) { - return getIt().onPaymentSuccess(); + Log.debug("Payment success deep link: ${uri.toString()}"); + final plan = uri.queryParameters['plan']; + return getIt().onPaymentSuccess(plan); } return _isAuthCallbackDeepLink(uri).fold( diff --git a/frontend/appflowy_flutter/lib/user/application/ai_service.dart b/frontend/appflowy_flutter/lib/user/application/ai_service.dart index 41a46b9156..46f0748641 100644 --- a/frontend/appflowy_flutter/lib/user/application/ai_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/ai_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:isolate'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart'; @@ -9,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/wid import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart' as fixnum; class AppFlowyAIService implements AIRepository { @@ -85,6 +87,15 @@ class CompletionStream { _port.handler = _controller.add; _subscription = _controller.stream.listen( (event) async { + if (event == "AI_RESPONSE_LIMIT") { + onError( + AIError( + message: LocaleKeys.sideBar_aiResponseLitmit.tr(), + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + } + if (event.startsWith("start:")) { await onStart(); } @@ -96,6 +107,7 @@ class CompletionStream { if (event.startsWith("finish:")) { await onEnd(); } + if (event.startsWith("error:")) { onError(AIError(message: event.substring(6))); } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index d6149e2111..25eec30992 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.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'; @@ -10,7 +11,10 @@ import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; abstract class IUserBackendService { - Future> cancelSubscription(String workspaceId); + Future> cancelSubscription( + String workspaceId, + SubscriptionPlanPB plan, + ); Future> createSubscription( String workspaceId, SubscriptionPlanPB plan, @@ -228,9 +232,10 @@ class UserBackendService implements IUserBackendService { return UserEventLeaveWorkspace(data).send(); } - static Future> - getWorkspaceSubscriptions() { - return UserEventGetWorkspaceSubscriptions().send(); + static Future> + getWorkspaceSubscriptionInfo(String workspaceId) { + final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventGetWorkspaceSubscriptionInfo(params).send(); } Future> @@ -250,15 +255,32 @@ class UserBackendService implements IUserBackendService { ..recurringInterval = RecurringIntervalPB.Year ..workspaceSubscriptionPlan = plan ..successUrl = - '${getIt().appflowyCloudConfig.base_url}/web/payment-success'; + '${getIt().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}'; return UserEventSubscribeWorkspace(request).send(); } @override Future> cancelSubscription( String workspaceId, + SubscriptionPlanPB plan, ) { - final request = UserWorkspaceIdPB()..workspaceId = workspaceId; + final request = CancelWorkspaceSubscriptionPB() + ..workspaceId = workspaceId + ..plan = plan; + return UserEventCancelWorkspaceSubscription(request).send(); } + + Future> updateSubscriptionPeriod( + String workspaceId, + SubscriptionPlanPB plan, + RecurringIntervalPB interval, + ) { + final request = UpdateWorkspaceSubscriptionPaymentPeriodPB() + ..workspaceId = workspaceId + ..plan = plan + ..recurringInterval = interval; + + return UserEventUpdateWorkspaceSubscriptionPaymentPeriod(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 index 81c96b3232..5e8636e1db 100644 --- 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 @@ -1,15 +1,24 @@ +import 'dart:async'; + 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/settings/plan/workspace_subscription_ext.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/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/log.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:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; -import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; part 'settings_billing_bloc.freezed.dart'; @@ -17,86 +26,273 @@ class SettingsBillingBloc extends Bloc { SettingsBillingBloc({ required this.workspaceId, + required Int64 userId, }) : super(const _Initial()) { + _userService = UserBackendService(userId: userId); _service = WorkspaceService(workspaceId: workspaceId); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); 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, - ); - } + final result = await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + final subscriptionInfo = result.fold( + (s) => s, + (e) { 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) { + if (subscriptionInfo == null || error != null) { return emit(SettingsBillingState.error(error: error)); } + if (!_billingPortalCompleter.isCompleted) { + unawaited(_fetchBillingPortal()); + unawaited( + _billingPortalCompleter.future.then( + (result) { + if (isClosed) return; + + result.fold( + (portal) { + _billingPortal = portal; + add( + SettingsBillingEvent.billingPortalFetched( + billingPortal: portal, + ), + ); + }, + (e) => Log.error('Error fetching billing portal: $e'), + ); + }, + ), + ); + } + emit( SettingsBillingState.ready( - subscription: subscription, - billingPortal: billingPortal, + subscriptionInfo: subscriptionInfo, + billingPortal: _billingPortal, ), ); }, + billingPortalFetched: (billingPortal) async => state.maybeWhen( + orElse: () {}, + ready: (subscriptionInfo, _, plan, isLoading) => emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: billingPortal, + successfulPlanUpgrade: plan, + isLoading: isLoading, + ), + ), + ), + openCustomerPortal: () async { + if (_billingPortalCompleter.isCompleted && _billingPortal != null) { + return afLaunchUrlString(_billingPortal!.url); + } + await _billingPortalCompleter.future; + if (_billingPortal != null) { + await afLaunchUrlString(_billingPortal!.url); + } + }, + addSubscription: (plan) async { + final result = + await _userService.createSubscription(workspaceId, plan); + + result.fold( + (link) => afLaunchUrlString(link.paymentLink), + (f) => Log.error(f.msg, f), + ); + }, + cancelSubscription: (plan) async { + final s = state.mapOrNull(ready: (s) => s); + if (s == null) { + return; + } + + emit(s.copyWith(isLoading: true)); + + final result = + await _userService.cancelSubscription(workspaceId, plan); + final successOrNull = result.fold( + (_) => true, + (f) { + Log.error( + 'Failed to cancel subscription of ${plan.label}: ${f.msg}', + f, + ); + return null; + }, + ); + + if (successOrNull != true) { + return; + } + + final subscriptionInfo = state.mapOrNull( + ready: (s) => s.subscriptionInfo, + ); + + // This is impossible, but for good measure + if (subscriptionInfo == null) { + return; + } + + subscriptionInfo.freeze(); + final newInfo = subscriptionInfo.rebuild((value) { + if (plan.isAddOn) { + value.addOns.removeWhere( + (addon) => addon.addOnSubscription.subscriptionPlan == plan, + ); + } + + if (plan == WorkspacePlanPB.ProPlan && + value.plan == WorkspacePlanPB.ProPlan) { + value.plan = WorkspacePlanPB.FreePlan; + value.planSubscription.freeze(); + value.planSubscription = value.planSubscription.rebuild((sub) { + sub.status = WorkspaceSubscriptionStatusPB.Active; + sub.subscriptionPlan = SubscriptionPlanPB.Free; + }); + } + }); + + emit( + SettingsBillingState.ready( + subscriptionInfo: newInfo, + billingPortal: _billingPortal, + ), + ); + }, + paymentSuccessful: (plan) async { + final result = await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + + final subscriptionInfo = result.toNullable(); + if (subscriptionInfo != null) { + emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: _billingPortal, + ), + ); + } + }, + updatePeriod: (plan, interval) async { + final s = state.mapOrNull(ready: (s) => s); + if (s == null) { + return; + } + + emit(s.copyWith(isLoading: true)); + + final result = await _userService.updateSubscriptionPeriod( + workspaceId, + plan, + interval, + ); + final successOrNull = result.fold((_) => true, (f) { + Log.error( + 'Failed to update subscription period of ${plan.label}: ${f.msg}', + f, + ); + return null; + }); + + if (successOrNull != true) { + return emit(s.copyWith(isLoading: false)); + } + + // Fetch new subscription info + final newResult = + await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + + final newSubscriptionInfo = newResult.toNullable(); + if (newSubscriptionInfo != null) { + emit( + SettingsBillingState.ready( + subscriptionInfo: newSubscriptionInfo, + billingPortal: _billingPortal, + ), + ); + } + }, ); }); } late final String workspaceId; late final WorkspaceService _service; + late final UserBackendService _userService; + final _billingPortalCompleter = + Completer>(); + + BillingPortalPB? _billingPortal; + late final SubscriptionSuccessListenable _successListenable; + + @override + Future close() { + _successListenable.removeListener(_onPaymentSuccessful); + return super.close(); + } + + Future _fetchBillingPortal() async { + final billingPortalResult = await _service.getBillingPortal(); + _billingPortalCompleter.complete(billingPortalResult); + } + + Future _onPaymentSuccessful() async => add( + SettingsBillingEvent.paymentSuccessful( + plan: _successListenable.subscribedPlan, + ), + ); } @freezed class SettingsBillingEvent with _$SettingsBillingEvent { const factory SettingsBillingEvent.started() = _Started; + + const factory SettingsBillingEvent.billingPortalFetched({ + required BillingPortalPB billingPortal, + }) = _BillingPortalFetched; + + const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal; + + const factory SettingsBillingEvent.addSubscription(SubscriptionPlanPB plan) = + _AddSubscription; + + const factory SettingsBillingEvent.cancelSubscription( + SubscriptionPlanPB plan, + ) = _CancelSubscription; + + const factory SettingsBillingEvent.paymentSuccessful({ + SubscriptionPlanPB? plan, + }) = _PaymentSuccessful; + + const factory SettingsBillingEvent.updatePeriod({ + required SubscriptionPlanPB plan, + required RecurringIntervalPB interval, + }) = _UpdatePeriod; } @freezed -class SettingsBillingState with _$SettingsBillingState { +class SettingsBillingState extends Equatable with _$SettingsBillingState { + const SettingsBillingState._(); + const factory SettingsBillingState.initial() = _Initial; const factory SettingsBillingState.loading() = _Loading; @@ -106,7 +302,22 @@ class SettingsBillingState with _$SettingsBillingState { }) = _Error; const factory SettingsBillingState.ready({ - required WorkspaceSubscriptionPB subscription, + required WorkspaceSubscriptionInfoPB subscriptionInfo, required BillingPortalPB? billingPortal, + @Default(null) SubscriptionPlanPB? successfulPlanUpgrade, + @Default(false) bool isLoading, }) = _Ready; + + @override + List get props => maybeWhen( + orElse: () => const [], + error: (error) => [error], + ready: (subscription, billingPortal, plan, isLoading) => [ + subscription, + billingPortal, + plan, + isLoading, + ...subscription.addOns, + ], + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart new file mode 100644 index 0000000000..551d97fa64 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/notification_helper.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-storage/notification.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class StoregeNotificationParser + extends NotificationParser { + StoregeNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == "storage" ? StorageNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +class StoreageNotificationListener { + StoreageNotificationListener({ + void Function(FlowyError error)? onError, + }) : _parser = StoregeNotificationParser( + callback: ( + StorageNotification ty, + FlowyResult result, + ) { + result.fold( + (data) { + try { + switch (ty) { + case StorageNotification.FileStorageLimitExceeded: + onError?.call(FlowyError.fromBuffer(data)); + break; + } + } catch (e) { + Log.error( + "$StoreageNotificationListener deserialize PB fail", + e, + ); + } + }, + (err) { + Log.error("Error in StoreageNotificationListener", err); + }, + ); + }, + ) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + StoregeNotificationParser? _parser; + StreamSubscription? _subscription; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart index 48cd6a739d..af2b5e3aaf 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -3,18 +3,18 @@ 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/settings/plan/workspace_subscription_ext.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:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; part 'settings_plan_bloc.freezed.dart'; @@ -30,13 +30,14 @@ class SettingsPlanBloc extends Bloc { on((event, emit) async { await event.when( - started: (withShowSuccessful) async { - emit(const SettingsPlanState.loading()); + started: (withSuccessfulUpgrade, shouldLoad) async { + if (shouldLoad) { + emit(const SettingsPlanState.loading()); + } final snapshots = await Future.wait([ _service.getWorkspaceUsage(), - UserBackendService.getWorkspaceSubscriptions(), - _service.getBillingPortal(), + UserBackendService.getWorkspaceSubscriptionInfo(workspaceId), ]); FlowyError? error; @@ -49,39 +50,16 @@ class SettingsPlanBloc extends Bloc { }, ); - final subscription = snapshots[1].fold( - (s) => - (s as RepeatedWorkspaceSubscriptionPB) - .items - .firstWhereOrNull((i) => i.workspaceId == workspaceId) ?? - WorkspaceSubscriptionPB( - workspaceId: workspaceId, - subscriptionPlan: SubscriptionPlanPB.None, - isActive: true, - ), + final subscriptionInfo = snapshots[1].fold( + (s) => s as WorkspaceSubscriptionInfoPB, (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 || + subscriptionInfo == null || error != null) { return emit(SettingsPlanState.error(error: error)); } @@ -89,18 +67,16 @@ class SettingsPlanBloc extends Bloc { emit( SettingsPlanState.ready( workspaceUsage: usageResult, - subscription: subscription, - billingPortal: billingPortal, - showSuccessDialog: withShowSuccessful, + subscriptionInfo: subscriptionInfo, + successfulPlanUpgrade: withSuccessfulUpgrade, ), ); - if (withShowSuccessful) { + if (withSuccessfulUpgrade != null) { emit( SettingsPlanState.ready( workspaceUsage: usageResult, - subscription: subscription, - billingPortal: billingPortal, + subscriptionInfo: subscriptionInfo, ), ); } @@ -108,12 +84,15 @@ class SettingsPlanBloc extends Bloc { addSubscription: (plan) async { final result = await _userService.createSubscription( workspaceId, - SubscriptionPlanPB.Pro, + plan, ); result.fold( (pl) => afLaunchUrlString(pl.paymentLink), - (f) => Log.error(f.msg, f), + (f) => Log.error( + 'Failed to fetch paymentlink for $plan: ${f.msg}', + f, + ), ); }, cancelSubscription: () async { @@ -121,16 +100,79 @@ class SettingsPlanBloc extends Bloc { .mapOrNull(ready: (state) => state) ?.copyWith(downgradeProcessing: true); emit(newState ?? state); - await _userService.cancelSubscription(workspaceId); - add(const SettingsPlanEvent.started()); + + // We can hardcode the subscription plan here because we cannot cancel addons + // on the Plan page + final result = await _userService.cancelSubscription( + workspaceId, + SubscriptionPlanPB.Pro, + ); + + final successOrNull = result.fold( + (_) => true, + (f) { + Log.error('Failed to cancel subscription of Pro: ${f.msg}', f); + return null; + }, + ); + + if (successOrNull != true) { + return; + } + + final subscriptionInfo = state.mapOrNull( + ready: (s) => s.subscriptionInfo, + ); + + // This is impossible, but for good measure + if (subscriptionInfo == null) { + return; + } + + // We assume their new plan is Free, since we only have Pro plan + // at the moment. + subscriptionInfo.freeze(); + final newInfo = subscriptionInfo.rebuild((value) { + value.plan = WorkspacePlanPB.FreePlan; + value.planSubscription.freeze(); + value.planSubscription = value.planSubscription.rebuild((sub) { + sub.status = WorkspaceSubscriptionStatusPB.Active; + sub.subscriptionPlan = SubscriptionPlanPB.Free; + }); + }); + + // We need to remove unlimited indicator for storage and + // AI usage, if they don't have an addon that changes this behavior. + final usage = state.mapOrNull(ready: (s) => s.workspaceUsage)!; + + usage.freeze(); + final newUsage = usage.rebuild((value) { + if (!newInfo.hasAIMax && !newInfo.hasAIOnDevice) { + value.aiResponsesUnlimited = false; + } + + value.storageBytesUnlimited = false; + }); + + emit( + SettingsPlanState.ready( + subscriptionInfo: newInfo, + workspaceUsage: newUsage, + ), + ); }, - paymentSuccessful: () { + paymentSuccessful: (plan) { final readyState = state.mapOrNull(ready: (state) => state); if (readyState == null) { return; } - add(const SettingsPlanEvent.started(withShowSuccessful: true)); + add( + SettingsPlanEvent.started( + withSuccessfulUpgrade: plan, + shouldLoad: false, + ), + ); }, ); }); @@ -141,9 +183,11 @@ class SettingsPlanBloc extends Bloc { late final IUserBackendService _userService; late final SubscriptionSuccessListenable _successListenable; - void _onPaymentSuccessful() { - add(const SettingsPlanEvent.paymentSuccessful()); - } + Future _onPaymentSuccessful() async => add( + SettingsPlanEvent.paymentSuccessful( + plan: _successListenable.subscribedPlan, + ), + ); @override Future close() async { @@ -155,12 +199,18 @@ class SettingsPlanBloc extends Bloc { @freezed class SettingsPlanEvent with _$SettingsPlanEvent { const factory SettingsPlanEvent.started({ - @Default(false) bool withShowSuccessful, + @Default(null) SubscriptionPlanPB? withSuccessfulUpgrade, + @Default(true) bool shouldLoad, }) = _Started; + const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; + const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; - const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful; + + const factory SettingsPlanEvent.paymentSuccessful({ + @Default(null) SubscriptionPlanPB? plan, + }) = _PaymentSuccessful; } @freezed @@ -175,9 +225,8 @@ class SettingsPlanState with _$SettingsPlanState { const factory SettingsPlanState.ready({ required WorkspaceUsagePB workspaceUsage, - required WorkspaceSubscriptionPB subscription, - required BillingPortalPB? billingPortal, - @Default(false) bool showSuccessDialog, + required WorkspaceSubscriptionInfoPB subscriptionInfo, + @Default(null) SubscriptionPlanPB? successfulPlanUpgrade, @Default(false) bool downgradeProcessing, }) = _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 index d6dde9e9c1..e232915b8a 100644 --- 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 @@ -1,26 +1,115 @@ 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:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:easy_localization/easy_localization.dart'; -extension SubscriptionLabels on WorkspaceSubscriptionPB { - String get label => switch (subscriptionPlan) { - SubscriptionPlanPB.None => +extension SubscriptionLabels on WorkspaceSubscriptionInfoPB { + String get label => switch (plan) { + WorkspacePlanPB.FreePlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), + WorkspacePlanPB.ProPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), + WorkspacePlanPB.TeamPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), + _ => 'N/A', + }; + + String get info => switch (plan) { + WorkspacePlanPB.FreePlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(), + WorkspacePlanPB.ProPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(), + WorkspacePlanPB.TeamPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), + _ => 'N/A', + }; +} + +extension AllSubscriptionLabels on SubscriptionPlanPB { + String get label => switch (this) { + SubscriptionPlanPB.Free => 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', - }; - - String get info => switch (subscriptionPlan) { - SubscriptionPlanPB.None => - LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(), - SubscriptionPlanPB.Pro => - LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(), - SubscriptionPlanPB.Team => - LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), + SubscriptionPlanPB.AiMax => + LocaleKeys.settings_billingPage_addons_aiMax_label.tr(), + SubscriptionPlanPB.AiLocal => + LocaleKeys.settings_billingPage_addons_aiOnDevice_label.tr(), _ => 'N/A', }; } + +extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB { + bool get isCanceled => + planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled; +} + +extension WorkspaceAddonsExt on WorkspaceSubscriptionInfoPB { + bool get hasAIMax => + addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiMax); + + bool get hasAIOnDevice => + addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiLocal); +} + +/// These have to match [SubscriptionSuccessListenable.subscribedPlan] labels +extension ToRecognizable on SubscriptionPlanPB { + String? toRecognizable() => switch (this) { + SubscriptionPlanPB.Free => 'free', + SubscriptionPlanPB.Pro => 'pro', + SubscriptionPlanPB.Team => 'team', + SubscriptionPlanPB.AiMax => 'ai_max', + SubscriptionPlanPB.AiLocal => 'ai_local', + _ => null, + }; +} + +extension PlanHelper on SubscriptionPlanPB { + /// Returns true if the plan is an add-on and not + /// a workspace plan. + /// + bool get isAddOn => switch (this) { + SubscriptionPlanPB.AiMax => true, + SubscriptionPlanPB.AiLocal => true, + _ => false, + }; + + String get priceMonthBilling => switch (this) { + SubscriptionPlanPB.Free => 'US\$0', + SubscriptionPlanPB.Pro => 'US\$12.5', + SubscriptionPlanPB.Team => 'US\$15', + SubscriptionPlanPB.AiMax => 'US\$10', + SubscriptionPlanPB.AiLocal => 'US\$10', + _ => 'US\$0', + }; + + String get priceAnnualBilling => switch (this) { + SubscriptionPlanPB.Free => 'US\$0', + SubscriptionPlanPB.Pro => 'US\$10', + SubscriptionPlanPB.Team => 'US\$12.5', + SubscriptionPlanPB.AiMax => 'US\$8', + SubscriptionPlanPB.AiLocal => 'US\$8', + _ => 'US\$0', + }; +} + +extension IntervalLabel on RecurringIntervalPB { + String get label => switch (this) { + RecurringIntervalPB.Month => + LocaleKeys.settings_billingPage_monthlyInterval.tr(), + RecurringIntervalPB.Year => + LocaleKeys.settings_billingPage_annualInterval.tr(), + _ => LocaleKeys.settings_billingPage_monthlyInterval.tr(), + }; + + String get priceInfo => switch (this) { + RecurringIntervalPB.Month => + LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(), + RecurringIntervalPB.Year => + LocaleKeys.settings_billingPage_annualPriceInfo.tr(), + _ => LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(), + }; +} 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 index 3dcd87ca58..ddaca15f5c 100644 --- 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 @@ -6,8 +6,13 @@ final _storageNumberFormat = NumberFormat() ..minimumFractionDigits = 0; extension PresentableUsage on WorkspaceUsagePB { - String get totalBlobInGb => - (totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString(); + String get totalBlobInGb { + if (storageBytesLimit == 0) { + return '0'; + } + return _storageNumberFormat + .format(storageBytesLimit.toInt() / (1024 * 1024 * 1024)); + } /// We use [NumberFormat] to format the current blob in GB. /// @@ -16,5 +21,5 @@ extension PresentableUsage on WorkspaceUsagePB { /// And [NumberFormat.minimumFractionDigits] is set to 0. /// String get currentBlobInGb => - _storageNumberFormat.format(totalBlobBytes.toInt() / 1024 / 1024 / 1024); + _storageNumberFormat.format(storageBytes.toInt() / 1024 / 1024 / 1024); } 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 17fbe73465..4e622fa9c9 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 @@ -26,9 +26,11 @@ enum SettingsPage { class SettingsDialogBloc extends Bloc { - SettingsDialogBloc(this.userProfile) - : _userListener = UserListener(userProfile: userProfile), - super(SettingsDialogState.initial(userProfile)) { + SettingsDialogBloc( + this.userProfile, { + SettingsPage? initPage, + }) : _userListener = UserListener(userProfile: userProfile), + super(SettingsDialogState.initial(userProfile, initPage)) { _dispatch(); } @@ -87,9 +89,12 @@ class SettingsDialogState with _$SettingsDialogState { required SettingsPage page, }) = _SettingsDialogState; - factory SettingsDialogState.initial(UserProfilePB userProfile) => + factory SettingsDialogState.initial( + UserProfilePB userProfile, + SettingsPage? page, + ) => SettingsDialogState( userProfile: userProfile, - page: SettingsPage.account, + page: page ?? SettingsPage.account, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart new file mode 100644 index 0000000000..563b47389b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/dispatch/error.dart'; +import 'package:appflowy_backend/log.dart'; +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/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'sidebar_plan_bloc.freezed.dart'; + +class SidebarPlanBloc extends Bloc { + SidebarPlanBloc() : super(const SidebarPlanState()) { + // After user pays for the subscription, the subscription success listenable will be triggered + final subscriptionListener = getIt(); + subscriptionListener.addListener(() { + final plan = subscriptionListener.subscribedPlan; + Log.info("Subscription success listenable triggered: $plan"); + + if (!isClosed) { + // Notify the user that they have switched to a new plan. It would be better if we use websocket to + // notify the client when plan switching. + if (state.workspaceId != null) { + final payload = SuccessWorkspaceSubscriptionPB( + workspaceId: state.workspaceId, + ); + + if (plan != null) { + payload.plan = plan; + } + + UserEventNotifyDidSwitchPlan(payload).send().then((result) { + result.fold( + // After the user has switched to a new plan, we need to refresh the workspace usage. + (_) => _checkWorkspaceUsage(), + (error) => Log.error("NotifyDidSwitchPlan failed: $error"), + ); + }); + } else { + Log.error( + "Unexpected empty workspace id when subscription success listenable triggered. It should not happen. If happens, it must be a bug", + ); + } + } + }); + + _storageListener = StoreageNotificationListener( + onError: (error) { + if (!isClosed) { + add(SidebarPlanEvent.receiveError(error)); + } + }, + ); + + _globalErrorListener = GlobalErrorCodeNotifier.add( + onError: (error) { + if (!isClosed) { + add(SidebarPlanEvent.receiveError(error)); + } + }, + onErrorIf: (error) { + const relevantErrorCodes = { + ErrorCode.AIResponseLimitExceeded, + ErrorCode.FileStorageLimitExceeded, + }; + return relevantErrorCodes.contains(error.code); + }, + ); + + on(_handleEvent); + } + + Future dispose() async { + if (_globalErrorListener != null) { + GlobalErrorCodeNotifier.remove(_globalErrorListener!); + } + await _storageListener?.stop(); + _storageListener = null; + } + + ErrorListener? _globalErrorListener; + StoreageNotificationListener? _storageListener; + + Future _handleEvent( + SidebarPlanEvent event, + Emitter emit, + ) async { + await event.when( + receiveError: (FlowyError error) async { + emit( + state.copyWith( + tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), + ), + ); + }, + init: (String workspaceId, UserProfilePB userProfile) { + emit( + state.copyWith( + workspaceId: workspaceId, + userProfile: userProfile, + ), + ); + _checkWorkspaceUsage(); + }, + updateWorkspaceUsage: (WorkspaceUsagePB usage) { + // when the user's storage bytes are limited, show the upgrade tier button + if (!usage.storageBytesUnlimited) { + if (usage.storageBytes >= usage.storageBytesLimit) { + add( + const SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator.storageLimitHit(), + ), + ); + + /// Checks if the user needs to upgrade to the Pro Plan. + /// If the user needs to upgrade, it means they don't need to enable the AI max tier. + /// This function simply returns without performing any further actions. + return; + } + } + + // when user's AI responses are limited, show the AI max tier button. + if (!usage.aiResponsesUnlimited) { + if (usage.aiResponsesCount >= usage.aiResponsesCountLimit) { + add( + const SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator.aiMaxiLimitHit(), + ), + ); + return; + } + } + + // hide the tier indicator + add( + const SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator.loading(), + ), + ); + }, + updateTierIndicator: (SidebarToastTierIndicator indicator) { + emit( + state.copyWith( + tierIndicator: indicator, + ), + ); + }, + ); + } + + void _checkWorkspaceUsage() { + if (state.workspaceId != null) { + final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!); + UserEventGetWorkspaceUsage(payload).send().then((result) { + result.fold( + (usage) { + add(SidebarPlanEvent.updateWorkspaceUsage(usage)); + }, + (error) { + Log.error("Failed to get workspace usage, error: $error"); + }, + ); + }); + } + } +} + +@freezed +class SidebarPlanEvent with _$SidebarPlanEvent { + const factory SidebarPlanEvent.init( + String workspaceId, + UserProfilePB userProfile, + ) = _Init; + const factory SidebarPlanEvent.updateWorkspaceUsage( + WorkspaceUsagePB usage, + ) = _UpdateWorkspaceUsage; + const factory SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator indicator, + ) = _UpdateTierIndicator; + const factory SidebarPlanEvent.receiveError(FlowyError error) = _ReceiveError; +} + +@freezed +class SidebarPlanState with _$SidebarPlanState { + const factory SidebarPlanState({ + FlowyError? error, + UserProfilePB? userProfile, + String? workspaceId, + WorkspaceUsagePB? usage, + @Default(SidebarToastTierIndicator.loading()) + SidebarToastTierIndicator tierIndicator, + }) = _SidebarPlanState; +} + +@freezed +class SidebarToastTierIndicator with _$SidebarToastTierIndicator { + // when start downloading the model + const factory SidebarToastTierIndicator.storageLimitHit() = _StorageLimitHit; + const factory SidebarToastTierIndicator.aiMaxiLimitHit() = _aiMaxLimitHit; + const factory SidebarToastTierIndicator.loading() = _Loading; +} 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 index b53c8237a6..0cf436630f 100644 --- 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 @@ -1,7 +1,25 @@ +import 'package:appflowy_backend/log.dart'; import 'package:flutter/foundation.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + class SubscriptionSuccessListenable extends ChangeNotifier { SubscriptionSuccessListenable(); - void onPaymentSuccess() => notifyListeners(); + String? _plan; + + SubscriptionPlanPB? get subscribedPlan => switch (_plan) { + 'free' => SubscriptionPlanPB.Free, + 'pro' => SubscriptionPlanPB.Pro, + 'team' => SubscriptionPlanPB.Team, + 'ai_max' => SubscriptionPlanPB.AiMax, + 'ai_local' => SubscriptionPlanPB.AiLocal, + _ => null, + }; + + void onPaymentSuccess(String? plan) { + Log.info("Payment success: $plan"); + _plan = plan; + notifyListeners(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart new file mode 100644 index 0000000000..2adbdb81a6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart @@ -0,0 +1,195 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarToast extends StatefulWidget { + const SidebarToast({super.key}); + + @override + State createState() => _SidebarToastState(); +} + +class _SidebarToastState extends State { + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + // Show a dialog when the user hits the storage limit, After user click ok, it will navigate to the plan page. + // Even though the dislog is dissmissed, if the user triggers the storage limit again, the dialog will show again. + state.tierIndicator.maybeWhen( + storageLimitHit: () { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _showStorageLimitDialog(context), + debugLabel: 'Sidebar.showStorageLimit', + ); + }, + orElse: () { + // Do nothing + }, + ); + }, + builder: (context, state) { + return BlocBuilder( + builder: (context, state) { + return state.tierIndicator.when( + storageLimitHit: () => Column( + children: [ + const Divider(height: 0.6), + PlanIndicator( + planName: "Pro", + text: LocaleKeys.sideBar_upgradeToPro.tr(), + onTap: () { + _hanldeOnTap(context, SubscriptionPlanPB.Pro); + }, + reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), + ), + ], + ), + aiMaxiLimitHit: () => Column( + children: [ + const Divider(height: 0.6), + PlanIndicator( + planName: "AI Max", + text: LocaleKeys.sideBar_upgradeToAIMax.tr(), + onTap: () { + _hanldeOnTap(context, SubscriptionPlanPB.AiMax); + }, + reason: LocaleKeys.sideBar_aiResponseLitmitDialogTitle.tr(), + ), + ], + ), + loading: () { + return const SizedBox.shrink(); + }, + ); + }, + ); + }, + ); + } + + void _showStorageLimitDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (dialogContext) => _StorageLimitDialog( + onOkPressed: () { + final userProfile = context.read().state.userProfile; + final userWorkspaceBloc = context.read(); + if (userProfile != null) { + showSettingsDialog( + context, + userProfile, + userWorkspaceBloc, + SettingsPage.plan, + ); + } else { + Log.error( + "UserProfile is null. It should not happen. If you see this error, it's a bug.", + ); + } + }, + ), + ); + } + + void _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) { + final userProfile = context.read().state.userProfile; + final userWorkspaceBloc = context.read(); + if (userProfile != null) { + showSettingsDialog( + context, + userProfile, + userWorkspaceBloc, + SettingsPage.plan, + ); + } + } +} + +class PlanIndicator extends StatelessWidget { + const PlanIndicator({ + required this.planName, + required this.text, + required this.onTap, + required this.reason, + super.key, + }); + + final String planName; + final String reason; + final String text; + final Function() onTap; + + final textColor = const Color(0xFFE8E2EE); + final secondaryColor = const Color(0xFF653E8C); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FlowyButton( + margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + text: FlowyText( + text, + color: textColor, + fontSize: 12, + ), + radius: BorderRadius.zero, + leftIconSize: const Size(40, 20), + leftIcon: Badge( + padding: const EdgeInsets.symmetric(horizontal: 6), + backgroundColor: secondaryColor, + label: FlowyText.semibold( + planName, + fontSize: 12, + color: textColor, + ), + ), + onTap: onTap, + ), + Padding( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 6), + child: Opacity( + opacity: 0.4, + child: FlowyText( + reason, + textAlign: TextAlign.start, + color: textColor, + fontSize: 8, + maxLines: 10, + ), + ), + ), + ], + ); + } +} + +class _StorageLimitDialog extends StatelessWidget { + const _StorageLimitDialog({ + required this.onOkPressed, + }); + final VoidCallback onOkPressed; + + @override + Widget build(BuildContext context) { + return NavigatorOkCancelDialog( + message: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), + okTitle: LocaleKeys.sideBar_purchaseStorageSpace.tr(), + onOkPressed: onOkPressed, + titleUpperCase: false, + ); + } +} 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 945976ef79..2cf35e9dad 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 @@ -1,3 +1,4 @@ +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -93,6 +94,7 @@ void showSettingsDialog( BuildContext context, UserProfilePB userProfile, [ UserWorkspaceBloc? bloc, + SettingsPage? initPage, ]) { AFFocusManager.of(context).notifyLoseFocus(); showDialog( @@ -107,6 +109,7 @@ void showSettingsDialog( ], child: SettingsDialog( userProfile, + initPage: initPage, didLogout: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 7c6761d800..92022daa60 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -13,6 +13,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/favorite/prelude.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; @@ -100,6 +101,9 @@ class HomeSideBar extends StatelessWidget { if (state.currentWorkspace == null) { return const SizedBox.shrink(); } + + final workspaceId = + state.currentWorkspace?.workspaceId ?? workspaceSetting.workspaceId; return MultiBlocProvider( providers: [ BlocProvider.value(value: getIt()), @@ -108,8 +112,7 @@ class HomeSideBar extends StatelessWidget { ..add( SidebarSectionsEvent.initial( userProfile, - state.currentWorkspace?.workspaceId ?? - workspaceSetting.workspaceId, + workspaceId, ), ), ), @@ -118,12 +121,15 @@ class HomeSideBar extends StatelessWidget { ..add( SpaceEvent.initial( userProfile, - state.currentWorkspace?.workspaceId ?? - workspaceSetting.workspaceId, + workspaceId, openFirstPage: false, ), ), ), + BlocProvider( + create: (_) => SidebarPlanBloc() + ..add(SidebarPlanEvent.init(workspaceId, userProfile)), + ), ], child: MultiBlocListener( listeners: [ @@ -357,6 +363,9 @@ class _SidebarState extends State<_Sidebar> { child: const SidebarFooter(), ), const VSpace(14), + + // toast + // const SidebarToast(), ], ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index 2a5e758cfb..97b4f18bcf 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -1,3 +1,7 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; @@ -18,9 +22,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpacePermissionSwitch extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 4823d513cd..8f58d20b17 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -151,7 +151,7 @@ class _ToggleLocalAIDialog extends StatelessWidget { @override Widget build(BuildContext context) { return NavigatorOkCancelDialog( - title: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(), + message: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(), okTitle: LocaleKeys.button_confirm.tr(), cancelTitle: LocaleKeys.button_cancel.tr(), onOkPressed: onOkPressed, 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 dd76251539..a82199d98d 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 @@ -10,7 +10,6 @@ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_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/settings_input_field.dart'; @@ -210,18 +209,19 @@ class SignInOutButton extends StatelessWidget { if (signIn) { _showSignInDialog(context); } else { - SettingsAlertDialog( + showConfirmDialog( + context: context, title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - subtitle: switch (userProfile.encryptionType) { + description: switch (userProfile.encryptionType) { EncryptionTypePB.Symmetric => LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr(), _ => LocaleKeys.settings_menu_logoutPrompt.tr(), }, - confirm: () async { + onConfirm: () async { await getIt().signOut(); onAction(); }, - ).show(context); + ); } }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart index d742451bb0..acbd701867 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -1,22 +1,34 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.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/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_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/settings_dashed_divider.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../generated/locale_keys.g.dart'; +import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; -class SettingsBillingView extends StatelessWidget { +const _buttonsMinWidth = 100.0; + +class SettingsBillingView extends StatefulWidget { const SettingsBillingView({ super.key, required this.workspaceId, @@ -26,12 +38,34 @@ class SettingsBillingView extends StatelessWidget { final String workspaceId; final UserProfilePB user; + @override + State createState() => _SettingsBillingViewState(); +} + +class _SettingsBillingViewState extends State { + Loading? loadingIndicator; + RecurringIntervalPB? selectedInterval; + final ValueNotifier enablePlanChangeNotifier = ValueNotifier(false); + @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => SettingsBillingBloc(workspaceId: workspaceId) - ..add(const SettingsBillingEvent.started()), - child: BlocBuilder( + create: (_) => SettingsBillingBloc( + workspaceId: widget.workspaceId, + userId: widget.user.id, + )..add(const SettingsBillingEvent.started()), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.mapOrNull(ready: (s) => s.isLoading) != + current.mapOrNull(ready: (s) => s.isLoading), + listener: (context, state) { + if (state.mapOrNull(ready: (s) => s.isLoading) == true) { + loadingIndicator = Loading(context)..start(); + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + }, builder: (context, state) { return state.map( initial: (_) => const SizedBox.shrink(), @@ -56,8 +90,8 @@ class SettingsBillingView extends StatelessWidget { return ErrorWidget.withDetails(message: 'Something went wrong!'); }, ready: (state) { - final billingPortalEnabled = state.billingPortal != null && - state.billingPortal!.url.isNotEmpty; + final billingPortalEnabled = + state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan; return SettingsBody( title: LocaleKeys.settings_billingPage_title.tr(), @@ -68,27 +102,67 @@ class SettingsBillingView extends StatelessWidget { SingleSettingAction( onPressed: () => _openPricingDialog( context, - workspaceId, - user.id, - state.subscription, + widget.workspaceId, + widget.user.id, + state.subscriptionInfo, ), fontWeight: FontWeight.w500, - label: state.subscription.label, + label: state.subscriptionInfo.label, buttonLabel: LocaleKeys .settings_billingPage_plan_planButtonLabel .tr(), + minWidth: _buttonsMinWidth, ), if (billingPortalEnabled) SingleSettingAction( - onPressed: () => - afLaunchUrlString(state.billingPortal!.url), + onPressed: () { + SettingsAlertDialog( + title: LocaleKeys + .settings_billingPage_changePeriod + .tr(), + enableConfirmNotifier: enablePlanChangeNotifier, + children: [ + ChangePeriod( + plan: state.subscriptionInfo.planSubscription + .subscriptionPlan, + selectedInterval: state.subscriptionInfo + .planSubscription.interval, + onSelected: (interval) { + enablePlanChangeNotifier.value = interval != + state.subscriptionInfo.planSubscription + .interval; + selectedInterval = interval; + }, + ), + ], + confirm: () { + if (selectedInterval != + state.subscriptionInfo.planSubscription + .interval) { + context.read().add( + SettingsBillingEvent.updatePeriod( + plan: state + .subscriptionInfo + .planSubscription + .subscriptionPlan, + interval: selectedInterval!, + ), + ); + } + Navigator.of(context).pop(); + }, + ).show(context); + }, label: LocaleKeys .settings_billingPage_plan_billingPeriod .tr(), + description: state + .subscriptionInfo.planSubscription.interval.label, fontWeight: FontWeight.w500, buttonLabel: LocaleKeys .settings_billingPage_plan_periodButtonLabel .tr(), + minWidth: _buttonsMinWidth, ), ], ), @@ -99,8 +173,11 @@ class SettingsBillingView extends StatelessWidget { .tr(), children: [ SingleSettingAction( - onPressed: () => - afLaunchUrlString(state.billingPortal!.url), + onPressed: () => context + .read() + .add( + const SettingsBillingEvent.openCustomerPortal(), + ), label: LocaleKeys .settings_billingPage_paymentDetails_methodLabel .tr(), @@ -108,9 +185,48 @@ class SettingsBillingView extends StatelessWidget { buttonLabel: LocaleKeys .settings_billingPage_paymentDetails_methodButtonLabel .tr(), + minWidth: _buttonsMinWidth, ), ], ), + SettingsCategory( + title: LocaleKeys.settings_billingPage_addons_title.tr(), + children: [ + _AITile( + plan: SubscriptionPlanPB.AiMax, + label: LocaleKeys + .settings_billingPage_addons_aiMax_label + .tr(), + description: LocaleKeys + .settings_billingPage_addons_aiMax_description, + activeDescription: LocaleKeys + .settings_billingPage_addons_aiMax_activeDescription, + canceledDescription: LocaleKeys + .settings_billingPage_addons_aiMax_canceledDescription, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiMax, + ), + ), + const SettingsDashedDivider(), + _AITile( + plan: SubscriptionPlanPB.AiLocal, + label: LocaleKeys + .settings_billingPage_addons_aiOnDevice_label + .tr(), + description: LocaleKeys + .settings_billingPage_addons_aiOnDevice_description, + activeDescription: LocaleKeys + .settings_billingPage_addons_aiOnDevice_activeDescription, + canceledDescription: LocaleKeys + .settings_billingPage_addons_aiOnDevice_canceledDescription, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, + ), + ), + ], + ), ], ); }, @@ -124,17 +240,17 @@ class SettingsBillingView extends StatelessWidget { BuildContext context, String workspaceId, Int64 userId, - WorkspaceSubscriptionPB subscription, + WorkspaceSubscriptionInfoPB subscriptionInfo, ) => showDialog( context: context, builder: (_) => BlocProvider( create: (_) => - SettingsPlanBloc(workspaceId: workspaceId, userId: user.id) + SettingsPlanBloc(workspaceId: workspaceId, userId: widget.user.id) ..add(const SettingsPlanEvent.started()), child: SettingsPlanComparisonDialog( workspaceId: workspaceId, - subscription: subscription, + subscriptionInfo: subscriptionInfo, ), ), ).then((didChangePlan) { @@ -145,3 +261,341 @@ class SettingsBillingView extends StatelessWidget { } }); } + +class _AITile extends StatefulWidget { + const _AITile({ + required this.label, + required this.description, + required this.canceledDescription, + required this.activeDescription, + required this.plan, + this.subscriptionInfo, + }); + + final String label; + final String description; + final String canceledDescription; + final String activeDescription; + final SubscriptionPlanPB plan; + final WorkspaceAddOnPB? subscriptionInfo; + + @override + State<_AITile> createState() => _AITileState(); +} + +class _AITileState extends State<_AITile> { + RecurringIntervalPB? selectedInterval; + + final enableConfirmNotifier = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + final isCanceled = widget.subscriptionInfo?.addOnSubscription.status == + WorkspaceSubscriptionStatusPB.Canceled; + + final dateFormat = context.read().state.dateFormat; + + return Column( + children: [ + SingleSettingAction( + label: widget.label, + description: widget.subscriptionInfo != null && isCanceled + ? widget.canceledDescription.tr( + args: [ + dateFormat.formatDate( + widget.subscriptionInfo!.addOnSubscription.endDate + .toDateTime(), + false, + ), + ], + ) + : widget.subscriptionInfo != null + ? widget.activeDescription.tr( + args: [ + dateFormat.formatDate( + widget.subscriptionInfo!.addOnSubscription.endDate + .toDateTime(), + false, + ), + ], + ) + : widget.description.tr(), + buttonLabel: widget.subscriptionInfo != null + ? isCanceled + ? LocaleKeys.settings_billingPage_addons_renewLabel.tr() + : LocaleKeys.settings_billingPage_addons_removeLabel.tr() + : LocaleKeys.settings_billingPage_addons_addLabel.tr(), + fontWeight: FontWeight.w500, + minWidth: _buttonsMinWidth, + onPressed: () { + if (widget.subscriptionInfo != null && isCanceled) { + // Show customer portal to renew + context + .read() + .add(const SettingsBillingEvent.openCustomerPortal()); + } else if (widget.subscriptionInfo != null) { + showConfirmDialog( + context: context, + style: ConfirmPopupStyle.cancelAndOk, + title: LocaleKeys.settings_billingPage_addons_removeDialog_title + .tr(args: [widget.plan.label]).tr(), + description: LocaleKeys + .settings_billingPage_addons_removeDialog_description + .tr(namedArgs: {"plan": widget.plan.label.tr()}), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () { + context.read().add( + SettingsBillingEvent.cancelSubscription(widget.plan), + ); + }, + ); + } else { + // Add the addon + context + .read() + .add(SettingsBillingEvent.addSubscription(widget.plan)); + } + }, + ), + if (widget.subscriptionInfo != null) ...[ + const VSpace(10), + SingleSettingAction( + label: LocaleKeys.settings_billingPage_planPeriod.tr( + args: [ + widget + .subscriptionInfo!.addOnSubscription.subscriptionPlan.label, + ], + ), + description: + widget.subscriptionInfo!.addOnSubscription.interval.label, + buttonLabel: + LocaleKeys.settings_billingPage_plan_periodButtonLabel.tr(), + minWidth: _buttonsMinWidth, + onPressed: () { + enableConfirmNotifier.value = false; + SettingsAlertDialog( + title: LocaleKeys.settings_billingPage_changePeriod.tr(), + enableConfirmNotifier: enableConfirmNotifier, + children: [ + ChangePeriod( + plan: widget + .subscriptionInfo!.addOnSubscription.subscriptionPlan, + selectedInterval: + widget.subscriptionInfo!.addOnSubscription.interval, + onSelected: (interval) { + enableConfirmNotifier.value = interval != + widget.subscriptionInfo!.addOnSubscription.interval; + selectedInterval = interval; + }, + ), + ], + confirm: () { + if (selectedInterval != + widget.subscriptionInfo!.addOnSubscription.interval) { + context.read().add( + SettingsBillingEvent.updatePeriod( + plan: widget.subscriptionInfo!.addOnSubscription + .subscriptionPlan, + interval: selectedInterval!, + ), + ); + } + Navigator.of(context).pop(); + }, + ).show(context); + }, + ), + ], + ], + ); + } +} + +class ChangePeriod extends StatefulWidget { + const ChangePeriod({ + super.key, + required this.plan, + required this.selectedInterval, + required this.onSelected, + }); + + final SubscriptionPlanPB plan; + final RecurringIntervalPB selectedInterval; + final Function(RecurringIntervalPB interval) onSelected; + + @override + State createState() => _ChangePeriodState(); +} + +class _ChangePeriodState extends State { + RecurringIntervalPB? _selectedInterval; + + @override + void initState() { + super.initState(); + _selectedInterval = widget.selectedInterval; + } + + @override + void didChangeDependencies() { + _selectedInterval = widget.selectedInterval; + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _PeriodSelector( + price: widget.plan.priceMonthBilling, + interval: RecurringIntervalPB.Month, + isSelected: _selectedInterval == RecurringIntervalPB.Month, + isCurrent: widget.selectedInterval == RecurringIntervalPB.Month, + onSelected: () { + widget.onSelected(RecurringIntervalPB.Month); + setState( + () => _selectedInterval = RecurringIntervalPB.Month, + ); + }, + ), + const VSpace(16), + _PeriodSelector( + price: widget.plan.priceAnnualBilling, + interval: RecurringIntervalPB.Year, + isSelected: _selectedInterval == RecurringIntervalPB.Year, + isCurrent: widget.selectedInterval == RecurringIntervalPB.Year, + onSelected: () { + widget.onSelected(RecurringIntervalPB.Year); + setState( + () => _selectedInterval = RecurringIntervalPB.Year, + ); + }, + ), + ], + ); + } +} + +class _PeriodSelector extends StatelessWidget { + const _PeriodSelector({ + required this.price, + required this.interval, + required this.onSelected, + required this.isSelected, + required this.isCurrent, + }); + + final String price; + final RecurringIntervalPB interval; + final VoidCallback onSelected; + final bool isSelected; + final bool isCurrent; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: isCurrent && !isSelected ? 0.7 : 1, + child: GestureDetector( + onTap: isCurrent ? null : onSelected, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FlowyText( + interval.label, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + if (isCurrent) ...[ + const HSpace(8), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + child: FlowyText( + LocaleKeys + .settings_billingPage_currentPeriodBadge + .tr(), + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ], + ], + ), + const VSpace(8), + FlowyText( + price, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + const VSpace(4), + FlowyText( + interval.priceInfo, + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ], + ), + const Spacer(), + if (!isCurrent && !isSelected || isSelected) ...[ + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + width: 1.5, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: SizedBox( + height: 22, + width: 22, + child: Center( + child: SizedBox( + width: 10, + height: 10, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + ), + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} 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 3e169eb806..180aee2848 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 @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -12,7 +15,6 @@ import 'package:appflowy/workspace/application/settings/settings_location_cubit. import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/pages/fix_data_widget.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_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'; @@ -27,8 +29,6 @@ import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -63,15 +63,15 @@ class SettingsManageDataView extends StatelessWidget { size: Size.square(20), ), label: LocaleKeys.settings_common_reset.tr(), - onPressed: () => SettingsAlertDialog( + onPressed: () => showConfirmDialog( + context: context, title: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_title .tr(), - subtitle: LocaleKeys + description: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_description .tr(), - implyLeading: true, - confirm: () async { + onConfirm: () async { final directory = await appFlowyApplicationDataDirectory(); final path = directory.path; @@ -85,10 +85,8 @@ class SettingsManageDataView extends StatelessWidget { .read() .resetDataStoragePathToApplicationDefault(); await runAppFlowy(isAnon: true); - - if (context.mounted) Navigator.of(context).pop(); }, - ).show(context), + ), ), ], children: state diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart index 7d8d3433b5..7e0465cb7a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -1,27 +1,30 @@ +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/theme_extension.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/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../generated/locale_keys.g.dart'; +import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; + class SettingsPlanComparisonDialog extends StatefulWidget { const SettingsPlanComparisonDialog({ super.key, required this.workspaceId, - required this.subscription, + required this.subscriptionInfo, }); final String workspaceId; - final WorkspaceSubscriptionPB subscription; + final WorkspaceSubscriptionInfoPB subscriptionInfo; @override State createState() => @@ -33,7 +36,9 @@ class _SettingsPlanComparisonDialogState final horizontalController = ScrollController(); final verticalController = ScrollController(); - late WorkspaceSubscriptionPB currentSubscription = widget.subscription; + late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo; + + Loading? loadingIndicator; @override void dispose() { @@ -54,32 +59,27 @@ class _SettingsPlanComparisonDialogState 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); + if (readyState.downgradeProcessing) { + loadingIndicator = Loading(context)..start(); + } else { + loadingIndicator?.stop(); + loadingIndicator = null; } - setState(() { - currentSubscription = readyState.subscription; - }); + if (readyState.successfulPlanUpgrade != null) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title + .tr(args: [readyState.successfulPlanUpgrade!.label]), + description: LocaleKeys + .settings_comparePlanDialog_paymentSuccess_description + .tr(args: [readyState.successfulPlanUpgrade!.label]), + confirmLabel: LocaleKeys.button_close.tr(), + onConfirm: () {}, + ); + } + + setState(() => currentInfo = readyState.subscriptionInfo); }, builder: (context, state) => FlowyDialog( constraints: const BoxConstraints(maxWidth: 784, minWidth: 674), @@ -99,8 +99,7 @@ class _SettingsPlanComparisonDialogState const Spacer(), GestureDetector( onTap: () => Navigator.of(context).pop( - currentSubscription.subscriptionPlan != - widget.subscription.subscriptionPlan, + currentInfo.plan != widget.subscriptionInfo.plan, ), child: MouseRegion( cursor: SystemMouseCursors.click, @@ -154,7 +153,7 @@ class _SettingsPlanComparisonDialogState : const Color(0xFFE8E0FF), ), ), - const SizedBox(height: 64), + const SizedBox(height: 96), const SizedBox(height: 56), ..._planLabels.map( (e) => _ComparisonCell( @@ -172,56 +171,55 @@ class _SettingsPlanComparisonDialogState description: LocaleKeys .settings_comparePlanDialog_freePlan_description .tr(), - // TODO(Mathias): the price should be dynamic based on the country and currency price: LocaleKeys .settings_comparePlanDialog_freePlan_price - .tr(args: ['\$0']), + .tr( + args: [ + SubscriptionPlanPB.Free.priceMonthBilling, + ], + ), priceInfo: LocaleKeys .settings_comparePlanDialog_freePlan_priceInfo .tr(), cells: _freeLabels, - isCurrent: currentSubscription.subscriptionPlan == - SubscriptionPlanPB.None, + isCurrent: + currentInfo.plan == WorkspacePlanPB.FreePlan, canDowngrade: - currentSubscription.subscriptionPlan != - SubscriptionPlanPB.None, - currentCanceled: currentSubscription.hasCanceled || + currentInfo.plan != WorkspacePlanPB.FreePlan, + currentCanceled: currentInfo.isCanceled || (context .watch() .state .mapOrNull( loading: (_) => true, - ready: (state) => - state.downgradeProcessing, + ready: (s) => s.downgradeProcessing, ) ?? false), onSelected: () async { - if (currentSubscription.subscriptionPlan == - SubscriptionPlanPB.None || - currentSubscription.hasCanceled) { + if (currentInfo.plan == + WorkspacePlanPB.FreePlan || + currentInfo.isCanceled) { return; } - await SettingsAlertDialog( + await showConfirmDialog( + context: context, title: LocaleKeys .settings_comparePlanDialog_downgradeDialog_title - .tr(args: [currentSubscription.label]), - subtitle: LocaleKeys + .tr(args: [currentInfo.label]), + description: 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); + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: () => + context.read().add( + const SettingsPlanEvent + .cancelSubscription(), + ), + ); }, ), _PlanTable( @@ -231,19 +229,22 @@ class _SettingsPlanComparisonDialogState description: LocaleKeys .settings_comparePlanDialog_proPlan_description .tr(), - // TODO(Mathias): the price should be dynamic based on the country and currency price: LocaleKeys .settings_comparePlanDialog_proPlan_price - .tr(args: ['\$10 ']), + .tr( + args: [SubscriptionPlanPB.Pro.priceAnnualBilling], + ), priceInfo: LocaleKeys .settings_comparePlanDialog_proPlan_priceInfo - .tr(), + .tr( + args: [SubscriptionPlanPB.Pro.priceMonthBilling], + ), cells: _proLabels, - isCurrent: currentSubscription.subscriptionPlan == - SubscriptionPlanPB.Pro, - canUpgrade: currentSubscription.subscriptionPlan == - SubscriptionPlanPB.None, - currentCanceled: currentSubscription.hasCanceled, + isCurrent: + currentInfo.plan == WorkspacePlanPB.ProPlan, + canUpgrade: + currentInfo.plan == WorkspacePlanPB.FreePlan, + currentCanceled: currentInfo.isCanceled, onSelected: () => context.read().add( const SettingsPlanEvent.addSubscription( @@ -335,7 +336,7 @@ class _PlanTable extends StatelessWidget { title: price, description: priceInfo, isPrimary: !highlightPlan, - height: 64, + height: 96, ), if (canUpgrade || canDowngrade) ...[ Opacity( @@ -589,21 +590,28 @@ class _Heading extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 175, + width: 185, height: height, child: Padding( padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText.semibold( - title, - fontSize: 24, - color: isPrimary - ? AFThemeExtension.of(context).strongText - : Theme.of(context).isLightMode - ? const Color(0xFF5C3699) - : const Color(0xFFC49BEC), + Row( + children: [ + Expanded( + child: FlowyText.semibold( + title, + fontSize: 24, + overflow: TextOverflow.ellipsis, + color: isPrimary + ? AFThemeExtension.of(context).strongText + : Theme.of(context).isLightMode + ? const Color(0xFF5C3699) + : const Color(0xFFC49BEC), + ), + ), + ], ), if (description != null && description!.isNotEmpty) ...[ const VSpace(4), @@ -637,24 +645,20 @@ final _planLabels = [ ), _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(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(), - ), - _PlanItem( - label: LocaleKeys.settings_comparePlanDialog_planLabels_itemEight.tr(), - tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipEight.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSeven.tr(), ), ]; @@ -677,20 +681,17 @@ final List<_CellItem> _freeLabels = [ ), _CellItem( LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(), + icon: FlowySvgs.check_m, ), _CellItem( LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), + icon: FlowySvgs.check_m, ), _CellItem( LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), - icon: FlowySvgs.check_m, ), _CellItem( LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(), - icon: FlowySvgs.check_m, - ), - _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemEight.tr(), ), ]; @@ -706,19 +707,17 @@ final List<_CellItem> _proLabels = [ ), _CellItem( LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(), + icon: FlowySvgs.check_m, ), _CellItem( LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), + icon: FlowySvgs.check_m, ), _CellItem( LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), - icon: FlowySvgs.check_m, ), _CellItem( LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(), icon: FlowySvgs.check_m, ), - _CellItem( - 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 index b876cf70ec..ebbc27d2d9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -1,3 +1,5 @@ +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'; @@ -14,13 +16,15 @@ import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.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/error_page.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class SettingsPlanView extends StatelessWidget { +import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class SettingsPlanView extends StatefulWidget { const SettingsPlanView({ super.key, required this.workspaceId, @@ -30,14 +34,32 @@ class SettingsPlanView extends StatelessWidget { final String workspaceId; final UserProfilePB user; + @override + State createState() => _SettingsPlanViewState(); +} + +class _SettingsPlanViewState extends State { + Loading? loadingIndicator; + @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SettingsPlanBloc( - workspaceId: workspaceId, - userId: user.id, + workspaceId: widget.workspaceId, + userId: widget.user.id, )..add(const SettingsPlanEvent.started()), - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => + previous.mapOrNull(ready: (s) => s.downgradeProcessing) != + current.mapOrNull(ready: (s) => s.downgradeProcessing), + listener: (context, state) { + if (state.mapOrNull(ready: (s) => s.downgradeProcessing) == true) { + loadingIndicator = Loading(context)..start(); + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + }, builder: (context, state) { return state.map( initial: (_) => const SizedBox.shrink(), @@ -67,10 +89,87 @@ class SettingsPlanView extends StatelessWidget { children: [ _PlanUsageSummary( usage: state.workspaceUsage, - subscription: state.subscription, + subscriptionInfo: state.subscriptionInfo, ), const VSpace(16), - _CurrentPlanBox(subscription: state.subscription), + _CurrentPlanBox(subscriptionInfo: state.subscriptionInfo), + const VSpace(16), + FlowyText( + LocaleKeys.settings_planPage_planUsage_addons_title.tr(), + fontSize: 18, + color: AFThemeExtension.of(context).strongText, + fontWeight: FontWeight.w600, + ), + const VSpace(8), + Row( + children: [ + Flexible( + child: _AddOnBox( + title: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_price + .tr( + args: [SubscriptionPlanPB.AiMax.priceAnnualBilling], + ), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_priceInfo + .tr(), + billingInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_billingInfo + .tr( + args: [SubscriptionPlanPB.AiMax.priceMonthBilling], + ), + buttonText: state.subscriptionInfo.hasAIMax + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIMax, + plan: SubscriptionPlanPB.AiMax, + ), + ), + const HSpace(8), + Flexible( + child: _AddOnBox( + title: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_price + .tr( + args: [SubscriptionPlanPB.AiLocal.priceAnnualBilling], + ), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_priceInfo + .tr(), + billingInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_billingInfo + .tr( + args: [SubscriptionPlanPB.AiLocal.priceMonthBilling], + ), + buttonText: state.subscriptionInfo.hasAIOnDevice + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIOnDevice, + plan: SubscriptionPlanPB.AiLocal, + ), + ), + ], + ), ], ), ); @@ -81,9 +180,9 @@ class SettingsPlanView extends StatelessWidget { } class _CurrentPlanBox extends StatefulWidget { - const _CurrentPlanBox({required this.subscription}); + const _CurrentPlanBox({required this.subscriptionInfo}); - final WorkspaceSubscriptionPB subscription; + final WorkspaceSubscriptionInfoPB subscriptionInfo; @override State<_CurrentPlanBox> createState() => _CurrentPlanBoxState(); @@ -115,68 +214,67 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { border: Border.all(color: const Color(0xFFBDBDBD)), borderRadius: BorderRadius.circular(16), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const VSpace(4), - FlowyText.semibold( - widget.subscription.label, - fontSize: 24, - color: AFThemeExtension.of(context).strongText, - ), - const VSpace(8), - FlowyText.regular( - widget.subscription.info, - fontSize: 16, - color: AFThemeExtension.of(context).strongText, - maxLines: 3, - ), - const VSpace(16), - FlowyGradientButton( - label: LocaleKeys - .settings_planPage_planUsage_currentPlan_upgrade - .tr(), - onPressed: () => _openPricingDialog( - context, - context.read().workspaceId, - widget.subscription, - ), - ), - if (widget.subscription.hasCanceled) ...[ - const VSpace(12), - FlowyText( - LocaleKeys - .settings_planPage_planUsage_currentPlan_canceledInfo - .tr( - args: [_canceledDate(context)], + Row( + children: [ + Expanded( + flex: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4), + FlowyText.semibold( + widget.subscriptionInfo.label, + fontSize: 24, + color: AFThemeExtension.of(context).strongText, ), - 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(widget.subscription.subscriptionPlan).map( - (s) => _ProConItem(label: s), + const VSpace(8), + FlowyText.regular( + widget.subscriptionInfo.info, + fontSize: 16, + color: AFThemeExtension.of(context).strongText, + maxLines: 3, + ), + ], ), - ..._getCons(widget.subscription.subscriptionPlan).map( - (s) => _ProConItem(label: s, isPro: false), + ), + Flexible( + flex: 5, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: FlowyGradientButton( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_upgrade + .tr(), + onPressed: () => _openPricingDialog( + context, + context.read().workspaceId, + widget.subscriptionInfo, + ), + ), + ), + ], ), - ], - ), + ), + ], ), + if (widget.subscriptionInfo.isCanceled) ...[ + const VSpace(12), + FlowyText( + LocaleKeys + .settings_planPage_planUsage_currentPlan_canceledInfo + .tr( + args: [_canceledDate(context)], + ), + maxLines: 5, + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ], ], ), ), @@ -184,14 +282,21 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { top: 0, left: 0, child: Container( - height: 32, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: const BoxDecoration(color: Color(0xFF4F3F5F)), + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: const BoxDecoration( + color: Color(0xFF4F3F5F), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), child: Center( child: FlowyText.semibold( LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel .tr(), - fontSize: 16, + fontSize: 14, color: Colors.white, ), ), @@ -204,16 +309,15 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { String _canceledDate(BuildContext context) { final appearance = context.read().state; return appearance.dateFormat.formatDate( - widget.subscription.canceledAt.toDateTime(), - true, - appearance.timeFormat, + widget.subscriptionInfo.planSubscription.endDate.toDateTime(), + false, ); } void _openPricingDialog( BuildContext context, String workspaceId, - WorkspaceSubscriptionPB subscription, + WorkspaceSubscriptionInfoPB subscriptionInfo, ) => showDialog( context: context, @@ -221,101 +325,20 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { value: planBloc, child: SettingsPlanComparisonDialog( workspaceId: workspaceId, - subscription: subscription, + subscriptionInfo: subscriptionInfo, ), ), ); - - 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( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 18, - child: FlowySvg( - isPro ? FlowySvgs.check_m : FlowySvgs.close_error_s, - size: const Size.square(18), - color: isPro - ? AFThemeExtension.of(context).strongText - : const Color(0xFF900000), - ), - ), - const HSpace(4), - Flexible( - child: FlowyText.regular( - label, - fontSize: 12, - color: AFThemeExtension.of(context).strongText, - maxLines: 3, - ), - ), - ], - ); - } } class _PlanUsageSummary extends StatelessWidget { - const _PlanUsageSummary({required this.usage, required this.subscription}); + const _PlanUsageSummary({ + required this.usage, + required this.subscriptionInfo, + }); final WorkspaceUsagePB usage; - final WorkspaceSubscriptionPB subscription; + final WorkspaceSubscriptionInfoPB subscriptionInfo; @override Widget build(BuildContext context) { @@ -331,61 +354,101 @@ class _PlanUsageSummary extends StatelessWidget { ), const VSpace(16), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _UsageBox( title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(), - replacementText: subscription.subscriptionPlan == - SubscriptionPlanPB.Pro - ? LocaleKeys.settings_planPage_planUsage_storageUnlimited - .tr() - : null, + unlimitedLabel: LocaleKeys + .settings_planPage_planUsage_unlimitedStorageLabel + .tr(), + unlimited: usage.storageBytesUnlimited, label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr( args: [ usage.currentBlobInGb, usage.totalBlobInGb, ], ), - value: usage.totalBlobBytes.toInt() / - usage.totalBlobBytesLimit.toInt(), + value: usage.storageBytes.toInt() / + usage.storageBytesLimit.toInt(), ), ), Expanded( child: _UsageBox( - title: LocaleKeys.settings_planPage_planUsage_collaboratorsLabel - .tr(), - label: LocaleKeys.settings_planPage_planUsage_collaboratorsUsage - .tr( + title: + LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(), + label: + LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr( args: [ - usage.memberCount.toString(), - usage.memberCountLimit.toString(), + usage.aiResponsesCount.toString(), + usage.aiResponsesCountLimit.toString(), ], ), - value: - usage.memberCount.toInt() / usage.memberCountLimit.toInt(), + unlimitedLabel: LocaleKeys + .settings_planPage_planUsage_unlimitedAILabel + .tr(), + unlimited: usage.aiResponsesUnlimited, + value: usage.aiResponsesCount.toInt() / + usage.aiResponsesCountLimit.toInt(), ), ), ], ), const VSpace(16), - Column( + SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const VSpace(4), 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(), - ), + if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[ + _ToggleMore( + value: false, + label: + LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), + badgeLabel: + LocaleKeys.settings_planPage_planUsage_proBadge.tr(), + onTap: () async { + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.Pro, + ), + ); + await Future.delayed(const Duration(seconds: 2), () {}); + }, + ), + ], + if (!subscriptionInfo.hasAIMax && !usage.aiResponsesUnlimited) ...[ + _ToggleMore( + value: false, + label: LocaleKeys.settings_planPage_planUsage_aiMaxToggle.tr(), + badgeLabel: + LocaleKeys.settings_planPage_planUsage_aiMaxBadge.tr(), + onTap: () async { + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.AiMax, + ), + ); + await Future.delayed(const Duration(seconds: 2), () {}); + }, + ), + ], + if (!subscriptionInfo.hasAIOnDevice) ...[ + _ToggleMore( + value: false, + label: LocaleKeys.settings_planPage_planUsage_aiOnDeviceToggle + .tr(), + badgeLabel: + LocaleKeys.settings_planPage_planUsage_aiOnDeviceBadge.tr(), + onTap: () async { + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.AiLocal, + ), + ); + await Future.delayed(const Duration(seconds: 2), () {}); + }, + ), + ], ], ), ], @@ -398,15 +461,18 @@ class _UsageBox extends StatelessWidget { required this.title, required this.label, required this.value, - this.replacementText, + required this.unlimitedLabel, + this.unlimited = false, }); final String title; final String label; final double value; - /// Replaces the progress indicator if not null - final String? replacementText; + final String unlimitedLabel; + + // Replaces the progress bar with an unlimited badge + final bool unlimited; @override Widget build(BuildContext context) { @@ -418,19 +484,27 @@ class _UsageBox extends StatelessWidget { fontSize: 11, color: AFThemeExtension.of(context).secondaryTextColor, ), - if (replacementText != null) ...[ - Row( - children: [ - Flexible( - child: FlowyText.medium( - replacementText!, - fontSize: 11, - color: AFThemeExtension.of(context).secondaryTextColor, + if (unlimited) ...[ + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.check_circle_outlined_s, + color: Color(0xFF9C00FB), ), - ), - ], + const HSpace(4), + FlowyText( + unlimitedLabel, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ], + ), ), ] else ...[ + const VSpace(4), _PlanProgressIndicator(label: label, progress: value), ], ], @@ -442,14 +516,14 @@ class _ToggleMore extends StatefulWidget { const _ToggleMore({ required this.value, required this.label, - required this.subscription, this.badgeLabel, + this.onTap, }); final bool value; final String label; - final WorkspaceSubscriptionPB subscription; final String? badgeLabel; + final Future Function()? onTap; @override State<_ToggleMore> createState() => _ToggleMoreState(); @@ -471,29 +545,17 @@ class _ToggleMoreState extends State<_ToggleMore> { Toggle( value: toggleValue, padding: EdgeInsets.zero, - onChanged: (_) { - setState(() => toggleValue = !toggleValue); + onChanged: (_) async { + if (widget.onTap == null || toggleValue) { + return; + } - 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); - } - }); - }); - } - }); + setState(() => toggleValue = !toggleValue); + await widget.onTap!(); + + if (mounted) { + setState(() => toggleValue = !toggleValue); + } }, ), const HSpace(10), @@ -553,7 +615,9 @@ class _PlanProgressIndicator extends StatelessWidget { widthFactor: progress, child: Container( decoration: BoxDecoration( - color: theme.colorScheme.primary, + color: progress >= 1 + ? theme.colorScheme.error + : theme.colorScheme.primary, ), ), ), @@ -574,6 +638,135 @@ class _PlanProgressIndicator extends StatelessWidget { } } +class _AddOnBox extends StatelessWidget { + const _AddOnBox({ + required this.title, + required this.description, + required this.price, + required this.priceInfo, + required this.billingInfo, + required this.buttonText, + required this.isActive, + required this.plan, + }); + + final String title; + final String description; + final String price; + final String priceInfo; + final String billingInfo; + final String buttonText; + final bool isActive; + final SubscriptionPlanPB plan; + + @override + Widget build(BuildContext context) { + final isLM = Theme.of(context).isLightMode; + + return Container( + height: 220, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border.all( + color: isActive ? const Color(0xFFBDBDBD) : const Color(0xFF9C00FB), + ), + color: const Color(0xFFF7F8FC).withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + title, + fontSize: 14, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(10), + FlowyText.regular( + description, + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + maxLines: 4, + ), + const VSpace(10), + FlowyText( + price, + fontSize: 24, + color: AFThemeExtension.of(context).strongText, + ), + FlowyText( + priceInfo, + fontSize: 12, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(12), + Row( + children: [ + Expanded( + child: FlowyText( + billingInfo, + color: AFThemeExtension.of(context).secondaryTextColor, + fontSize: 11, + maxLines: 2, + ), + ), + ], + ), + const Spacer(), + Row( + children: [ + Expanded( + child: FlowyTextButton( + buttonText, + heading: isActive + ? const FlowySvg( + FlowySvgs.check_circle_outlined_s, + color: Color(0xFF9C00FB), + ) + : null, + mainAxisAlignment: MainAxisAlignment.center, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + fillColor: isActive + ? const Color(0xFFE8E2EE) + : isLM + ? Colors.transparent + : const Color(0xFF5C3699), + constraints: const BoxConstraints(minWidth: 115), + radius: Corners.s16Border, + hoverColor: isActive + ? const Color(0xFFE8E2EE) + : isLM + ? const Color(0xFF5C3699) + : const Color(0xFF4d3472), + fontColor: + isLM || isActive ? const Color(0xFF5C3699) : Colors.white, + fontHoverColor: + isActive ? const Color(0xFF5C3699) : Colors.white, + borderColor: isActive + ? const Color(0xFFE8E2EE) + : isLM + ? const Color(0xFF5C3699) + : const Color(0xFF4d3472), + fontSize: 12, + onPressed: isActive + ? null + : () => context + .read() + .add(SettingsPlanEvent.addSubscription(plan)), + ), + ), + ], + ), + ], + ), + ); + } +} + /// Uncomment if we need it in the future // class _DealBox extends StatelessWidget { // const _DealBox(); 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 357b9ce17b..7119761694 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 @@ -22,7 +22,6 @@ import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu import 'package:appflowy/workspace/presentation/settings/shared/document_color_setting_button.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_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/settings_category_spacer.dart'; @@ -183,7 +182,8 @@ class SettingsWorkspaceView extends StatelessWidget { .tr(), fontSize: 16, fontWeight: FontWeight.w600, - onPressed: () => SettingsAlertDialog( + onPressed: () => showConfirmDialog( + context: context, title: workspaceMember?.role.isOwner ?? false ? LocaleKeys .settings_workspacePage_deleteWorkspacePrompt_title @@ -191,24 +191,21 @@ class SettingsWorkspaceView extends StatelessWidget { : LocaleKeys .settings_workspacePage_leaveWorkspacePrompt_title .tr(), - subtitle: workspaceMember?.role.isOwner ?? false + description: workspaceMember?.role.isOwner ?? false ? LocaleKeys .settings_workspacePage_deleteWorkspacePrompt_content .tr() : LocaleKeys .settings_workspacePage_leaveWorkspacePrompt_content .tr(), - isDangerous: true, - confirm: () { - context.read().add( - workspaceMember?.role.isOwner ?? false - ? const WorkspaceSettingsEvent.deleteWorkspace() - : const WorkspaceSettingsEvent.leaveWorkspace(), - ); - Navigator.of(context).pop(); - }, - ).show(context), - isDangerous: true, + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: () => context.read().add( + workspaceMember?.role.isOwner ?? false + ? const WorkspaceSettingsEvent.deleteWorkspace() + : const WorkspaceSettingsEvent.leaveWorkspace(), + ), + ), + buttonType: SingleSettingsButtonType.danger, buttonLabel: workspaceMember?.role.isOwner ?? false ? LocaleKeys .settings_workspacePage_manageWorkspace_deleteWorkspace 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 9e79e3eb39..40ca1c4816 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -1,6 +1,5 @@ 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/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; @@ -26,18 +25,22 @@ class SettingsDialog extends StatelessWidget { required this.dismissDialog, required this.didLogout, required this.restartApp, + this.initPage, }) : super(key: ValueKey(user.id)); final VoidCallback dismissDialog; final VoidCallback didLogout; final VoidCallback restartApp; final UserProfilePB user; + final SettingsPage? initPage; @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => getIt(param1: user) - ..add(const SettingsDialogEvent.initial()), + create: (context) => SettingsDialogBloc( + user, + initPage: initPage, + )..add(const SettingsDialogEvent.initial()), child: BlocBuilder( builder: (context, state) => FlowyDialog( width: MediaQuery.of(context).size.width * 0.7, @@ -120,7 +123,10 @@ class SettingsDialog extends StatelessWidget { return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud(); } case SettingsPage.member: - return WorkspaceMembersPage(userProfile: user); + return WorkspaceMembersPage( + userProfile: user, + workspaceId: workspaceId, + ); case SettingsPage.plan: return SettingsPlanView(workspaceId: workspaceId, user: user); case SettingsPage.billing: 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 index 2d56c9dce8..eb39df8b32 100644 --- 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 @@ -71,7 +71,7 @@ class _FlowyGradientButtonState extends State { ), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: FlowyText( widget.label, fontSize: 16, 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 4efc31b06d..61a29b77a1 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 @@ -21,6 +21,7 @@ class SettingsAlertDialog extends StatefulWidget { this.hideCancelButton = false, this.isDangerous = false, this.implyLeading = false, + this.enableConfirmNotifier, }); final Widget? icon; @@ -32,6 +33,7 @@ class SettingsAlertDialog extends StatefulWidget { final String? confirmLabel; final bool hideCancelButton; final bool isDangerous; + final ValueNotifier? enableConfirmNotifier; /// If true, a back button will show in the top left corner final bool implyLeading; @@ -41,6 +43,37 @@ class SettingsAlertDialog extends StatefulWidget { } class _SettingsAlertDialogState extends State { + bool enableConfirm = true; + + @override + void initState() { + super.initState(); + if (widget.enableConfirmNotifier != null) { + widget.enableConfirmNotifier!.addListener(_updateEnableConfirm); + enableConfirm = widget.enableConfirmNotifier!.value; + } + } + + void _updateEnableConfirm() { + setState(() => enableConfirm = widget.enableConfirmNotifier!.value); + } + + @override + void dispose() { + if (widget.enableConfirmNotifier != null) { + widget.enableConfirmNotifier!.removeListener(_updateEnableConfirm); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant SettingsAlertDialog oldWidget) { + oldWidget.enableConfirmNotifier?.removeListener(_updateEnableConfirm); + widget.enableConfirmNotifier?.addListener(_updateEnableConfirm); + enableConfirm = widget.enableConfirmNotifier?.value ?? true; + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { return StyledDialog( @@ -136,6 +169,7 @@ class _SettingsAlertDialogState extends State { cancel: widget.cancel, confirm: widget.confirm, isDangerous: widget.isDangerous, + enableConfirm: enableConfirm, ), ], ), @@ -150,6 +184,7 @@ class _Actions extends StatelessWidget { this.cancel, this.confirm, this.isDangerous = false, + this.enableConfirm = true, }); final bool hideCancelButton; @@ -157,6 +192,7 @@ class _Actions extends StatelessWidget { final VoidCallback? cancel; final VoidCallback? confirm; final bool isDangerous; + final bool enableConfirm; @override Widget build(BuildContext context) { @@ -197,14 +233,18 @@ class _Actions extends StatelessWidget { ), radius: Corners.s12Border, fontColor: isDangerous ? Colors.white : null, - fontHoverColor: Colors.white, - fillColor: isDangerous - ? Theme.of(context).colorScheme.error - : Theme.of(context).colorScheme.primary, - hoverColor: isDangerous - ? Theme.of(context).colorScheme.error - : const Color(0xFF005483), - onPressed: confirm, + fontHoverColor: !enableConfirm ? null : Colors.white, + fillColor: !enableConfirm + ? Theme.of(context).dividerColor + : isDangerous + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + hoverColor: !enableConfirm + ? Theme.of(context).dividerColor + : isDangerous + ? Theme.of(context).colorScheme.error + : const Color(0xFF005483), + onPressed: enableConfirm ? confirm : null, ), ), ], 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 8fc8e33280..646f712c3a 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 @@ -6,6 +6,16 @@ import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +enum SingleSettingsButtonType { + primary, + danger, + highlight; + + bool get isPrimary => this == primary; + bool get isDangerous => this == danger; + bool get isHighlight => this == highlight; +} + /// This is used to describe a single setting action /// /// This will render a simple action that takes the title, @@ -18,15 +28,18 @@ class SingleSettingAction extends StatelessWidget { const SingleSettingAction({ super.key, required this.label, + this.description, this.labelMaxLines, required this.buttonLabel, this.onPressed, - this.isDangerous = false, + this.buttonType = SingleSettingsButtonType.primary, this.fontSize = 14, this.fontWeight = FontWeight.normal, + this.minWidth, }); final String label; + final String? description; final int? labelMaxLines; final String buttonLabel; @@ -36,46 +49,115 @@ class SingleSettingAction extends StatelessWidget { /// final VoidCallback? onPressed; - /// If isDangerous is true, the button will be rendered as a dangerous - /// action, with a red outline. - /// - final bool isDangerous; + final SingleSettingsButtonType buttonType; final double fontSize; final FontWeight fontWeight; + final double? minWidth; @override Widget build(BuildContext context) { return Row( children: [ Expanded( - child: FlowyText( - label, - fontSize: fontSize, - fontWeight: fontWeight, - maxLines: labelMaxLines, - overflow: TextOverflow.ellipsis, - color: AFThemeExtension.of(context).secondaryTextColor, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: FlowyText( + label, + fontSize: fontSize, + fontWeight: fontWeight, + maxLines: labelMaxLines, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ), + ], + ), + if (description != null) ...[ + const VSpace(4), + Row( + children: [ + Expanded( + child: FlowyText.regular( + description!, + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + maxLines: 2, + ), + ), + ], + ), + ], + ], ), ), const HSpace(24), - SizedBox( - height: 32, + ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth ?? 0.0, + maxHeight: 32, + minHeight: 32, + ), child: FlowyTextButton( buttonLabel, 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, + fillColor: fillColor(context), + radius: Corners.s8Border, + hoverColor: hoverColor(context), + fontColor: fontColor(context), + fontHoverColor: fontHoverColor(context), + borderColor: borderColor(context), fontSize: 12, - isDangerous: isDangerous, + isDangerous: buttonType.isDangerous, onPressed: onPressed, ), ), ], ); } + + Color? fillColor(BuildContext context) { + if (buttonType.isPrimary) { + return Theme.of(context).colorScheme.primary; + } + return Colors.transparent; + } + + Color? hoverColor(BuildContext context) { + if (buttonType.isPrimary) { + return const Color(0xFF005483); + } + + if (buttonType.isHighlight) { + return const Color(0xFF5C3699); + } + return null; + } + + Color? fontColor(BuildContext context) { + if (buttonType.isDangerous) { + return Theme.of(context).colorScheme.error; + } + + if (buttonType.isHighlight) { + return const Color(0xFF5C3699); + } + + return null; + } + + Color? fontHoverColor(BuildContext context) { + return Colors.white; + } + + Color? borderColor(BuildContext context) { + if (buttonType.isHighlight) { + return const Color(0xFF5C3699); + } + + return null; + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart index 7b9dc4354b..a35af6ce80 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; @@ -24,13 +27,14 @@ class WorkspaceMemberBloc extends Bloc { WorkspaceMemberBloc({ required this.userProfile, + String? workspaceId, this.workspace, }) : _userBackendService = UserBackendService(userId: userProfile.id), super(WorkspaceMemberState.initial()) { on((event, emit) async { await event.when( initial: () async { - await _setCurrentWorkspaceId(); + await _setCurrentWorkspaceId(workspaceId); final result = await _userBackendService.getWorkspaceMembers( _workspaceId, @@ -135,9 +139,7 @@ class WorkspaceMemberBloc (s) => state.members.map((e) { if (e.email == email) { e.freeze(); - return e.rebuild((p0) { - p0.role = role; - }); + return e.rebuild((p0) => p0.role = role); } return e; }).toList(), @@ -153,6 +155,26 @@ class WorkspaceMemberBloc ), ); }, + updateSubscriptionInfo: (info) async => + emit(state.copyWith(subscriptionInfo: info)), + upgradePlan: () async { + final plan = state.subscriptionInfo?.plan; + if (plan == null) { + return Log.error('Failed to upgrade plan: plan is null'); + } + + if (plan == WorkspacePlanPB.FreePlan) { + final checkoutLink = await _userBackendService.createSubscription( + _workspaceId, + SubscriptionPlanPB.Pro, + ); + + checkoutLink.fold( + (pl) => afLaunchUrlString(pl.paymentLink), + (f) => Log.error('Failed to create subscription: ${f.msg}', f), + ); + } + }, ); }); } @@ -178,9 +200,11 @@ class WorkspaceMemberBloc return role; } - Future _setCurrentWorkspaceId() async { + Future _setCurrentWorkspaceId(String? workspaceId) async { if (workspace != null) { _workspaceId = workspace!.workspaceId; + } else if (workspaceId != null && workspaceId.isNotEmpty) { + _workspaceId = workspaceId; } else { final currentWorkspace = await FolderEventReadCurrentWorkspace().send(); currentWorkspace.fold((s) { @@ -191,6 +215,20 @@ class WorkspaceMemberBloc _workspaceId = ''; }); } + + unawaited(_fetchWorkspaceSubscriptionInfo()); + } + + // We fetch workspace subscription info lazily as it's not needed in the first + // render of the page. + Future _fetchWorkspaceSubscriptionInfo() async { + final result = + await UserBackendService.getWorkspaceSubscriptionInfo(_workspaceId); + + result.fold( + (info) => add(WorkspaceMemberEvent.updateSubscriptionInfo(info)), + (f) => Log.error('Failed to fetch subscription info: ${f.msg}', f), + ); } } @@ -209,6 +247,11 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent { String email, AFRolePB role, ) = UpdateWorkspaceMember; + const factory WorkspaceMemberEvent.updateSubscriptionInfo( + WorkspaceSubscriptionInfoPB subscriptionInfo, + ) = UpdateSubscriptionInfo; + + const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan; } enum WorkspaceMemberActionType { @@ -241,6 +284,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState { @Default(AFRolePB.Guest) AFRolePB myRole, @Default(null) WorkspaceMemberActionResult? actionResult, @Default(true) bool isLoading, + @Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo, }) = _WorkspaceMemberState; factory WorkspaceMemberState.initial() => const WorkspaceMemberState(); @@ -255,6 +299,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState { return other is WorkspaceMemberState && other.members == members && other.myRole == myRole && + other.subscriptionInfo == subscriptionInfo && identical(other.actionResult, actionResult); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart index 2d2f2e57b7..ae4cc082b6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart @@ -1,18 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.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:flowy_infra_ui/widget/rounded_button.dart'; @@ -20,22 +22,34 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; class WorkspaceMembersPage extends StatelessWidget { - const WorkspaceMembersPage({super.key, required this.userProfile}); + const WorkspaceMembersPage({ + super.key, + required this.userProfile, + required this.workspaceId, + }); final UserProfilePB userProfile; + final String workspaceId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => WorkspaceMemberBloc(userProfile: userProfile) ..add(const WorkspaceMemberEvent.initial()), - child: BlocConsumer( - listener: _showResultDialog, + child: BlocBuilder( builder: (context, state) { return SettingsBody( title: LocaleKeys.settings_appearance_members_title.tr(), + autoSeparate: false, children: [ - if (state.myRole.canInvite) const _InviteMember(), + if (state.actionResult != null) ...[ + _showMemberLimitWarning(context, state), + const VSpace(16), + ], + if (state.myRole.canInvite) ...[ + const _InviteMember(), + const SettingsCategorySpacer(), + ], if (state.members.isNotEmpty) _MemberList( members: state.members, @@ -49,62 +63,105 @@ class WorkspaceMembersPage extends StatelessWidget { ); } - void _showResultDialog(BuildContext context, WorkspaceMemberState state) { - final actionResult = state.actionResult; - if (actionResult == null) { - return; + Widget _showMemberLimitWarning( + BuildContext context, + WorkspaceMemberState state, + ) { + // We promise that state.actionResult != null before calling + // this method + final actionResult = state.actionResult!.result; + final actionType = state.actionResult!.actionType; + + debugPrint("Plan: ${state.subscriptionInfo?.plan}"); + + if (actionType == WorkspaceMemberActionType.invite && + actionResult.isFailure) { + final error = actionResult.getFailure().code; + if (error == ErrorCode.WorkspaceMemberLimitExceeded) { + return Row( + children: [ + const FlowySvg( + FlowySvgs.warning_s, + blendMode: BlendMode.dst, + size: Size.square(20), + ), + const HSpace(12), + Expanded( + child: RichText( + text: TextSpan( + children: [ + if (state.subscriptionInfo?.plan == + WorkspacePlanPB.ProPlan) ...[ + TextSpan( + text: LocaleKeys + .settings_appearance_members_memberLimitExceededPro + .tr(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AFThemeExtension.of(context).strongText, + ), + ), + WidgetSpan( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + // Hardcoded support email, in the future we might + // want to add this to an environment variable + onTap: () async => afLaunchUrlString( + 'mailto:support@appflowy.io', + ), + child: FlowyText( + LocaleKeys + .settings_appearance_members_memberLimitExceededProContact + .tr(), + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ] else ...[ + TextSpan( + text: LocaleKeys + .settings_appearance_members_memberLimitExceeded + .tr(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AFThemeExtension.of(context).strongText, + ), + ), + WidgetSpan( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context + .read() + .add(const WorkspaceMemberEvent.upgradePlan()), + child: FlowyText( + LocaleKeys + .settings_appearance_members_memberLimitExceededUpgrade + .tr(), + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ], + ], + ), + ), + ), + ], + ); + } } - final actionType = actionResult.actionType; - final result = actionResult.result; - - // only show the result dialog when the action is WorkspaceMemberActionType.add - if (actionType == WorkspaceMemberActionType.add) { - result.fold( - (s) { - showSnackBarMessage( - context, - LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), - ); - }, - (f) { - Log.error('add workspace member failed: $f'); - final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded - ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() - : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); - showDialog( - context: context, - builder: (context) => NavigatorOkCancelDialog(message: message), - ); - }, - ); - } else if (actionType == WorkspaceMemberActionType.invite) { - result.fold( - (s) { - showSnackBarMessage( - context, - LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), - ); - }, - (f) { - Log.error('invite workspace member failed: $f'); - final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded - ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() - : LocaleKeys.settings_appearance_members_failedToInviteMember - .tr(); - showDialog( - context: context, - builder: (context) => NavigatorOkCancelDialog(message: message), - ); - }, - ); - } - - result.onFailure((f) { - Log.error( - '[Member] Failed to perform ${actionType.toString()} action: $f', - ); - }); + return const SizedBox.shrink(); } } 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 08fb46d58d..7ccd1a67c3 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 @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; @@ -112,26 +111,26 @@ class SettingsMenu extends StatelessWidget { ), changeSelectedPage: changeSelectedPage, ), - if (FeatureFlag.planBilling.isOn && - userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud && - member != null && - member!.role.isOwner) ...[ - 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 (FeatureFlag.planBilling.isOn && + // userProfile.authenticator == + // AuthenticatorPB.AppFlowyCloud && + // member != null && + // member!.role.isOwner) ...[ + // 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/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 2533b61a0e..188303c3f7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -186,6 +186,7 @@ class NavigatorOkCancelDialog extends StatelessWidget { this.message, this.maxWidth, this.titleUpperCase = true, + this.autoDismiss = true, }); final VoidCallback? onOkPressed; @@ -196,9 +197,18 @@ class NavigatorOkCancelDialog extends StatelessWidget { final String? message; final double? maxWidth; final bool titleUpperCase; + final bool autoDismiss; @override Widget build(BuildContext context) { + final onCancel = onCancelPressed == null + ? null + : () { + onCancelPressed?.call(); + if (autoDismiss) { + Navigator.of(context).pop(); + } + }; return StyledDialog( maxWidth: maxWidth ?? 500, padding: EdgeInsets.symmetric(horizontal: Insets.xl, vertical: Insets.l), @@ -227,12 +237,11 @@ class NavigatorOkCancelDialog extends StatelessWidget { OkCancelButton( onOkPressed: () { onOkPressed?.call(); - Navigator.of(context).pop(); - }, - onCancelPressed: () { - onCancelPressed?.call(); - Navigator.of(context).pop(); + if (autoDismiss) { + Navigator.of(context).pop(); + } }, + onCancelPressed: onCancel, okTitle: okTitle?.toUpperCase(), cancelTitle: cancelTitle?.toUpperCase(), ), diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index cd95941a15..621b0e3154 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -73,7 +73,9 @@ Future> _extractPayload( case FFIStatusCode.Ok: return FlowySuccess(Uint8List.fromList(response.payload)); case FFIStatusCode.Err: - return FlowyFailure(Uint8List.fromList(response.payload)); + final errorBytes = Uint8List.fromList(response.payload); + GlobalErrorCodeNotifier.receiveErrorBytes(errorBytes); + return FlowyFailure(errorBytes); case FFIStatusCode.Internal: final error = utf8.decode(response.payload); Log.error("Dispatch internal error: $error"); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart index 4019f6723f..b8b62b63ea 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart @@ -1,4 +1,7 @@ +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; +import 'package:flutter/foundation.dart'; class FlowyInternalError { late FFIStatusCode _statusCode; @@ -20,15 +23,13 @@ class FlowyInternalError { return "$_statusCode: $_error"; } - FlowyInternalError( - {required FFIStatusCode statusCode, required String error}) { + FlowyInternalError({ + required FFIStatusCode statusCode, + required String error, + }) { _statusCode = statusCode; _error = error; } - - factory FlowyInternalError.from(FFIResponse resp) { - return FlowyInternalError(statusCode: resp.code, error: ""); - } } class StackTraceError { @@ -48,3 +49,61 @@ class StackTraceError { return '${error.runtimeType}. Stack trace: $trace'; } } + +typedef void ErrorListener(); + +class GlobalErrorCodeNotifier extends ChangeNotifier { + // Static instance with lazy initialization + static final GlobalErrorCodeNotifier _instance = + GlobalErrorCodeNotifier._internal(); + + FlowyError? _error; + + // Private internal constructor + GlobalErrorCodeNotifier._internal(); + + // Factory constructor to return the same instance + factory GlobalErrorCodeNotifier() { + return _instance; + } + + static void receiveError(FlowyError error) { + if (_instance._error?.code != error.code) { + _instance._error = error; + _instance.notifyListeners(); + } + } + + static void receiveErrorBytes(Uint8List bytes) { + try { + final error = FlowyError.fromBuffer(bytes); + if (_instance._error?.code != error.code) { + _instance._error = error; + _instance.notifyListeners(); + } + } catch (e) { + Log.error("Can not parse error bytes: $e"); + } + } + + static ErrorListener add({ + required void Function(FlowyError error) onError, + bool Function(FlowyError code)? onErrorIf, + }) { + void listener() { + final error = _instance._error; + if (error != null) { + if (onErrorIf == null || onErrorIf(error)) { + onError(error); + } + } + } + + _instance.addListener(listener); + return listener; + } + + static void remove(ErrorListener listener) { + _instance.removeListener(listener); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart index b66d836bb3..b9c6d565f8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart @@ -82,4 +82,7 @@ class Corners { static const BorderRadius s12Border = BorderRadius.all(s12Radius); static const Radius s12Radius = Radius.circular(12); + + static const BorderRadius s16Border = BorderRadius.all(s16Radius); + static const Radius s16Radius = Radius.circular(16); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index eb32f60f61..a548a1a9be 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -165,6 +165,7 @@ class FlowyTextButton extends StatelessWidget { this.decoration, this.fontFamily, this.isDangerous = false, + this.borderColor, }); final String text; @@ -188,6 +189,7 @@ class FlowyTextButton extends StatelessWidget { final String? fontFamily; final bool isDangerous; + final Color? borderColor; @override Widget build(BuildContext context) { @@ -211,7 +213,7 @@ class FlowyTextButton extends StatelessWidget { child = ConstrainedBox( constraints: constraints, child: TextButton( - onPressed: onPressed ?? () {}, + onPressed: onPressed, focusNode: FocusNode(skipTraversal: onPressed == null), style: ButtonStyle( overlayColor: const WidgetStatePropertyAll(Colors.transparent), @@ -222,9 +224,10 @@ class FlowyTextButton extends StatelessWidget { shape: WidgetStateProperty.all( RoundedRectangleBorder( side: BorderSide( - color: isDangerous - ? Theme.of(context).colorScheme.error - : Colors.transparent, + color: borderColor ?? + (isDangerous + ? Theme.of(context).colorScheme.error + : Colors.transparent), ), borderRadius: radius ?? Corners.s6Border, ), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart index 6207419009..e2fbd49db3 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart @@ -44,7 +44,7 @@ class PrimaryButton extends StatelessWidget { return BaseStyledButton( minWidth: mode.size.width, minHeight: mode.size.height, - contentPadding: EdgeInsets.zero, + contentPadding: const EdgeInsets.symmetric(horizontal: 6), bgColor: backgroundColor ?? Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primaryContainer, borderRadius: mode.borderRadius, diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index e20df35efd..88ce19eca4 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bincode", @@ -192,7 +192,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bytes", @@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "again", "anyhow", @@ -876,7 +876,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "collab-entity", "collab-rt-entity", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "futures-channel", "futures-util", @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bincode", @@ -1153,7 +1153,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "async-trait", @@ -1417,7 +1417,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.6", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1528,7 +1528,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", @@ -2103,6 +2103,7 @@ dependencies = [ "client-api", "collab", "collab-entity", + "flowy-error", "lib-infra", ] @@ -2474,12 +2475,16 @@ dependencies = [ "async-trait", "bytes", "chrono", + "flowy-codegen", + "flowy-derive", "flowy-error", + "flowy-notification", "flowy-sqlite", "flowy-storage-pub", "fxhash", "lib-infra", "mime_guess", + "protobuf", "serde", "serde_json", "tokio", @@ -2512,6 +2517,7 @@ dependencies = [ "base64 0.21.5", "bytes", "chrono", + "client-api", "collab", "collab-database", "collab-document", @@ -3028,7 +3034,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "futures-util", @@ -3045,7 +3051,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", @@ -3477,7 +3483,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bytes", @@ -6021,7 +6027,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index d2f66f002c..f128965d6d 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -53,7 +53,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 = "76a8993cac42ff89acb507cdb99942cac7c9bfd0" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" } [dependencies] serde_json.workspace = true diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock index 7b24a985b9..54a5eb6fc0 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bytes", @@ -800,7 +800,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "again", "anyhow", @@ -850,7 +850,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "collab-entity", "collab-rt-entity", @@ -862,7 +862,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "futures-channel", "futures-util", @@ -1111,7 +1111,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bincode", @@ -1136,7 +1136,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "async-trait", @@ -1407,7 +1407,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.10", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1518,7 +1518,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", @@ -2133,6 +2133,7 @@ dependencies = [ "client-api", "collab", "collab-entity", + "flowy-error", "lib-infra", ] @@ -2504,12 +2505,16 @@ dependencies = [ "async-trait", "bytes", "chrono", + "flowy-codegen", + "flowy-derive", "flowy-error", + "flowy-notification", "flowy-sqlite", "flowy-storage-pub", "fxhash", "lib-infra", "mime_guess", + "protobuf", "serde", "serde_json", "tokio", @@ -2542,6 +2547,7 @@ dependencies = [ "base64 0.21.7", "bytes", "chrono", + "client-api", "collab", "collab-database", "collab-document", @@ -3095,7 +3101,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "futures-util", @@ -3112,7 +3118,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", @@ -3549,7 +3555,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bytes", @@ -6085,7 +6091,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 4c3dbac913..9591ac709f 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 = "76a8993cac42ff89acb507cdb99942cac7c9bfd0" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" } [dependencies] serde_json.workspace = true diff --git a/frontend/resources/flowy_icons/16x/check_circle_outlined.svg b/frontend/resources/flowy_icons/16x/check_circle_outlined.svg new file mode 100644 index 0000000000..3677adcc96 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/check_circle_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/warning.svg b/frontend/resources/flowy_icons/16x/warning.svg new file mode 100644 index 0000000000..7ac605dffc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/translations/am-ET.json b/frontend/resources/translations/am-ET.json index b07649f0e4..9cebbf8241 100644 --- a/frontend/resources/translations/am-ET.json +++ b/frontend/resources/translations/am-ET.json @@ -358,7 +358,7 @@ "email": "ኢሜል", "tooltipSelectIcon": "አዶን ይምረጡ", "selectAnIcon": "አዶን ይምረጡ", - "pleaseInputYourOpenAIKey": "እባክዎን OpenAI ቁልፍዎን ያስገቡ", + "pleaseInputYourOpenAIKey": "እባክዎን AI ቁልፍዎን ያስገቡ", "pleaseInputYourStabilityAIKey": "እባክዎ Stability AI ቁልፍን ያስገቡ", "clickToLogout": "የአሁኑን ተጠቃሚ ለመግባት ጠቅ ያድርጉ" }, @@ -556,12 +556,12 @@ "referencedBoard": "ማጣቀሻ ቦርድ", "referencedGrid": "ማጣቀሻ ፍርግርግ", "referencedCalendar": "የቀን ቀን መቁጠሪያ", - "autoGeneratorMenuItemName": "OpenAI ጸሐፊ", - "autoGeneratorTitleName": "OpenAI ማንኛውንም ነገር እንዲጽፉ ይጠይቁ ...", + "autoGeneratorMenuItemName": "AI ጸሐፊ", + "autoGeneratorTitleName": "AI ማንኛውንም ነገር እንዲጽፉ ይጠይቁ ...", "autoGeneratorLearnMore": "ተጨማሪ እወቅ", "autoGeneratorGenerate": "ማመንጨት", - "autoGeneratorHintText": "OpenAI ይጠይቁ ...", - "autoGeneratorCantGetOpenAIKey": "የ OpenAI ቁልፍ ማግኘት አልተቻለም", + "autoGeneratorHintText": "AI ይጠይቁ ...", + "autoGeneratorCantGetOpenAIKey": "የ AI ቁልፍ ማግኘት አልተቻለም", "autoGeneratorRewrite": "እንደገና ይፃፉ", "smartEdit": "ረዳቶች", "openAI": "ኦፔና", @@ -572,7 +572,7 @@ "smartEditMakeLonger": "ረዘም ላለ ጊዜ ያድርጉ", "smartEditCouldNotFetchResult": "ከOpenAI ውጤት ማምለጥ አልተቻለም", "smartEditCouldNotFetchKey": "ኦፕናይ ቁልፍን ማጣት አልተቻለም", - "smartEditDisabled": "በቅንብሮች ውስጥ OpenAI ያገናኙ", + "smartEditDisabled": "በቅንብሮች ውስጥ AI ያገናኙ", "discardResponse": "የ AI ምላሾችን መጣል ይፈልጋሉ?", "createInlineMathEquation": "እኩልነት ይፍጠሩ", "toggleList": "የተስተካከለ ዝርዝር", @@ -657,8 +657,8 @@ "placeholder": "የምስል ዩአርኤል ያስገቡ" }, "ai": { - "label": "ምስል OpenAI ውስጥ ምስልን ማመንጨት", - "placeholder": "ምስልን ለማመንጨት እባክዎን ለ OpenAI ይጠይቁ" + "label": "ምስል AI ውስጥ ምስልን ማመንጨት", + "placeholder": "ምስልን ለማመንጨት እባክዎን ለ AI ይጠይቁ" }, "stability_ai": { "label": "ምስልን Stability AI ያመነጫል", diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 1e1bc31ae6..1af3de1c6b 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -406,7 +406,7 @@ "email": "بريد إلكتروني", "tooltipSelectIcon": "حدد أيقونة", "selectAnIcon": "حدد أيقونة", - "pleaseInputYourOpenAIKey": "الرجاء إدخال مفتاح OpenAI الخاص بك", + "pleaseInputYourOpenAIKey": "الرجاء إدخال مفتاح AI الخاص بك", "pleaseInputYourStabilityAIKey": "يرجى إدخال رمز Stability AI الخاص بك", "clickToLogout": "انقر لتسجيل خروج المستخدم الحالي" }, @@ -659,23 +659,23 @@ "referencedGrid": "الشبكة المشار إليها", "referencedCalendar": "التقويم المشار إليه", "referencedDocument": "الوثيقة المشار إليها", - "autoGeneratorMenuItemName": "كاتب OpenAI", - "autoGeneratorTitleName": "OpenAI: اطلب من الذكاء الاصطناعي كتابة أي شيء ...", + "autoGeneratorMenuItemName": "كاتب AI", + "autoGeneratorTitleName": "AI: اطلب من الذكاء الاصطناعي كتابة أي شيء ...", "autoGeneratorLearnMore": "يتعلم أكثر", "autoGeneratorGenerate": "يولد", - "autoGeneratorHintText": "اسأل OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "لا يمكن الحصول على مفتاح OpenAI", + "autoGeneratorHintText": "اسأل AI ...", + "autoGeneratorCantGetOpenAIKey": "لا يمكن الحصول على مفتاح AI", "autoGeneratorRewrite": "اعادة كتابة", "smartEdit": "مساعدي الذكاء الاصطناعي", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "أصلح التهجئة", "warning": "⚠️ يمكن أن تكون استجابات الذكاء الاصطناعي غير دقيقة أو مضللة.", "smartEditSummarize": "لخص", "smartEditImproveWriting": "تحسين الكتابة", "smartEditMakeLonger": "اجعله أطول", - "smartEditCouldNotFetchResult": "تعذر جلب النتيجة من OpenAI", - "smartEditCouldNotFetchKey": "تعذر جلب مفتاح OpenAI", - "smartEditDisabled": "قم بتوصيل OpenAI في الإعدادات", + "smartEditCouldNotFetchResult": "تعذر جلب النتيجة من AI", + "smartEditCouldNotFetchKey": "تعذر جلب مفتاح AI", + "smartEditDisabled": "قم بتوصيل AI في الإعدادات", "discardResponse": "هل تريد تجاهل استجابات الذكاء الاصطناعي؟", "createInlineMathEquation": "اصنع معادلة", "fonts": "الخطوط", @@ -770,8 +770,8 @@ "placeholder": "أدخل عنوان URL للصورة" }, "ai": { - "label": "إنشاء صورة من OpenAI", - "placeholder": "يرجى إدخال الامر الواصف لـ OpenAI لإنشاء الصورة" + "label": "إنشاء صورة من AI", + "placeholder": "يرجى إدخال الامر الواصف لـ AI لإنشاء الصورة" }, "stability_ai": { "label": "إنشاء صورة من Stability AI", @@ -792,7 +792,7 @@ "label": "Unsplash" }, "searchForAnImage": "ابحث عن صورة", - "pleaseInputYourOpenAIKey": "يرجى إدخال مفتاح OpenAI الخاص بك في صفحة الإعدادات", + "pleaseInputYourOpenAIKey": "يرجى إدخال مفتاح AI الخاص بك في صفحة الإعدادات", "pleaseInputYourStabilityAIKey": "يرجى إدخال مفتاح Stability AI الخاص بك في صفحة الإعدادات", "saveImageToGallery": "احفظ الصورة", "failedToAddImageToGallery": "فشلت إضافة الصورة إلى المعرض", diff --git a/frontend/resources/translations/ca-ES.json b/frontend/resources/translations/ca-ES.json index 1612d60dbc..dc8517101f 100644 --- a/frontend/resources/translations/ca-ES.json +++ b/frontend/resources/translations/ca-ES.json @@ -383,7 +383,7 @@ "email": "Correu electrònic", "tooltipSelectIcon": "Seleccioneu la icona", "selectAnIcon": "Seleccioneu una icona", - "pleaseInputYourOpenAIKey": "si us plau, introduïu la vostra clau OpenAI" + "pleaseInputYourOpenAIKey": "si us plau, introduïu la vostra clau AI" }, "mobile": { "personalInfo": "Informació personal", @@ -602,23 +602,23 @@ "referencedBoard": "Junta de referència", "referencedGrid": "Quadrícula de referència", "referencedCalendar": "Calendari de referència", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Demana a AI que escrigui qualsevol cosa...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Demana a AI que escrigui qualsevol cosa...", "autoGeneratorLearnMore": "Aprèn més", "autoGeneratorGenerate": "Generar", - "autoGeneratorHintText": "Pregunta a OpenAI...", - "autoGeneratorCantGetOpenAIKey": "No es pot obtenir la clau OpenAI", + "autoGeneratorHintText": "Pregunta a AI...", + "autoGeneratorCantGetOpenAIKey": "No es pot obtenir la clau AI", "autoGeneratorRewrite": "Reescriure", "smartEdit": "Assistents d'IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corregir l'ortografia", "warning": "⚠️ Les respostes de la IA poden ser inexactes o enganyoses.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "Millorar l'escriptura", "smartEditMakeLonger": "Fer més llarg", - "smartEditCouldNotFetchResult": "No s'ha pogut obtenir el resultat d'OpenAI", - "smartEditCouldNotFetchKey": "No s'ha pogut obtenir la clau OpenAI", - "smartEditDisabled": "Connecteu OpenAI a Configuració", + "smartEditCouldNotFetchResult": "No s'ha pogut obtenir el resultat d'AI", + "smartEditCouldNotFetchKey": "No s'ha pogut obtenir la clau AI", + "smartEditDisabled": "Connecteu AI a Configuració", "discardResponse": "Voleu descartar les respostes d'IA?", "createInlineMathEquation": "Crea una equació", "fonts": "Fonts", @@ -714,7 +714,7 @@ "placeholder": "Introduïu l'URL de la imatge" }, "ai": { - "label": "Generar imatge des d'OpenAI" + "label": "Generar imatge des d'AI" }, "support": "El límit de mida de la imatge és de 5 MB. Formats admesos: JPEG, PNG, GIF, SVG", "error": { diff --git a/frontend/resources/translations/ckb-KU.json b/frontend/resources/translations/ckb-KU.json index a7dd404f90..74dddc3aa2 100644 --- a/frontend/resources/translations/ckb-KU.json +++ b/frontend/resources/translations/ckb-KU.json @@ -480,7 +480,7 @@ "email": "ئیمەیڵ", "tooltipSelectIcon": "هەڵبژاەدنی وێنۆچكه‌", "selectAnIcon": "هەڵبژاردنی وێنۆچكه‌", - "pleaseInputYourOpenAIKey": "تکایە کلیلی OpenAI ـەکەت بنووسە", + "pleaseInputYourOpenAIKey": "تکایە کلیلی AI ـەکەت بنووسە", "pleaseInputYourStabilityAIKey": "تکایە جێگیری کلیلی AI ـەکەت بنووسە", "clickToLogout": "بۆ دەرچوون لە بەکارهێنەری ئێستا کلیک بکە" }, @@ -734,23 +734,23 @@ "referencedGrid": "تۆڕی ئاماژەپێکراو", "referencedCalendar": "ساڵنامەی ئاماژەپێکراو", "referencedDocument": "بەڵگەنامەی ئاماژەپێکراو", - "autoGeneratorMenuItemName": "OpenAI نووسەری", + "autoGeneratorMenuItemName": "AI نووسەری", "autoGeneratorTitleName": "داوا لە AI بکە هەر شتێک بنووسێت...", "autoGeneratorLearnMore": "زیاتر زانین", "autoGeneratorGenerate": "بنووسە", - "autoGeneratorHintText": "لە OpenAI پرسیار بکە...", - "autoGeneratorCantGetOpenAIKey": "نەتوانرا کلیلی OpenAI بەدەست بهێنرێت", + "autoGeneratorHintText": "لە AI پرسیار بکە...", + "autoGeneratorCantGetOpenAIKey": "نەتوانرا کلیلی AI بەدەست بهێنرێت", "autoGeneratorRewrite": "دووبارە نووسینەوە", "smartEdit": "یاریدەدەری زیرەک", - "openAI": "OpenAI ژیری دەستکرد", + "openAI": "AI ژیری دەستکرد", "smartEditFixSpelling": "ڕاستکردنەوەی نووسین", "warning": "⚠️ وەڵامەکانی AI دەتوانن هەڵە یان چەواشەکارانە بن", "smartEditSummarize": "کورتەنووسی", "smartEditImproveWriting": "پێشخستن نوووسین", "smartEditMakeLonger": "درێژتری بکەرەوە", - "smartEditCouldNotFetchResult": "هیچ ئەنجامێک لە OpenAI وەرنەگیرا", - "smartEditCouldNotFetchKey": "نەتوانرا کلیلی OpenAI بهێنێتە ئاراوە", - "smartEditDisabled": "لە ڕێکخستنەکاندا پەیوەندی بە OpenAI بکە", + "smartEditCouldNotFetchResult": "هیچ ئەنجامێک لە AI وەرنەگیرا", + "smartEditCouldNotFetchKey": "نەتوانرا کلیلی AI بهێنێتە ئاراوە", + "smartEditDisabled": "لە ڕێکخستنەکاندا پەیوەندی بە AI بکە", "discardResponse": "ئایا دەتەوێت وەڵامەکانی AI بسڕیتەوە؟", "createInlineMathEquation": "درووست کردنی هاوکێشە", "fonts": "فۆنتەکان", diff --git a/frontend/resources/translations/cs-CZ.json b/frontend/resources/translations/cs-CZ.json index d56e74d530..d4a47bb22f 100644 --- a/frontend/resources/translations/cs-CZ.json +++ b/frontend/resources/translations/cs-CZ.json @@ -378,7 +378,7 @@ "email": "E-mail", "tooltipSelectIcon": "Vyberte ikonu", "selectAnIcon": "Vyberte ikonu", - "pleaseInputYourOpenAIKey": "Prosím vložte svůj OpenAI klíč", + "pleaseInputYourOpenAIKey": "Prosím vložte svůj AI klíč", "pleaseInputYourStabilityAIKey": "Prosím vložte svůj Stability AI klíč", "clickToLogout": "Klin" }, @@ -606,23 +606,23 @@ "referencedGrid": "Odkazovaná mřížka", "referencedCalendar": "Odkazovaný kalendář", "referencedDocument": "Odkazovaný dokument", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Zeptej se AI na cokoliv...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Zeptej se AI na cokoliv...", "autoGeneratorLearnMore": "Zjistit více", "autoGeneratorGenerate": "Vygenerovat", - "autoGeneratorHintText": "Zeptat se OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Nepodařilo se získat klíč OpenAI", + "autoGeneratorHintText": "Zeptat se AI...", + "autoGeneratorCantGetOpenAIKey": "Nepodařilo se získat klíč AI", "autoGeneratorRewrite": "Přepsat", "smartEdit": "AI asistenti", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Opravit pravopis", "warning": "⚠️ odpovědi AI mohou být nepřesné nebo zavádějící.", "smartEditSummarize": "Shrnout", "smartEditImproveWriting": "Vylepšit styl psaní", "smartEditMakeLonger": "Prodloužit", - "smartEditCouldNotFetchResult": "Nepodařilo se stáhnout výsledek z OpenAI", - "smartEditCouldNotFetchKey": "Nepodařilo se stáhnout klíč OpenAI", - "smartEditDisabled": "Propojit s OpenAI v Nastavení", + "smartEditCouldNotFetchResult": "Nepodařilo se stáhnout výsledek z AI", + "smartEditCouldNotFetchKey": "Nepodařilo se stáhnout klíč AI", + "smartEditDisabled": "Propojit s AI v Nastavení", "discardResponse": "Opravdu chcete zahodit odpovědi od AI?", "createInlineMathEquation": "Vytvořit rovnici", "fonts": "Písma", @@ -716,7 +716,7 @@ "placeholder": "Vlože URL adresu obrázku" }, "ai": { - "label": "Vygenerujte obrázek pomocí OpenAI", + "label": "Vygenerujte obrázek pomocí AI", "placeholder": "Prosím vlo" }, "stability_ai": { @@ -735,7 +735,7 @@ "placeholder": "Vložte nebo napište odkaz na obrázek" }, "searchForAnImage": "Hledat obrázek", - "pleaseInputYourOpenAIKey": "zadejte prosím svůj OpenAI klíč v Nastavení", + "pleaseInputYourOpenAIKey": "zadejte prosím svůj AI klíč v Nastavení", "pleaseInputYourStabilityAIKey": "prosím vložte svůjStability AI klíč v Nastavení", "saveImageToGallery": "Uložit obrázek", "failedToAddImageToGallery": "Nepodařilo se přidat obrázek do galerie", diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index 08125d94c8..298d6f8d78 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -389,15 +389,6 @@ "loginLabel": "Anmeldung", "logoutLabel": "Ausloggen" }, - "keys": { - "title": "KI API-Schlüssel", - "openAILabel": "OpenAI API-Schlüssel", - "openAITooltip": "Der für die KI-Modelle zu verwendende OpenAI-API-Schlüssel", - "openAIHint": "OpenAI API-Schlüssel eingeben", - "stabilityAILabel": "Stability API-Schlüssel", - "stabilityAITooltip": "Der für die KI-Modelle zu verwendende Stability API-Schlüssel", - "stabilityAIHint": "Stability API-Schlüssel eingeben" - }, "description": "Passe dein Profil an, verwalte deine Sicherheitseinstellungen und KI API-Schlüssel oder melde dich bei deinem Konto an." }, "workspacePage": { @@ -642,13 +633,7 @@ "aiSettingsDescription": "Wähle oder konfiguriere KI-Modelle, die in @:appName verwendet werden. Für eine optimale Leistung empfehlen wir die Verwendung der Standardmodelloptionen", "loginToEnableAIFeature": "KI-Funktionen werden erst nach der Anmeldung bei @:appName Cloud aktiviert. Wenn du kein @:appName-Konto hast, gehe zu „Mein Konto“, um dich zu registrieren", "llmModel": "Sprachmodell", - "title": "KI-API-Schlüssel", - "openAILabel": "OpenAI API-Schlüssel", - "openAITooltip": "Du findest deinen geheimen API-Schlüssel auf der API-Schlüsselseite", - "openAIHint": "Gebe deinen OpenAI API-Schlüssel ein", - "stabilityAILabel": "Stability API-Schlüssel", - "stabilityAITooltip": "Dein Stability API-Schlüssel, der zur Authentifizierung deiner Anfragen verwendet wird", - "stabilityAIHint": "Gebe deinen Stability API-Schlüssel ein" + "title": "KI-API-Schlüssel" } }, "planPage": { @@ -1006,7 +991,7 @@ "email": "E-Mail", "tooltipSelectIcon": "Symbol auswählen", "selectAnIcon": "Ein Symbol auswählen", - "pleaseInputYourOpenAIKey": "Bitte gebe den OpenAI-Schlüssel ein", + "pleaseInputYourOpenAIKey": "Bitte gebe den AI-Schlüssel ein", "pleaseInputYourStabilityAIKey": "Bitte gebe den Stability AI Schlüssel ein", "clickToLogout": "Klicken, um den aktuellen Nutzer auszulogen" }, @@ -1343,23 +1328,23 @@ "referencedGrid": "Referenziertes Raster", "referencedCalendar": "Referenzierter Kalender", "referencedDocument": "Referenziertes Dokument", - "autoGeneratorMenuItemName": "OpenAI-Autor", - "autoGeneratorTitleName": "OpenAI: Die KI bitten, etwas zu schreiben ...", + "autoGeneratorMenuItemName": "AI-Autor", + "autoGeneratorTitleName": "AI: Die KI bitten, etwas zu schreiben ...", "autoGeneratorLearnMore": "Mehr erfahren", "autoGeneratorGenerate": "Erstellen", - "autoGeneratorHintText": "OpenAI fragen ...", - "autoGeneratorCantGetOpenAIKey": "Der OpenAI-Schlüssel kann nicht abgerufen werden", + "autoGeneratorHintText": "AI fragen ...", + "autoGeneratorCantGetOpenAIKey": "Der AI-Schlüssel kann nicht abgerufen werden", "autoGeneratorRewrite": "Umschreiben", "smartEdit": "KI-Assistenten", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Korrigiere Rechtschreibung", "warning": "⚠️ KI-Antworten können ungenau oder irreführend sein.", "smartEditSummarize": "Zusammenfassen", "smartEditImproveWriting": "Das Geschriebene verbessern", "smartEditMakeLonger": "Länger machen", - "smartEditCouldNotFetchResult": "Das Ergebnis konnte nicht von OpenAI abgerufen werden", - "smartEditCouldNotFetchKey": "Der OpenAI-Schlüssel konnte nicht abgerufen werden", - "smartEditDisabled": "OpenAI in den Einstellungen verbinden", + "smartEditCouldNotFetchResult": "Das Ergebnis konnte nicht von AI abgerufen werden", + "smartEditCouldNotFetchKey": "Der AI-Schlüssel konnte nicht abgerufen werden", + "smartEditDisabled": "AI in den Einstellungen verbinden", "appflowyAIEditDisabled": "Melde dich an, um KI-Funktionen zu aktivieren", "discardResponse": "Möchtest du die KI-Antworten verwerfen?", "createInlineMathEquation": "Formel erstellen", @@ -1489,8 +1474,8 @@ "placeholder": "Bild-URL eingeben" }, "ai": { - "label": "Bild mit OpenAI erstellen", - "placeholder": "Bitte den Prompt für OpenAI eingeben, um ein Bild zu erstellen" + "label": "Bild mit AI erstellen", + "placeholder": "Bitte den Prompt für AI eingeben, um ein Bild zu erstellen" }, "stability_ai": { "label": "Bild mit Stability AI erstellen", @@ -1512,7 +1497,7 @@ "label": "Unsplash" }, "searchForAnImage": "Nach einem Bild suchen", - "pleaseInputYourOpenAIKey": "biitte den OpenAI Schlüssel in der Einstellungsseite eingeben", + "pleaseInputYourOpenAIKey": "biitte den AI Schlüssel in der Einstellungsseite eingeben", "pleaseInputYourStabilityAIKey": "biitte den Stability AI Schlüssel in der Einstellungsseite eingeben", "saveImageToGallery": "Bild speichern", "failedToAddImageToGallery": "Das Bild konnte nicht zur Galerie hinzugefügt werden", diff --git a/frontend/resources/translations/el-GR.json b/frontend/resources/translations/el-GR.json index 633a4adf65..ce8d8510b3 100644 --- a/frontend/resources/translations/el-GR.json +++ b/frontend/resources/translations/el-GR.json @@ -477,7 +477,7 @@ "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", - "pleaseInputYourOpenAIKey": "παρακαλώ εισάγετε το OpenAI κλειδί σας", + "pleaseInputYourOpenAIKey": "παρακαλώ εισάγετε το AI κλειδί σας", "pleaseInputYourStabilityAIKey": "παρακαλώ εισάγετε το Stability AI κλειδί σας", "clickToLogout": "Κάντε κλικ για αποσύνδεση του τρέχοντος χρήστη" }, @@ -789,23 +789,23 @@ "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Μάθετε περισσότερα", "autoGeneratorGenerate": "Generate", - "autoGeneratorHintText": "Ρωτήστε Το OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "Αδυναμία λήψης κλειδιού OpenAI", + "autoGeneratorHintText": "Ρωτήστε Το AI ...", + "autoGeneratorCantGetOpenAIKey": "Αδυναμία λήψης κλειδιού AI", "autoGeneratorRewrite": "Rewrite", "smartEdit": "AI Assistants", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Διόρθωση ορθογραφίας", "warning": "⚠️ Οι απαντήσεις AI μπορεί να είναι ανακριβείς ή παραπλανητικές.", "smartEditSummarize": "Summarize", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", - "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI", - "smartEditCouldNotFetchKey": "Could not fetch OpenAI key", - "smartEditDisabled": "Connect OpenAI in Settings", + "smartEditCouldNotFetchResult": "Could not fetch result from AI", + "smartEditCouldNotFetchKey": "Could not fetch AI key", + "smartEditDisabled": "Connect AI in Settings", "discardResponse": "Do you want to discard the AI responses?", "createInlineMathEquation": "Create equation", "fonts": "Γραμματοσειρές", @@ -919,8 +919,8 @@ "placeholder": "Enter image URL" }, "ai": { - "label": "Generate image from OpenAI", - "placeholder": "Please input the prompt for OpenAI to generate image" + "label": "Generate image from AI", + "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", @@ -942,7 +942,7 @@ "label": "Unsplash" }, "searchForAnImage": "Search for an image", - "pleaseInputYourOpenAIKey": "please input your OpenAI key in Settings page", + "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page", "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to add image to gallery", diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2571e7ead1..09a20d3628 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -282,7 +282,15 @@ "removeSuccess": "Removed successfully", "favoriteSpace": "Favorites", "RecentSpace": "Recent", - "Spaces": "Spaces" + "Spaces": "Spaces", + "upgradeToPro": "Upgrade to Pro Plan", + "upgradeToAIMax": "Unlock unlimited AI", + "storageLimitDialogTitle": "You are running out of storage space. Upgrade to Pro Plan to get more storage", + "aiResponseLitmitDialogTitle": "You are running out of AI responses. Upgrade to Pro Plan or AI Max to get more AI responses", + "aiResponseLitmit": "You are running out of AI responses. Go to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", + "purchaseStorageSpace": "Purchase Storage Space", + "purchaseAIResponse": "Purchase ", + "upgradeToAILocal": "AI offline on your device" }, "notifications": { "export": { @@ -646,14 +654,7 @@ "restartLocalAI": "Restart Local AI", "disableLocalAIDialog": "Do you want to disable local AI?", "localAIToggleTitle": "Toggle to enable or disable local AI", - "fetchLocalModel": "Fetch local model configuration", - "title": "AI API Keys", - "openAILabel": "OpenAI API key", - "openAITooltip": "You can find your Secret API key on the API key page", - "openAIHint": "Input your OpenAI API Key", - "stabilityAILabel": "Stability API key", - "stabilityAITooltip": "Your Stability API key, used to authenticate your requests", - "stabilityAIHint": "Input your Stability API Key" + "fetchLocalModel": "Fetch local model configuration" } }, "planPage": { @@ -663,14 +664,18 @@ "title": "Plan usage summary", "storageLabel": "Storage", "storageUsage": "{} of {} GB", - "collaboratorsLabel": "Collaborators", + "unlimitedStorageLabel": "Unlimited storage", + "collaboratorsLabel": "Members", "collaboratorsUsage": "{} of {}", "aiResponseLabel": "AI Responses", "aiResponseUsage": "{} of {}", + "unlimitedAILabel": "Unlimited responses", "proBadge": "Pro", - "memberProToggle": "Unlimited members", - "guestCollabToggle": "10 guest collaborators", - "storageUnlimited": "Unlimited storage with your Pro Plan", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "AI On-device", + "memberProToggle": "More members & unlimited AI", + "aiMaxToggle": "Unlimited AI responses", + "aiOnDeviceToggle": "On-device AI for ultimate privacy", "aiCredit": { "title": "Add @:appName AI Credit", "price": "{}", @@ -688,25 +693,28 @@ "freeInfo": "Perfect for individuals or small teams up to 3 members.", "proInfo": "Perfect for small and medium teams up to 10 members.", "teamInfo": "Perfect for all productive and well-organized teams..", - "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", + "upgrade": "Change plan", "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." }, + "addons": { + "title": "Add-ons", + "addLabel": "Add", + "activeLabel": "Added", + "aiMax": { + "title": "AI Max", + "description": "Unlock unlimited AI", + "price": "{}", + "priceInfo": "/user per month", + "billingInfo": "billed annually or {} billed monthly" + }, + "aiOnDevice": { + "title": "AI On-device", + "description": "AI offline on your device", + "price": "{}", + "priceInfo": "/user per month", + "billingInfo": "billed annually or {} billed monthly" + } + }, "deal": { "bannerLabel": "New year deal!", "title": "Grow your team!", @@ -730,7 +738,36 @@ "title": "Payment details", "methodLabel": "Payment method", "methodButtonLabel": "Edit method" - } + }, + "addons": { + "title": "Add-ons", + "addLabel": "Add", + "removeLabel": "Remove", + "renewLabel": "Renew", + "aiMax": { + "label": "AI Max", + "description": "Unlock unlimited AI and advanced models", + "activeDescription": "Next invoice due on {}", + "canceledDescription": "AI Max will be available until {}" + }, + "aiOnDevice": { + "label": "AI On-device", + "description": "Unlock unlimited AI offline on your device", + "activeDescription": "Next invoice due on {}", + "canceledDescription": "AI On-device will be available until {}" + }, + "removeDialog": { + "title": "Remove {}", + "description": "Are you sure you want to remove {plan}? You will lose access to the features and benefits of {plan} immediately." + } + }, + "currentPeriodBadge": "CURRENT", + "changePeriod": "Change period", + "planPeriod": "{} period", + "monthlyInterval": "Monthly", + "monthlyPriceInfo": "per seat billed monthly", + "annualInterval": "Annually", + "annualPriceInfo": "per seat billed annually" }, "comparePlanDialog": { "title": "Compare & select plan", @@ -744,48 +781,44 @@ }, "freePlan": { "title": "Free", - "description": "For organizing every corner of your work & life.", + "description": "For individuals and small groups to organize everything", "price": "{}", "priceInfo": "free forever" }, "proPlan": { - "title": "Professional", - "description": "A place for small groups to plan & get organized.", - "price": "{}/month", - "priceInfo": "billed annually" + "title": "Pro", + "description": "For small teams to manage projects and team knowledge", + "price": "{}", + "priceInfo": "/user per month billed annually\n\n{} billed monthly" }, "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" + "itemThree": "Storage", + "itemFour": "Real-time collaboration", + "itemFive": "Mobile app", + "itemSix": "AI Responses", + "tooltipSix": "Lifetime means the number of responses never reset", + "itemSeven": "Custom namespace", + "tooltipSeven": "Allows you to customize part of the URL for your workspace" }, "freeLabels": { "itemOne": "charged per workspace", - "itemTwo": "3", - "itemThree": " ", - "itemFour": "0", - "itemFive": "5 GB", - "itemSix": "yes", - "itemSeven": "yes", - "itemEight": "1,000 lifetime" + "itemTwo": "up to 3", + "itemThree": "5 GB", + "itemFour": "yes", + "itemFive": "yes", + "itemSix": "100 lifetime", + "itemSeven": "" }, "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" + "itemThree": "unlimited", + "itemFour": "yes", + "itemFive": "yes", + "itemSix": "unlimited", + "itemSeven": "" }, "paymentSuccess": { "title": "You are now on the {} plan!", @@ -793,7 +826,7 @@ }, "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.", + "description": "Downgrading your plan will revert you back to the Free plan. Members may lose access to this workspace and you may need to free up space to meet the storage limits of the Free plan.", "downgradeLabel": "Downgrade plan" } }, @@ -808,7 +841,7 @@ "notifications": "Notifications", "open": "Open Settings", "logout": "Logout", - "logoutPrompt": "Are you sure to logout?", + "logoutPrompt": "Are you sure you want to logout?", "selfEncryptionLogoutPrompt": "Are you sure you want to log out? Please ensure you have copied the encryption secret", "syncSetting": "Sync Setting", "cloudSettings": "Cloud Settings", @@ -958,7 +991,10 @@ "one": "{} member", "other": "{} members" }, - "memberLimitExceeded": "You've reached the maximum member limit allowed for your account. If you want to add more additional members to continue your work, please request on Github", + "memberLimitExceeded": "Member limit reached, to invite more members, please ", + "memberLimitExceededUpgrade": "upgrade", + "memberLimitExceededPro": "Member limit reached, if you require more members contact ", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Failed to add member", "addMemberSuccess": "Member added successfully", "removeMember": "Remove Member", @@ -1012,7 +1048,7 @@ "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", - "pleaseInputYourOpenAIKey": "please input your OpenAI key", + "pleaseInputYourOpenAIKey": "please input your AI key", "pleaseInputYourStabilityAIKey": "please input your Stability AI key", "clickToLogout": "Click to logout the current user" }, @@ -1327,23 +1363,23 @@ "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", "autoGeneratorGenerate": "Generate", - "autoGeneratorHintText": "Ask OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key", + "autoGeneratorHintText": "Ask AI ...", + "autoGeneratorCantGetOpenAIKey": "Can't get AI key", "autoGeneratorRewrite": "Rewrite", "smartEdit": "AI Assistants", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Fix spelling", "warning": "⚠️ AI responses can be inaccurate or misleading.", "smartEditSummarize": "Summarize", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", - "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI", - "smartEditCouldNotFetchKey": "Could not fetch OpenAI key", - "smartEditDisabled": "Connect OpenAI in Settings", + "smartEditCouldNotFetchResult": "Could not fetch result from AI", + "smartEditCouldNotFetchKey": "Could not fetch AI key", + "smartEditDisabled": "Connect AI in Settings", "appflowyAIEditDisabled": "Sign in to enable AI features", "discardResponse": "Do you want to discard the AI responses?", "createInlineMathEquation": "Create equation", @@ -1473,8 +1509,8 @@ "placeholder": "Enter image URL" }, "ai": { - "label": "Generate image from OpenAI", - "placeholder": "Please input the prompt for OpenAI to generate image" + "label": "Generate image from AI", + "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", @@ -1496,7 +1532,7 @@ "label": "Unsplash" }, "searchForAnImage": "Search for an image", - "pleaseInputYourOpenAIKey": "please input your OpenAI key in Settings page", + "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page", "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to add image to gallery", diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index 0cdeab411a..24b73ae627 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -361,15 +361,6 @@ "loginLabel": "Inicio de sesión", "logoutLabel": "Cerrar sesión" }, - "keys": { - "title": "Claves API de IA", - "openAILabel": "Clave API de OpenAI", - "openAITooltip": "La clave API de OpenAI para usar en los modelos de IA", - "openAIHint": "Ingresa tu clave API de OpenAI", - "stabilityAILabel": "Clave API de Stability", - "stabilityAITooltip": "La clave API de Stability que se utilizará en los modelos de IA", - "stabilityAIHint": "Ingresa tu clave API de Stability" - }, "description": "Personaliza tu perfil, administra la seguridad de la cuenta y las claves API de IA, o inicia sesión en tu cuenta." }, "menu": { @@ -577,7 +568,7 @@ "email": "Correo electrónico", "tooltipSelectIcon": "Seleccionar icono", "selectAnIcon": "Seleccione un icono", - "pleaseInputYourOpenAIKey": "por favor ingrese su clave OpenAI", + "pleaseInputYourOpenAIKey": "por favor ingrese su clave AI", "pleaseInputYourStabilityAIKey": "por favor ingrese su clave de estabilidad AI", "clickToLogout": "Haga clic para cerrar la sesión del usuario actual." }, @@ -902,12 +893,12 @@ "referencedGrid": "Cuadrícula referenciada", "referencedCalendar": "Calendario referenciado", "referencedDocument": "Documento referenciado", - "autoGeneratorMenuItemName": "Escritor de OpenAI", - "autoGeneratorTitleName": "OpenAI: Pídele a AI que escriba cualquier cosa...", + "autoGeneratorMenuItemName": "Escritor de AI", + "autoGeneratorTitleName": "AI: Pídele a AI que escriba cualquier cosa...", "autoGeneratorLearnMore": "Aprende más", "autoGeneratorGenerate": "Generar", - "autoGeneratorHintText": "Pregúntale a OpenAI...", - "autoGeneratorCantGetOpenAIKey": "No puedo obtener la clave de OpenAI", + "autoGeneratorHintText": "Pregúntale a AI...", + "autoGeneratorCantGetOpenAIKey": "No puedo obtener la clave de AI", "autoGeneratorRewrite": "Volver a escribir", "smartEdit": "Asistentes de IA", "openAI": "IA abierta", @@ -916,9 +907,9 @@ "smartEditSummarize": "Resumir", "smartEditImproveWriting": "Mejorar la escritura", "smartEditMakeLonger": "hacer más largo", - "smartEditCouldNotFetchResult": "No se pudo obtener el resultado de OpenAI", - "smartEditCouldNotFetchKey": "No se pudo obtener la clave de OpenAI", - "smartEditDisabled": "Conectar OpenAI en Configuración", + "smartEditCouldNotFetchResult": "No se pudo obtener el resultado de AI", + "smartEditCouldNotFetchKey": "No se pudo obtener la clave de AI", + "smartEditDisabled": "Conectar AI en Configuración", "discardResponse": "¿Quieres descartar las respuestas de IA?", "createInlineMathEquation": "Crear ecuación", "fonts": "Tipo de letra", @@ -1034,8 +1025,8 @@ "placeholder": "Introduce la URL de la imagen" }, "ai": { - "label": "Generar imagen desde OpenAI", - "placeholder": "Ingrese el prompt para que OpenAI genere una imagen" + "label": "Generar imagen desde AI", + "placeholder": "Ingrese el prompt para que AI genere una imagen" }, "stability_ai": { "label": "Generar imagen desde Stability AI", @@ -1057,7 +1048,7 @@ "label": "Desempaquetar" }, "searchForAnImage": "Buscar una imagen", - "pleaseInputYourOpenAIKey": "ingresa tu clave OpenAI en la página de Configuración", + "pleaseInputYourOpenAIKey": "ingresa tu clave AI en la página de Configuración", "pleaseInputYourStabilityAIKey": "ingresa tu clave de Stability AI en la página de configuración", "saveImageToGallery": "Guardar imagen", "failedToAddImageToGallery": "No se pudo agregar la imagen a la galería", diff --git a/frontend/resources/translations/eu-ES.json b/frontend/resources/translations/eu-ES.json index e7029a0d38..031b9d9391 100644 --- a/frontend/resources/translations/eu-ES.json +++ b/frontend/resources/translations/eu-ES.json @@ -273,7 +273,7 @@ "user": { "name": "Izena", "selectAnIcon": "Hautatu ikono bat", - "pleaseInputYourOpenAIKey": "mesedez sartu zure OpenAI gakoa" + "pleaseInputYourOpenAIKey": "mesedez sartu zure AI gakoa" } }, "grid": { @@ -430,23 +430,23 @@ "referencedBoard": "Erreferentziazko Batzordea", "referencedGrid": "Erreferentziazko Sarea", "referencedCalendar": "Erreferentziazko Egutegia", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Eskatu AIri edozer idazteko...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Eskatu AIri edozer idazteko...", "autoGeneratorLearnMore": "Gehiago ikasi", "autoGeneratorGenerate": "Sortu", - "autoGeneratorHintText": "Galdetu OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Ezin da lortu OpenAI gakoa", + "autoGeneratorHintText": "Galdetu AI...", + "autoGeneratorCantGetOpenAIKey": "Ezin da lortu AI gakoa", "autoGeneratorRewrite": "Berridatzi", "smartEdit": "AI Laguntzaileak", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Ortografia konpondu", "warning": "⚠️ AI erantzunak okerrak edo engainagarriak izan daitezke.", "smartEditSummarize": "Laburtu", "smartEditImproveWriting": "Hobetu idazkera", "smartEditMakeLonger": "Luzatu", - "smartEditCouldNotFetchResult": "Ezin izan da emaitzarik eskuratu OpenAI-tik", - "smartEditCouldNotFetchKey": "Ezin izan da OpenAI gakoa eskuratu", - "smartEditDisabled": "Konektatu OpenAI Ezarpenetan", + "smartEditCouldNotFetchResult": "Ezin izan da emaitzarik eskuratu AI-tik", + "smartEditCouldNotFetchKey": "Ezin izan da AI gakoa eskuratu", + "smartEditDisabled": "Konektatu AI Ezarpenetan", "discardResponse": "AI erantzunak baztertu nahi dituzu?", "createInlineMathEquation": "Sortu ekuazioa", "toggleList": "Aldatu zerrenda", diff --git a/frontend/resources/translations/fa.json b/frontend/resources/translations/fa.json index 147bca02b6..f91727e0e0 100644 --- a/frontend/resources/translations/fa.json +++ b/frontend/resources/translations/fa.json @@ -296,7 +296,7 @@ "user": { "name": "نام", "selectAnIcon": "انتخاب یک آیکون", - "pleaseInputYourOpenAIKey": "لطفا کلید OpenAI خود را وارد کنید", + "pleaseInputYourOpenAIKey": "لطفا کلید AI خود را وارد کنید", "clickToLogout": "برای خروج از کاربر فعلی کلیک کنید" }, "shortcuts": { @@ -465,23 +465,23 @@ "referencedBoard": "بورد مرجع", "referencedGrid": "شبکه‌نمایش مرجع", "referencedCalendar": "تقویم مرجع", - "autoGeneratorMenuItemName": "OpenAI نویسنده", + "autoGeneratorMenuItemName": "AI نویسنده", "autoGeneratorTitleName": "از هوش مصنوعی بخواهید هر چیزی بنویسد...", "autoGeneratorLearnMore": "بیشتر بدانید", "autoGeneratorGenerate": "بنویس", - "autoGeneratorHintText": "از OpenAI بپرسید ...", - "autoGeneratorCantGetOpenAIKey": "کلید OpenAI را نمی توان دریافت کرد", + "autoGeneratorHintText": "از AI بپرسید ...", + "autoGeneratorCantGetOpenAIKey": "کلید AI را نمی توان دریافت کرد", "autoGeneratorRewrite": "بازنویس", "smartEdit": "دستیاران هوشمند", - "openAI": "OpenAI", + "openAI": "AI", "smartEditFixSpelling": "اصلاح نگارش", "warning": "⚠️ پاسخ‌های هوش مصنوعی می‌توانند نادرست یا گمراه‌کننده باشند", "smartEditSummarize": "خلاصه‌نویسی", "smartEditImproveWriting": "بهبود نگارش", "smartEditMakeLonger": "به نوشته اضافه کن", - "smartEditCouldNotFetchResult": "نتیجه‌ای از OpenAI گرفته نشد", - "smartEditCouldNotFetchKey": "کلید OpenAI واکشی نشد", - "smartEditDisabled": "به OpenAI در تنظیمات وصل شوید", + "smartEditCouldNotFetchResult": "نتیجه‌ای از AI گرفته نشد", + "smartEditCouldNotFetchKey": "کلید AI واکشی نشد", + "smartEditDisabled": "به AI در تنظیمات وصل شوید", "discardResponse": "آیا می خواهید پاسخ های هوش مصنوعی را حذف کنید؟", "createInlineMathEquation": "ایجاد معادله", "toggleList": "Toggle لیست", diff --git a/frontend/resources/translations/fr-CA.json b/frontend/resources/translations/fr-CA.json index 5d2f52fe67..b71325af6e 100644 --- a/frontend/resources/translations/fr-CA.json +++ b/frontend/resources/translations/fr-CA.json @@ -428,7 +428,7 @@ "email": "Courriel", "tooltipSelectIcon": "Sélectionner l'icône", "selectAnIcon": "Sélectionnez une icône", - "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé OpenAI", + "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé AI", "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI", "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel" }, @@ -701,23 +701,23 @@ "referencedGrid": "Grille référencée", "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", - "autoGeneratorMenuItemName": "Rédacteur OpenAI", - "autoGeneratorTitleName": "OpenAI : Demandez à l'IA d'écrire quelque chose...", + "autoGeneratorMenuItemName": "Rédacteur AI", + "autoGeneratorTitleName": "AI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", - "autoGeneratorHintText": "Demandez à OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé OpenAI", + "autoGeneratorHintText": "Demandez à AI...", + "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé AI", "autoGeneratorRewrite": "Réécrire", "smartEdit": "Assistants IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corriger l'orthographe", "warning": "⚠️ Les réponses de l'IA peuvent être inexactes ou trompeuses.", "smartEditSummarize": "Résumer", "smartEditImproveWriting": "Améliorer l'écriture", "smartEditMakeLonger": "Rallonger", - "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'OpenAI", - "smartEditCouldNotFetchKey": "Impossible de récupérer la clé OpenAI", - "smartEditDisabled": "Connectez OpenAI dans les paramètres", + "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'AI", + "smartEditCouldNotFetchKey": "Impossible de récupérer la clé AI", + "smartEditDisabled": "Connectez AI dans les paramètres", "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", @@ -824,8 +824,8 @@ "placeholder": "Entrez l'URL de l'image" }, "ai": { - "label": "Générer une image à partir d'OpenAI", - "placeholder": "Veuillez saisir l'invite pour qu'OpenAI génère l'image" + "label": "Générer une image à partir d'AI", + "placeholder": "Veuillez saisir l'invite pour qu'AI génère l'image" }, "stability_ai": { "label": "Générer une image à partir de Stability AI", @@ -846,7 +846,7 @@ "label": "Unsplash" }, "searchForAnImage": "Rechercher une image", - "pleaseInputYourOpenAIKey": "veuillez saisir votre clé OpenAI dans la page Paramètres", + "pleaseInputYourOpenAIKey": "veuillez saisir votre clé AI dans la page Paramètres", "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres", "saveImageToGallery": "Enregistrer l'image", "failedToAddImageToGallery": "Échec de l'ajout d'une image à la galerie", diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index 1db313a9ee..135caa4715 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -618,7 +618,7 @@ "email": "Courriel", "tooltipSelectIcon": "Sélectionner l'icône", "selectAnIcon": "Sélectionnez une icône", - "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé OpenAI", + "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé AI", "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI", "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel" }, @@ -942,23 +942,23 @@ "referencedGrid": "Grille référencée", "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", - "autoGeneratorMenuItemName": "Rédacteur OpenAI", - "autoGeneratorTitleName": "OpenAI : Demandez à l'IA d'écrire quelque chose...", + "autoGeneratorMenuItemName": "Rédacteur AI", + "autoGeneratorTitleName": "AI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", - "autoGeneratorHintText": "Demandez à OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé OpenAI", + "autoGeneratorHintText": "Demandez à AI...", + "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé AI", "autoGeneratorRewrite": "Réécrire", "smartEdit": "Assistants IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corriger l'orthographe", "warning": "⚠️ Les réponses de l'IA peuvent être inexactes ou trompeuses.", "smartEditSummarize": "Résumer", "smartEditImproveWriting": "Améliorer l'écriture", "smartEditMakeLonger": "Rallonger", - "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'OpenAI", - "smartEditCouldNotFetchKey": "Impossible de récupérer la clé OpenAI", - "smartEditDisabled": "Connectez OpenAI dans les paramètres", + "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'AI", + "smartEditCouldNotFetchKey": "Impossible de récupérer la clé AI", + "smartEditDisabled": "Connectez AI dans les paramètres", "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", @@ -1073,8 +1073,8 @@ "placeholder": "Entrez l'URL de l'image" }, "ai": { - "label": "Générer une image à partir d'OpenAI", - "placeholder": "Veuillez saisir l'invite pour qu'OpenAI génère l'image" + "label": "Générer une image à partir d'AI", + "placeholder": "Veuillez saisir l'invite pour qu'AI génère l'image" }, "stability_ai": { "label": "Générer une image à partir de Stability AI", @@ -1096,7 +1096,7 @@ "label": "Unsplash" }, "searchForAnImage": "Rechercher une image", - "pleaseInputYourOpenAIKey": "veuillez saisir votre clé OpenAI dans la page Paramètres", + "pleaseInputYourOpenAIKey": "veuillez saisir votre clé AI dans la page Paramètres", "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres", "saveImageToGallery": "Enregistrer l'image", "failedToAddImageToGallery": "Échec de l'ajout d'une image à la galerie", diff --git a/frontend/resources/translations/hin.json b/frontend/resources/translations/hin.json index 8ce86ed96b..7351d119c2 100644 --- a/frontend/resources/translations/hin.json +++ b/frontend/resources/translations/hin.json @@ -333,7 +333,7 @@ "email": "ईमेल", "tooltipSelectIcon": "आइकन चुनें", "selectAnIcon": "एक आइकन चुनें", - "pleaseInputYourOpenAIKey": "कृपया अपनी OpenAI key इनपुट करें", + "pleaseInputYourOpenAIKey": "कृपया अपनी AI key इनपुट करें", "clickToLogout": "वर्तमान उपयोगकर्ता को लॉगआउट करने के लिए क्लिक करें" }, "shortcuts": { @@ -515,23 +515,23 @@ "referencedBoard": "रेफेरेंस बोर्ड", "referencedGrid": "रेफेरेंस ग्रिड", "referencedCalendar": "रेफेरेंस कैलेंडर", - "autoGeneratorMenuItemName": "OpenAI लेखक", - "autoGeneratorTitleName": "OpenAI: AI को कुछ भी लिखने के लिए कहें...", + "autoGeneratorMenuItemName": "AI लेखक", + "autoGeneratorTitleName": "AI: AI को कुछ भी लिखने के लिए कहें...", "autoGeneratorLearnMore": "और जानें", "autoGeneratorGenerate": "उत्पन्न करें", - "autoGeneratorHintText": "OpenAI से पूछें...", - "autoGeneratorCantGetOpenAIKey": "OpenAI key नहीं मिल सकी", + "autoGeneratorHintText": "AI से पूछें...", + "autoGeneratorCantGetOpenAIKey": "AI key नहीं मिल सकी", "autoGeneratorRewrite": "पुनः लिखें", "smartEdit": "AI सहायक", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "वर्तनी ठीक करें", "warning": "⚠️ AI प्रतिक्रियाएँ गलत या भ्रामक हो सकती हैं।", "smartEditSummarize": "सारांश", "smartEditImproveWriting": "लेख में सुधार करें", "smartEditMakeLonger": "लंबा बनाएं", - "smartEditCouldNotFetchResult": "OpenAI से परिणाम प्राप्त नहीं किया जा सका", - "smartEditCouldNotFetchKey": "OpenAI key नहीं लायी जा सकी", - "smartEditDisabled": "सेटिंग्स में OpenAI कनेक्ट करें", + "smartEditCouldNotFetchResult": "AI से परिणाम प्राप्त नहीं किया जा सका", + "smartEditCouldNotFetchKey": "AI key नहीं लायी जा सकी", + "smartEditDisabled": "सेटिंग्स में AI कनेक्ट करें", "discardResponse": "क्या आप AI प्रतिक्रियाओं को छोड़ना चाहते हैं?", "createInlineMathEquation": "समीकरण बनाएं", "toggleList": "सूची टॉगल करें", diff --git a/frontend/resources/translations/hu-HU.json b/frontend/resources/translations/hu-HU.json index 1a60a1c6f5..78134fd8ab 100644 --- a/frontend/resources/translations/hu-HU.json +++ b/frontend/resources/translations/hu-HU.json @@ -277,7 +277,7 @@ "user": { "name": "Név", "selectAnIcon": "Válasszon ki egy ikont", - "pleaseInputYourOpenAIKey": "kérjük, adja meg OpenAI kulcsát" + "pleaseInputYourOpenAIKey": "kérjük, adja meg AI kulcsát" } }, "grid": { @@ -432,23 +432,23 @@ "referencedBoard": "Hivatkozott feladat tábla", "referencedGrid": "Hivatkozott táblázat", "referencedCalendar": "Hivatkozott naptár", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Kérd meg az AI-t, hogy írjon bármit...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Kérd meg az AI-t, hogy írjon bármit...", "autoGeneratorLearnMore": "Tudj meg többet", "autoGeneratorGenerate": "generál", - "autoGeneratorHintText": "Kérdezd meg az OpenAI-t...", - "autoGeneratorCantGetOpenAIKey": "Nem lehet beszerezni az OpenAI kulcsot", + "autoGeneratorHintText": "Kérdezd meg az AI-t...", + "autoGeneratorCantGetOpenAIKey": "Nem lehet beszerezni az AI kulcsot", "autoGeneratorRewrite": "Újraírni", "smartEdit": "AI asszisztensek", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Helyesírás javítása", "warning": "⚠️ Az AI-válaszok pontatlanok vagy félrevezetőek lehetnek.", "smartEditSummarize": "Összesít", "smartEditImproveWriting": "Az írás javítása", "smartEditMakeLonger": "Hosszabb legyen", - "smartEditCouldNotFetchResult": "Nem sikerült lekérni az eredményt az OpenAI-ból", - "smartEditCouldNotFetchKey": "Nem sikerült lekérni az OpenAI kulcsot", - "smartEditDisabled": "Csatlakoztassa az OpenAI-t a Beállításokban", + "smartEditCouldNotFetchResult": "Nem sikerült lekérni az eredményt az AI-ból", + "smartEditCouldNotFetchKey": "Nem sikerült lekérni az AI kulcsot", + "smartEditDisabled": "Csatlakoztassa az AI-t a Beállításokban", "discardResponse": "El szeretné vetni az AI-válaszokat?", "createInlineMathEquation": "Hozzon létre egyenletet", "toggleList": "Lista váltása", diff --git a/frontend/resources/translations/id-ID.json b/frontend/resources/translations/id-ID.json index da3598d11a..60c3f041a8 100644 --- a/frontend/resources/translations/id-ID.json +++ b/frontend/resources/translations/id-ID.json @@ -376,7 +376,7 @@ "email": "Surel", "tooltipSelectIcon": "Pilih ikon", "selectAnIcon": "Pilih ikon", - "pleaseInputYourOpenAIKey": "silakan masukkan kunci OpenAI Anda", + "pleaseInputYourOpenAIKey": "silakan masukkan kunci AI Anda", "pleaseInputYourStabilityAIKey": "Masukkan kunci Stability AI anda", "clickToLogout": "Klik untuk keluar dari pengguna saat ini" }, @@ -589,23 +589,23 @@ "referencedBoard": "Papan Referensi", "referencedGrid": "Kisi yang Direferensikan", "referencedCalendar": "Kalender Referensi", - "autoGeneratorMenuItemName": "Penulis OpenAI", - "autoGeneratorTitleName": "OpenAI: Minta AI untuk menulis apa saja...", + "autoGeneratorMenuItemName": "Penulis AI", + "autoGeneratorTitleName": "AI: Minta AI untuk menulis apa saja...", "autoGeneratorLearnMore": "Belajarlah lagi", "autoGeneratorGenerate": "Menghasilkan", - "autoGeneratorHintText": "Tanya OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Tidak bisa mendapatkan kunci OpenAI", + "autoGeneratorHintText": "Tanya AI...", + "autoGeneratorCantGetOpenAIKey": "Tidak bisa mendapatkan kunci AI", "autoGeneratorRewrite": "Menulis kembali", "smartEdit": "Asisten AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Perbaiki ejaan", "warning": "⚠️ Respons AI bisa jadi tidak akurat atau menyesatkan.", "smartEditSummarize": "Meringkaskan", "smartEditImproveWriting": "Perbaiki tulisan", "smartEditMakeLonger": "Buat lebih lama", - "smartEditCouldNotFetchResult": "Tidak dapat mengambil hasil dari OpenAI", - "smartEditCouldNotFetchKey": "Tidak dapat mengambil kunci OpenAI", - "smartEditDisabled": "Hubungkan OpenAI di Pengaturan", + "smartEditCouldNotFetchResult": "Tidak dapat mengambil hasil dari AI", + "smartEditCouldNotFetchKey": "Tidak dapat mengambil kunci AI", + "smartEditDisabled": "Hubungkan AI di Pengaturan", "discardResponse": "Apakah Anda ingin membuang respons AI?", "createInlineMathEquation": "Buat persamaan", "toggleList": "Beralih Daftar", @@ -690,8 +690,8 @@ "placeholder": "Masukkan URL gambar" }, "ai": { - "label": "Buat gambar dari OpenAI", - "placeholder": "Masukkan perintah agar OpenAI menghasilkan gambar" + "label": "Buat gambar dari AI", + "placeholder": "Masukkan perintah agar AI menghasilkan gambar" }, "stability_ai": { "label": "Buat gambar dari Stability AI", @@ -709,7 +709,7 @@ "placeholder": "Tempel atau ketik tautan gambar" }, "searchForAnImage": "Mencari gambar", - "pleaseInputYourOpenAIKey": "masukkan kunci OpenAI Anda di halaman Pengaturan", + "pleaseInputYourOpenAIKey": "masukkan kunci AI Anda di halaman Pengaturan", "pleaseInputYourStabilityAIKey": "masukkan kunci AI Stabilitas Anda di halaman Pengaturan" }, "codeBlock": { diff --git a/frontend/resources/translations/it-IT.json b/frontend/resources/translations/it-IT.json index 4cc563bc6e..09d1260f04 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -427,7 +427,7 @@ "email": "E-mail", "tooltipSelectIcon": "Seleziona l'icona", "selectAnIcon": "Seleziona un'icona", - "pleaseInputYourOpenAIKey": "inserisci la tua chiave OpenAI", + "pleaseInputYourOpenAIKey": "inserisci la tua chiave AI", "pleaseInputYourStabilityAIKey": "per favore inserisci la tua chiave Stability AI", "clickToLogout": "Fare clic per disconnettere l'utente corrente" }, @@ -706,23 +706,23 @@ "referencedGrid": "Griglia di riferimento", "referencedCalendar": "Calendario referenziato", "referencedDocument": "Documento riferito", - "autoGeneratorMenuItemName": "Scrittore OpenAI", - "autoGeneratorTitleName": "OpenAI: chiedi all'AI di scrivere qualsiasi cosa...", + "autoGeneratorMenuItemName": "Scrittore AI", + "autoGeneratorTitleName": "AI: chiedi all'AI di scrivere qualsiasi cosa...", "autoGeneratorLearnMore": "Saperne di più", "autoGeneratorGenerate": "creare", - "autoGeneratorHintText": "Chiedi a OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Impossibile ottenere la chiave OpenAI", + "autoGeneratorHintText": "Chiedi a AI...", + "autoGeneratorCantGetOpenAIKey": "Impossibile ottenere la chiave AI", "autoGeneratorRewrite": "Riscrivere", "smartEdit": "Assistenti AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Correggi l'ortografia", "warning": "⚠️ Le risposte AI possono essere imprecise o fuorvianti.", "smartEditSummarize": "Riassumere", "smartEditImproveWriting": "Migliora la scrittura", "smartEditMakeLonger": "Rendi più lungo", - "smartEditCouldNotFetchResult": "Impossibile recuperare il risultato da OpenAI", - "smartEditCouldNotFetchKey": "Impossibile recuperare la chiave OpenAI", - "smartEditDisabled": "Connetti OpenAI in Impostazioni", + "smartEditCouldNotFetchResult": "Impossibile recuperare il risultato da AI", + "smartEditCouldNotFetchKey": "Impossibile recuperare la chiave AI", + "smartEditDisabled": "Connetti AI in Impostazioni", "discardResponse": "Vuoi scartare le risposte AI?", "createInlineMathEquation": "Crea un'equazione", "fonts": "Caratteri", @@ -831,8 +831,8 @@ "placeholder": "Inserisci l'URL dell'immagine" }, "ai": { - "label": "Genera immagine da OpenAI", - "placeholder": "Inserisci la richiesta affinché OpenAI generi l'immagine" + "label": "Genera immagine da AI", + "placeholder": "Inserisci la richiesta affinché AI generi l'immagine" }, "stability_ai": { "label": "Genera immagine da Stability AI", @@ -853,7 +853,7 @@ "label": "Unsplash" }, "searchForAnImage": "Cerca un'immagine", - "pleaseInputYourOpenAIKey": "inserisci la tua chiave OpenAI nella pagina Impostazioni", + "pleaseInputYourOpenAIKey": "inserisci la tua chiave AI nella pagina Impostazioni", "pleaseInputYourStabilityAIKey": "inserisci la chiave Stability AI nella pagina Impostazioni", "saveImageToGallery": "Salva immagine", "failedToAddImageToGallery": "Impossibile aggiungere l'immagine alla galleria", diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index 79fd2f4dc9..895d6b0175 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -343,7 +343,7 @@ "user": { "name": "名前", "selectAnIcon": "アイコンを選択してください", - "pleaseInputYourOpenAIKey": "OpenAI キーを入力してください" + "pleaseInputYourOpenAIKey": "AI キーを入力してください" }, "mobile": { "username": "ユーザー名", @@ -519,23 +519,23 @@ "referencedBoard": "参照ボード", "referencedGrid": "参照されるグリッド", "referencedCalendar": "参照カレンダー", - "autoGeneratorMenuItemName": "OpenAI ライター", - "autoGeneratorTitleName": "OpenAI: AI に何でも書いてもらいます...", + "autoGeneratorMenuItemName": "AI ライター", + "autoGeneratorTitleName": "AI: AI に何でも書いてもらいます...", "autoGeneratorLearnMore": "もっと詳しく知る", "autoGeneratorGenerate": "生成", "autoGeneratorHintText": "OpenAIに質問してください...", - "autoGeneratorCantGetOpenAIKey": "OpenAI キーを取得できません", + "autoGeneratorCantGetOpenAIKey": "AI キーを取得できません", "autoGeneratorRewrite": "リライト", "smartEdit": "AIアシスタント", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "スペルを修正", "warning": "⚠️ AI の応答は不正確または誤解を招く可能性があります。", "smartEditSummarize": "要約する", "smartEditImproveWriting": "ライティングを改善する", "smartEditMakeLonger": "もっと長くする", "smartEditCouldNotFetchResult": "OpenAIから結果を取得できませんでした", - "smartEditCouldNotFetchKey": "OpenAI キーを取得できませんでした", - "smartEditDisabled": "設定で OpenAI に接続する", + "smartEditCouldNotFetchKey": "AI キーを取得できませんでした", + "smartEditDisabled": "設定で AI に接続する", "discardResponse": "AI の応答を破棄しますか?", "createInlineMathEquation": "方程式の作成", "toggleList": "リストの切り替え", diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index b0b7a472b4..a9d97d944d 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -275,7 +275,7 @@ "user": { "name": "이름", "selectAnIcon": "아이콘을 선택하세요", - "pleaseInputYourOpenAIKey": "OpenAI 키를 입력하십시오" + "pleaseInputYourOpenAIKey": "AI 키를 입력하십시오" } }, "grid": { @@ -431,23 +431,23 @@ "referencedBoard": "참조 보드", "referencedGrid": "참조된 그리드", "referencedCalendar": "참조된 달력", - "autoGeneratorMenuItemName": "OpenAI 작성자", - "autoGeneratorTitleName": "OpenAI: AI에게 무엇이든 쓰라고 요청하세요...", + "autoGeneratorMenuItemName": "AI 작성자", + "autoGeneratorTitleName": "AI: AI에게 무엇이든 쓰라고 요청하세요...", "autoGeneratorLearnMore": "더 알아보기", "autoGeneratorGenerate": "생성하다", "autoGeneratorHintText": "OpenAI에게 물어보세요 ...", - "autoGeneratorCantGetOpenAIKey": "OpenAI 키를 가져올 수 없습니다.", + "autoGeneratorCantGetOpenAIKey": "AI 키를 가져올 수 없습니다.", "autoGeneratorRewrite": "고쳐 쓰기", "smartEdit": "AI 어시스턴트", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "맞춤법 수정", "warning": "⚠️ AI 응답은 부정확하거나 오해의 소지가 있을 수 있습니다.", "smartEditSummarize": "요약하다", "smartEditImproveWriting": "쓰기 향상", "smartEditMakeLonger": "더 길게", "smartEditCouldNotFetchResult": "OpenAI에서 결과를 가져올 수 없습니다.", - "smartEditCouldNotFetchKey": "OpenAI 키를 가져올 수 없습니다.", - "smartEditDisabled": "설정에서 OpenAI 연결", + "smartEditCouldNotFetchKey": "AI 키를 가져올 수 없습니다.", + "smartEditDisabled": "설정에서 AI 연결", "discardResponse": "AI 응답을 삭제하시겠습니까?", "createInlineMathEquation": "방정식 만들기", "toggleList": "토글 목록", diff --git a/frontend/resources/translations/pl-PL.json b/frontend/resources/translations/pl-PL.json index 372b71d1e0..bfa08f0078 100644 --- a/frontend/resources/translations/pl-PL.json +++ b/frontend/resources/translations/pl-PL.json @@ -446,7 +446,7 @@ "email": "E-mail", "tooltipSelectIcon": "Wybierz ikonę", "selectAnIcon": "Wybierz ikonę", - "pleaseInputYourOpenAIKey": "wprowadź swój klucz OpenAI", + "pleaseInputYourOpenAIKey": "wprowadź swój klucz AI", "pleaseInputYourStabilityAIKey": "wprowadź swój klucz Stability AI", "clickToLogout": "Kliknij, aby wylogować bieżącego użytkownika" }, @@ -667,23 +667,23 @@ "referencedGrid": "Siatka referencyjna", "referencedCalendar": "Kalendarz referencyjny", "referencedDocument": "Dokument referencyjny", - "autoGeneratorMenuItemName": "Pisarz OpenAI", - "autoGeneratorTitleName": "OpenAI: Poproś AI o napisanie czegokolwiek...", + "autoGeneratorMenuItemName": "Pisarz AI", + "autoGeneratorTitleName": "AI: Poproś AI o napisanie czegokolwiek...", "autoGeneratorLearnMore": "Dowiedz się więcej", "autoGeneratorGenerate": "Generuj", - "autoGeneratorHintText": "Zapytaj OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Nie można uzyskać klucza OpenAI", + "autoGeneratorHintText": "Zapytaj AI...", + "autoGeneratorCantGetOpenAIKey": "Nie można uzyskać klucza AI", "autoGeneratorRewrite": "Przepisz", "smartEdit": "Asystenci AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Popraw pisownię", "warning": "⚠️ Odpowiedzi AI mogą być niedokładne lub mylące.", "smartEditSummarize": "Podsumuj", "smartEditImproveWriting": "Popraw pisanie", "smartEditMakeLonger": "Dłużej", - "smartEditCouldNotFetchResult": "Nie można pobrać wyniku z OpenAI", - "smartEditCouldNotFetchKey": "Nie można pobrać klucza OpenAI", - "smartEditDisabled": "Połącz OpenAI w Ustawieniach", + "smartEditCouldNotFetchResult": "Nie można pobrać wyniku z AI", + "smartEditCouldNotFetchKey": "Nie można pobrać klucza AI", + "smartEditDisabled": "Połącz AI w Ustawieniach", "discardResponse": "Czy chcesz odrzucić odpowiedzi AI?", "createInlineMathEquation": "Utwórz równanie", "toggleList": "Przełącz listę", @@ -776,8 +776,8 @@ "placeholder": "Wprowadź adres URL obrazu" }, "ai": { - "label": "Wygeneruj obraz z OpenAI", - "placeholder": "Wpisz treść podpowiedzi dla OpenAI, aby wygenerować obraz" + "label": "Wygeneruj obraz z AI", + "placeholder": "Wpisz treść podpowiedzi dla AI, aby wygenerować obraz" }, "stability_ai": { "label": "Wygeneruj obraz z Stability AI", @@ -795,7 +795,7 @@ "placeholder": "Wklej lub wpisz link obrazu" }, "searchForAnImage": "Szukaj obrazu", - "pleaseInputYourOpenAIKey": "wpisz swój klucz OpenAI w ustawieniach", + "pleaseInputYourOpenAIKey": "wpisz swój klucz AI w ustawieniach", "pleaseInputYourStabilityAIKey": "wpisz swój klucz Stability AI w ustawieniach", "saveImageToGallery": "Zapisz obraz", "failedToAddImageToGallery": "Nie udało się dodać obrazu do galerii", diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index 10cb323032..969b4e2392 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -421,7 +421,7 @@ "email": "E-mail", "tooltipSelectIcon": "Selecionar ícone", "selectAnIcon": "Escolha um ícone", - "pleaseInputYourOpenAIKey": "por favor insira sua chave OpenAI", + "pleaseInputYourOpenAIKey": "por favor insira sua chave AI", "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI", "clickToLogout": "Clique para sair do usuário atual" }, @@ -696,18 +696,18 @@ "autoGeneratorLearnMore": "Saiba mais", "autoGeneratorGenerate": "Gerar", "autoGeneratorHintText": "Diga-nos o que você deseja gerar por IA ...", - "autoGeneratorCantGetOpenAIKey": "Não foi possível obter a chave da OpenAI", + "autoGeneratorCantGetOpenAIKey": "Não foi possível obter a chave da AI", "autoGeneratorRewrite": "Reescrever", "smartEdit": "Assistentes de IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corrigir ortografia", "warning": "⚠️ As respostas da IA podem ser imprecisas ou enganosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "melhorar a escrita", "smartEditMakeLonger": "Faça mais", - "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do OpenAI", - "smartEditCouldNotFetchKey": "Não foi possível obter a chave OpenAI", - "smartEditDisabled": "Conecte OpenAI em Configurações", + "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do AI", + "smartEditCouldNotFetchKey": "Não foi possível obter a chave AI", + "smartEditDisabled": "Conecte AI em Configurações", "discardResponse": "Deseja descartar as respostas de IA?", "createInlineMathEquation": "Criar equação", "fonts": "Fontes", @@ -815,8 +815,8 @@ "placeholder": "Insira o URL da imagem" }, "ai": { - "label": "Gerar imagem da OpenAI", - "placeholder": "Insira o prompt para OpenAI gerar imagem" + "label": "Gerar imagem da AI", + "placeholder": "Insira o prompt para AI gerar imagem" }, "stability_ai": { "label": "Gerar imagem da Stability AI", @@ -837,7 +837,7 @@ "label": "Remover respingo" }, "searchForAnImage": "Procurar uma imagem", - "pleaseInputYourOpenAIKey": "insira sua chave OpenAI na página configurações", + "pleaseInputYourOpenAIKey": "insira sua chave AI na página configurações", "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI na página Configurações", "saveImageToGallery": "Salvar imagem", "failedToAddImageToGallery": "Falha ao adicionar imagem à galeria", diff --git a/frontend/resources/translations/pt-PT.json b/frontend/resources/translations/pt-PT.json index 1b5ee1fcd1..8fe5e41d9e 100644 --- a/frontend/resources/translations/pt-PT.json +++ b/frontend/resources/translations/pt-PT.json @@ -362,7 +362,7 @@ "email": "E-mail", "tooltipSelectIcon": "Selecione o ícone", "selectAnIcon": "Selecione um ícone", - "pleaseInputYourOpenAIKey": "por favor insira sua chave OpenAI", + "pleaseInputYourOpenAIKey": "por favor insira sua chave AI", "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI", "clickToLogout": "Clique para fazer logout" }, @@ -561,23 +561,23 @@ "referencedBoard": "Conselho Referenciado", "referencedGrid": "grade referenciada", "referencedCalendar": "calendário referenciado", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Peça à IA para escrever qualquer coisa...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Peça à IA para escrever qualquer coisa...", "autoGeneratorLearnMore": "Saber mais", "autoGeneratorGenerate": "Gerar", - "autoGeneratorHintText": "Pergunte ao OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Não é possível obter a chave OpenAI", + "autoGeneratorHintText": "Pergunte ao AI...", + "autoGeneratorCantGetOpenAIKey": "Não é possível obter a chave AI", "autoGeneratorRewrite": "Reescrever", "smartEdit": "Assistentes de IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "corrigir ortografia", "warning": "⚠️ As respostas da IA podem ser imprecisas ou enganosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "melhorar a escrita", "smartEditMakeLonger": "Faça mais", - "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do OpenAI", - "smartEditCouldNotFetchKey": "Não foi possível obter a chave OpenAI", - "smartEditDisabled": "Conecte OpenAI em Configurações", + "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do AI", + "smartEditCouldNotFetchKey": "Não foi possível obter a chave AI", + "smartEditDisabled": "Conecte AI em Configurações", "discardResponse": "Deseja descartar as respostas de IA?", "createInlineMathEquation": "Criar equação", "toggleList": "Alternar lista", @@ -662,8 +662,8 @@ "placeholder": "Insira o URL da imagem" }, "ai": { - "label": "Gerar imagem da OpenAI", - "placeholder": "Por favor, insira o comando para a OpenAI gerar a imagem" + "label": "Gerar imagem da AI", + "placeholder": "Por favor, insira o comando para a AI gerar a imagem" }, "stability_ai": { "label": "Gerar imagem da Stability AI", @@ -681,7 +681,7 @@ "placeholder": "Cole ou digite uma hiperligação de imagem" }, "searchForAnImage": "Procure uma imagem", - "pleaseInputYourOpenAIKey": "por favor, insira a sua chave OpenAI na página Configurações", + "pleaseInputYourOpenAIKey": "por favor, insira a sua chave AI na página Configurações", "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI na página Configurações" }, "codeBlock": { diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index 515471cf81..ce999fca69 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -999,7 +999,7 @@ "email": "Электронная почта", "tooltipSelectIcon": "Выберите иконку", "selectAnIcon": "Выбрать иконку", - "pleaseInputYourOpenAIKey": "Пожалуйста, введите токен OpenAI", + "pleaseInputYourOpenAIKey": "Пожалуйста, введите токен AI", "pleaseInputYourStabilityAIKey": "Пожалуйста, введите свой токен Stability AI", "clickToLogout": "Нажмите, чтобы выйти из текущего аккаунта" }, @@ -1326,15 +1326,15 @@ "referencedGrid": "Связанные сетки", "referencedCalendar": "Связанные календари", "referencedDocument": "Связанные документы", - "autoGeneratorMenuItemName": "OpenAI Генератор", - "autoGeneratorTitleName": "OpenAI: попросить ИИ написать что угодно...", + "autoGeneratorMenuItemName": "AI Генератор", + "autoGeneratorTitleName": "AI: попросить ИИ написать что угодно...", "autoGeneratorLearnMore": "Узнать больше", "autoGeneratorGenerate": "Генерировать", - "autoGeneratorHintText": "Спросить OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "Не могу получить токен OpenAI", + "autoGeneratorHintText": "Спросить AI ...", + "autoGeneratorCantGetOpenAIKey": "Не могу получить токен AI", "autoGeneratorRewrite": "Переписать", "smartEdit": "ИИ-ассистенты", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Исправить правописание", "warning": "⚠️ Ответы ИИ могут быть неправильными или неточными.", "smartEditSummarize": "Обобщить", @@ -1472,8 +1472,8 @@ "placeholder": "Введите URL-адрес изображения" }, "ai": { - "label": "Сгенерировать изображение через OpenAI", - "placeholder": "Пожалуйста, введите запрос для OpenAI чтобы сгенерировать изображение" + "label": "Сгенерировать изображение через AI", + "placeholder": "Пожалуйста, введите запрос для AI чтобы сгенерировать изображение" }, "stability_ai": { "label": "Сгенерировать изображение через Stability AI", @@ -1495,7 +1495,7 @@ "label": "Unsplash" }, "searchForAnImage": "Поиск изображения", - "pleaseInputYourOpenAIKey": "пожалуйста, введите свой токен OpenAI на странице настроек", + "pleaseInputYourOpenAIKey": "пожалуйста, введите свой токен AI на странице настроек", "pleaseInputYourStabilityAIKey": "пожалуйста, введите свой токен Stability AI на странице настроек", "saveImageToGallery": "Сохранить изображение", "failedToAddImageToGallery": "Ошибка добавления изображения в галерею", @@ -2103,4 +2103,4 @@ "signInError": "Ошибка входа", "login": "Зарегистрироваться или войти" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/sv-SE.json b/frontend/resources/translations/sv-SE.json index 50be68350d..6284a61d9a 100644 --- a/frontend/resources/translations/sv-SE.json +++ b/frontend/resources/translations/sv-SE.json @@ -345,7 +345,7 @@ "user": { "name": "namn", "selectAnIcon": "Välj en ikon", - "pleaseInputYourOpenAIKey": "vänligen ange din OpenAI-nyckel" + "pleaseInputYourOpenAIKey": "vänligen ange din AI-nyckel" } }, "grid": { @@ -501,23 +501,23 @@ "referencedBoard": "Refererad tavla", "referencedGrid": "Refererade tabell", "referencedCalendar": "Refererad kalender", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Be AI skriva vad som helst...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Be AI skriva vad som helst...", "autoGeneratorLearnMore": "Läs mer", "autoGeneratorGenerate": "Generera", - "autoGeneratorHintText": "Fråga OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Kan inte hämta OpenAI-nyckeln", + "autoGeneratorHintText": "Fråga AI...", + "autoGeneratorCantGetOpenAIKey": "Kan inte hämta AI-nyckeln", "autoGeneratorRewrite": "Skriva om", "smartEdit": "AI-assistenter", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Fixa stavningen", "warning": "⚠️ AI-svar kan vara felaktiga eller vilseledande.", "smartEditSummarize": "Sammanfatta", "smartEditImproveWriting": "Förbättra skrivandet", "smartEditMakeLonger": "Gör längre", - "smartEditCouldNotFetchResult": "Det gick inte att hämta resultatet från OpenAI", - "smartEditCouldNotFetchKey": "Det gick inte att hämta OpenAI-nyckeln", - "smartEditDisabled": "Anslut OpenAI i Inställningar", + "smartEditCouldNotFetchResult": "Det gick inte att hämta resultatet från AI", + "smartEditCouldNotFetchKey": "Det gick inte att hämta AI-nyckeln", + "smartEditDisabled": "Anslut AI i Inställningar", "discardResponse": "Vill du kassera AI-svaren?", "createInlineMathEquation": "Skapa ekvation", "toggleList": "Växla lista", diff --git a/frontend/resources/translations/th-TH.json b/frontend/resources/translations/th-TH.json index 2986fa6264..7b2fa49a88 100644 --- a/frontend/resources/translations/th-TH.json +++ b/frontend/resources/translations/th-TH.json @@ -399,7 +399,7 @@ "email": "อีเมล", "tooltipSelectIcon": "เลือกไอคอน", "selectAnIcon": "เลือกไอคอน", - "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ OpenAI ของคุณ", + "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ AI ของคุณ", "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณ", "clickToLogout": "คลิกเพื่อออกจากระบบผู้ใช้ปัจจุบัน" }, @@ -647,23 +647,23 @@ "referencedGrid": "ตารางอ้างอิง", "referencedCalendar": "ปฏิทินที่อ้างอิง", "referencedDocument": "เอกสารอ้างอิง", - "autoGeneratorMenuItemName": "นักเขียน OpenAI", - "autoGeneratorTitleName": "OpenAI: สอบถาม AI เพื่อให้เขียนอะไรก็ได้...", + "autoGeneratorMenuItemName": "นักเขียน AI", + "autoGeneratorTitleName": "AI: สอบถาม AI เพื่อให้เขียนอะไรก็ได้...", "autoGeneratorLearnMore": "เรียนรู้เพิ่มเติม", "autoGeneratorGenerate": "สร้าง", - "autoGeneratorHintText": "ถาม OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "ไม่สามารถรับคีย์ OpenAI ได้", + "autoGeneratorHintText": "ถาม AI ...", + "autoGeneratorCantGetOpenAIKey": "ไม่สามารถรับคีย์ AI ได้", "autoGeneratorRewrite": "เขียนใหม่", "smartEdit": "ผู้ช่วย AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "แก้ไขการสะกด", "warning": "⚠️ คำตอบของ AI อาจจะไม่ถูกต้องหรืออาจจะเข้าใจผิดได้", "smartEditSummarize": "สรุป", "smartEditImproveWriting": "ปรับปรุงการเขียน", "smartEditMakeLonger": "ทำให้ยาวขึ้น", - "smartEditCouldNotFetchResult": "ไม่สามารถดึงผลลัพธ์จาก OpenAI ได้", - "smartEditCouldNotFetchKey": "ไม่สามารถดึงคีย์ OpenAI ได้", - "smartEditDisabled": "เชื่อมต่อ OpenAI ในการตั้งค่า", + "smartEditCouldNotFetchResult": "ไม่สามารถดึงผลลัพธ์จาก AI ได้", + "smartEditCouldNotFetchKey": "ไม่สามารถดึงคีย์ AI ได้", + "smartEditDisabled": "เชื่อมต่อ AI ในการตั้งค่า", "discardResponse": "คุณต้องการทิ้งการตอบกลับของ AI หรือไม่", "createInlineMathEquation": "สร้างสมการ", "fonts": "แบบอักษร", @@ -757,8 +757,8 @@ "placeholder": "ป้อน URL รูปภาพ" }, "ai": { - "label": "สร้างรูปภาพจาก OpenAI", - "placeholder": "โปรดระบุคำขอให้ OpenAI สร้างรูปภาพ" + "label": "สร้างรูปภาพจาก AI", + "placeholder": "โปรดระบุคำขอให้ AI สร้างรูปภาพ" }, "stability_ai": { "label": "สร้างรูปภาพจาก Stability AI", @@ -779,7 +779,7 @@ "label": "Unsplash" }, "searchForAnImage": "ค้นหารูปภาพ", - "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ OpenAI ของคุณในหน้าการตั้งค่า", + "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ AI ของคุณในหน้าการตั้งค่า", "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณในหน้าการตั้งค่า", "saveImageToGallery": "บันทึกภาพ", "failedToAddImageToGallery": "ไม่สามารถเพิ่มรูปภาพลงในแกลเลอรี่ได้", diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index 51bb4bf8b9..a353dcbea6 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -337,7 +337,7 @@ "logoutLabel": "Çıkış Yap" }, "keys": { - "openAIHint": "OpenAI API Anahtarını Gir" + "openAIHint": "AI API Anahtarını Gir" } }, "workspacePage": { @@ -553,7 +553,7 @@ "email": "E-posta", "tooltipSelectIcon": "Simge seç", "selectAnIcon": "Bir simge seçin", - "pleaseInputYourOpenAIKey": "Lütfen OpenAI anahtarınızı girin", + "pleaseInputYourOpenAIKey": "Lütfen AI anahtarınızı girin", "pleaseInputYourStabilityAIKey": "Lütfen Stability AI anahtarınızı girin", "clickToLogout": "Geçerli kullanıcıdan çıkış yapmak için tıklayın" }, @@ -882,23 +882,23 @@ "referencedGrid": "Referans Gösterilen Tablo", "referencedCalendar": "Referans Gösterilen Takvim", "referencedDocument": "Referans Gösterilen Belge", - "autoGeneratorMenuItemName": "OpenAI Yazar", - "autoGeneratorTitleName": "OpenAI: AI'dan istediğinizi yazmasını isteyin...", + "autoGeneratorMenuItemName": "AI Yazar", + "autoGeneratorTitleName": "AI: AI'dan istediğinizi yazmasını isteyin...", "autoGeneratorLearnMore": "Daha fazla bilgi edinin", "autoGeneratorGenerate": "Oluştur", - "autoGeneratorHintText": "OpenAI'ya sorun ...", - "autoGeneratorCantGetOpenAIKey": "OpenAI anahtarı alınamıyor", + "autoGeneratorHintText": "AI'ya sorun ...", + "autoGeneratorCantGetOpenAIKey": "AI anahtarı alınamıyor", "autoGeneratorRewrite": "Yeniden yaz", "smartEdit": "AI Asistanları", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Yazımı düzelt", "warning": "⚠️ AI yanıtları yanlış veya yanıltıcı olabilir.", "smartEditSummarize": "Özetle", "smartEditImproveWriting": "Yazımı geliştir", "smartEditMakeLonger": "Daha uzun yap", - "smartEditCouldNotFetchResult": "OpenAI'dan sonuç alınamadı", - "smartEditCouldNotFetchKey": "OpenAI anahtarı alınamadı", - "smartEditDisabled": "Ayarlar'da OpenAI'yı bağlayın", + "smartEditCouldNotFetchResult": "AI'dan sonuç alınamadı", + "smartEditCouldNotFetchKey": "AI anahtarı alınamadı", + "smartEditDisabled": "Ayarlar'da AI'yı bağlayın", "discardResponse": "AI yanıtlarını silmek ister misiniz?", "createInlineMathEquation": "Denklem oluştur", "fonts": "Yazı Tipleri", @@ -1013,8 +1013,8 @@ "placeholder": "Resim URL'sini girin" }, "ai": { - "label": "OpenAI ile resim oluştur", - "placeholder": "Lütfen OpenAI'nin resim oluşturması için komutu girin" + "label": "AI ile resim oluştur", + "placeholder": "Lütfen AI'nin resim oluşturması için komutu girin" }, "stability_ai": { "label": "Stability AI ile resim oluştur", @@ -1036,7 +1036,7 @@ "label": "Unsplash" }, "searchForAnImage": "Bir resim arayın", - "pleaseInputYourOpenAIKey": "Lütfen Ayarlar sayfasında OpenAI anahtarınızı girin", + "pleaseInputYourOpenAIKey": "Lütfen Ayarlar sayfasında AI anahtarınızı girin", "pleaseInputYourStabilityAIKey": "Lütfen Ayarlar sayfasında Stability AI anahtarınızı girin", "saveImageToGallery": "Resmi kaydet", "failedToAddImageToGallery": "Resim galeriye eklenemedi", diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index b8d8828f6f..ad82b8544a 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -351,7 +351,7 @@ "email": "Електронна пошта", "tooltipSelectIcon": "Обрати значок", "selectAnIcon": "Обрати значок", - "pleaseInputYourOpenAIKey": "Будь ласка, введіть ваш ключ OpenAI", + "pleaseInputYourOpenAIKey": "Будь ласка, введіть ваш ключ AI", "clickToLogout": "Натисніть, щоб вийти з поточного облікового запису" }, "shortcuts": { @@ -547,23 +547,23 @@ "referencedBoard": "Пов'язані дошки", "referencedGrid": "Пов'язані сітки", "referencedCalendar": "Календар посилань", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Запитайте штучний інтелект написати будь-що...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Запитайте штучний інтелект написати будь-що...", "autoGeneratorLearnMore": "Дізнатися більше", "autoGeneratorGenerate": "Генерувати", - "autoGeneratorHintText": "Запитайте OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Не вдається отримати ключ OpenAI", + "autoGeneratorHintText": "Запитайте AI...", + "autoGeneratorCantGetOpenAIKey": "Не вдається отримати ключ AI", "autoGeneratorRewrite": "Переписати", "smartEdit": "AI Асистенти", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Виправити правопис", "warning": "⚠️ Відповіді AI можуть бути неточними або вводити в оману.", "smartEditSummarize": "Підсумувати", "smartEditImproveWriting": "Покращити написання", "smartEditMakeLonger": "Зробити довше", - "smartEditCouldNotFetchResult": "Не вдалося отримати результат від OpenAI", - "smartEditCouldNotFetchKey": "Не вдалося отримати ключ OpenAI", - "smartEditDisabled": "Підключіть OpenAI в Налаштуваннях", + "smartEditCouldNotFetchResult": "Не вдалося отримати результат від AI", + "smartEditCouldNotFetchKey": "Не вдалося отримати ключ AI", + "smartEditDisabled": "Підключіть AI в Налаштуваннях", "discardResponse": "Ви хочете відкинути відповіді AI?", "createInlineMathEquation": "Створити рівняння", "toggleList": "Перемкнути список", diff --git a/frontend/resources/translations/ur.json b/frontend/resources/translations/ur.json index 1d4f936d37..e981a41f3b 100644 --- a/frontend/resources/translations/ur.json +++ b/frontend/resources/translations/ur.json @@ -331,7 +331,7 @@ "name": "نام", "email": "ای میل", "selectAnIcon": "آئیکن منتخب کریں", - "pleaseInputYourOpenAIKey": "براہ کرم اپنی OpenAI کی درج کریں", + "pleaseInputYourOpenAIKey": "براہ کرم اپنی AI کی درج کریں", "clickToLogout": "موجودہ صارف سے لاگ آؤٹ کرنے کے لیے کلک کریں" }, "shortcuts": { @@ -511,23 +511,23 @@ "referencedBoard": "حوالہ شدہ بورڈ", "referencedGrid": "حوالہ شدہ گرِڈ", "referencedCalendar": "حوالہ شدہ کیلنڈر", - "autoGeneratorMenuItemName": "OpenAI رائٹر", - "autoGeneratorTitleName": "OpenAI: AI سے کچھ بھی لکھنے کے لیے کہیں...", + "autoGeneratorMenuItemName": "AI رائٹر", + "autoGeneratorTitleName": "AI: AI سے کچھ بھی لکھنے کے لیے کہیں...", "autoGeneratorLearnMore": "مزید جانئے", "autoGeneratorGenerate": "جنریٹ کریں", - "autoGeneratorHintText": "OpenAI سے پوچھیں...", - "autoGeneratorCantGetOpenAIKey": "OpenAI کی حاصل نہیں کر سکتا", + "autoGeneratorHintText": "AI سے پوچھیں...", + "autoGeneratorCantGetOpenAIKey": "AI کی حاصل نہیں کر سکتا", "autoGeneratorRewrite": "دوبارہ لکھیں", "smartEdit": "AI اسسٹنٹ", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "املا درست کریں", "warning": "⚠️ AI کی پاسخیں غلط یا گمراہ کن ہو سکتی ہیں۔", "smartEditSummarize": "سارے لکھیں", "smartEditImproveWriting": "تحریر بہتر بنائیں", "smartEditMakeLonger": "طویل تر بنائیں", - "smartEditCouldNotFetchResult": "OpenAI سے نتیجہ حاصل نہیں کر سکا", - "smartEditCouldNotFetchKey": "OpenAI کی حاصل نہیں کر سکا", - "smartEditDisabled": "Settings میں OpenAI سے منسلک کریں", + "smartEditCouldNotFetchResult": "AI سے نتیجہ حاصل نہیں کر سکا", + "smartEditCouldNotFetchKey": "AI کی حاصل نہیں کر سکا", + "smartEditDisabled": "Settings میں AI سے منسلک کریں", "discardResponse": "کیا آپ AI کی پاسخیں حذف کرنا چاہتے ہیں؟", "createInlineMathEquation": "مساوات بنائیں", "toggleList": "فہرست ٹوگل کریں", diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index be97d77022..0f390ab2ff 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -465,7 +465,7 @@ "email": "Email", "tooltipSelectIcon": "Chọn biểu tượng", "selectAnIcon": "Chọn một biểu tượng", - "pleaseInputYourOpenAIKey": "vui lòng nhập khóa OpenAI của bạn", + "pleaseInputYourOpenAIKey": "vui lòng nhập khóa AI của bạn", "pleaseInputYourStabilityAIKey": "vui lòng nhập khóa Stability AI của bạn", "clickToLogout": "Nhấn để đăng xuất" }, @@ -675,7 +675,7 @@ } }, "plugins": { - "openAI": "OpenAI", + "aI": "AI", "optionAction": { "delete": "Xóa", "color": "Màu", diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 2e4241fa7e..6bbccea840 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -376,13 +376,6 @@ "loginLabel": "登录", "logoutLabel": "退出登录" }, - "keys": { - "title": "AI API 密钥", - "openAILabel": "OpenAI API 密钥", - "openAIHint": "输入你的 OpenAI API Key", - "stabilityAILabel": "Stability API key", - "stabilityAIHint": "输入你的 Stability API Key" - }, "description": "自定义您的简介,管理账户安全信息和 AI API keys,或登陆您的账户" }, "workspacePage": { @@ -694,7 +687,7 @@ "email": "电子邮件", "tooltipSelectIcon": "选择图标", "selectAnIcon": "选择一个图标", - "pleaseInputYourOpenAIKey": "请输入您的 OpenAI 密钥", + "pleaseInputYourOpenAIKey": "请输入您的 AI 密钥", "pleaseInputYourStabilityAIKey": "请输入您的 Stability AI 密钥", "clickToLogout": "点击退出当前用户" }, @@ -1022,23 +1015,23 @@ "referencedGrid": "引用的网格", "referencedCalendar": "引用的日历", "referencedDocument": "参考文档", - "autoGeneratorMenuItemName": "OpenAI 创作", - "autoGeneratorTitleName": "OpenAI: 让 AI 写些什么...", + "autoGeneratorMenuItemName": "AI 创作", + "autoGeneratorTitleName": "AI: 让 AI 写些什么...", "autoGeneratorLearnMore": "学习更多", "autoGeneratorGenerate": "生成", - "autoGeneratorHintText": "让 OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "无法获得 OpenAI 密钥", + "autoGeneratorHintText": "让 AI ...", + "autoGeneratorCantGetOpenAIKey": "无法获得 AI 密钥", "autoGeneratorRewrite": "重写", "smartEdit": "AI 助手", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "修正拼写", "warning": "⚠️ AI 可能不准确或具有误导性.", "smartEditSummarize": "总结", "smartEditImproveWriting": "提高写作水平", "smartEditMakeLonger": "丰富内容", - "smartEditCouldNotFetchResult": "无法从 OpenAI 获取到结果", - "smartEditCouldNotFetchKey": "无法获取到 OpenAI 密钥", - "smartEditDisabled": "在设置中连接 OpenAI", + "smartEditCouldNotFetchResult": "无法从 AI 获取到结果", + "smartEditCouldNotFetchKey": "无法获取到 AI 密钥", + "smartEditDisabled": "在设置中连接 AI", "discardResponse": "您是否要放弃 AI 继续写作?", "createInlineMathEquation": "创建方程", "fonts": "字体", @@ -1156,8 +1149,8 @@ "placeholder": "输入图片网址" }, "ai": { - "label": "从 OpenAI 生成图像", - "placeholder": "请输入 OpenAI 生成图像的提示" + "label": "从 AI 生成图像", + "placeholder": "请输入 AI 生成图像的提示" }, "stability_ai": { "label": "从 Stability AI 生成图像", @@ -1179,7 +1172,7 @@ "label": "Unsplash" }, "searchForAnImage": "搜索图像", - "pleaseInputYourOpenAIKey": "请在设置页面输入您的 OpenAI 密钥", + "pleaseInputYourOpenAIKey": "请在设置页面输入您的 AI 密钥", "pleaseInputYourStabilityAIKey": "请在设置页面输入您的 Stability AI 密钥", "saveImageToGallery": "保存图片", "failedToAddImageToGallery": "无法将图像添加到图库", diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index 7479681c0c..1a520215b3 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -333,15 +333,6 @@ "loginLabel": "登入", "logoutLabel": "登出" }, - "keys": { - "title": "AI API 金鑰", - "openAILabel": "Open AI API 金鑰", - "openAITooltip": "以OpenAI API 金鑰使用AI 模型", - "openAIHint": "輸入您的 OpenAI API 金鑰", - "stabilityAILabel": "Stability API 金鑰", - "stabilityAITooltip": "以Stability API 金鑰使用AI 模型", - "stabilityAIHint": "輸入您的Stability API 金鑰" - }, "description": "自訂您的個人資料、管理帳戶安全性和 AI API 金鑰,或登入您的帳號" }, "menu": { @@ -542,7 +533,7 @@ "email": "電子郵件", "tooltipSelectIcon": "選擇圖示", "selectAnIcon": "選擇圖示", - "pleaseInputYourOpenAIKey": "請輸入您的 OpenAI 金鑰", + "pleaseInputYourOpenAIKey": "請輸入您的 AI 金鑰", "pleaseInputYourStabilityAIKey": "請輸入您的 Stability AI 金鑰", "clickToLogout": "點選以登出目前使用者" }, @@ -820,23 +811,23 @@ "referencedGrid": "已連結的網格", "referencedCalendar": "已連結的日曆", "referencedDocument": "已連結的文件", - "autoGeneratorMenuItemName": "OpenAI 寫手", - "autoGeneratorTitleName": "OpenAI:讓 AI 撰寫任何內容……", + "autoGeneratorMenuItemName": "AI 寫手", + "autoGeneratorTitleName": "AI:讓 AI 撰寫任何內容……", "autoGeneratorLearnMore": "瞭解更多", "autoGeneratorGenerate": "產生", - "autoGeneratorHintText": "問 OpenAI……", - "autoGeneratorCantGetOpenAIKey": "無法取得 OpenAI 金鑰", + "autoGeneratorHintText": "問 AI……", + "autoGeneratorCantGetOpenAIKey": "無法取得 AI 金鑰", "autoGeneratorRewrite": "改寫", "smartEdit": "AI 助理", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "修正拼寫", "warning": "⚠️ AI 的回覆可能不準確或具有誤導性。", "smartEditSummarize": "總結", "smartEditImproveWriting": "提高寫作水準", "smartEditMakeLonger": "做得更長", - "smartEditCouldNotFetchResult": "無法取得 OpenAI 的結果", - "smartEditCouldNotFetchKey": "無法取得 OpenAI 金鑰", - "smartEditDisabled": "在設定連結 OpenAI ", + "smartEditCouldNotFetchResult": "無法取得 AI 的結果", + "smartEditCouldNotFetchKey": "無法取得 AI 金鑰", + "smartEditDisabled": "在設定連結 AI ", "discardResponse": "確定捨棄 AI 的回覆?", "createInlineMathEquation": "建立公式", "fonts": "字型", @@ -951,8 +942,8 @@ "placeholder": "輸入圖片網址" }, "ai": { - "label": "由 OpenAI 生成圖片", - "placeholder": "請輸入提示讓 OpenAI 生成圖片" + "label": "由 AI 生成圖片", + "placeholder": "請輸入提示讓 AI 生成圖片" }, "stability_ai": { "label": "由 Stability AI 生成圖片", @@ -974,7 +965,7 @@ "label": "Unsplash" }, "searchForAnImage": "搜尋圖片", - "pleaseInputYourOpenAIKey": "請在設定頁面輸入您的 OpenAI 金鑰", + "pleaseInputYourOpenAIKey": "請在設定頁面輸入您的 AI 金鑰", "pleaseInputYourStabilityAIKey": "請在設定頁面輸入您的 Stability AI 金鑰", "saveImageToGallery": "儲存圖片", "failedToAddImageToGallery": "無法將圖片新增到相簿", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index b21abcf505..1715d2fc9a 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bytes", @@ -718,7 +718,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "again", "anyhow", @@ -768,7 +768,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "collab-entity", "collab-rt-entity", @@ -780,7 +780,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "futures-channel", "futures-util", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "async-trait", @@ -847,7 +847,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "async-trait", @@ -877,7 +877,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "collab", @@ -897,7 +897,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "bytes", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "chrono", @@ -950,7 +950,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "async-stream", @@ -989,7 +989,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bincode", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "async-trait", @@ -1031,7 +1031,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=43b1c98435d63c225229c9def79f2f5213d6eaf1#43b1c98435d63c225229c9def79f2f5213d6eaf1" dependencies = [ "anyhow", "collab", @@ -1352,7 +1352,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", @@ -1918,6 +1918,7 @@ dependencies = [ "client-api", "collab", "collab-entity", + "flowy-error", "lib-infra", ] @@ -2299,12 +2300,16 @@ dependencies = [ "async-trait", "bytes", "chrono", + "flowy-codegen", + "flowy-derive", "flowy-error", + "flowy-notification", "flowy-sqlite", "flowy-storage-pub", "fxhash", "lib-infra", "mime_guess", + "protobuf", "rand 0.8.5", "serde", "serde_json", @@ -2339,6 +2344,7 @@ dependencies = [ "base64 0.21.5", "bytes", "chrono", + "client-api", "collab", "collab-database", "collab-document", @@ -2695,7 +2701,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "futures-util", @@ -2712,7 +2718,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", @@ -3077,7 +3083,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "bytes", @@ -5223,7 +5229,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=76a8993cac42ff89acb507cdb99942cac7c9bfd0#76a8993cac42ff89acb507cdb99942cac7c9bfd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index e8ad15b712..2b4896b45e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -89,7 +89,7 @@ collab-document = { version = "0.2" } collab-database = { version = "0.2" } collab-plugins = { version = "0.2" } collab-user = { version = "0.2" } -yrs = "0.19.1" +yrs = "0.19.2" validator = { version = "0.16.1", features = ["derive"] } tokio-util = "0.7.11" zip = "2.1.3" @@ -99,8 +99,8 @@ zip = "2.1.3" # 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 = "76a8993cac42ff89acb507cdb99942cac7c9bfd0" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "76a8993cac42ff89acb507cdb99942cac7c9bfd0" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" } [profile.dev] opt-level = 0 @@ -135,13 +135,13 @@ rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec1 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "43b1c98435d63c225229c9def79f2f5213d6eaf1" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/rust-lib/flowy-chat/src/chat_manager.rs b/frontend/rust-lib/flowy-chat/src/chat_manager.rs index 8da9bac044..219a098f1f 100644 --- a/frontend/rust-lib/flowy-chat/src/chat_manager.rs +++ b/frontend/rust-lib/flowy-chat/src/chat_manager.rs @@ -226,6 +226,10 @@ impl ChatManager { chat.index_file(file_path).await?; Ok(()) } + + pub fn local_ai_purchased(&self) { + // TODO(nathan): enable local ai + } } fn save_chat(conn: DBConnection, chat_id: &str) -> FlowyResult<()> { diff --git a/frontend/rust-lib/flowy-chat/src/tools.rs b/frontend/rust-lib/flowy-chat/src/tools.rs index b910d098e9..493416fff7 100644 --- a/frontend/rust-lib/flowy-chat/src/tools.rs +++ b/frontend/rust-lib/flowy-chat/src/tools.rs @@ -11,7 +11,7 @@ use lib_infra::isolate_stream::IsolateSink; use std::sync::{Arc, Weak}; use tokio::select; -use tracing::{error, trace}; +use tracing::trace; pub struct AITools { tasks: Arc>>, @@ -83,54 +83,59 @@ impl ToolTask { pub async fn start(mut self) { tokio::spawn(async move { let mut sink = IsolateSink::new(Isolate::new(self.context.stream_port)); - match self.cloud_service.upgrade() { - None => {}, - Some(cloud_service) => { - let complete_type = match self.context.completion_type { - CompletionTypePB::UnknownCompletionType => CompletionType::ImproveWriting, - CompletionTypePB::ImproveWriting => CompletionType::ImproveWriting, - CompletionTypePB::SpellingAndGrammar => CompletionType::SpellingAndGrammar, - CompletionTypePB::MakeShorter => CompletionType::MakeShorter, - CompletionTypePB::MakeLonger => CompletionType::MakeLonger, - CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting, - }; - let _ = sink.send("start:".to_string()).await; - match cloud_service - .stream_complete(&self.workspace_id, &self.context.text, complete_type) - .await - { - Ok(mut stream) => loop { - select! { + + if let Some(cloud_service) = self.cloud_service.upgrade() { + let complete_type = match self.context.completion_type { + CompletionTypePB::UnknownCompletionType | CompletionTypePB::ImproveWriting => { + CompletionType::ImproveWriting + }, + CompletionTypePB::SpellingAndGrammar => CompletionType::SpellingAndGrammar, + CompletionTypePB::MakeShorter => CompletionType::MakeShorter, + CompletionTypePB::MakeLonger => CompletionType::MakeLonger, + CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting, + }; + + let _ = sink.send("start:".to_string()).await; + match cloud_service + .stream_complete(&self.workspace_id, &self.context.text, complete_type) + .await + { + Ok(mut stream) => loop { + select! { _ = self.stop_rx.recv() => { - return; + return; }, result = stream.next() => { match result { - Some(Ok(data)) => { - let s = String::from_utf8(data.to_vec()).unwrap_or_default(); - trace!("stream completion data: {}", s); - let _ = sink.send(format!("data:{}", s)).await; - }, - Some(Err(error)) => { - error!("stream error: {}", error); - let _ = sink.send(format!("error:{}", error)).await; - return; - }, - None => { - let _ = sink.send(format!("finish:{}", self.task_id)).await; - return; - }, - } + Some(Ok(data)) => { + let s = String::from_utf8(data.to_vec()).unwrap_or_default(); + trace!("stream completion data: {}", s); + let _ = sink.send(format!("data:{}", s)).await; + }, + Some(Err(error)) => { + handle_error(&mut sink, FlowyError::from(error)).await; + return; + }, + None => { + let _ = sink.send(format!("finish:{}", self.task_id)).await; + return; + }, + } } - } - }, - Err(error) => { - error!("stream complete error: {}", error); - let _ = sink.send(format!("error:{}", error)).await; - }, - } - }, + } + }, + Err(error) => { + handle_error(&mut sink, error).await; + }, + } } }); } } +async fn handle_error(sink: &mut IsolateSink, error: FlowyError) { + if error.is_ai_response_limit_exceeded() { + let _ = sink.send("AI_RESPONSE_LIMIT".to_string()).await; + } else { + let _ = sink.send(format!("error:{}", error)).await; + } +} diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 3642847fa4..bb5b69c269 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -66,6 +66,7 @@ dart = [ "flowy-folder/dart", "flowy-database2/dart", "flowy-chat/dart", + "flowy-storage/dart", ] ts = [ "flowy-user/tauri_ts", @@ -74,6 +75,7 @@ ts = [ "flowy-database2/ts", "flowy-config/tauri_ts", "flowy-chat/tauri_ts", + "flowy-storage/tauri_ts", ] openssl_vendored = ["flowy-sqlite/openssl_vendored"] diff --git a/frontend/rust-lib/flowy-core/src/integrate/log.rs b/frontend/rust-lib/flowy-core/src/integrate/log.rs index 18d3a6db86..3fd887b973 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/log.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/log.rs @@ -60,6 +60,7 @@ pub fn create_log_filter( filters.push(format!("appflowy_local_ai={}", level)); filters.push(format!("appflowy_plugin={}", level)); filters.push(format!("flowy_ai={}", level)); + filters.push(format!("flowy_storage={}", level)); // Enable the frontend logs. DO NOT DISABLE. // These logs are essential for debugging and verifying frontend behavior. filters.push(format!("dart_ffi={}", level)); diff --git a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs index 7b569633ca..18ec68c04d 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs @@ -418,7 +418,7 @@ impl DatabaseCloudService for ServerProvider { workspace_id: &str, object_id: &str, summary_row: SummaryRowContent, - ) -> FutureResult { + ) -> FutureResult { let workspace_id = workspace_id.to_string(); let server = self.get_server(); let object_id = object_id.to_string(); @@ -435,7 +435,7 @@ impl DatabaseCloudService for ServerProvider { workspace_id: &str, translate_row: TranslateRowContent, language: &str, - ) -> FutureResult { + ) -> FutureResult { let workspace_id = workspace_id.to_string(); let server = self.get_server(); let language = language.to_string(); diff --git a/frontend/rust-lib/flowy-core/src/integrate/user.rs b/frontend/rust-lib/flowy-core/src/integrate/user.rs index 5288a0ee24..6e64fa7840 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/user.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/user.rs @@ -1,10 +1,12 @@ use std::sync::Arc; use anyhow::Context; +use client_api::entity::billing_dto::SubscriptionPlan; use tracing::{event, trace}; use collab_entity::CollabType; use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use flowy_chat::chat_manager::ChatManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; use flowy_error::{FlowyError, FlowyResult}; @@ -24,6 +26,7 @@ pub(crate) struct UserStatusCallbackImpl { pub(crate) document_manager: Arc, pub(crate) server_provider: Arc, pub(crate) storage_manager: Arc, + pub(crate) chat_manager: Arc, } impl UserStatusCallback for UserStatusCallbackImpl { @@ -216,4 +219,31 @@ impl UserStatusCallback for UserStatusCallbackImpl { self.collab_builder.update_network(reachable); self.storage_manager.update_network_reachable(reachable); } + + fn did_update_plans(&self, plans: Vec) { + let mut storage_plan_changed = false; + let mut local_ai_enabled = false; + for plan in &plans { + match plan { + SubscriptionPlan::Pro | SubscriptionPlan::Team => storage_plan_changed = true, + SubscriptionPlan::AiLocal => local_ai_enabled = true, + _ => {}, + } + } + if storage_plan_changed { + self.storage_manager.enable_storage_write_access(); + } + + if local_ai_enabled { + self.chat_manager.local_ai_purchased(); + } + } + + fn did_update_storage_limitation(&self, can_write: bool) { + if can_write { + self.storage_manager.enable_storage_write_access(); + } else { + self.storage_manager.disable_storage_write_access(); + } + } } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index ee82157d58..257882c1c7 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -241,6 +241,7 @@ impl AppFlowyCore { document_manager: document_manager.clone(), server_provider: server_provider.clone(), storage_manager: storage_manager.clone(), + chat_manager: chat_manager.clone(), }; let collab_interact_impl = CollabInteractImpl { diff --git a/frontend/rust-lib/flowy-database-pub/Cargo.toml b/frontend/rust-lib/flowy-database-pub/Cargo.toml index fb258183a8..91426a5c87 100644 --- a/frontend/rust-lib/flowy-database-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-database-pub/Cargo.toml @@ -11,3 +11,4 @@ collab-entity = { workspace = true } collab = { workspace = true } anyhow.workspace = true client-api = { workspace = true } +flowy-error = { workspace = true } \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database-pub/src/cloud.rs b/frontend/rust-lib/flowy-database-pub/src/cloud.rs index 3a43eb36da..0ceecf8930 100644 --- a/frontend/rust-lib/flowy-database-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-database-pub/src/cloud.rs @@ -2,6 +2,7 @@ use anyhow::Error; pub use client_api::entity::ai_dto::{TranslateItem, TranslateRowResponse}; use collab::core::collab::DataSource; use collab_entity::CollabType; +use flowy_error::FlowyError; use lib_infra::future::FutureResult; use std::collections::HashMap; @@ -40,14 +41,14 @@ pub trait DatabaseCloudService: Send + Sync { workspace_id: &str, object_id: &str, summary_row: SummaryRowContent, - ) -> FutureResult; + ) -> FutureResult; fn translate_database_row( &self, workspace_id: &str, translate_row: TranslateRowContent, language: &str, - ) -> FutureResult; + ) -> FutureResult; } pub struct DatabaseSnapshot { diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index 8a5be01139..cc32f944f5 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -17,10 +17,11 @@ flowy-derive.workspace = true flowy-notification = { workspace = true } parking_lot.workspace = true protobuf.workspace = true -flowy-error = { workspace = true, features = [ +flowy-error = { path = "../flowy-error", features = [ "impl_from_dispatch_error", "impl_from_collab_database", -] } +]} + lib-dispatch = { workspace = true } tokio = { workspace = true, features = ["sync"] } bytes.workspace = true diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index 406300e2a2..27f8f26f71 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -104,6 +104,17 @@ pub struct UploadedFilePB { pub local_file_path: String, } +#[derive(Default, ProtoBuf, Validate)] +pub struct DownloadFilePB { + #[pb(index = 1)] + #[validate(url)] + pub url: String, + + #[pb(index = 2)] + #[validate(custom = "required_valid_path")] + pub local_file_path: String, +} + #[derive(Default, ProtoBuf)] pub struct CreateDocumentPayloadPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-document/src/event_handler.rs b/frontend/rust-lib/flowy-document/src/event_handler.rs index eeb7e13f57..bbba8c2d98 100644 --- a/frontend/rust-lib/flowy-document/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document/src/event_handler.rs @@ -443,22 +443,22 @@ pub(crate) async fn upload_file_handler( } = params.try_into_inner()?; let manager = upgrade_document(manager)?; - let url = manager + let upload = manager .upload_file(workspace_id, &document_id, &local_file_path) .await?; - Ok(AFPluginData(UploadedFilePB { - url, + data_result_ok(UploadedFilePB { + url: upload.url, local_file_path, - })) + }) } #[instrument(level = "debug", skip_all, err)] pub(crate) async fn download_file_handler( - params: AFPluginData, + params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { - let UploadedFilePB { + let DownloadFilePB { url, local_file_path, } = params.try_into_inner()?; @@ -469,10 +469,10 @@ pub(crate) async fn download_file_handler( // Handler for deleting file pub(crate) async fn delete_file_handler( - params: AFPluginData, + params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { - let UploadedFilePB { + let DownloadFilePB { url, local_file_path, } = params.try_into_inner()?; diff --git a/frontend/rust-lib/flowy-document/src/event_map.rs b/frontend/rust-lib/flowy-document/src/event_map.rs index a6ce6959d9..8cd619793b 100644 --- a/frontend/rust-lib/flowy-document/src/event_map.rs +++ b/frontend/rust-lib/flowy-document/src/event_map.rs @@ -123,9 +123,9 @@ pub enum DocumentEvent { #[event(input = "UploadFileParamsPB", output = "UploadedFilePB")] UploadFile = 15, - #[event(input = "UploadedFilePB")] + #[event(input = "DownloadFilePB")] DownloadFile = 16, - #[event(input = "UploadedFilePB")] + #[event(input = "DownloadFilePB")] DeleteFile = 17, #[event(input = "UpdateDocumentAwarenessStatePB")] diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 813270edcc..79d2edd035 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -20,7 +20,7 @@ use tracing::{event, instrument}; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; -use flowy_storage_pub::storage::StorageService; +use flowy_storage_pub::storage::{CreatedUpload, StorageService}; use lib_dispatch::prelude::af_spawn; use crate::document::MutexDocument; @@ -346,13 +346,12 @@ impl DocumentManager { workspace_id: String, document_id: &str, local_file_path: &str, - ) -> FlowyResult { + ) -> FlowyResult { let storage_service = self.storage_service_upgrade()?; - let url = storage_service + let upload = storage_service .create_upload(&workspace_id, document_id, local_file_path) - .await? - .url; - Ok(url) + .await?; + Ok(upload) } pub async fn download_file(&self, local_file_path: String, url: String) -> FlowyResult<()> { diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index c36c5df211..6376b392ba 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -286,6 +286,18 @@ pub enum ErrorCode { #[error("Local AI unavailable")] LocalAIUnavailable = 99, + + #[error("File storage limit exceeded")] + FileStorageLimitExceeded = 100, + + #[error("AI Response limit exceeded")] + AIResponseLimitExceeded = 101, + + #[error("Duplicate record")] + DuplicateSqliteRecord = 102, + + #[error("Response timeout")] + ResponseTimeout = 103, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index a442a224ca..0092a38b00 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -72,6 +72,14 @@ impl FlowyError { self.code == ErrorCode::LocalVersionNotSupport } + pub fn is_file_limit_exceeded(&self) -> bool { + self.code == ErrorCode::FileStorageLimitExceeded + } + + pub fn is_ai_response_limit_exceeded(&self) -> bool { + self.code == ErrorCode::AIResponseLimitExceeded + } + static_flowy_error!(internal, ErrorCode::Internal); static_flowy_error!(record_not_found, ErrorCode::RecordNotFound); static_flowy_error!(workspace_initialize, ErrorCode::WorkspaceInitializeError); @@ -120,6 +128,8 @@ impl FlowyError { static_flowy_error!(workspace_data_not_match, ErrorCode::WorkspaceDataNotMatch); static_flowy_error!(local_ai, ErrorCode::LocalAIError); static_flowy_error!(local_ai_unavailable, ErrorCode::LocalAIUnavailable); + static_flowy_error!(response_timeout, ErrorCode::ResponseTimeout); + static_flowy_error!(file_storage_limit, ErrorCode::FileStorageLimitExceeded); } impl std::convert::From for FlowyError { @@ -188,3 +198,9 @@ impl From for FlowyError { FlowyError::internal().with_context(e) } } + +impl From for FlowyError { + fn from(e: String) -> Self { + FlowyError::internal().with_context(e) + } +} 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 b7af102c26..bb4059164f 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs @@ -24,6 +24,8 @@ impl From for FlowyError { AppErrorCode::UserUnAuthorized => ErrorCode::UserUnauthorized, AppErrorCode::WorkspaceLimitExceeded => ErrorCode::WorkspaceLimitExceeded, AppErrorCode::WorkspaceMemberLimitExceeded => ErrorCode::WorkspaceMemberLimitExceeded, + AppErrorCode::AIResponseLimitExceeded => ErrorCode::AIResponseLimitExceeded, + AppErrorCode::FileStorageLimitExceeded => ErrorCode::FileStorageLimitExceeded, _ => ErrorCode::Internal, }; diff --git a/frontend/rust-lib/flowy-error/src/impl_from/mod.rs b/frontend/rust-lib/flowy-error/src/impl_from/mod.rs index b3d0351cd4..a2d11c66e4 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/mod.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/mod.rs @@ -13,7 +13,11 @@ pub mod reqwest; #[cfg(feature = "impl_from_sqlite")] pub mod database; -#[cfg(feature = "impl_from_collab_document")] +#[cfg(any( + feature = "impl_from_collab_document", + feature = "impl_from_collab_folder", + feature = "impl_from_collab_database" +))] pub mod collab; #[cfg(feature = "impl_from_collab_persistence")] diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index b1dc6c0f00..1288bda52e 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -1098,7 +1098,7 @@ impl FolderManager { /// Get the publish info of the view with the given view id. /// The publish info contains the namespace and publish_name of the view. - #[tracing::instrument(level = "debug", skip(self), err)] + #[tracing::instrument(level = "debug", skip(self))] pub async fn get_publish_info(&self, view_id: &str) -> FlowyResult { let publish_info = self.cloud_service.get_publish_info(view_id).await?; Ok(publish_info) diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index f44a82ad3e..c26c8d262d 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -16,6 +16,7 @@ use flowy_database_pub::cloud::{ CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, TranslateRowContent, TranslateRowResponse, }; +use flowy_error::FlowyError; use lib_infra::future::FutureResult; use crate::af_cloud::define::ServerUser; @@ -126,7 +127,7 @@ where workspace_id: &str, _object_id: &str, summary_row: SummaryRowContent, - ) -> FutureResult { + ) -> FutureResult { let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); FutureResult::new(async move { @@ -148,7 +149,7 @@ where workspace_id: &str, translate_row: TranslateRowContent, language: &str, - ) -> FutureResult { + ) -> FutureResult { let language = language.to_string(); let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); 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 b3b5dac8c8..76e26cb7e8 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 @@ -3,7 +3,8 @@ use std::sync::Arc; use anyhow::anyhow; use client_api::entity::billing_dto::{ - SubscriptionCancelRequest, SubscriptionPlan, SubscriptionStatus, WorkspaceSubscriptionStatus, + RecurringInterval, SetSubscriptionRecurringInterval, SubscriptionCancelRequest, SubscriptionPlan, + SubscriptionPlanDetail, WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit, }; use client_api::entity::workspace_dto::{ CreateWorkspaceParam, PatchWorkspaceParam, WorkspaceMemberChangeset, WorkspaceMemberInvitation, @@ -23,7 +24,6 @@ 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; @@ -476,19 +476,18 @@ where fn subscribe_workspace( &self, workspace_id: String, - recurring_interval: flowy_user_pub::entities::RecurringInterval, - workspace_subscription_plan: flowy_user_pub::entities::SubscriptionPlan, + recurring_interval: RecurringInterval, + subscription_plan: 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 = client .create_subscription( &workspace_id, - to_recurring_interval(recurring_interval), + recurring_interval, subscription_plan, &success_url, ) @@ -525,46 +524,74 @@ where }) } - fn get_workspace_subscriptions(&self) -> FutureResult, FlowyError> { + 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 = client - .list_subscription() - .await? - .into_iter() - .map(to_workspace_subscription) - .collect(); + let workspace_subscriptions = client.list_subscription().await?; Ok(workspace_subscriptions) }) } - fn cancel_workspace_subscription(&self, workspace_id: String) -> FutureResult<(), FlowyError> { + fn get_workspace_subscription_one( + &self, + workspace_id: String, + ) -> FutureResult, FlowyError> { let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let client = try_get_client?; - let request = SubscriptionCancelRequest { - workspace_id, - plan: SubscriptionPlan::Pro, - sync: false, - reason: Some("User requested".to_string()), - }; - client.cancel_subscription(&request).await?; + let workspace_subscriptions = client.get_workspace_subscriptions(&workspace_id).await?; + Ok(workspace_subscriptions) + }) + } + + fn cancel_workspace_subscription( + &self, + workspace_id: String, + plan: SubscriptionPlan, + reason: Option, + ) -> FutureResult<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + client + .cancel_subscription(&SubscriptionCancelRequest { + workspace_id, + plan, + sync: true, + reason, + }) + .await?; Ok(()) }) } - fn get_workspace_usage(&self, workspace_id: String) -> FutureResult { + fn get_workspace_plan( + &self, + workspace_id: String, + ) -> FutureResult, FlowyError> { + let workspace_id = workspace_id.to_string(); + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let plans = client + .get_active_workspace_subscriptions(&workspace_id) + .await?; + Ok(plans) + }) + } + + 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 = client.get_workspace_usage_and_limit(&workspace_id).await?; - Ok(WorkspaceUsage { - member_count: usage.member_count as usize, - member_count_limit: usage.member_count_limit as usize, - total_blob_bytes: usage.storage_bytes as usize, - total_blob_bytes_limit: usage.storage_bytes_limit as usize, - }) + Ok(usage) }) } @@ -577,6 +604,35 @@ where }) } + fn update_workspace_subscription_payment_period( + &self, + workspace_id: String, + plan: SubscriptionPlan, + recurring_interval: RecurringInterval, + ) -> FutureResult<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + client + .set_subscription_recurring_interval(&SetSubscriptionRecurringInterval { + workspace_id, + plan, + recurring_interval, + }) + .await?; + Ok(()) + }) + } + + fn get_subscription_plan_details(&self) -> FutureResult, FlowyError> { + let try_get_client = self.server.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let plan_details = client.get_subscription_plan_details().await?; + Ok(plan_details) + }) + } + fn get_workspace_setting( &self, workspace_id: &str, @@ -710,46 +766,3 @@ fn oauth_params_from_box_any(any: BoxAny) -> Result client_api::entity::billing_dto::RecurringInterval { - match r { - flowy_user_pub::entities::RecurringInterval::Month => { - client_api::entity::billing_dto::RecurringInterval::Month - }, - flowy_user_pub::entities::RecurringInterval::Year => { - client_api::entity::billing_dto::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: flowy_user_pub::entities::SubscriptionPlan::None, - recurring_interval: match s.recurring_interval { - client_api::entity::billing_dto::RecurringInterval::Month => { - flowy_user_pub::entities::RecurringInterval::Month - }, - client_api::entity::billing_dto::RecurringInterval::Year => { - flowy_user_pub::entities::RecurringInterval::Year - }, - }, - is_active: matches!(s.subscription_status, SubscriptionStatus::Active), - canceled_at: s.cancel_at, - } -} diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index d0bd3b0552..30cc6415d1 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -8,6 +8,7 @@ use flowy_database_pub::cloud::{ CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, TranslateRowContent, TranslateRowResponse, }; +use flowy_error::FlowyError; use lib_infra::future::FutureResult; pub(crate) struct LocalServerDatabaseCloudServiceImpl(); @@ -82,7 +83,7 @@ impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { _workspace_id: &str, _object_id: &str, _summary_row: SummaryRowContent, - ) -> FutureResult { + ) -> FutureResult { // TODO(lucas): local ai FutureResult::new(async move { Ok("".to_string()) }) } @@ -92,7 +93,7 @@ impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { _workspace_id: &str, _translate_row: TranslateRowContent, _language: &str, - ) -> FutureResult { + ) -> FutureResult { // TODO(lucas): local ai FutureResult::new(async move { Ok(TranslateRowResponse::default()) }) } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs index afba36d585..aed2be3adc 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs @@ -6,6 +6,7 @@ use flowy_database_pub::cloud::{ CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, TranslateRowContent, TranslateRowResponse, }; +use flowy_error::FlowyError; use lib_dispatch::prelude::af_spawn; use lib_infra::future::FutureResult; @@ -103,7 +104,7 @@ where _workspace_id: &str, _object_id: &str, _summary_row: SummaryRowContent, - ) -> FutureResult { + ) -> FutureResult { FutureResult::new(async move { Ok("".to_string()) }) } @@ -112,7 +113,7 @@ where _workspace_id: &str, _translate_row: TranslateRowContent, _language: &str, - ) -> FutureResult { + ) -> FutureResult { FutureResult::new(async move { Ok(TranslateRowResponse::default()) }) } } diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index aa2fe73042..8bbd24e4ff 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -19,8 +19,18 @@ mime_guess = "2.0.4" fxhash = "0.2.1" anyhow = "1.0.86" chrono = "0.4.33" +flowy-notification = { workspace = true } +flowy-derive.workspace = true +protobuf = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } uuid = "1.6.1" rand = { version = "0.8", features = ["std_rng"] } + +[features] +dart = ["flowy-codegen/dart", "flowy-notification/dart"] +tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] + +[build-dependencies] +flowy-codegen.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage/Flowy.toml b/frontend/rust-lib/flowy-storage/Flowy.toml new file mode 100644 index 0000000000..f2e1792f46 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/Flowy.toml @@ -0,0 +1,2 @@ +# Check out the FlowyConfig (located in flowy_toml.rs) for more details. +proto_input = ["src/notification.rs"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage/build.rs b/frontend/rust-lib/flowy-storage/build.rs new file mode 100644 index 0000000000..e015eb2580 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/build.rs @@ -0,0 +1,23 @@ +fn main() { + #[cfg(feature = "dart")] + { + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); + } + + #[cfg(feature = "tauri_ts")] + { + flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + flowy_codegen::protobuf_file::ts_gen( + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + flowy_codegen::Project::Tauri, + ); + flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); + flowy_codegen::protobuf_file::ts_gen( + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + flowy_codegen::Project::TauriApp, + ); + } +} diff --git a/frontend/rust-lib/flowy-storage/src/lib.rs b/frontend/rust-lib/flowy-storage/src/lib.rs index 45e528233e..3d5e3033d3 100644 --- a/frontend/rust-lib/flowy-storage/src/lib.rs +++ b/frontend/rust-lib/flowy-storage/src/lib.rs @@ -1,4 +1,6 @@ mod file_cache; pub mod manager; +mod notification; +mod protobuf; pub mod sqlite_sql; mod uploader; diff --git a/frontend/rust-lib/flowy-storage/src/manager.rs b/frontend/rust-lib/flowy-storage/src/manager.rs index 43017419c3..0ae2751b5a 100644 --- a/frontend/rust-lib/flowy-storage/src/manager.rs +++ b/frontend/rust-lib/flowy-storage/src/manager.rs @@ -1,4 +1,5 @@ use crate::file_cache::FileTempStorage; +use crate::notification::{make_notification, StorageNotification}; use crate::sqlite_sql::{ batch_select_upload_file, delete_upload_file, insert_upload_file, insert_upload_part, select_upload_file, select_upload_parts, update_upload_file_upload_id, UploadFilePartTable, @@ -6,7 +7,7 @@ use crate::sqlite_sql::{ }; use crate::uploader::{FileUploader, FileUploaderRunner, Signal, UploadTask, UploadTaskQueue}; use async_trait::async_trait; -use flowy_error::{FlowyError, FlowyResult}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; use flowy_storage_pub::chunked_byte::{ChunkedBytes, MIN_CHUNK_SIZE}; use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; @@ -19,6 +20,7 @@ use lib_infra::future::FutureResult; use lib_infra::util::timestamp; use std::io::ErrorKind; use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -35,7 +37,7 @@ pub trait StorageUserService: Send + Sync + 'static { pub struct StorageManager { pub storage_service: Arc, uploader: Arc, - broadcast: tokio::sync::broadcast::Sender, + upload_status_notifier: tokio::sync::broadcast::Sender, } impl Drop for StorageManager { @@ -49,23 +51,29 @@ impl StorageManager { cloud_service: Arc, user_service: Arc, ) -> Self { + let is_exceed_storage_limit = Arc::new(AtomicBool::new(false)); let temp_storage_path = PathBuf::from(format!( "{}/cache_files", user_service.get_application_root_dir() )); let temp_storage = Arc::new(FileTempStorage::new(temp_storage_path)); let (notifier, notifier_rx) = watch::channel(Signal::Proceed); - let (broadcast, _) = tokio::sync::broadcast::channel::(100); + let (upload_status_notifier, _) = tokio::sync::broadcast::channel::(100); let task_queue = Arc::new(UploadTaskQueue::new(notifier)); let storage_service = Arc::new(StorageServiceImpl { cloud_service, user_service: user_service.clone(), temp_storage, task_queue: task_queue.clone(), - upload_status_notifier: broadcast.clone(), + upload_status_notifier: upload_status_notifier.clone(), + is_exceed_storage_limit: is_exceed_storage_limit.clone(), }); - let uploader = Arc::new(FileUploader::new(storage_service.clone(), task_queue)); + let uploader = Arc::new(FileUploader::new( + storage_service.clone(), + task_queue, + is_exceed_storage_limit, + )); tokio::spawn(FileUploaderRunner::run( Arc::downgrade(&uploader), notifier_rx, @@ -85,7 +93,7 @@ impl StorageManager { Self { storage_service, uploader, - broadcast, + upload_status_notifier, } } @@ -97,8 +105,18 @@ impl StorageManager { } } + pub fn disable_storage_write_access(&self) { + // when storage is purchased, resume the uploader + self.uploader.disable_storage_write(); + } + + pub fn enable_storage_write_access(&self) { + // when storage is purchased, resume the uploader + self.uploader.enable_storage_write(); + } + pub fn subscribe_upload_result(&self) -> tokio::sync::broadcast::Receiver { - self.broadcast.subscribe() + self.upload_status_notifier.subscribe() } } @@ -130,6 +148,7 @@ pub struct StorageServiceImpl { temp_storage: Arc, task_queue: Arc, upload_status_notifier: tokio::sync::broadcast::Sender, + is_exceed_storage_limit: Arc, } #[async_trait] @@ -239,8 +258,18 @@ impl StorageService for StorageServiceImpl { let task_queue = self.task_queue.clone(); let user_service = self.user_service.clone(); let cloud_service = self.cloud_service.clone(); + let is_exceed_storage_limit = self.is_exceed_storage_limit.clone(); FutureResult::new(async move { + let is_exceed_limit = is_exceed_storage_limit.load(std::sync::atomic::Ordering::Relaxed); + if is_exceed_limit { + make_notification(StorageNotification::FileStorageLimitExceeded) + .payload(FlowyError::file_storage_limit()) + .send(); + + return Err(FlowyError::file_storage_limit()); + } + let local_file_path = temp_storage .create_temp_file_from_existing(Path::new(&file_path)) .await @@ -256,25 +285,34 @@ impl StorageService for StorageServiceImpl { // 2. save the record to sqlite let conn = user_service.sqlite_connection(user_service.user_id()?)?; - insert_upload_file(conn, &record)?; - - // 3. generate url for given file let url = cloud_service.get_object_url_v1( &record.workspace_id, &record.parent_dir, &record.file_id, )?; let file_id = record.file_id.clone(); + match insert_upload_file(conn, &record) { + Ok(_) => { + // 3. generate url for given file + task_queue + .queue_task(UploadTask::Task { + chunks, + record, + retry_count: 0, + }) + .await; - task_queue - .queue_task(UploadTask::Task { - chunks, - record, - retry_count: 0, - }) - .await; - - Ok::<_, FlowyError>(CreatedUpload { url, file_id }) + Ok::<_, FlowyError>(CreatedUpload { url, file_id }) + }, + Err(err) => { + if matches!(err.code, ErrorCode::DuplicateSqliteRecord) { + info!("upload record already exists, skip creating new upload task"); + Ok::<_, FlowyError>(CreatedUpload { url, file_id }) + } else { + Err(err) + } + }, + } }) } @@ -392,14 +430,23 @@ async fn start_upload( upload_file.file_id ); - let create_upload_resp = cloud_service + let create_upload_resp_result = cloud_service .create_upload( &upload_file.workspace_id, &upload_file.parent_dir, &upload_file.file_id, &upload_file.content_type, ) - .await?; + .await; + if let Err(err) = create_upload_resp_result.as_ref() { + if err.is_file_limit_exceeded() { + make_notification(StorageNotification::FileStorageLimitExceeded) + .payload(err.clone()) + .send(); + } + } + let create_upload_resp = create_upload_resp_result?; + // 2. update upload_id let conn = user_service.sqlite_connection(user_service.user_id()?)?; update_upload_file_upload_id( @@ -468,6 +515,12 @@ async fn start_upload( }); }, Err(err) => { + if err.is_file_limit_exceeded() { + make_notification(StorageNotification::FileStorageLimitExceeded) + .payload(err.clone()) + .send(); + } + error!("[File] {} upload part failed: {}", upload_file.file_id, err); return Err(err); }, @@ -475,7 +528,7 @@ async fn start_upload( } // mark it as completed - complete_upload( + let complete_upload_result = complete_upload( cloud_service, user_service, temp_storage, @@ -483,7 +536,14 @@ async fn start_upload( completed_parts, notifier, ) - .await?; + .await; + if let Err(err) = complete_upload_result { + if err.is_file_limit_exceeded() { + make_notification(StorageNotification::FileStorageLimitExceeded) + .payload(err.clone()) + .send(); + } + } trace!("[File] {} upload completed", upload_file.file_id); Ok(()) diff --git a/frontend/rust-lib/flowy-storage/src/notification.rs b/frontend/rust-lib/flowy-storage/src/notification.rs new file mode 100644 index 0000000000..a1b990bbd1 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/notification.rs @@ -0,0 +1,21 @@ +use flowy_derive::ProtoBuf_Enum; +use flowy_notification::NotificationBuilder; + +const OBSERVABLE_SOURCE: &str = "storage"; + +#[derive(ProtoBuf_Enum, Debug, Default)] +pub(crate) enum StorageNotification { + #[default] + FileStorageLimitExceeded = 0, +} + +impl std::convert::From for i32 { + fn from(notification: StorageNotification) -> Self { + notification as i32 + } +} + +#[tracing::instrument(level = "trace")] +pub(crate) fn make_notification(ty: StorageNotification) -> NotificationBuilder { + NotificationBuilder::new("appflowy_file_storage_notification", ty, OBSERVABLE_SOURCE) +} diff --git a/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs b/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs index 339b3e4993..c05800341f 100644 --- a/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs +++ b/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs @@ -1,4 +1,6 @@ use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::result::DatabaseErrorKind; +use flowy_sqlite::result::Error::DatabaseError; use flowy_sqlite::schema::{upload_file_part, upload_file_table}; use flowy_sqlite::{ diesel, AsChangeset, BoolExpressionMethods, DBConnection, ExpressionMethods, Identifiable, @@ -52,10 +54,17 @@ pub fn insert_upload_file( mut conn: DBConnection, upload_file: &UploadFileTable, ) -> FlowyResult<()> { - diesel::insert_into(upload_file_table::table) + match diesel::insert_into(upload_file_table::table) .values(upload_file) - .execute(&mut *conn)?; - Ok(()) + .execute(&mut *conn) + { + Ok(_) => Ok(()), + Err(DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => Err(FlowyError::new( + flowy_error::ErrorCode::DuplicateSqliteRecord, + "Upload file already exists", + )), + Err(e) => Err(e.into()), + } } pub fn update_upload_file_upload_id( diff --git a/frontend/rust-lib/flowy-storage/src/uploader.rs b/frontend/rust-lib/flowy-storage/src/uploader.rs index 9d6fb0ecb0..f05878c7f8 100644 --- a/frontend/rust-lib/flowy-storage/src/uploader.rs +++ b/frontend/rust-lib/flowy-storage/src/uploader.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, AtomicU8}; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::sync::{watch, RwLock}; -use tracing::{info, trace}; +use tracing::{error, info, trace}; #[derive(Clone)] pub enum Signal { @@ -44,6 +44,7 @@ pub struct FileUploader { max_uploads: u8, current_uploads: AtomicU8, pause_sync: AtomicBool, + has_exceeded_limit: Arc, } impl Drop for FileUploader { @@ -53,13 +54,18 @@ impl Drop for FileUploader { } impl FileUploader { - pub fn new(storage_service: Arc, queue: Arc) -> Self { + pub fn new( + storage_service: Arc, + queue: Arc, + is_exceed_limit: Arc, + ) -> Self { Self { storage_service, queue, max_uploads: 3, current_uploads: Default::default(), pause_sync: Default::default(), + has_exceeded_limit: is_exceed_limit, } } @@ -77,7 +83,25 @@ impl FileUploader { .store(true, std::sync::atomic::Ordering::SeqCst); } + pub fn disable_storage_write(&self) { + self + .has_exceeded_limit + .store(true, std::sync::atomic::Ordering::SeqCst); + self.pause(); + } + + pub fn enable_storage_write(&self) { + self + .has_exceeded_limit + .store(false, std::sync::atomic::Ordering::SeqCst); + self.resume(); + } + pub fn resume(&self) { + if self.pause_sync.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + self .pause_sync .store(false, std::sync::atomic::Ordering::SeqCst); @@ -108,6 +132,14 @@ impl FileUploader { return None; } + if self + .has_exceeded_limit + .load(std::sync::atomic::Ordering::SeqCst) + { + // If the storage limitation is enabled, do not proceed. + return None; + } + let task = self.queue.tasks.write().await.pop()?; if task.retry_count() > 5 { // If the task has been retried more than 5 times, we should not retry it anymore. @@ -128,6 +160,11 @@ impl FileUploader { } => { let record = BoxAny::new(record); if let Err(err) = self.storage_service.start_upload(&chunks, &record).await { + if err.is_file_limit_exceeded() { + error!("Failed to upload file: {}", err); + self.disable_storage_write(); + } + info!( "Failed to upload file: {}, retry_count:{}", err, retry_count @@ -154,6 +191,11 @@ impl FileUploader { .resume_upload(&workspace_id, &parent_dir, &file_id) .await { + if err.is_file_limit_exceeded() { + error!("Failed to upload file: {}", err); + self.disable_storage_write(); + } + info!( "Failed to resume upload file: {}, retry_count:{}", err, retry_count diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index 44f215906b..117efac414 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -1,3 +1,9 @@ +use client_api::entity::billing_dto::RecurringInterval; +use client_api::entity::billing_dto::SubscriptionPlan; +use client_api::entity::billing_dto::SubscriptionPlanDetail; +pub use client_api::entity::billing_dto::SubscriptionStatus; +use client_api::entity::billing_dto::WorkspaceSubscriptionStatus; +use client_api::entity::billing_dto::WorkspaceUsageAndLimit; pub use client_api::entity::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use collab_entity::{CollabObject, CollabType}; use flowy_error::{internal_error, ErrorCode, FlowyError}; @@ -13,9 +19,8 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::entities::{ - AuthResponse, Authenticator, RecurringInterval, Role, SubscriptionPlan, UpdateUserProfileParams, - UserCredentials, UserProfile, UserTokenState, UserWorkspace, WorkspaceInvitation, - WorkspaceInvitationStatus, WorkspaceMember, WorkspaceSubscription, WorkspaceUsage, + AuthResponse, Authenticator, Role, UpdateUserProfileParams, UserCredentials, UserProfile, + UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -281,15 +286,41 @@ pub trait UserCloudService: Send + Sync + 'static { FutureResult::new(async { Err(FlowyError::not_support()) }) } - fn get_workspace_subscriptions(&self) -> FutureResult, FlowyError> { + /// Get all subscriptions for all workspaces for a user (email) + fn get_workspace_subscriptions( + &self, + ) -> FutureResult, FlowyError> { FutureResult::new(async { Err(FlowyError::not_support()) }) } - fn cancel_workspace_subscription(&self, workspace_id: String) -> FutureResult<(), FlowyError> { + /// Get the workspace subscriptions for a workspace + fn get_workspace_subscription_one( + &self, + workspace_id: String, + ) -> FutureResult, FlowyError> { FutureResult::new(async { Err(FlowyError::not_support()) }) } - fn get_workspace_usage(&self, workspace_id: String) -> FutureResult { + fn cancel_workspace_subscription( + &self, + workspace_id: String, + plan: SubscriptionPlan, + reason: Option, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + + fn get_workspace_plan( + &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()) }) } @@ -297,6 +328,19 @@ pub trait UserCloudService: Send + Sync + 'static { FutureResult::new(async { Err(FlowyError::not_support()) }) } + fn update_workspace_subscription_payment_period( + &self, + workspace_id: String, + plan: SubscriptionPlan, + recurring_interval: RecurringInterval, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + + fn get_subscription_plan_details(&self) -> FutureResult, FlowyError> { + FutureResult::new(async { Err(FlowyError::not_support()) }) + } + fn get_workspace_setting( &self, workspace_id: &str, diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 642004a8f9..95a38ab3c0 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -1,6 +1,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; +pub use client_api::entity::billing_dto::RecurringInterval; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_repr::*; @@ -452,29 +453,3 @@ 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/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 0d53b9f6b7..f2eb89dc4c 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -24,6 +24,7 @@ collab-user = { workspace = true } collab-entity = { workspace = true } collab-plugins = { workspace = true } flowy-user-pub = { workspace = true } +client-api = { workspace = true } anyhow.workspace = true tracing.workspace = true bytes.workspace = true diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index 2d48e911bd..afb1af83ff 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -1,12 +1,14 @@ +use client_api::entity::billing_dto::{ + Currency, RecurringInterval, SubscriptionPlan, SubscriptionPlanDetail, + WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit, +}; +use serde::{Deserialize, Serialize}; use std::str::FromStr; use validator::Validate; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; -use flowy_user_pub::entities::{ - RecurringInterval, Role, SubscriptionPlan, WorkspaceInvitation, WorkspaceMember, - WorkspaceSubscription, -}; +use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember}; use lib_infra::validator_fn::required_not_empty_str; #[derive(ProtoBuf, Default, Clone)] @@ -180,6 +182,29 @@ pub struct UserWorkspaceIdPB { pub workspace_id: String, } +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct CancelWorkspaceSubscriptionPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, + + #[pb(index = 2)] + pub plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub reason: String, +} + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct SuccessWorkspaceSubscriptionPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, + + #[pb(index = 2, one_of)] + pub plan: Option, +} + #[derive(ProtoBuf, Default, Clone)] pub struct WorkspaceMemberIdPB { #[pb(index = 1)] @@ -230,7 +255,7 @@ pub struct SubscribeWorkspacePB { pub success_url: String, } -#[derive(ProtoBuf_Enum, Clone, Default, Debug)] +#[derive(ProtoBuf_Enum, Clone, Default, Debug, Serialize, Deserialize)] pub enum RecurringIntervalPB { #[default] Month = 0, @@ -255,12 +280,26 @@ impl From for RecurringIntervalPB { } } -#[derive(ProtoBuf_Enum, Clone, Default, Debug)] +#[derive(ProtoBuf_Enum, Clone, Default, Debug, Serialize, Deserialize)] pub enum SubscriptionPlanPB { #[default] - None = 0, + Free = 0, Pro = 1, Team = 2, + + // Add-ons + AiMax = 3, + AiLocal = 4, +} + +impl From for SubscriptionPlanPB { + fn from(value: WorkspacePlanPB) -> Self { + match value { + WorkspacePlanPB::FreePlan => SubscriptionPlanPB::Free, + WorkspacePlanPB::ProPlan => SubscriptionPlanPB::Pro, + WorkspacePlanPB::TeamPlan => SubscriptionPlanPB::Team, + } + } } impl From for SubscriptionPlan { @@ -268,7 +307,9 @@ impl From for SubscriptionPlan { match value { SubscriptionPlanPB::Pro => SubscriptionPlan::Pro, SubscriptionPlanPB::Team => SubscriptionPlan::Team, - SubscriptionPlanPB::None => SubscriptionPlan::None, + SubscriptionPlanPB::Free => SubscriptionPlan::Free, + SubscriptionPlanPB::AiMax => SubscriptionPlan::AiMax, + SubscriptionPlanPB::AiLocal => SubscriptionPlan::AiLocal, } } } @@ -278,7 +319,9 @@ impl From for SubscriptionPlanPB { match value { SubscriptionPlan::Pro => SubscriptionPlanPB::Pro, SubscriptionPlan::Team => SubscriptionPlanPB::Team, - SubscriptionPlan::None => SubscriptionPlanPB::None, + SubscriptionPlan::Free => SubscriptionPlanPB::Free, + SubscriptionPlan::AiMax => SubscriptionPlanPB::AiMax, + SubscriptionPlan::AiLocal => SubscriptionPlanPB::AiLocal, } } } @@ -289,46 +332,6 @@ pub struct PaymentLinkPB { 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)] @@ -336,9 +339,35 @@ pub struct WorkspaceUsagePB { #[pb(index = 2)] pub member_count_limit: u64, #[pb(index = 3)] - pub total_blob_bytes: u64, + pub storage_bytes: u64, #[pb(index = 4)] - pub total_blob_bytes_limit: u64, + pub storage_bytes_limit: u64, + #[pb(index = 5)] + pub storage_bytes_unlimited: bool, + #[pb(index = 6)] + pub ai_responses_count: u64, + #[pb(index = 7)] + pub ai_responses_count_limit: u64, + #[pb(index = 8)] + pub ai_responses_unlimited: bool, + #[pb(index = 9)] + pub local_ai: bool, +} + +impl From for WorkspaceUsagePB { + fn from(workspace_usage: WorkspaceUsageAndLimit) -> Self { + WorkspaceUsagePB { + member_count: workspace_usage.member_count as u64, + member_count_limit: workspace_usage.member_count_limit as u64, + storage_bytes: workspace_usage.storage_bytes as u64, + storage_bytes_limit: workspace_usage.storage_bytes_limit as u64, + storage_bytes_unlimited: workspace_usage.storage_bytes_unlimited, + ai_responses_count: workspace_usage.ai_responses_count as u64, + ai_responses_count_limit: workspace_usage.ai_responses_count_limit as u64, + ai_responses_unlimited: workspace_usage.ai_responses_unlimited, + local_ai: workspace_usage.local_ai, + } + } } #[derive(Debug, ProtoBuf, Default, Clone)] @@ -426,3 +455,243 @@ impl FromStr for AIModelPB { } } } + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct WorkspaceSubscriptionInfoPB { + #[pb(index = 1)] + pub plan: WorkspacePlanPB, + #[pb(index = 2)] + pub plan_subscription: WorkspaceSubscriptionV2PB, // valid if plan is not WorkspacePlanFree + #[pb(index = 3)] + pub add_ons: Vec, +} + +impl WorkspaceSubscriptionInfoPB { + pub fn default_from_workspace_id(workspace_id: String) -> Self { + Self { + plan: WorkspacePlanPB::FreePlan, + plan_subscription: WorkspaceSubscriptionV2PB { + workspace_id, + subscription_plan: SubscriptionPlanPB::Free, + status: WorkspaceSubscriptionStatusPB::Active, + end_date: 0, + interval: RecurringIntervalPB::Month, + }, + add_ons: Vec::new(), + } + } +} + +impl From> for WorkspaceSubscriptionInfoPB { + fn from(subs: Vec) -> Self { + let mut plan = WorkspacePlanPB::FreePlan; + let mut plan_subscription = WorkspaceSubscriptionV2PB::default(); + let mut add_ons = Vec::new(); + for sub in subs { + match sub.workspace_plan { + SubscriptionPlan::Free => { + plan = WorkspacePlanPB::FreePlan; + }, + SubscriptionPlan::Pro => { + plan = WorkspacePlanPB::ProPlan; + plan_subscription = sub.into(); + }, + SubscriptionPlan::Team => { + plan = WorkspacePlanPB::TeamPlan; + }, + SubscriptionPlan::AiMax => { + if plan_subscription.workspace_id.is_empty() { + plan_subscription = + WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone()); + } + + add_ons.push(WorkspaceAddOnPB { + type_: WorkspaceAddOnPBType::AddOnAiMax, + add_on_subscription: sub.into(), + }); + }, + SubscriptionPlan::AiLocal => { + if plan_subscription.workspace_id.is_empty() { + plan_subscription = + WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone()); + } + + add_ons.push(WorkspaceAddOnPB { + type_: WorkspaceAddOnPBType::AddOnAiLocal, + add_on_subscription: sub.into(), + }); + }, + } + } + + WorkspaceSubscriptionInfoPB { + plan, + plan_subscription, + add_ons, + } + } +} + +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)] +pub enum WorkspacePlanPB { + #[default] + FreePlan = 0, + ProPlan = 1, + TeamPlan = 2, +} + +impl From for i64 { + fn from(val: WorkspacePlanPB) -> Self { + val as i64 + } +} + +impl From for WorkspacePlanPB { + fn from(value: i64) -> Self { + match value { + 0 => WorkspacePlanPB::FreePlan, + 1 => WorkspacePlanPB::ProPlan, + 2 => WorkspacePlanPB::TeamPlan, + _ => WorkspacePlanPB::FreePlan, + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)] +pub struct WorkspaceAddOnPB { + #[pb(index = 1)] + type_: WorkspaceAddOnPBType, + #[pb(index = 2)] + add_on_subscription: WorkspaceSubscriptionV2PB, +} + +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] +pub enum WorkspaceAddOnPBType { + #[default] + AddOnAiLocal = 0, + AddOnAiMax = 1, +} + +#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)] +pub struct WorkspaceSubscriptionV2PB { + #[pb(index = 1)] + pub workspace_id: String, + + #[pb(index = 2)] + pub subscription_plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub status: WorkspaceSubscriptionStatusPB, + + #[pb(index = 4)] + pub end_date: i64, // Unix timestamp of when this subscription cycle ends + + #[pb(index = 5)] + pub interval: RecurringIntervalPB, +} + +impl WorkspaceSubscriptionV2PB { + pub fn default_with_workspace_id(workspace_id: String) -> Self { + Self { + workspace_id, + subscription_plan: SubscriptionPlanPB::Free, + status: WorkspaceSubscriptionStatusPB::Active, + end_date: 0, + interval: RecurringIntervalPB::Month, + } + } +} + +impl From for WorkspaceSubscriptionV2PB { + fn from(sub: WorkspaceSubscriptionStatus) -> Self { + Self { + workspace_id: sub.workspace_id, + subscription_plan: sub.workspace_plan.clone().into(), + status: if sub.cancel_at.is_some() { + WorkspaceSubscriptionStatusPB::Canceled + } else { + WorkspaceSubscriptionStatusPB::Active + }, + interval: sub.recurring_interval.into(), + end_date: sub.current_period_end, + } + } +} + +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] +pub enum WorkspaceSubscriptionStatusPB { + #[default] + Active = 0, + Canceled = 1, +} + +impl From for i64 { + fn from(val: WorkspaceSubscriptionStatusPB) -> Self { + val as i64 + } +} + +impl From for WorkspaceSubscriptionStatusPB { + fn from(value: i64) -> Self { + match value { + 0 => WorkspaceSubscriptionStatusPB::Active, + _ => WorkspaceSubscriptionStatusPB::Canceled, + } + } +} + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct UpdateWorkspaceSubscriptionPaymentPeriodPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, + + #[pb(index = 2)] + pub plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub recurring_interval: RecurringIntervalPB, +} + +#[derive(ProtoBuf, Default, Clone)] +pub struct RepeatedSubscriptionPlanDetailPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Clone)] +pub struct SubscriptionPlanDetailPB { + #[pb(index = 1)] + pub currency: CurrencyPB, + #[pb(index = 2)] + pub price_cents: i64, + #[pb(index = 3)] + pub recurring_interval: RecurringIntervalPB, + #[pb(index = 4)] + pub plan: SubscriptionPlanPB, +} + +impl From for SubscriptionPlanDetailPB { + fn from(value: SubscriptionPlanDetail) -> Self { + Self { + currency: value.currency.into(), + price_cents: value.price_cents, + recurring_interval: value.recurring_interval.into(), + plan: value.plan.into(), + } + } +} + +#[derive(ProtoBuf_Enum, Clone, Default)] +pub enum CurrencyPB { + #[default] + USD = 0, +} + +impl From for CurrencyPB { + fn from(value: Currency) -> Self { + match value { + Currency::USD => CurrencyPB::USD, + } + } +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index c10bd4484f..2ecd11608c 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -33,7 +33,9 @@ fn upgrade_store_preferences( Ok(store) } -#[tracing::instrument(level = "debug", name = "sign_in", skip(data, manager), fields(email = %data.email), err)] +#[tracing::instrument(level = "debug", name = "sign_in", skip(data, manager), fields( + email = % data.email +), err)] pub async fn sign_in_with_email_password_handler( data: AFPluginData, manager: AFPluginState>, @@ -59,8 +61,8 @@ pub async fn sign_in_with_email_password_handler( name = "sign_up", skip(data, manager), fields( - email = %data.email, - name = %data.name, + email = % data.email, + name = % data.name, ), err )] @@ -774,27 +776,28 @@ pub async fn subscribe_workspace_handler( } #[tracing::instrument(level = "debug", skip_all, err)] -pub async fn get_workspace_subscriptions_handler( +pub async fn get_workspace_subscription_info_handler( + params: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { + let params = params.try_into_inner()?; let manager = upgrade_manager(manager)?; let subs = manager - .get_workspace_subscriptions() - .await? - .into_iter() - .map(WorkspaceSubscriptionPB::from) - .collect::>(); - data_result_ok(RepeatedWorkspaceSubscriptionPB { items: subs }) + .get_workspace_subscription_info(params.workspace_id) + .await?; + data_result_ok(subs) } #[tracing::instrument(level = "debug", skip_all, err)] pub async fn cancel_workspace_subscription_handler( - param: AFPluginData, + param: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { - let workspace_id = param.into_inner().workspace_id; + let params = param.into_inner(); let manager = upgrade_manager(manager)?; - manager.cancel_workspace_subscription(workspace_id).await?; + manager + .cancel_workspace_subscription(params.workspace_id, params.plan.into(), Some(params.reason)) + .await?; Ok(()) } @@ -806,12 +809,7 @@ pub async fn get_workspace_usage_handler( 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, - }) + data_result_ok(WorkspaceUsagePB::from(workspace_usage)) } #[tracing::instrument(level = "debug", skip_all, err)] @@ -823,6 +821,36 @@ pub async fn get_billing_portal_handler( data_result_ok(BillingPortalPB { url }) } +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn update_workspace_subscription_payment_period_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let params = params.try_into_inner()?; + let manager = upgrade_manager(manager)?; + manager + .update_workspace_subscription_payment_period( + params.workspace_id, + params.plan.into(), + params.recurring_interval.into(), + ) + .await +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_subscription_plan_details_handler( + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let plans = manager + .get_subscription_plan_details() + .await? + .into_iter() + .map(SubscriptionPlanDetailPB::from) + .collect::>(); + data_result_ok(RepeatedSubscriptionPlanDetailPB { items: plans }) +} + #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_workspace_member_info( param: AFPluginData, @@ -854,3 +882,14 @@ pub async fn get_workspace_setting( let pb = manager.get_workspace_settings(¶ms.workspace_id).await?; data_result_ok(pb) } + +#[tracing::instrument(level = "info", skip_all, err)] +pub async fn notify_did_switch_plan_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let success = params.into_inner(); + let manager = upgrade_manager(manager)?; + manager.notify_did_switch_plan(success).await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index bb9a08c823..6a9a1403d5 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -1,5 +1,5 @@ +use client_api::entity::billing_dto::SubscriptionPlan; use std::sync::Weak; - use strum_macros::Display; use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; @@ -70,13 +70,16 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler) // Billing .event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler) - .event(UserEvent::GetWorkspaceSubscriptions, get_workspace_subscriptions_handler) + .event(UserEvent::GetWorkspaceSubscriptionInfo, get_workspace_subscription_info_handler) .event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler) .event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler) .event(UserEvent::GetBillingPortal, get_billing_portal_handler) + .event(UserEvent::UpdateWorkspaceSubscriptionPaymentPeriod, update_workspace_subscription_payment_period_handler) + .event(UserEvent::GetSubscriptionPlanDetails, get_subscription_plan_details_handler) // Workspace Setting .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting) .event(UserEvent::GetWorkspaceSetting, get_workspace_setting) + .event(UserEvent::NotifyDidSwitchPlan, notify_did_switch_plan_handler) } @@ -242,10 +245,7 @@ pub enum UserEvent { #[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")] SubscribeWorkspace = 51, - #[event(output = "RepeatedWorkspaceSubscriptionPB")] - GetWorkspaceSubscriptions = 52, - - #[event(input = "UserWorkspaceIdPB")] + #[event(input = "CancelWorkspaceSubscriptionPB")] CancelWorkspaceSubscription = 53, #[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")] @@ -262,6 +262,18 @@ pub enum UserEvent { #[event(input = "UserWorkspaceIdPB", output = "UseAISettingPB")] GetWorkspaceSetting = 58, + + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSubscriptionInfoPB")] + GetWorkspaceSubscriptionInfo = 59, + + #[event(input = "UpdateWorkspaceSubscriptionPaymentPeriodPB")] + UpdateWorkspaceSubscriptionPaymentPeriod = 61, + + #[event(output = "RepeatedSubscriptionPlanDetailPB")] + GetSubscriptionPlanDetails = 62, + + #[event(input = "SuccessWorkspaceSubscriptionPB")] + NotifyDidSwitchPlan = 63, } pub trait UserStatusCallback: Send + Sync + 'static { @@ -297,6 +309,8 @@ pub trait UserStatusCallback: Send + Sync + 'static { fn did_expired(&self, token: &str, user_id: i64) -> Fut>; fn open_workspace(&self, user_id: i64, user_workspace: &UserWorkspace) -> Fut>; fn did_update_network(&self, _reachable: bool) {} + fn did_update_plans(&self, _plans: Vec) {} + fn did_update_storage_limitation(&self, _can_write: bool) {} } /// Acts as a placeholder [UserStatusCallback] for the user session, but does not perform any function diff --git a/frontend/rust-lib/flowy-user/src/services/billing_check.rs b/frontend/rust-lib/flowy-user/src/services/billing_check.rs new file mode 100644 index 0000000000..b0b123fc41 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/services/billing_check.rs @@ -0,0 +1,88 @@ +use crate::services::authenticate_user::AuthenticateUser; +use client_api::entity::billing_dto::SubscriptionPlan; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_user_pub::cloud::UserCloudServiceProvider; +use std::sync::Weak; +use std::time::Duration; + +/// `PeriodicallyCheckBillingState` is designed to periodically verify the subscription +/// plan of a given workspace. It utilizes a cloud service provider to fetch the current +/// subscription plans and compares them with an expected plan. +/// +/// If the expected plan is found, the check stops. Otherwise, it continues to check +/// at specified intervals until the expected plan is found or the maximum number of +/// attempts is reached. +pub struct PeriodicallyCheckBillingState { + workspace_id: String, + cloud_service: Weak, + expected_plan: Option, + user: Weak, +} + +impl PeriodicallyCheckBillingState { + pub fn new( + workspace_id: String, + expected_plan: Option, + cloud_service: Weak, + user: Weak, + ) -> Self { + Self { + workspace_id, + cloud_service, + expected_plan, + user, + } + } + + pub async fn start(&self) -> FlowyResult> { + let cloud_service = self + .cloud_service + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Cloud service is not available"))?; + + let mut attempts = 0; + let max_attempts = 5; + let delay_duration = Duration::from_secs(4); + while attempts < max_attempts { + let plans = cloud_service + .get_user_service()? + .get_workspace_plan(self.workspace_id.clone()) + .await?; + + // If the expected plan is not set, return the plans immediately. Otherwise, + // check if the expected plan is found in the list of plans. + if let Some(expected_plan) = &self.expected_plan { + if plans.contains(expected_plan) { + return Ok(plans); + } + attempts += 1; + } else { + attempts += 2; + } + + tokio::time::sleep(delay_duration).await; + if let Some(user) = self.user.upgrade() { + if let Ok(current_workspace_id) = user.workspace_id() { + if current_workspace_id != self.workspace_id { + return Err( + FlowyError::internal() + .with_context("Workspace ID has changed while checking the billing state"), + ); + } + } else { + break; + } + } + + // After last retry, return plans even if the expected plan is not found + if attempts >= max_attempts { + return Ok(plans); + } + } + + Err( + FlowyError::response_timeout() + .with_context("Exceeded maximum number of checks without finding the expected plan"), + ) + } +} diff --git a/frontend/rust-lib/flowy-user/src/services/mod.rs b/frontend/rust-lib/flowy-user/src/services/mod.rs index f7fc8ae7b6..66316fa01a 100644 --- a/frontend/rust-lib/flowy-user/src/services/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod authenticate_user; +pub(crate) mod billing_check; pub mod cloud_config; pub mod collab_interact; pub mod data_import; 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 97d5f924aa..6b448ba6f8 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 @@ -1,4 +1,7 @@ use chrono::{Duration, NaiveDateTime, Utc}; +use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlanDetail}; +use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; + use std::convert::TryFrom; use std::sync::Arc; @@ -12,16 +15,17 @@ use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::entities::{ Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, - WorkspaceMember, WorkspaceSubscription, WorkspaceUsage, + WorkspaceMember, }; use lib_dispatch::prelude::af_spawn; use crate::entities::{ - RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, UpdateUserWorkspaceSettingPB, - UseAISettingPB, UserWorkspacePB, + RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, + UpdateUserWorkspaceSettingPB, UseAISettingPB, UserWorkspacePB, WorkspaceSubscriptionInfoPB, }; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; +use crate::services::billing_check::PeriodicallyCheckBillingState; use crate::services::data_import::{ generate_import_data, upload_collab_objects_data, ImportedFolder, ImportedSource, }; @@ -446,32 +450,85 @@ impl UserManager { } #[instrument(level = "info", skip(self), err)] - pub async fn get_workspace_subscriptions(&self) -> FlowyResult> { - let res = self + pub async fn get_workspace_subscription_info( + &self, + workspace_id: String, + ) -> FlowyResult { + let subscriptions = self .cloud_services .get_user_service()? - .get_workspace_subscriptions() + .get_workspace_subscription_one(workspace_id.clone()) .await?; - Ok(res) + + Ok(WorkspaceSubscriptionInfoPB::from(subscriptions)) } #[instrument(level = "info", skip(self), err)] - pub async fn cancel_workspace_subscription(&self, workspace_id: String) -> FlowyResult<()> { + pub async fn cancel_workspace_subscription( + &self, + workspace_id: String, + plan: SubscriptionPlan, + reason: Option, + ) -> FlowyResult<()> { self .cloud_services .get_user_service()? - .cancel_workspace_subscription(workspace_id) + .cancel_workspace_subscription(workspace_id, plan, reason) .await?; Ok(()) } #[instrument(level = "info", skip(self), err)] - pub async fn get_workspace_usage(&self, workspace_id: String) -> FlowyResult { + pub async fn update_workspace_subscription_payment_period( + &self, + workspace_id: String, + plan: SubscriptionPlan, + recurring_interval: RecurringInterval, + ) -> FlowyResult<()> { + self + .cloud_services + .get_user_service()? + .update_workspace_subscription_payment_period(workspace_id, plan, recurring_interval) + .await?; + Ok(()) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_subscription_plan_details(&self) -> FlowyResult> { + let plan_details = self + .cloud_services + .get_user_service()? + .get_subscription_plan_details() + .await?; + Ok(plan_details) + } + + #[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?; + + // Check if the current workspace storage is not unlimited. If it is not unlimited, + // verify whether the storage bytes exceed the storage limit. + // If the storage is unlimited, allow writing. Otherwise, allow writing only if + // the storage bytes are less than the storage limit. + let can_write = if workspace_usage.storage_bytes_unlimited { + true + } else { + workspace_usage.storage_bytes < workspace_usage.storage_bytes_limit + }; + self + .user_status_callback + .read() + .await + .did_update_storage_limitation(can_write); + Ok(workspace_usage) } @@ -577,6 +634,29 @@ impl UserManager { upsert_workspace_member(db, record)?; Ok(member) } + + pub async fn notify_did_switch_plan( + &self, + success: SuccessWorkspaceSubscriptionPB, + ) -> FlowyResult<()> { + // periodically check the billing state + let plans = PeriodicallyCheckBillingState::new( + success.workspace_id, + success.plan.map(SubscriptionPlan::from), + Arc::downgrade(&self.cloud_services), + Arc::downgrade(&self.authenticate_user), + ) + .start() + .await?; + + trace!("Current plans: {:?}", plans); + self + .user_status_callback + .read() + .await + .did_update_plans(plans); + Ok(()) + } } /// This method is used to save one user workspace to the SQLite database