diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart index a806a5c97d..de52749b8d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart @@ -17,17 +17,23 @@ class ChatAIMessageBloc extends Bloc { required this.questionId, }) : super(ChatAIMessageState.initial(message)) { if (state.stream != null) { - _subscription = state.stream!.listen((text) { - if (isClosed) { - return; - } - - if (text.startsWith("data:")) { - add(ChatAIMessageEvent.newText(text.substring(5))); - } else if (text.startsWith("error:")) { - add(ChatAIMessageEvent.receiveError(text.substring(5))); - } - }); + _subscription = state.stream!.listen( + onData: (text) { + if (!isClosed) { + add(ChatAIMessageEvent.updateText(text)); + } + }, + onError: (error) { + if (!isClosed) { + add(ChatAIMessageEvent.receiveError(error.toString())); + } + }, + onAIResponseLimit: () { + if (!isClosed) { + add(const ChatAIMessageEvent.onAIResponseLimit()); + } + }, + ); if (state.stream!.error != null) { Future.delayed(const Duration(milliseconds: 300), () { @@ -42,11 +48,16 @@ class ChatAIMessageBloc extends Bloc { (event, emit) async { await event.when( initial: () async {}, - newText: (newText) { - emit(state.copyWith(text: state.text + newText, error: null)); + updateText: (newText) { + emit( + state.copyWith( + text: newText, + messageState: const MessageState.ready(), + ), + ); }, receiveError: (error) { - emit(state.copyWith(error: error)); + emit(state.copyWith(messageState: MessageState.onError(error))); }, retry: () { if (questionId is! Int64) { @@ -55,8 +66,7 @@ class ChatAIMessageBloc extends Bloc { } emit( state.copyWith( - retryState: const LoadingState.loading(), - error: null, + messageState: const MessageState.loading(), ), ); @@ -82,8 +92,14 @@ class ChatAIMessageBloc extends Bloc { emit( state.copyWith( text: text, - error: null, - retryState: const LoadingState.finish(), + messageState: const MessageState.ready(), + ), + ); + }, + onAIResponseLimit: () { + emit( + state.copyWith( + messageState: const MessageState.onAIResponseLimit(), ), ); }, @@ -98,7 +114,7 @@ class ChatAIMessageBloc extends Bloc { return super.close(); } - StreamSubscription? _subscription; + StreamSubscription? _subscription; final String chatId; final Int64? questionId; } @@ -106,26 +122,34 @@ class ChatAIMessageBloc extends Bloc { @freezed class ChatAIMessageEvent with _$ChatAIMessageEvent { const factory ChatAIMessageEvent.initial() = Initial; - const factory ChatAIMessageEvent.newText(String text) = _NewText; + const factory ChatAIMessageEvent.updateText(String text) = _UpdateText; const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError; const factory ChatAIMessageEvent.retry() = _Retry; const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult; + const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit; } @freezed class ChatAIMessageState with _$ChatAIMessageState { const factory ChatAIMessageState({ AnswerStream? stream, - String? error, required String text, - required LoadingState retryState, + required MessageState messageState, }) = _ChatAIMessageState; factory ChatAIMessageState.initial(dynamic text) { return ChatAIMessageState( text: text is String ? text : "", stream: text is AnswerStream ? text : null, - retryState: const LoadingState.finish(), + messageState: const MessageState.ready(), ); } } + +@freezed +class MessageState with _$MessageState { + const factory MessageState.onError(String error) = _Error; + const factory MessageState.onAIResponseLimit() = _AIResponseLimit; + const factory MessageState.ready() = _Ready; + const factory MessageState.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 2dc4926cc8..01a8e561ad 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -525,8 +525,6 @@ OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) { return null; } -typedef AnswerStreamElement = String; - class AnswerStream { AnswerStream() { _port.handler = _controller.add; @@ -534,23 +532,53 @@ class AnswerStream { (event) { if (event.startsWith("data:")) { _hasStarted = true; + final newText = event.substring(5); + _text += newText; + if (_onData != null) { + _onData!(_text); + } } else if (event.startsWith("error:")) { _error = event.substring(5); + if (_onError != null) { + _onError!(_error!); + } + } else if (event == "AI_RESPONSE_LIMIT") { + if (_onAIResponseLimit != null) { + _onAIResponseLimit!(); + } + } + }, + onDone: () { + if (_onEnd != null) { + _onEnd!(); + } + }, + onError: (error) { + if (_onError != null) { + _onError!(error.toString()); } }, ); } final RawReceivePort _port = RawReceivePort(); - final StreamController _controller = - StreamController.broadcast(); - late StreamSubscription _subscription; + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; bool _hasStarted = false; String? _error; + String _text = ""; + + // Callbacks + void Function(String text)? _onData; + void Function()? _onStart; + void Function()? _onEnd; + void Function(String error)? _onError; + void Function()? _onAIResponseLimit; int get nativePort => _port.sendPort.nativePort; bool get hasStarted => _hasStarted; String? get error => _error; + String get text => _text; Future dispose() async { await _controller.close(); @@ -558,9 +586,23 @@ class AnswerStream { _port.close(); } - StreamSubscription listen( - void Function(AnswerStreamElement event)? onData, - ) { - return _controller.stream.listen(onData); + StreamSubscription listen({ + void Function(String text)? onData, + void Function()? onStart, + void Function()? onEnd, + void Function(String error)? onError, + void Function()? onAIResponseLimit, + }) { + _onData = onData; + _onStart = onStart; + _onEnd = onEnd; + _onError = onError; + _onAIResponseLimit = onAIResponseLimit; + + if (_onStart != null) { + _onStart!(); + } + + return _subscription; } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart index ba0bc3db43..4aa615dc03 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -1,6 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -38,25 +37,34 @@ class ChatAITextMessageWidget extends StatelessWidget { )..add(const ChatAIMessageEvent.initial()), child: BlocBuilder( builder: (context, state) { - if (state.error != null) { - return StreamingError( - onRetryPressed: () { - context.read().add( - const ChatAIMessageEvent.retry(), - ); - }, - ); - } - - if (state.retryState == const LoadingState.loading()) { - return const ChatAILoading(); - } - - if (state.text.isEmpty) { - return const ChatAILoading(); - } else { - return AIMarkdownText(markdown: state.text); - } + return state.messageState.when( + onError: (err) { + return StreamingError( + onRetryPressed: () { + context.read().add( + const ChatAIMessageEvent.retry(), + ); + }, + ); + }, + onAIResponseLimit: () { + return FlowyText( + LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), + maxLines: 10, + lineHeight: 1.5, + ); + }, + ready: () { + if (state.text.isEmpty) { + return const ChatAILoading(); + } else { + return AIMarkdownText(markdown: state.text); + } + }, + loading: () { + return const ChatAILoading(); + }, + ); }, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart index 4b291719bb..d3b43b0d17 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart @@ -1,3 +1,6 @@ +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/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; @@ -7,19 +10,18 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/error.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/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; abstract class IEditableSummaryCellSkin { @@ -149,7 +151,22 @@ class SummaryCellAccessory extends StatelessWidget { rowId: rowId, fieldId: fieldId, ), - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) { + return previous.error != current.error; + }, + listener: (context, state) { + if (state.error != null) { + if (state.error!.isAIResponseLimitExceeded) { + showSnackBarMessage( + context, + LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + ); + } else { + showSnackBarMessage(context, state.error!.msg); + } + } + }, builder: (context, state) { return const Row( children: [SummaryButton(), HSpace(6), CopyButton()], @@ -169,13 +186,13 @@ class SummaryButton extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return state.loadingState.map( - loading: (_) { + return state.loadingState.when( + loading: () { return const Center( child: CircularProgressIndicator.adaptive(), ); }, - finish: (_) { + finish: () { return FlowyTooltip( message: LocaleKeys.tooltip_aiGenerate.tr(), child: Container( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart index 2d3fd33751..b4ff26d946 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart @@ -1,3 +1,6 @@ +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/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; @@ -7,19 +10,18 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/error.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/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; abstract class IEditableTranslateCellSkin { @@ -150,7 +152,22 @@ class TranslateCellAccessory extends StatelessWidget { rowId: rowId, fieldId: fieldId, ), - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) { + return previous.error != current.error; + }, + listener: (context, state) { + if (state.error != null) { + if (state.error!.isAIResponseLimitExceeded) { + showSnackBarMessage( + context, + LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + ); + } else { + showSnackBarMessage(context, state.error!.msg); + } + } + }, builder: (context, state) { return const Row( children: [TranslateButton(), HSpace(6), CopyButton()], 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 5935cf6eaf..774f08cebb 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 @@ -6,8 +6,8 @@ import 'package:appflowy/shared/custom_image_cache_manager.dart'; 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/dispatch/error.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; @@ -65,7 +65,7 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage( return (s.url, null); }, (err) { - if (err.code == ErrorCode.FileStorageLimitExceeded) { + if (err.isStorageLimitExceeded) { return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); } else { return (null, err.msg); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart index 20ef4593a9..9c48cf6a1b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + 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'; @@ -7,7 +9,6 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; class OpenAIImageWidget extends StatefulWidget { const OpenAIImageWidget({ 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 index 218240ab63..3a57a7f49c 100644 --- 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 @@ -1,35 +1,13 @@ +import 'package:flutter/material.dart'; + 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( + showConfirmDialog( context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) => _AILimitDialog( - message: message, - onOkPressed: () {}, - ), + title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + description: message, ); } 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 5534ca94b8..a4fe873c92 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,3 +1,5 @@ +import 'package:flutter/material.dart'; + 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'; @@ -10,12 +12,7 @@ import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; -import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:provider/provider.dart'; import 'ai_limit_dialog.dart'; @@ -46,7 +43,7 @@ Node autoCompletionNode({ SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr, iconData: Icons.generating_tokens, - keywords: ['ai', 'openai' 'writer', 'autogenerator'], + keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'], nodeBuilder: (editorState, _) { final node = autoCompletionNode(start: editorState.selection!); return node; @@ -130,7 +127,6 @@ class _AutoCompletionBlockComponentState _unsubscribeSelectionGesture(); controller.dispose(); textFieldFocusNode.dispose(); - super.dispose(); } @@ -181,9 +177,7 @@ class _AutoCompletionBlockComponentState final transaction = editorState.transaction..deleteNode(widget.node); await editorState.apply( transaction, - options: const ApplyOptions( - recordUndo: false, - ), + options: const ApplyOptions(recordUndo: false), ); } @@ -230,6 +224,7 @@ class _AutoCompletionBlockComponentState if (mounted) { if (error.isLimitExceeded) { showAILimitDialog(context, error.message); + await _onDiscard(); } else { showSnackBarMessage( context, @@ -417,12 +412,10 @@ class _AutoCompletionBlockComponentState // show dialog showDialog( context: context, - builder: (context) { - return DiscardDialog( - onConfirm: () => _onDiscard(), - onCancel: () {}, - ); - }, + builder: (_) => DiscardDialog( + onConfirm: _onDiscard, + onCancel: () {}, + ), ); } else if (controller.text.isEmpty) { _onExit(); @@ -445,9 +438,7 @@ class _AutoCompletionBlockComponentState } class AutoCompletionHeader extends StatelessWidget { - const AutoCompletionHeader({ - super.key, - }); + const AutoCompletionHeader({super.key}); @override Widget build(BuildContext context) { @@ -471,23 +462,27 @@ class AutoCompletionInputFooter extends StatelessWidget { @override Widget build(BuildContext context) { return Row( + mainAxisSize: MainAxisSize.min, children: [ - PrimaryTextButton( - LocaleKeys.button_generate.tr(), + FlowyTextButton.primary( + text: LocaleKeys.button_generate.tr(), + context: context, onPressed: onGenerate, ), const Space(10, 0), - SecondaryTextButton( - LocaleKeys.button_cancel.tr(), + FlowyTextButton.secondary( + text: LocaleKeys.button_cancel.tr(), + context: context, onPressed: onExit, ), - Expanded( + Flexible( child: Container( alignment: Alignment.centerRight, child: FlowyText.regular( LocaleKeys.document_plugins_warning.tr(), color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, + fontSize: 12, ), ), ), @@ -512,18 +507,21 @@ class AutoCompletionFooter extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - PrimaryTextButton( - LocaleKeys.button_keep.tr(), + FlowyTextButton.primary( + context: context, + text: LocaleKeys.button_keep.tr(), onPressed: onKeep, ), const Space(10, 0), - SecondaryTextButton( - LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), + FlowyTextButton.secondary( + context: context, + text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), onPressed: onRewrite, ), const Space(10, 0), - SecondaryTextButton( - LocaleKeys.button_discard.tr(), + FlowyTextButton.secondary( + context: context, + text: LocaleKeys.button_discard.tr(), onPressed: onDiscard, ), ], diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart index c90e30ac34..27498cc65e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; @@ -23,7 +25,6 @@ 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/style_widget/snap_bar.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; @@ -239,19 +240,15 @@ class PageStyleCoverImage extends StatelessWidget { return; } if (result == null) { - showSnapBar( + return showSnapBar( context, - LocaleKeys.document_plugins_image_imageUploadFailed, + LocaleKeys.document_plugins_image_imageUploadFailed.tr(), ); - return; } context.read().add( DocumentPageStyleEvent.updateCoverImage( - PageStyleCover( - type: type, - value: result, - ), + PageStyleCover(type: type, value: result), ), ); } @@ -282,10 +279,7 @@ class PageStyleCoverImage extends StatelessWidget { }, builder: (_) { return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: maxHeight, - minHeight: 80, - ), + constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80), child: BlocProvider.value( value: pageStyleBloc, child: Padding( diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart index 391b0836d7..feca33b2a7 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; @@ -6,7 +8,6 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ShareMenuButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index bc87836659..8a1b230461 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -91,7 +91,7 @@ enum FeatureFlag { bool get isOn { if ([ - FeatureFlag.planBilling, + // FeatureFlag.planBilling, // release this feature in version 0.6.1 FeatureFlag.spaceDesign, // release this feature in version 0.5.9 diff --git a/frontend/appflowy_flutter/lib/user/application/ai_service.dart b/frontend/appflowy_flutter/lib/user/application/ai_service.dart index 46f0748641..59f8bf5008 100644 --- a/frontend/appflowy_flutter/lib/user/application/ai_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/ai_service.dart @@ -90,7 +90,7 @@ class CompletionStream { if (event == "AI_RESPONSE_LIMIT") { onError( AIError( - message: LocaleKeys.sideBar_aiResponseLitmit.tr(), + message: LocaleKeys.sideBar_aiResponseLimit.tr(), code: AIErrorCode.aiResponseLimitExceeded, ), ); diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 25eec30992..cbec823539 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/startup/startup.dart'; +import 'package:flutter/foundation.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'; @@ -14,6 +14,7 @@ abstract class IUserBackendService { Future> cancelSubscription( String workspaceId, SubscriptionPlanPB plan, + String? reason, ); Future> createSubscription( String workspaceId, @@ -21,6 +22,9 @@ abstract class IUserBackendService { ); } +const _baseBetaUrl = 'https://beta.appflowy.com'; +const _baseProdUrl = 'https://appflowy.com'; + class UserBackendService implements IUserBackendService { UserBackendService({required this.userId}); @@ -255,19 +259,24 @@ class UserBackendService implements IUserBackendService { ..recurringInterval = RecurringIntervalPB.Year ..workspaceSubscriptionPlan = plan ..successUrl = - '${getIt().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}'; + '${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}'; return UserEventSubscribeWorkspace(request).send(); } @override Future> cancelSubscription( String workspaceId, - SubscriptionPlanPB plan, - ) { + SubscriptionPlanPB plan, [ + String? reason, + ]) { final request = CancelWorkspaceSubscriptionPB() ..workspaceId = workspaceId ..plan = plan; + if (reason != null) { + request.reason = reason; + } + return UserEventCancelWorkspaceSubscription(request).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart index 5e8636e1db..ab324df87f 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 @@ -115,7 +115,7 @@ class SettingsBillingBloc (f) => Log.error(f.msg, f), ); }, - cancelSubscription: (plan) async { + cancelSubscription: (plan, reason) async { final s = state.mapOrNull(ready: (s) => s); if (s == null) { return; @@ -124,7 +124,7 @@ class SettingsBillingBloc emit(s.copyWith(isLoading: true)); final result = - await _userService.cancelSubscription(workspaceId, plan); + await _userService.cancelSubscription(workspaceId, plan, reason); final successOrNull = result.fold( (_) => true, (f) { @@ -276,8 +276,9 @@ class SettingsBillingEvent with _$SettingsBillingEvent { _AddSubscription; const factory SettingsBillingEvent.cancelSubscription( - SubscriptionPlanPB plan, - ) = _CancelSubscription; + SubscriptionPlanPB plan, { + @Default(null) String? reason, + }) = _CancelSubscription; const factory SettingsBillingEvent.paymentSuccessful({ SubscriptionPlanPB? plan, 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 af2b5e3aaf..f7512a834e 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 @@ -95,7 +95,7 @@ class SettingsPlanBloc extends Bloc { ), ); }, - cancelSubscription: () async { + cancelSubscription: (reason) async { final newState = state .mapOrNull(ready: (state) => state) ?.copyWith(downgradeProcessing: true); @@ -106,6 +106,7 @@ class SettingsPlanBloc extends Bloc { final result = await _userService.cancelSubscription( workspaceId, SubscriptionPlanPB.Pro, + reason, ); final successOrNull = result.fold( @@ -206,7 +207,9 @@ class SettingsPlanEvent with _$SettingsPlanEvent { const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; - const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; + const factory SettingsPlanEvent.cancelSubscription({ + @Default(null) String? reason, + }) = _CancelSubscription; const factory SettingsPlanEvent.paymentSuccessful({ @Default(null) SubscriptionPlanPB? plan, 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 e232915b8a..9d91ade4d3 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 @@ -4,7 +4,7 @@ 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 WorkspaceSubscriptionInfoPB { +extension SubscriptionInfoHelpers on WorkspaceSubscriptionInfoPB { String get label => switch (plan) { WorkspacePlanPB.FreePlan => LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), @@ -24,6 +24,14 @@ extension SubscriptionLabels on WorkspaceSubscriptionInfoPB { LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), _ => 'N/A', }; + + bool get isBillingPortalEnabled { + if (plan != WorkspacePlanPB.FreePlan || addOns.isNotEmpty) { + return true; + } + + return false; + } } extension AllSubscriptionLabels on SubscriptionPlanPB { 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 index 563b47389b..e8265703ef 100644 --- 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 @@ -16,7 +16,7 @@ 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 + // 1. Listen to user subscription payment callback. After user client 'Open AppFlowy', this listenable will be triggered. final subscriptionListener = getIt(); subscriptionListener.addListener(() { final plan = subscriptionListener.subscribedPlan; @@ -49,6 +49,7 @@ class SidebarPlanBloc extends Bloc { } }); + // 2. Listen to the storage notification _storageListener = StoreageNotificationListener( onError: (error) { if (!isClosed) { @@ -57,6 +58,7 @@ class SidebarPlanBloc extends Bloc { }, ); + // 3. Listen to specific error codes _globalErrorListener = GlobalErrorCodeNotifier.add( onError: (error) { if (!isClosed) { @@ -92,11 +94,21 @@ class SidebarPlanBloc extends Bloc { ) async { await event.when( receiveError: (FlowyError error) async { - emit( - state.copyWith( - tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), - ), - ); + if (error.code == ErrorCode.AIResponseLimitExceeded) { + emit( + state.copyWith( + tierIndicator: const SidebarToastTierIndicator.aiMaxiLimitHit(), + ), + ); + } else if (error.code == ErrorCode.FileStorageLimitExceeded) { + emit( + state.copyWith( + tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), + ), + ); + } else { + Log.error("Unhandle Unexpected error: $error"); + } }, init: (String workspaceId, UserProfilePB userProfile) { emit( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart index 8af1c0eb9e..7825023263 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart @@ -1,3 +1,7 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; @@ -7,31 +11,33 @@ import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; class SidebarFooter extends StatelessWidget { const SidebarFooter({super.key}); @override Widget build(BuildContext context) { - return const Row( + return Column( children: [ - Expanded(child: SidebarTrashButton()), - // Enable it when the widget button is ready - // SizedBox( - // height: 16, - // child: VerticalDivider(width: 1, color: Color(0x141F2329)), - // ), - // Expanded(child: SidebarWidgetButton()), + if (FeatureFlag.planBilling.isOn) const SidebarToast(), + const Row( + children: [ + Expanded(child: SidebarTrashButton()), + // Enable it when the widget button is ready + // SizedBox( + // height: 16, + // child: VerticalDivider(width: 1, color: Color(0x141F2329)), + // ), + // Expanded(child: SidebarWidgetButton()), + ], + ), ], ); } } class SidebarTrashButton extends StatelessWidget { - const SidebarTrashButton({ - super.key, - }); + const SidebarTrashButton({super.key}); @override Widget build(BuildContext context) { 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 index 2adbdb81a6..c25314b169 100644 --- 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 @@ -1,4 +1,9 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.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'; @@ -6,125 +11,110 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar 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:appflowy_popover/appflowy_popover.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:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class SidebarToast extends StatefulWidget { +class SidebarToast extends StatelessWidget { const SidebarToast({super.key}); - @override - State createState() => _SidebarToastState(); -} - -class _SidebarToastState extends State { @override Widget build(BuildContext context) { return BlocConsumer( - listener: (context, state) { + listener: (_, 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 - }, + storageLimitHit: () => WidgetsBinding.instance.addPostFrameCallback( + (_) => _showStorageLimitDialog(context), + ), + orElse: () {}, ); }, - 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(); - }, - ); - }, + builder: (_, state) { + return state.tierIndicator.when( + loading: () => const SizedBox.shrink(), + storageLimitHit: () => PlanIndicator( + planName: SubscriptionPlanPB.Free.label, + text: LocaleKeys.sideBar_upgradeToPro.tr(), + onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.Pro), + reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), + ), + aiMaxiLimitHit: () => PlanIndicator( + planName: SubscriptionPlanPB.AiMax.label, + text: LocaleKeys.sideBar_upgradeToAIMax.tr(), + onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.AiMax), + reason: LocaleKeys.sideBar_aiResponseLimitTitle.tr(), + ), ); }, ); } - 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 _showStorageLimitDialog(BuildContext context) => showConfirmDialog( + context: context, + title: LocaleKeys.sideBar_purchaseStorageSpace.tr(), + description: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), + confirmLabel: + LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), + onConfirm: () { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _hanldeOnTap(context, SubscriptionPlanPB.Pro), + ); }, - ), - ); - } + ); void _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) { final userProfile = context.read().state.userProfile; + if (userProfile == null) { + return Log.error( + 'UserProfile is null, this should NOT happen! Please file a bug report', + ); + } + final userWorkspaceBloc = context.read(); - if (userProfile != null) { + final member = userWorkspaceBloc.state.currentWorkspaceMember; + if (member == null) { + return Log.error( + "Member is null. It should not happen. If you see this error, it's a bug", + ); + } + + // Only if the user is the workspace owner will we navigate to the plan page. + if (member.role.isOwner) { showSettingsDialog( context, userProfile, userWorkspaceBloc, SettingsPage.plan, ); + } else { + final message = plan == SubscriptionPlanPB.AiMax + ? LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr() + : LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr(); + + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (dialogContext) => _AskOwnerToChangePlan( + message: message, + onOkPressed: () {}, + ), + ); } } } -class PlanIndicator extends StatelessWidget { +class PlanIndicator extends StatefulWidget { const PlanIndicator({ + super.key, required this.planName, required this.text, required this.onTap, required this.reason, - super.key, }); final String planName; @@ -132,62 +122,150 @@ class PlanIndicator extends StatelessWidget { final String text; final Function() onTap; - final textColor = const Color(0xFFE8E2EE); - final secondaryColor = const Color(0xFF653E8C); + @override + State createState() => _PlanIndicatorState(); +} + +class _PlanIndicatorState extends State { + final popoverController = PopoverController(); + + @override + void dispose() { + popoverController.close(); + super.dispose(); + } @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, - ), - ), - ), + const textGradient = LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], + stops: [0.1545, 0.8225], + ); + + final backgroundGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF8032FF).withOpacity(.1), + const Color(0xFFEF35FF).withOpacity(.1), ], ); + + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.rightWithBottomAligned, + offset: const Offset(10, -12), + popupBuilder: (context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + widget.text, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(12), + Opacity( + opacity: 0.7, + child: FlowyText.regular( + widget.reason, + maxLines: null, + lineHeight: 1.3, + textAlign: TextAlign.center, + ), + ), + const VSpace(12), + Row( + children: [ + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + popoverController.close(); + widget.onTap(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: FlowyText( + LocaleKeys + .settings_comparePlanDialog_actions_upgrade + .tr(), + color: Colors.white, + fontSize: 12, + strutStyle: const StrutStyle( + forceStrutHeight: true, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + gradient: backgroundGradient, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.upgrade_storage_s, + blendMode: null, + ), + const HSpace(6), + ShaderMask( + shaderCallback: (bounds) => textGradient.createShader(bounds), + blendMode: BlendMode.srcIn, + child: FlowyText( + widget.text, + color: AFThemeExtension.of(context).strongText, + ), + ), + ], + ), + ), + ), + ); } } -class _StorageLimitDialog extends StatelessWidget { - const _StorageLimitDialog({ +class _AskOwnerToChangePlan extends StatelessWidget { + const _AskOwnerToChangePlan({ + required this.message, required this.onOkPressed, }); + final String message; final VoidCallback onOkPressed; @override Widget build(BuildContext context) { return NavigatorOkCancelDialog( - message: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), - okTitle: LocaleKeys.sideBar_purchaseStorageSpace.tr(), + message: message, + okTitle: LocaleKeys.button_ok.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 2cf35e9dad..2f6da2db3b 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,10 +1,10 @@ -import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.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/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; 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 136516253e..b87e62ccbe 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 @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/blank/blank.dart'; @@ -36,7 +38,6 @@ 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:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; Loading? _duplicateSpaceLoading; @@ -358,9 +359,6 @@ 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/space_migration.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart index 905082721d..10ef94ba01 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart @@ -1,10 +1,11 @@ +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/sidebar/space/space_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpaceMigration extends StatefulWidget { @@ -70,14 +71,8 @@ class _SpaceMigrationState extends State { const linearGradient = LinearGradient( begin: Alignment.bottomLeft, end: Alignment.bottomRight, - colors: [ - Color(0xFF8032FF), - Color(0xFFEF35FF), - ], - stops: [ - 0.1545, - 0.8225, - ], + colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], + stops: [0.1545, 0.8225], ); return GestureDetector( behavior: HitTestBehavior.translucent, 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 acbd701867..47bfc2a7b9 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 @@ -91,7 +91,7 @@ class _SettingsBillingViewState extends State { }, ready: (state) { final billingPortalEnabled = - state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan; + state.subscriptionInfo.isBillingPortalEnabled; return SettingsBody( title: LocaleKeys.settings_billingPage_title.tr(), @@ -327,14 +327,9 @@ class _AITileState extends State<_AITile> { : 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( + onPressed: () async { + if (widget.subscriptionInfo != null) { + await showConfirmDialog( context: context, style: ConfirmPopupStyle.cancelAndOk, title: LocaleKeys.settings_billingPage_addons_removeDialog_title @@ -343,11 +338,9 @@ class _AITileState extends State<_AITile> { .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), - ); - }, + onConfirm: () => context + .read() + .add(SettingsBillingEvent.cancelSubscription(widget.plan)), ); } else { // Add the addon 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 7e0465cb7a..07d662f38d 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 @@ -5,6 +5,7 @@ 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/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -141,7 +142,7 @@ class _SettingsPlanComparisonDialogState children: [ const VSpace(30), SizedBox( - height: 100, + height: 116, child: FlowyText.semibold( LocaleKeys .settings_comparePlanDialog_planFeatures @@ -153,7 +154,7 @@ class _SettingsPlanComparisonDialogState : const Color(0xFFE8E0FF), ), ), - const SizedBox(height: 96), + const SizedBox(height: 116), const SizedBox(height: 56), ..._planLabels.map( (e) => _ComparisonCell( @@ -184,17 +185,9 @@ class _SettingsPlanComparisonDialogState cells: _freeLabels, isCurrent: currentInfo.plan == WorkspacePlanPB.FreePlan, - canDowngrade: - currentInfo.plan != WorkspacePlanPB.FreePlan, - currentCanceled: currentInfo.isCanceled || - (context - .watch() - .state - .mapOrNull( - loading: (_) => true, - ready: (s) => s.downgradeProcessing, - ) ?? - false), + buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor( + currentInfo.plan, + ), onSelected: () async { if (currentInfo.plan == WorkspacePlanPB.FreePlan || @@ -202,6 +195,12 @@ class _SettingsPlanComparisonDialogState return; } + final reason = + await showCancelSurveyDialog(context); + if (reason == null || !context.mounted) { + return; + } + await showConfirmDialog( context: context, title: LocaleKeys @@ -216,8 +215,9 @@ class _SettingsPlanComparisonDialogState style: ConfirmPopupStyle.cancelAndOk, onConfirm: () => context.read().add( - const SettingsPlanEvent - .cancelSubscription(), + SettingsPlanEvent.cancelSubscription( + reason: reason, + ), ), ); }, @@ -242,9 +242,9 @@ class _SettingsPlanComparisonDialogState cells: _proLabels, isCurrent: currentInfo.plan == WorkspacePlanPB.ProPlan, - canUpgrade: - currentInfo.plan == WorkspacePlanPB.FreePlan, - currentCanceled: currentInfo.isCanceled, + buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor( + currentInfo.plan, + ), onSelected: () => context.read().add( const SettingsPlanEvent.addSubscription( @@ -266,6 +266,35 @@ class _SettingsPlanComparisonDialogState } } +enum _PlanButtonType { + none, + upgrade, + downgrade; + + bool get isDowngrade => this == downgrade; + bool get isUpgrade => this == upgrade; +} + +extension _ButtonTypeFrom on WorkspacePlanPB { + /// Returns the button type for the given plan, taking the + /// current plan as [other]. + /// + _PlanButtonType buttonTypeFor(WorkspacePlanPB other) { + /// Current plan, no action + if (this == other) { + return _PlanButtonType.none; + } + + // Free plan, can downgrade if not on the free plan + if (this == WorkspacePlanPB.FreePlan && other != WorkspacePlanPB.FreePlan) { + return _PlanButtonType.downgrade; + } + + // Else we can assume it's an upgrade + return _PlanButtonType.upgrade; + } +} + class _PlanTable extends StatelessWidget { const _PlanTable({ required this.title, @@ -275,9 +304,7 @@ class _PlanTable extends StatelessWidget { required this.cells, required this.isCurrent, required this.onSelected, - this.canUpgrade = false, - this.canDowngrade = false, - this.currentCanceled = false, + this.buttonType = _PlanButtonType.none, }); final String title; @@ -288,13 +315,11 @@ class _PlanTable extends StatelessWidget { final List<_CellItem> cells; final bool isCurrent; final VoidCallback onSelected; - final bool canUpgrade; - final bool canDowngrade; - final bool currentCanceled; + final _PlanButtonType buttonType; @override Widget build(BuildContext context) { - final highlightPlan = !isCurrent && !canDowngrade && canUpgrade; + final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade; final isLM = Theme.of(context).isLightMode; return Container( @@ -336,37 +361,29 @@ class _PlanTable extends StatelessWidget { title: price, description: priceInfo, isPrimary: !highlightPlan, - height: 96, ), - if (canUpgrade || canDowngrade) ...[ + if (buttonType == _PlanButtonType.none) ...[ + const SizedBox(height: 56), + ] else ...[ Opacity( - opacity: canDowngrade && currentCanceled ? 0.5 : 1, + opacity: 1, child: Padding( padding: EdgeInsets.only( - left: 12 + (canUpgrade && !canDowngrade ? 12 : 0), + left: 12 + (buttonType.isUpgrade ? 12 : 0), ), child: _ActionButton( - label: canUpgrade && !canDowngrade + label: buttonType.isUpgrade ? LocaleKeys.settings_comparePlanDialog_actions_upgrade .tr() : LocaleKeys .settings_comparePlanDialog_actions_downgrade .tr(), - onPressed: !canUpgrade && canDowngrade && currentCanceled - ? null - : onSelected, - tooltip: !canUpgrade && canDowngrade && currentCanceled - ? LocaleKeys - .settings_comparePlanDialog_actions_downgradeDisabledTooltip - .tr() - : null, - isUpgrade: canUpgrade && !canDowngrade, - useGradientBorder: !isCurrent && canUpgrade, + onPressed: onSelected, + isUpgrade: buttonType.isUpgrade, + useGradientBorder: buttonType.isUpgrade, ), ), ), - ] else ...[ - const SizedBox(height: 56), ], ...cells.map( (cell) => _ComparisonCell( @@ -467,14 +484,12 @@ class _ComparisonCell extends StatelessWidget { class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, - this.tooltip, required this.onPressed, required this.isUpgrade, this.useGradientBorder = false, }); final String label; - final String? tooltip; final VoidCallback? onPressed; final bool isUpgrade; final bool useGradientBorder; @@ -487,30 +502,27 @@ class _ActionButton extends StatelessWidget { height: 56, child: Row( children: [ - FlowyTooltip( - message: tooltip, - child: GestureDetector( - onTap: onPressed, - child: MouseRegion( - cursor: onPressed != null - ? SystemMouseCursors.click - : MouseCursor.defer, - child: _drawBorder( - context, - isLM: isLM, - isUpgrade: isUpgrade, - child: Container( - height: 36, - width: 148, - decoration: BoxDecoration( - color: useGradientBorder - ? Theme.of(context).cardColor - : Colors.transparent, - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.circular(14), - ), - child: Center(child: _drawText(label, isLM, isUpgrade)), + GestureDetector( + onTap: onPressed, + child: MouseRegion( + cursor: onPressed != null + ? SystemMouseCursors.click + : MouseCursor.defer, + child: _drawBorder( + context, + isLM: isLM, + isUpgrade: isUpgrade, + child: Container( + height: 36, + width: 148, + decoration: BoxDecoration( + color: useGradientBorder + ? Theme.of(context).cardColor + : Colors.transparent, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.circular(14), ), + child: Center(child: _drawText(label, isLM, isUpgrade)), ), ), ), @@ -538,10 +550,7 @@ class _ActionButton extends StatelessWidget { shaderCallback: (bounds) => const LinearGradient( transform: GradientRotation(-1.55), stops: [0.4, 1], - colors: [ - Color(0xFF251D37), - Color(0xFF7547C0), - ], + colors: [Color(0xFF251D37), Color(0xFF7547C0)], ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), child: child, ); @@ -579,19 +588,17 @@ class _Heading extends StatelessWidget { required this.title, this.description, this.isPrimary = true, - this.height = 100, }); final String title; final String? description; final bool isPrimary; - final double height; @override Widget build(BuildContext context) { return SizedBox( width: 185, - height: height, + height: 116, child: Padding( padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)), child: Column( @@ -615,11 +622,13 @@ class _Heading extends StatelessWidget { ), if (description != null && description!.isNotEmpty) ...[ const VSpace(4), - FlowyText.regular( - description!, - fontSize: 12, - maxLines: 3, - lineHeight: 1.5, + Flexible( + child: FlowyText.regular( + description!, + fontSize: 12, + maxLines: 5, + lineHeight: 1.5, + ), ), ], ], 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 ebbc27d2d9..0aaf2148a7 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 @@ -232,7 +232,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { const VSpace(8), FlowyText.regular( widget.subscriptionInfo.info, - fontSize: 16, + fontSize: 14, color: AFThemeExtension.of(context).strongText, maxLines: 3, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart new file mode 100644 index 0000000000..e606292572 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart @@ -0,0 +1,431 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:collection/collection.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'; + +Future showCancelSurveyDialog(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const _Survey(), + ); +} + +class _Survey extends StatefulWidget { + const _Survey(); + + @override + State<_Survey> createState() => _SurveyState(); +} + +class _SurveyState extends State<_Survey> { + final PageController pageController = PageController(); + final Map answers = {}; + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 674, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Survey title + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FlowyText( + LocaleKeys.settings_cancelSurveyDialog_title.tr(), + fontSize: 22.0, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).strongText, + ), + ), + FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg(FlowySvgs.upgrade_close_s), + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + const VSpace(12), + // Survey explanation + FlowyText( + LocaleKeys.settings_cancelSurveyDialog_description.tr(), + maxLines: 3, + ), + const VSpace(8), + const Divider(), + const VSpace(8), + // Question "sheet" + SizedBox( + height: 400, + width: 650, + child: PageView.builder( + controller: pageController, + itemCount: _questionsAndAnswers.length, + itemBuilder: (context, index) => _QAPage( + qa: _questionsAndAnswers[index], + isFirstQuestion: index == 0, + isFinalQuestion: + index == _questionsAndAnswers.length - 1, + selectedAnswer: + answers[_questionsAndAnswers[index].question], + onPrevious: () { + if (index > 0) { + pageController.animateToPage( + index - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + onAnswerChanged: (answer) { + answers[_questionsAndAnswers[index].question] = + answer; + }, + onAnswerSelected: (answer) { + answers[_questionsAndAnswers[index].question] = + answer; + + if (index == _questionsAndAnswers.length - 1) { + Navigator.of(context).pop(jsonEncode(answers)); + } else { + pageController.animateToPage( + index + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _QAPage extends StatefulWidget { + const _QAPage({ + required this.qa, + required this.onAnswerSelected, + required this.onAnswerChanged, + required this.onPrevious, + this.selectedAnswer, + this.isFirstQuestion = false, + this.isFinalQuestion = false, + }); + + final _QA qa; + final String? selectedAnswer; + + /// Called when "Next" is pressed + /// + final Function(String) onAnswerSelected; + + /// Called whenever an answer is selected or changed + /// + final Function(String) onAnswerChanged; + final VoidCallback onPrevious; + final bool isFirstQuestion; + final bool isFinalQuestion; + + @override + State<_QAPage> createState() => _QAPageState(); +} + +class _QAPageState extends State<_QAPage> { + final otherController = TextEditingController(); + + int _selectedIndex = -1; + String? answer; + + @override + void initState() { + super.initState(); + if (widget.selectedAnswer != null) { + answer = widget.selectedAnswer; + _selectedIndex = widget.qa.answers.indexOf(widget.selectedAnswer!); + if (_selectedIndex == -1) { + // We assume the last question is "Other" + _selectedIndex = widget.qa.answers.length - 1; + otherController.text = widget.selectedAnswer!; + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + widget.qa.question, + fontSize: 16.0, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(18), + SeparatedColumn( + separatorBuilder: () => const VSpace(6), + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.qa.answers + .mapIndexed( + (index, option) => _AnswerOption( + prefix: _indexToLetter(index), + option: option, + isSelected: _selectedIndex == index, + onTap: () => setState(() { + _selectedIndex = index; + if (_selectedIndex == widget.qa.answers.length - 1 && + widget.qa.lastIsOther) { + answer = otherController.text; + } else { + answer = option; + } + widget.onAnswerChanged(option); + }), + ), + ) + .toList(), + ), + if (widget.qa.lastIsOther && + _selectedIndex == widget.qa.answers.length - 1) ...[ + const VSpace(8), + FlowyTextField( + controller: otherController, + hintText: LocaleKeys.settings_cancelSurveyDialog_otherHint.tr(), + onChanged: (value) => setState(() { + answer = value; + widget.onAnswerChanged(value); + }), + ), + ], + const VSpace(20), + Row( + children: [ + if (!widget.isFirstQuestion) ...[ + DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x1E14171B)), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + text: FlowyText.regular(LocaleKeys.button_previous.tr()), + onTap: widget.onPrevious, + ), + ), + const HSpace(12.0), + ], + DecoratedBox( + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: BorderRadius.circular(8), + text: FlowyText.regular( + widget.isFinalQuestion + ? LocaleKeys.button_submit.tr() + : LocaleKeys.button_next.tr(), + color: Colors.white, + ), + disable: !canProceed(), + onTap: canProceed() + ? () => widget.onAnswerSelected( + answer ?? widget.qa.answers[_selectedIndex], + ) + : null, + ), + ), + ], + ), + ], + ); + } + + bool canProceed() { + if (_selectedIndex == widget.qa.answers.length - 1 && + widget.qa.lastIsOther) { + return answer != null && + answer!.isNotEmpty && + answer != LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(); + } + + return _selectedIndex != -1; + } +} + +class _AnswerOption extends StatelessWidget { + const _AnswerOption({ + required this.prefix, + required this.option, + required this.onTap, + this.isSelected = false, + }); + + final String prefix; + final String option; + final VoidCallback onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(2), + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + borderRadius: Corners.s6Border, + ), + child: Center( + child: FlowyText( + prefix, + color: isSelected ? Colors.white : null, + ), + ), + ), + const HSpace(8), + FlowyText( + option, + fontWeight: FontWeight.w400, + fontSize: 16.0, + color: AFThemeExtension.of(context).strongText, + ), + const HSpace(6), + ], + ), + ), + ), + ); + } +} + +final _questionsAndAnswers = [ + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionOne_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFive.tr(), + LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), + ], + lastIsOther: true, + ), + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionTwo_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFive.tr(), + ], + ), + _QA( + question: + LocaleKeys.settings_cancelSurveyDialog_questionThree_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), + ], + lastIsOther: true, + ), + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionFour_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFive.tr(), + ], + ), +]; + +class _QA { + const _QA({ + required this.question, + required this.answers, + this.lastIsOther = false, + }); + + final String question; + final List answers; + final bool lastIsOther; +} + +/// Returns the letter corresponding to the index. +/// +/// Eg. 0 -> A, 1 -> B, 2 -> C, ..., and so forth. +/// +String _indexToLetter(int index) { + return String.fromCharCode(65 + index); +} 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 ae4cc082b6..7d132c2962 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 @@ -72,8 +72,6 @@ class WorkspaceMembersPage extends StatelessWidget { 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; 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 7ccd1a67c3..08fb46d58d 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,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package: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'; @@ -111,26 +112,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/packages/appflowy_backend/lib/dispatch/error.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart index b8b62b63ea..639945f102 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart @@ -1,5 +1,6 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:flutter/foundation.dart'; @@ -52,6 +53,8 @@ class StackTraceError { typedef void ErrorListener(); +/// Receive error when Rust backend send error message back to the flutter frontend +/// class GlobalErrorCodeNotifier extends ChangeNotifier { // Static instance with lazy initialization static final GlobalErrorCodeNotifier _instance = @@ -107,3 +110,10 @@ class GlobalErrorCodeNotifier extends ChangeNotifier { _instance.removeListener(listener); } } + +extension FlowyErrorExtension on FlowyError { + bool get isAIResponseLimitExceeded => + code == ErrorCode.AIResponseLimitExceeded; + + bool get isStorageLimitExceeded => code == ErrorCode.FileStorageLimitExceeded; +} 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 a548a1a9be..e5649961fc 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 @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; @@ -168,6 +169,37 @@ class FlowyTextButton extends StatelessWidget { this.borderColor, }); + factory FlowyTextButton.primary({ + required BuildContext context, + required String text, + VoidCallback? onPressed, + }) => + FlowyTextButton( + text, + constraints: const BoxConstraints(minHeight: 32), + fillColor: Theme.of(context).colorScheme.primary, + hoverColor: const Color(0xFF005483), + fontColor: AFThemeExtension.of(context).strongText, + fontHoverColor: Colors.white, + onPressed: onPressed, + ); + + factory FlowyTextButton.secondary({ + required BuildContext context, + required String text, + VoidCallback? onPressed, + }) => + FlowyTextButton( + text, + constraints: const BoxConstraints(minHeight: 32), + fillColor: Colors.transparent, + hoverColor: Theme.of(context).colorScheme.primary, + fontColor: Theme.of(context).colorScheme.primary, + borderColor: Theme.of(context).colorScheme.primary, + fontHoverColor: Colors.white, + onPressed: onPressed, + ); + final String text; final FontWeight? fontWeight; final Color? fontColor; diff --git a/frontend/resources/flowy_icons/16x/upgrade_storage.svg b/frontend/resources/flowy_icons/16x/upgrade_storage.svg new file mode 100644 index 0000000000..ec0ff3a41b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/upgrade_storage.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index ab34a07158..7e0eb80f5a 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -283,11 +283,14 @@ "favoriteSpace": "Favorites", "RecentSpace": "Recent", "Spaces": "Spaces", - "upgradeToPro": "Upgrade to Pro Plan", + "upgradeToPro": "Upgrade to Pro", "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", + "storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage", + "aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", + "aiResponseLimitDialogTitle": "AI Responses limit reached", + "aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", + "askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan", + "askOwnerToUpgradeToAIMax": "Your workspace is running out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons", "purchaseStorageSpace": "Purchase Storage Space", "purchaseAIResponse": "Purchase ", "upgradeToAILocal": "AI offline on your device" @@ -350,7 +353,10 @@ "signInDiscord": "Continue with Discord", "more": "More", "create": "Create", - "close": "Close" + "close": "Close", + "next": "Next", + "previous": "Previous", + "submit": "Submit" }, "label": { "welcome": "Welcome!", @@ -638,7 +644,7 @@ "menuLabel": "AI Settings", "keys": { "enableAISearchTitle": "AI Search", - "aiSettingsDescription": "Select or configure Ai models used on @:appName. For best performance we recommend using the default model options", + "aiSettingsDescription": "Select or configure AI models used on @:appName. For best performance we recommend using the default model options", "loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up", "llmModel": "Language Model", "llmModelType": "Language Model Type", @@ -704,14 +710,14 @@ "title": "AI Max", "description": "Unlock unlimited AI", "price": "{}", - "priceInfo": "/user per month", + "priceInfo": "per 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", + "priceInfo": "per user per month", "billingInfo": "billed annually or {} billed monthly" } }, @@ -776,7 +782,6 @@ "actions": { "upgrade": "Upgrade", "downgrade": "Downgrade", - "downgradeDisabledTooltip": "You will automatically downgrade at the end of the billing cycle", "current": "Current" }, "freePlan": { @@ -789,7 +794,7 @@ "title": "Pro", "description": "For small teams to manage projects and team knowledge", "price": "{}", - "priceInfo": "/user per month billed annually\n\n{} billed monthly" + "priceInfo": "per user per month \nbilled annually\n\n{} billed monthly" }, "planLabels": { "itemOne": "Workspaces", @@ -830,6 +835,43 @@ "downgradeLabel": "Downgrade plan" } }, + "cancelSurveyDialog": { + "title": "Sorry to see you go", + "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve @:appName. Please take a moment to answer a few questions.", + "commonOther": "Other", + "otherHint": "Write your answer here", + "questionOne": { + "question": "What prompted you to cancel your AppFlowy Pro subscription?", + "answerOne": "Cost too high", + "answerTwo": "Features did not meet expectations", + "answerThree": "Found a better alternative", + "answerFour": "Did not use it enough to justify the expense", + "answerFive": "Service issue or technical difficulties" + }, + "questionTwo": { + "question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?", + "answerOne": "Very likely", + "answerTwo": "Somewhat likely", + "answerThree": "Not sure", + "answerFour": "Unlikely", + "answerFive": "Very unlikely" + }, + "questionThree": { + "question": "Which Pro feature did you value the most during your subscription?", + "answerOne": "Multi-user collaboration", + "answerTwo": "Longer time version history", + "answerThree": "Unlimited AI responses", + "answerFour": "Access to local AI models" + }, + "questionFour": { + "question": "How would you describe your overall experience with AppFlowy?", + "answerOne": "Great", + "answerTwo": "Good", + "answerThree": "Average", + "answerFour": "Below average", + "answerFive": "Unsatisfied" + } + }, "common": { "reset": "Reset" }, @@ -1441,7 +1483,7 @@ "image": { "copiedToPasteBoard": "The image link has been copied to the clipboard", "addAnImage": "Add an image", - "imageUploadFailed": "Image upload failed", + "imageUploadFailed": "Upload failed", "errorCode": "Error code" }, "math": { @@ -2130,7 +2172,7 @@ "zero": "Publish {} selected view", "one": "Publish {} selected views", "many": "Publish {} selected views", - "other":"Publish {} selected views" + "other": "Publish {} selected views" }, "mustSelectPrimaryDatabase": "The primary view must be selected", "noDatabaseSelected": "No database selected, please select at least one database.", @@ -2149,4 +2191,4 @@ "signInError": "Sign in error", "login": "Sign up or log in" } -} +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-chat/src/chat.rs b/frontend/rust-lib/flowy-chat/src/chat.rs index 747947e933..86727b5ce0 100644 --- a/frontend/rust-lib/flowy-chat/src/chat.rs +++ b/frontend/rust-lib/flowy-chat/src/chat.rs @@ -148,7 +148,12 @@ impl Chat { }, Err(err) => { error!("[Chat] failed to stream answer: {}", err); - let _ = text_sink.send(format!("error:{}", err)).await; + if err.is_ai_response_limit_exceeded() { + let _ = text_sink.send("AI_RESPONSE_LIMIT".to_string()).await; + } else { + let _ = text_sink.send(format!("error:{}", err)).await; + } + let pb = ChatMessageErrorPB { chat_id: chat_id.clone(), error_message: err.to_string(),