chore: enable billing (#5779)

* chore: enable billing

* chore: adjust bright mode UI

* chore: show corresponding error in sidebar

* chore: dismiss dialog in ai writter when hit ai response

* fix: improvements from test session

* chore: ai error message for database

* chore: different prompt for workspace owner

* feat: cancel plan survey

* chore: show ai repsonse limit on chat

* fix: sidebar toast after merge

* chore: remove unused debug print

* fix: popover close on action

* fix: minor copy changes

* chore: disable billing

* chore: disbale billing

---------

Co-authored-by: Mathias Mogensen <mathiasrieckm@gmail.com>
This commit is contained in:
Nathan.fooo 2024-07-24 14:23:09 +08:00 committed by GitHub
parent b5d799655a
commit 4a5eda6eeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1161 additions and 435 deletions

View File

@ -17,17 +17,23 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
required this.questionId, required this.questionId,
}) : super(ChatAIMessageState.initial(message)) { }) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) { if (state.stream != null) {
_subscription = state.stream!.listen((text) { _subscription = state.stream!.listen(
if (isClosed) { onData: (text) {
return; if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
} }
},
if (text.startsWith("data:")) { onError: (error) {
add(ChatAIMessageEvent.newText(text.substring(5))); if (!isClosed) {
} else if (text.startsWith("error:")) { add(ChatAIMessageEvent.receiveError(error.toString()));
add(ChatAIMessageEvent.receiveError(text.substring(5)));
} }
}); },
onAIResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
);
if (state.stream!.error != null) { if (state.stream!.error != null) {
Future.delayed(const Duration(milliseconds: 300), () { Future.delayed(const Duration(milliseconds: 300), () {
@ -42,11 +48,16 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
(event, emit) async { (event, emit) async {
await event.when( await event.when(
initial: () async {}, initial: () async {},
newText: (newText) { updateText: (newText) {
emit(state.copyWith(text: state.text + newText, error: null)); emit(
state.copyWith(
text: newText,
messageState: const MessageState.ready(),
),
);
}, },
receiveError: (error) { receiveError: (error) {
emit(state.copyWith(error: error)); emit(state.copyWith(messageState: MessageState.onError(error)));
}, },
retry: () { retry: () {
if (questionId is! Int64) { if (questionId is! Int64) {
@ -55,8 +66,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
} }
emit( emit(
state.copyWith( state.copyWith(
retryState: const LoadingState.loading(), messageState: const MessageState.loading(),
error: null,
), ),
); );
@ -82,8 +92,14 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
emit( emit(
state.copyWith( state.copyWith(
text: text, text: text,
error: null, messageState: const MessageState.ready(),
retryState: const LoadingState.finish(), ),
);
},
onAIResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
), ),
); );
}, },
@ -98,7 +114,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
return super.close(); return super.close();
} }
StreamSubscription<AnswerStreamElement>? _subscription; StreamSubscription<String>? _subscription;
final String chatId; final String chatId;
final Int64? questionId; final Int64? questionId;
} }
@ -106,26 +122,34 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
@freezed @freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent { class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial; 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.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry; const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult; const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
} }
@freezed @freezed
class ChatAIMessageState with _$ChatAIMessageState { class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({ const factory ChatAIMessageState({
AnswerStream? stream, AnswerStream? stream,
String? error,
required String text, required String text,
required LoadingState retryState, required MessageState messageState,
}) = _ChatAIMessageState; }) = _ChatAIMessageState;
factory ChatAIMessageState.initial(dynamic text) { factory ChatAIMessageState.initial(dynamic text) {
return ChatAIMessageState( return ChatAIMessageState(
text: text is String ? text : "", text: text is String ? text : "",
stream: text is AnswerStream ? text : null, 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;
}

View File

@ -525,8 +525,6 @@ OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
return null; return null;
} }
typedef AnswerStreamElement = String;
class AnswerStream { class AnswerStream {
AnswerStream() { AnswerStream() {
_port.handler = _controller.add; _port.handler = _controller.add;
@ -534,23 +532,53 @@ class AnswerStream {
(event) { (event) {
if (event.startsWith("data:")) { if (event.startsWith("data:")) {
_hasStarted = true; _hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) { } else if (event.startsWith("error:")) {
_error = event.substring(5); _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 RawReceivePort _port = RawReceivePort();
final StreamController<AnswerStreamElement> _controller = final StreamController<String> _controller = StreamController.broadcast();
StreamController.broadcast(); late StreamSubscription<String> _subscription;
late StreamSubscription<AnswerStreamElement> _subscription;
bool _hasStarted = false; bool _hasStarted = false;
String? _error; 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; int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted; bool get hasStarted => _hasStarted;
String? get error => _error; String? get error => _error;
String get text => _text;
Future<void> dispose() async { Future<void> dispose() async {
await _controller.close(); await _controller.close();
@ -558,9 +586,23 @@ class AnswerStream {
_port.close(); _port.close();
} }
StreamSubscription<AnswerStreamElement> listen( StreamSubscription<String> listen({
void Function(AnswerStreamElement event)? onData, void Function(String text)? onData,
) { void Function()? onStart,
return _controller.stream.listen(onData); 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;
} }
} }

View File

@ -1,6 +1,5 @@
import 'package:appflowy/generated/locale_keys.g.dart'; 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_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/chat_loading.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -38,7 +37,8 @@ class ChatAITextMessageWidget extends StatelessWidget {
)..add(const ChatAIMessageEvent.initial()), )..add(const ChatAIMessageEvent.initial()),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>( child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) { builder: (context, state) {
if (state.error != null) { return state.messageState.when(
onError: (err) {
return StreamingError( return StreamingError(
onRetryPressed: () { onRetryPressed: () {
context.read<ChatAIMessageBloc>().add( context.read<ChatAIMessageBloc>().add(
@ -46,18 +46,26 @@ class ChatAITextMessageWidget extends StatelessWidget {
); );
}, },
); );
} },
onAIResponseLimit: () {
if (state.retryState == const LoadingState.loading()) { return FlowyText(
return const ChatAILoading(); LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
} maxLines: 10,
lineHeight: 1.5,
);
},
ready: () {
if (state.text.isEmpty) { if (state.text.isEmpty) {
return const ChatAILoading(); return const ChatAILoading();
} else { } else {
return AIMarkdownText(markdown: state.text); return AIMarkdownText(markdown: state.text);
} }
}, },
loading: () {
return const ChatAILoading();
},
);
},
), ),
); );
} }

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.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/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_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/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_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/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/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/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.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/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.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'; import 'package:flutter_bloc/flutter_bloc.dart';
abstract class IEditableSummaryCellSkin { abstract class IEditableSummaryCellSkin {
@ -149,7 +151,22 @@ class SummaryCellAccessory extends StatelessWidget {
rowId: rowId, rowId: rowId,
fieldId: fieldId, fieldId: fieldId,
), ),
child: BlocBuilder<SummaryRowBloc, SummaryRowState>( child: BlocConsumer<SummaryRowBloc, SummaryRowState>(
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) { builder: (context, state) {
return const Row( return const Row(
children: [SummaryButton(), HSpace(6), CopyButton()], children: [SummaryButton(), HSpace(6), CopyButton()],
@ -169,13 +186,13 @@ class SummaryButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<SummaryRowBloc, SummaryRowState>( return BlocBuilder<SummaryRowBloc, SummaryRowState>(
builder: (context, state) { builder: (context, state) {
return state.loadingState.map( return state.loadingState.when(
loading: (_) { loading: () {
return const Center( return const Center(
child: CircularProgressIndicator.adaptive(), child: CircularProgressIndicator.adaptive(),
); );
}, },
finish: (_) { finish: () {
return FlowyTooltip( return FlowyTooltip(
message: LocaleKeys.tooltip_aiGenerate.tr(), message: LocaleKeys.tooltip_aiGenerate.tr(),
child: Container( child: Container(

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.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/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_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/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_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/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/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/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.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/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.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'; import 'package:flutter_bloc/flutter_bloc.dart';
abstract class IEditableTranslateCellSkin { abstract class IEditableTranslateCellSkin {
@ -150,7 +152,22 @@ class TranslateCellAccessory extends StatelessWidget {
rowId: rowId, rowId: rowId,
fieldId: fieldId, fieldId: fieldId,
), ),
child: BlocBuilder<TranslateRowBloc, TranslateRowState>( child: BlocConsumer<TranslateRowBloc, TranslateRowState>(
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) { builder: (context, state) {
return const Row( return const Row(
children: [TranslateButton(), HSpace(6), CopyButton()], children: [TranslateButton(), HSpace(6), CopyButton()],

View File

@ -6,8 +6,8 @@ import 'package:appflowy/shared/custom_image_cache_manager.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_extension.dart'; import 'package:appflowy/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.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/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra/uuid.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -65,7 +65,7 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
return (s.url, null); return (s.url, null);
}, },
(err) { (err) {
if (err.code == ErrorCode.FileStorageLimitExceeded) { if (err.isStorageLimitExceeded) {
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
} else { } else {
return (null, err.msg); return (null, err.msg);

View File

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.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/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/error.dart';
@ -7,7 +9,6 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class OpenAIImageWidget extends StatefulWidget { class OpenAIImageWidget extends StatefulWidget {
const OpenAIImageWidget({ const OpenAIImageWidget({

View File

@ -1,35 +1,13 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.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) { void showAILimitDialog(BuildContext context, String message) {
showDialog( showConfirmDialog(
context: context, context: context,
barrierDismissible: false, title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(),
useRootNavigator: false, description: message,
builder: (dialogContext) => _AILimitDialog(
message: message,
onOkPressed: () {},
),
); );
} }

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.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/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.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_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.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:provider/provider.dart'; import 'package:provider/provider.dart';
import 'ai_limit_dialog.dart'; import 'ai_limit_dialog.dart';
@ -46,7 +43,7 @@ Node autoCompletionNode({
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node( SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr, getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr,
iconData: Icons.generating_tokens, iconData: Icons.generating_tokens,
keywords: ['ai', 'openai' 'writer', 'autogenerator'], keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'],
nodeBuilder: (editorState, _) { nodeBuilder: (editorState, _) {
final node = autoCompletionNode(start: editorState.selection!); final node = autoCompletionNode(start: editorState.selection!);
return node; return node;
@ -130,7 +127,6 @@ class _AutoCompletionBlockComponentState
_unsubscribeSelectionGesture(); _unsubscribeSelectionGesture();
controller.dispose(); controller.dispose();
textFieldFocusNode.dispose(); textFieldFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -181,9 +177,7 @@ class _AutoCompletionBlockComponentState
final transaction = editorState.transaction..deleteNode(widget.node); final transaction = editorState.transaction..deleteNode(widget.node);
await editorState.apply( await editorState.apply(
transaction, transaction,
options: const ApplyOptions( options: const ApplyOptions(recordUndo: false),
recordUndo: false,
),
); );
} }
@ -230,6 +224,7 @@ class _AutoCompletionBlockComponentState
if (mounted) { if (mounted) {
if (error.isLimitExceeded) { if (error.isLimitExceeded) {
showAILimitDialog(context, error.message); showAILimitDialog(context, error.message);
await _onDiscard();
} else { } else {
showSnackBarMessage( showSnackBarMessage(
context, context,
@ -417,12 +412,10 @@ class _AutoCompletionBlockComponentState
// show dialog // show dialog
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (_) => DiscardDialog(
return DiscardDialog( onConfirm: _onDiscard,
onConfirm: () => _onDiscard(),
onCancel: () {}, onCancel: () {},
); ),
},
); );
} else if (controller.text.isEmpty) { } else if (controller.text.isEmpty) {
_onExit(); _onExit();
@ -445,9 +438,7 @@ class _AutoCompletionBlockComponentState
} }
class AutoCompletionHeader extends StatelessWidget { class AutoCompletionHeader extends StatelessWidget {
const AutoCompletionHeader({ const AutoCompletionHeader({super.key});
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -471,23 +462,27 @@ class AutoCompletionInputFooter extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
PrimaryTextButton( FlowyTextButton.primary(
LocaleKeys.button_generate.tr(), text: LocaleKeys.button_generate.tr(),
context: context,
onPressed: onGenerate, onPressed: onGenerate,
), ),
const Space(10, 0), const Space(10, 0),
SecondaryTextButton( FlowyTextButton.secondary(
LocaleKeys.button_cancel.tr(), text: LocaleKeys.button_cancel.tr(),
context: context,
onPressed: onExit, onPressed: onExit,
), ),
Expanded( Flexible(
child: Container( child: Container(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: FlowyText.regular( child: FlowyText.regular(
LocaleKeys.document_plugins_warning.tr(), LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
fontSize: 12,
), ),
), ),
), ),
@ -512,18 +507,21 @@ class AutoCompletionFooter extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
PrimaryTextButton( FlowyTextButton.primary(
LocaleKeys.button_keep.tr(), context: context,
text: LocaleKeys.button_keep.tr(),
onPressed: onKeep, onPressed: onKeep,
), ),
const Space(10, 0), const Space(10, 0),
SecondaryTextButton( FlowyTextButton.secondary(
LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), context: context,
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
onPressed: onRewrite, onPressed: onRewrite,
), ),
const Space(10, 0), const Space(10, 0),
SecondaryTextButton( FlowyTextButton.secondary(
LocaleKeys.button_discard.tr(), context: context,
text: LocaleKeys.button_discard.tr(),
onPressed: onDiscard, onPressed: onDiscard,
), ),
], ],

View File

@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.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:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -239,19 +240,15 @@ class PageStyleCoverImage extends StatelessWidget {
return; return;
} }
if (result == null) { if (result == null) {
showSnapBar( return showSnapBar(
context, context,
LocaleKeys.document_plugins_image_imageUploadFailed, LocaleKeys.document_plugins_image_imageUploadFailed.tr(),
); );
return;
} }
context.read<DocumentPageStyleBloc>().add( context.read<DocumentPageStyleBloc>().add(
DocumentPageStyleEvent.updateCoverImage( DocumentPageStyleEvent.updateCoverImage(
PageStyleCover( PageStyleCover(type: type, value: result),
type: type,
value: result,
),
), ),
); );
} }
@ -282,10 +279,7 @@ class PageStyleCoverImage extends StatelessWidget {
}, },
builder: (_) { builder: (_) {
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80),
maxHeight: maxHeight,
minHeight: 80,
),
child: BlocProvider.value( child: BlocProvider.value(
value: pageStyleBloc, value: pageStyleBloc,
child: Padding( child: Padding(

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/shared/share/share_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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class ShareMenuButton extends StatelessWidget { class ShareMenuButton extends StatelessWidget {

View File

@ -91,7 +91,7 @@ enum FeatureFlag {
bool get isOn { bool get isOn {
if ([ if ([
FeatureFlag.planBilling, // FeatureFlag.planBilling,
// release this feature in version 0.6.1 // release this feature in version 0.6.1
FeatureFlag.spaceDesign, FeatureFlag.spaceDesign,
// release this feature in version 0.5.9 // release this feature in version 0.5.9

View File

@ -90,7 +90,7 @@ class CompletionStream {
if (event == "AI_RESPONSE_LIMIT") { if (event == "AI_RESPONSE_LIMIT") {
onError( onError(
AIError( AIError(
message: LocaleKeys.sideBar_aiResponseLitmit.tr(), message: LocaleKeys.sideBar_aiResponseLimit.tr(),
code: AIErrorCode.aiResponseLimitExceeded, code: AIErrorCode.aiResponseLimitExceeded,
), ),
); );

View File

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:appflowy/env/cloud_env.dart'; import 'package:flutter/foundation.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@ -14,6 +14,7 @@ abstract class IUserBackendService {
Future<FlowyResult<void, FlowyError>> cancelSubscription( Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId, String workspaceId,
SubscriptionPlanPB plan, SubscriptionPlanPB plan,
String? reason,
); );
Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription( Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
String workspaceId, String workspaceId,
@ -21,6 +22,9 @@ abstract class IUserBackendService {
); );
} }
const _baseBetaUrl = 'https://beta.appflowy.com';
const _baseProdUrl = 'https://appflowy.com';
class UserBackendService implements IUserBackendService { class UserBackendService implements IUserBackendService {
UserBackendService({required this.userId}); UserBackendService({required this.userId});
@ -255,19 +259,24 @@ class UserBackendService implements IUserBackendService {
..recurringInterval = RecurringIntervalPB.Year ..recurringInterval = RecurringIntervalPB.Year
..workspaceSubscriptionPlan = plan ..workspaceSubscriptionPlan = plan
..successUrl = ..successUrl =
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}'; '${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}';
return UserEventSubscribeWorkspace(request).send(); return UserEventSubscribeWorkspace(request).send();
} }
@override @override
Future<FlowyResult<void, FlowyError>> cancelSubscription( Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId, String workspaceId,
SubscriptionPlanPB plan, SubscriptionPlanPB plan, [
) { String? reason,
]) {
final request = CancelWorkspaceSubscriptionPB() final request = CancelWorkspaceSubscriptionPB()
..workspaceId = workspaceId ..workspaceId = workspaceId
..plan = plan; ..plan = plan;
if (reason != null) {
request.reason = reason;
}
return UserEventCancelWorkspaceSubscription(request).send(); return UserEventCancelWorkspaceSubscription(request).send();
} }

View File

@ -115,7 +115,7 @@ class SettingsBillingBloc
(f) => Log.error(f.msg, f), (f) => Log.error(f.msg, f),
); );
}, },
cancelSubscription: (plan) async { cancelSubscription: (plan, reason) async {
final s = state.mapOrNull(ready: (s) => s); final s = state.mapOrNull(ready: (s) => s);
if (s == null) { if (s == null) {
return; return;
@ -124,7 +124,7 @@ class SettingsBillingBloc
emit(s.copyWith(isLoading: true)); emit(s.copyWith(isLoading: true));
final result = final result =
await _userService.cancelSubscription(workspaceId, plan); await _userService.cancelSubscription(workspaceId, plan, reason);
final successOrNull = result.fold( final successOrNull = result.fold(
(_) => true, (_) => true,
(f) { (f) {
@ -276,8 +276,9 @@ class SettingsBillingEvent with _$SettingsBillingEvent {
_AddSubscription; _AddSubscription;
const factory SettingsBillingEvent.cancelSubscription( const factory SettingsBillingEvent.cancelSubscription(
SubscriptionPlanPB plan, SubscriptionPlanPB plan, {
) = _CancelSubscription; @Default(null) String? reason,
}) = _CancelSubscription;
const factory SettingsBillingEvent.paymentSuccessful({ const factory SettingsBillingEvent.paymentSuccessful({
SubscriptionPlanPB? plan, SubscriptionPlanPB? plan,

View File

@ -95,7 +95,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
), ),
); );
}, },
cancelSubscription: () async { cancelSubscription: (reason) async {
final newState = state final newState = state
.mapOrNull(ready: (state) => state) .mapOrNull(ready: (state) => state)
?.copyWith(downgradeProcessing: true); ?.copyWith(downgradeProcessing: true);
@ -106,6 +106,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
final result = await _userService.cancelSubscription( final result = await _userService.cancelSubscription(
workspaceId, workspaceId,
SubscriptionPlanPB.Pro, SubscriptionPlanPB.Pro,
reason,
); );
final successOrNull = result.fold( final successOrNull = result.fold(
@ -206,7 +207,9 @@ class SettingsPlanEvent with _$SettingsPlanEvent {
const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) =
_AddSubscription; _AddSubscription;
const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; const factory SettingsPlanEvent.cancelSubscription({
@Default(null) String? reason,
}) = _CancelSubscription;
const factory SettingsPlanEvent.paymentSuccessful({ const factory SettingsPlanEvent.paymentSuccessful({
@Default(null) SubscriptionPlanPB? plan, @Default(null) SubscriptionPlanPB? plan,

View File

@ -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:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
extension SubscriptionLabels on WorkspaceSubscriptionInfoPB { extension SubscriptionInfoHelpers on WorkspaceSubscriptionInfoPB {
String get label => switch (plan) { String get label => switch (plan) {
WorkspacePlanPB.FreePlan => WorkspacePlanPB.FreePlan =>
LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(),
@ -24,6 +24,14 @@ extension SubscriptionLabels on WorkspaceSubscriptionInfoPB {
LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(),
_ => 'N/A', _ => 'N/A',
}; };
bool get isBillingPortalEnabled {
if (plan != WorkspacePlanPB.FreePlan || addOns.isNotEmpty) {
return true;
}
return false;
}
} }
extension AllSubscriptionLabels on SubscriptionPlanPB { extension AllSubscriptionLabels on SubscriptionPlanPB {

View File

@ -16,7 +16,7 @@ part 'sidebar_plan_bloc.freezed.dart';
class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> { class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
SidebarPlanBloc() : super(const SidebarPlanState()) { 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<SubscriptionSuccessListenable>(); final subscriptionListener = getIt<SubscriptionSuccessListenable>();
subscriptionListener.addListener(() { subscriptionListener.addListener(() {
final plan = subscriptionListener.subscribedPlan; final plan = subscriptionListener.subscribedPlan;
@ -49,6 +49,7 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
} }
}); });
// 2. Listen to the storage notification
_storageListener = StoreageNotificationListener( _storageListener = StoreageNotificationListener(
onError: (error) { onError: (error) {
if (!isClosed) { if (!isClosed) {
@ -57,6 +58,7 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
}, },
); );
// 3. Listen to specific error codes
_globalErrorListener = GlobalErrorCodeNotifier.add( _globalErrorListener = GlobalErrorCodeNotifier.add(
onError: (error) { onError: (error) {
if (!isClosed) { if (!isClosed) {
@ -92,11 +94,21 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
) async { ) async {
await event.when( await event.when(
receiveError: (FlowyError error) async { receiveError: (FlowyError error) async {
if (error.code == ErrorCode.AIResponseLimitExceeded) {
emit(
state.copyWith(
tierIndicator: const SidebarToastTierIndicator.aiMaxiLimitHit(),
),
);
} else if (error.code == ErrorCode.FileStorageLimitExceeded) {
emit( emit(
state.copyWith( state.copyWith(
tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), tierIndicator: const SidebarToastTierIndicator.storageLimitHit(),
), ),
); );
} else {
Log.error("Unhandle Unexpected error: $error");
}
}, },
init: (String workspaceId, UserProfilePB userProfile) { init: (String workspaceId, UserProfilePB userProfile) {
emit( emit(

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/plugin/plugin.dart';
@ -7,14 +11,16 @@ import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class SidebarFooter extends StatelessWidget { class SidebarFooter extends StatelessWidget {
const SidebarFooter({super.key}); const SidebarFooter({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Row( return Column(
children: [
if (FeatureFlag.planBilling.isOn) const SidebarToast(),
const Row(
children: [ children: [
Expanded(child: SidebarTrashButton()), Expanded(child: SidebarTrashButton()),
// Enable it when the widget button is ready // Enable it when the widget button is ready
@ -24,14 +30,14 @@ class SidebarFooter extends StatelessWidget {
// ), // ),
// Expanded(child: SidebarWidgetButton()), // Expanded(child: SidebarWidgetButton()),
], ],
),
],
); );
} }
} }
class SidebarTrashButton extends StatelessWidget { class SidebarTrashButton extends StatelessWidget {
const SidebarTrashButton({ const SidebarTrashButton({super.key});
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -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/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/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_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/application/user/user_workspace_bloc.dart';
@ -6,88 +11,79 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class SidebarToast extends StatefulWidget { class SidebarToast extends StatelessWidget {
const SidebarToast({super.key}); const SidebarToast({super.key});
@override
State<SidebarToast> createState() => _SidebarToastState();
}
class _SidebarToastState extends State<SidebarToast> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocConsumer<SidebarPlanBloc, SidebarPlanState>( return BlocConsumer<SidebarPlanBloc, SidebarPlanState>(
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. // 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. // Even though the dislog is dissmissed, if the user triggers the storage limit again, the dialog will show again.
state.tierIndicator.maybeWhen( state.tierIndicator.maybeWhen(
storageLimitHit: () { storageLimitHit: () => WidgetsBinding.instance.addPostFrameCallback(
WidgetsBinding.instance.addPostFrameCallback(
(_) => _showStorageLimitDialog(context), (_) => _showStorageLimitDialog(context),
debugLabel: 'Sidebar.showStorageLimit', ),
orElse: () {},
); );
}, },
orElse: () { builder: (_, state) {
// Do nothing
},
);
},
builder: (context, state) {
return BlocBuilder<SidebarPlanBloc, SidebarPlanState>(
builder: (context, state) {
return state.tierIndicator.when( return state.tierIndicator.when(
storageLimitHit: () => Column( loading: () => const SizedBox.shrink(),
children: [ storageLimitHit: () => PlanIndicator(
const Divider(height: 0.6), planName: SubscriptionPlanPB.Free.label,
PlanIndicator(
planName: "Pro",
text: LocaleKeys.sideBar_upgradeToPro.tr(), text: LocaleKeys.sideBar_upgradeToPro.tr(),
onTap: () { onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.Pro),
_hanldeOnTap(context, SubscriptionPlanPB.Pro);
},
reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(),
), ),
], aiMaxiLimitHit: () => PlanIndicator(
), planName: SubscriptionPlanPB.AiMax.label,
aiMaxiLimitHit: () => Column(
children: [
const Divider(height: 0.6),
PlanIndicator(
planName: "AI Max",
text: LocaleKeys.sideBar_upgradeToAIMax.tr(), text: LocaleKeys.sideBar_upgradeToAIMax.tr(),
onTap: () { onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.AiMax),
_hanldeOnTap(context, SubscriptionPlanPB.AiMax); reason: LocaleKeys.sideBar_aiResponseLimitTitle.tr(),
},
reason: LocaleKeys.sideBar_aiResponseLitmitDialogTitle.tr(),
), ),
],
),
loading: () {
return const SizedBox.shrink();
},
);
},
); );
}, },
); );
} }
void _showStorageLimitDialog(BuildContext context) { void _showStorageLimitDialog(BuildContext context) => showConfirmDialog(
showDialog(
context: context, context: context,
barrierDismissible: false, title: LocaleKeys.sideBar_purchaseStorageSpace.tr(),
useRootNavigator: false, description: LocaleKeys.sideBar_storageLimitDialogTitle.tr(),
builder: (dialogContext) => _StorageLimitDialog( confirmLabel:
onOkPressed: () { LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(),
onConfirm: () {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _hanldeOnTap(context, SubscriptionPlanPB.Pro),
);
},
);
void _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) {
final userProfile = context.read<SidebarPlanBloc>().state.userProfile; final userProfile = context.read<SidebarPlanBloc>().state.userProfile;
if (userProfile == null) {
return Log.error(
'UserProfile is null, this should NOT happen! Please file a bug report',
);
}
final userWorkspaceBloc = context.read<UserWorkspaceBloc>(); final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
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( showSettingsDialog(
context, context,
userProfile, userProfile,
@ -95,36 +91,30 @@ class _SidebarToastState extends State<SidebarToast> {
SettingsPage.plan, SettingsPage.plan,
); );
} else { } else {
Log.error( final message = plan == SubscriptionPlanPB.AiMax
"UserProfile is null. It should not happen. If you see this error, it's a bug.", ? LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr()
); : LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr();
}
}, showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) => _AskOwnerToChangePlan(
message: message,
onOkPressed: () {},
), ),
); );
} }
void _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) {
final userProfile = context.read<SidebarPlanBloc>().state.userProfile;
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
if (userProfile != null) {
showSettingsDialog(
context,
userProfile,
userWorkspaceBloc,
SettingsPage.plan,
);
}
} }
} }
class PlanIndicator extends StatelessWidget { class PlanIndicator extends StatefulWidget {
const PlanIndicator({ const PlanIndicator({
super.key,
required this.planName, required this.planName,
required this.text, required this.text,
required this.onTap, required this.onTap,
required this.reason, required this.reason,
super.key,
}); });
final String planName; final String planName;
@ -132,62 +122,150 @@ class PlanIndicator extends StatelessWidget {
final String text; final String text;
final Function() onTap; final Function() onTap;
final textColor = const Color(0xFFE8E2EE); @override
final secondaryColor = const Color(0xFF653E8C); State<PlanIndicator> createState() => _PlanIndicatorState();
}
class _PlanIndicatorState extends State<PlanIndicator> {
final popoverController = PopoverController();
@override
void dispose() {
popoverController.close();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( 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: [ children: [
FlowyButton( FlowyText(
margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), widget.text,
text: FlowyText( color: AFThemeExtension.of(context).strongText,
text,
color: textColor,
fontSize: 12,
), ),
radius: BorderRadius.zero, const VSpace(12),
leftIconSize: const Size(40, 20), Opacity(
leftIcon: Badge( opacity: 0.7,
padding: const EdgeInsets.symmetric(horizontal: 6), child: FlowyText.regular(
backgroundColor: secondaryColor, widget.reason,
label: FlowyText.semibold( maxLines: null,
planName, lineHeight: 1.3,
fontSize: 12, textAlign: TextAlign.center,
color: textColor,
), ),
), ),
onTap: onTap, 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,
), ),
Padding( decoration: BoxDecoration(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 6), color: Theme.of(context).colorScheme.primary,
child: Opacity( borderRadius: BorderRadius.circular(9),
opacity: 0.4, ),
child: Center(
child: FlowyText( child: FlowyText(
reason, LocaleKeys
textAlign: TextAlign.start, .settings_comparePlanDialog_actions_upgrade
color: textColor, .tr(),
fontSize: 8, color: Colors.white,
maxLines: 10, 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 { class _AskOwnerToChangePlan extends StatelessWidget {
const _StorageLimitDialog({ const _AskOwnerToChangePlan({
required this.message,
required this.onOkPressed, required this.onOkPressed,
}); });
final String message;
final VoidCallback onOkPressed; final VoidCallback onOkPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NavigatorOkCancelDialog( return NavigatorOkCancelDialog(
message: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), message: message,
okTitle: LocaleKeys.sideBar_purchaseStorageSpace.tr(), okTitle: LocaleKeys.button_ok.tr(),
onOkPressed: onOkPressed, onOkPressed: onOkPressed,
titleUpperCase: false, titleUpperCase: false,
); );

View File

@ -1,10 +1,10 @@
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/startup/startup.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/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart';

View File

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/blank/blank.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/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
Loading? _duplicateSpaceLoading; Loading? _duplicateSpaceLoading;
@ -358,9 +359,6 @@ class _SidebarState extends State<_Sidebar> {
child: const SidebarFooter(), child: const SidebarFooter(),
), ),
const VSpace(14), const VSpace(14),
// toast
// const SidebarToast(),
], ],
), ),
), ),

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class SpaceMigration extends StatefulWidget { class SpaceMigration extends StatefulWidget {
@ -70,14 +71,8 @@ class _SpaceMigrationState extends State<SpaceMigration> {
const linearGradient = LinearGradient( const linearGradient = LinearGradient(
begin: Alignment.bottomLeft, begin: Alignment.bottomLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [Color(0xFF8032FF), Color(0xFFEF35FF)],
Color(0xFF8032FF), stops: [0.1545, 0.8225],
Color(0xFFEF35FF),
],
stops: [
0.1545,
0.8225,
],
); );
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,

View File

@ -91,7 +91,7 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
}, },
ready: (state) { ready: (state) {
final billingPortalEnabled = final billingPortalEnabled =
state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan; state.subscriptionInfo.isBillingPortalEnabled;
return SettingsBody( return SettingsBody(
title: LocaleKeys.settings_billingPage_title.tr(), title: LocaleKeys.settings_billingPage_title.tr(),
@ -327,14 +327,9 @@ class _AITileState extends State<_AITile> {
: LocaleKeys.settings_billingPage_addons_addLabel.tr(), : LocaleKeys.settings_billingPage_addons_addLabel.tr(),
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth, minWidth: _buttonsMinWidth,
onPressed: () { onPressed: () async {
if (widget.subscriptionInfo != null && isCanceled) { if (widget.subscriptionInfo != null) {
// Show customer portal to renew await showConfirmDialog(
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.openCustomerPortal());
} else if (widget.subscriptionInfo != null) {
showConfirmDialog(
context: context, context: context,
style: ConfirmPopupStyle.cancelAndOk, style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.settings_billingPage_addons_removeDialog_title title: LocaleKeys.settings_billingPage_addons_removeDialog_title
@ -343,11 +338,9 @@ class _AITileState extends State<_AITile> {
.settings_billingPage_addons_removeDialog_description .settings_billingPage_addons_removeDialog_description
.tr(namedArgs: {"plan": widget.plan.label.tr()}), .tr(namedArgs: {"plan": widget.plan.label.tr()}),
confirmLabel: LocaleKeys.button_confirm.tr(), confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () { onConfirm: () => context
context.read<SettingsBillingBloc>().add( .read<SettingsBillingBloc>()
SettingsBillingEvent.cancelSubscription(widget.plan), .add(SettingsBillingEvent.cancelSubscription(widget.plan)),
);
},
); );
} else { } else {
// Add the addon // Add the addon

View File

@ -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/settings_plan_bloc.dart';
import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.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/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/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -141,7 +142,7 @@ class _SettingsPlanComparisonDialogState
children: [ children: [
const VSpace(30), const VSpace(30),
SizedBox( SizedBox(
height: 100, height: 116,
child: FlowyText.semibold( child: FlowyText.semibold(
LocaleKeys LocaleKeys
.settings_comparePlanDialog_planFeatures .settings_comparePlanDialog_planFeatures
@ -153,7 +154,7 @@ class _SettingsPlanComparisonDialogState
: const Color(0xFFE8E0FF), : const Color(0xFFE8E0FF),
), ),
), ),
const SizedBox(height: 96), const SizedBox(height: 116),
const SizedBox(height: 56), const SizedBox(height: 56),
..._planLabels.map( ..._planLabels.map(
(e) => _ComparisonCell( (e) => _ComparisonCell(
@ -184,17 +185,9 @@ class _SettingsPlanComparisonDialogState
cells: _freeLabels, cells: _freeLabels,
isCurrent: isCurrent:
currentInfo.plan == WorkspacePlanPB.FreePlan, currentInfo.plan == WorkspacePlanPB.FreePlan,
canDowngrade: buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor(
currentInfo.plan != WorkspacePlanPB.FreePlan, currentInfo.plan,
currentCanceled: currentInfo.isCanceled || ),
(context
.watch<SettingsPlanBloc>()
.state
.mapOrNull(
loading: (_) => true,
ready: (s) => s.downgradeProcessing,
) ??
false),
onSelected: () async { onSelected: () async {
if (currentInfo.plan == if (currentInfo.plan ==
WorkspacePlanPB.FreePlan || WorkspacePlanPB.FreePlan ||
@ -202,6 +195,12 @@ class _SettingsPlanComparisonDialogState
return; return;
} }
final reason =
await showCancelSurveyDialog(context);
if (reason == null || !context.mounted) {
return;
}
await showConfirmDialog( await showConfirmDialog(
context: context, context: context,
title: LocaleKeys title: LocaleKeys
@ -216,8 +215,9 @@ class _SettingsPlanComparisonDialogState
style: ConfirmPopupStyle.cancelAndOk, style: ConfirmPopupStyle.cancelAndOk,
onConfirm: () => onConfirm: () =>
context.read<SettingsPlanBloc>().add( context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent SettingsPlanEvent.cancelSubscription(
.cancelSubscription(), reason: reason,
),
), ),
); );
}, },
@ -242,9 +242,9 @@ class _SettingsPlanComparisonDialogState
cells: _proLabels, cells: _proLabels,
isCurrent: isCurrent:
currentInfo.plan == WorkspacePlanPB.ProPlan, currentInfo.plan == WorkspacePlanPB.ProPlan,
canUpgrade: buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor(
currentInfo.plan == WorkspacePlanPB.FreePlan, currentInfo.plan,
currentCanceled: currentInfo.isCanceled, ),
onSelected: () => onSelected: () =>
context.read<SettingsPlanBloc>().add( context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription( 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 { class _PlanTable extends StatelessWidget {
const _PlanTable({ const _PlanTable({
required this.title, required this.title,
@ -275,9 +304,7 @@ class _PlanTable extends StatelessWidget {
required this.cells, required this.cells,
required this.isCurrent, required this.isCurrent,
required this.onSelected, required this.onSelected,
this.canUpgrade = false, this.buttonType = _PlanButtonType.none,
this.canDowngrade = false,
this.currentCanceled = false,
}); });
final String title; final String title;
@ -288,13 +315,11 @@ class _PlanTable extends StatelessWidget {
final List<_CellItem> cells; final List<_CellItem> cells;
final bool isCurrent; final bool isCurrent;
final VoidCallback onSelected; final VoidCallback onSelected;
final bool canUpgrade; final _PlanButtonType buttonType;
final bool canDowngrade;
final bool currentCanceled;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final highlightPlan = !isCurrent && !canDowngrade && canUpgrade; final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade;
final isLM = Theme.of(context).isLightMode; final isLM = Theme.of(context).isLightMode;
return Container( return Container(
@ -336,37 +361,29 @@ class _PlanTable extends StatelessWidget {
title: price, title: price,
description: priceInfo, description: priceInfo,
isPrimary: !highlightPlan, isPrimary: !highlightPlan,
height: 96,
), ),
if (canUpgrade || canDowngrade) ...[ if (buttonType == _PlanButtonType.none) ...[
const SizedBox(height: 56),
] else ...[
Opacity( Opacity(
opacity: canDowngrade && currentCanceled ? 0.5 : 1, opacity: 1,
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 12 + (canUpgrade && !canDowngrade ? 12 : 0), left: 12 + (buttonType.isUpgrade ? 12 : 0),
), ),
child: _ActionButton( child: _ActionButton(
label: canUpgrade && !canDowngrade label: buttonType.isUpgrade
? LocaleKeys.settings_comparePlanDialog_actions_upgrade ? LocaleKeys.settings_comparePlanDialog_actions_upgrade
.tr() .tr()
: LocaleKeys : LocaleKeys
.settings_comparePlanDialog_actions_downgrade .settings_comparePlanDialog_actions_downgrade
.tr(), .tr(),
onPressed: !canUpgrade && canDowngrade && currentCanceled onPressed: onSelected,
? null isUpgrade: buttonType.isUpgrade,
: onSelected, useGradientBorder: buttonType.isUpgrade,
tooltip: !canUpgrade && canDowngrade && currentCanceled
? LocaleKeys
.settings_comparePlanDialog_actions_downgradeDisabledTooltip
.tr()
: null,
isUpgrade: canUpgrade && !canDowngrade,
useGradientBorder: !isCurrent && canUpgrade,
), ),
), ),
), ),
] else ...[
const SizedBox(height: 56),
], ],
...cells.map( ...cells.map(
(cell) => _ComparisonCell( (cell) => _ComparisonCell(
@ -467,14 +484,12 @@ class _ComparisonCell extends StatelessWidget {
class _ActionButton extends StatelessWidget { class _ActionButton extends StatelessWidget {
const _ActionButton({ const _ActionButton({
required this.label, required this.label,
this.tooltip,
required this.onPressed, required this.onPressed,
required this.isUpgrade, required this.isUpgrade,
this.useGradientBorder = false, this.useGradientBorder = false,
}); });
final String label; final String label;
final String? tooltip;
final VoidCallback? onPressed; final VoidCallback? onPressed;
final bool isUpgrade; final bool isUpgrade;
final bool useGradientBorder; final bool useGradientBorder;
@ -487,9 +502,7 @@ class _ActionButton extends StatelessWidget {
height: 56, height: 56,
child: Row( child: Row(
children: [ children: [
FlowyTooltip( GestureDetector(
message: tooltip,
child: GestureDetector(
onTap: onPressed, onTap: onPressed,
child: MouseRegion( child: MouseRegion(
cursor: onPressed != null cursor: onPressed != null
@ -514,7 +527,6 @@ class _ActionButton extends StatelessWidget {
), ),
), ),
), ),
),
], ],
), ),
); );
@ -538,10 +550,7 @@ class _ActionButton extends StatelessWidget {
shaderCallback: (bounds) => const LinearGradient( shaderCallback: (bounds) => const LinearGradient(
transform: GradientRotation(-1.55), transform: GradientRotation(-1.55),
stops: [0.4, 1], stops: [0.4, 1],
colors: [ colors: [Color(0xFF251D37), Color(0xFF7547C0)],
Color(0xFF251D37),
Color(0xFF7547C0),
],
).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
child: child, child: child,
); );
@ -579,19 +588,17 @@ class _Heading extends StatelessWidget {
required this.title, required this.title,
this.description, this.description,
this.isPrimary = true, this.isPrimary = true,
this.height = 100,
}); });
final String title; final String title;
final String? description; final String? description;
final bool isPrimary; final bool isPrimary;
final double height;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
width: 185, width: 185,
height: height, height: 116,
child: Padding( child: Padding(
padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)), padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)),
child: Column( child: Column(
@ -615,12 +622,14 @@ class _Heading extends StatelessWidget {
), ),
if (description != null && description!.isNotEmpty) ...[ if (description != null && description!.isNotEmpty) ...[
const VSpace(4), const VSpace(4),
FlowyText.regular( Flexible(
child: FlowyText.regular(
description!, description!,
fontSize: 12, fontSize: 12,
maxLines: 3, maxLines: 5,
lineHeight: 1.5, lineHeight: 1.5,
), ),
),
], ],
], ],
), ),

View File

@ -232,7 +232,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
const VSpace(8), const VSpace(8),
FlowyText.regular( FlowyText.regular(
widget.subscriptionInfo.info, widget.subscriptionInfo.info,
fontSize: 16, fontSize: 14,
color: AFThemeExtension.of(context).strongText, color: AFThemeExtension.of(context).strongText,
maxLines: 3, maxLines: 3,
), ),

View File

@ -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<String?> showCancelSurveyDialog(BuildContext context) {
return showDialog<String?>(
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<String, String> 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<String> 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);
}

View File

@ -72,8 +72,6 @@ class WorkspaceMembersPage extends StatelessWidget {
final actionResult = state.actionResult!.result; final actionResult = state.actionResult!.result;
final actionType = state.actionResult!.actionType; final actionType = state.actionResult!.actionType;
debugPrint("Plan: ${state.subscriptionInfo?.plan}");
if (actionType == WorkspaceMemberActionType.invite && if (actionType == WorkspaceMemberActionType.invite &&
actionResult.isFailure) { actionResult.isFailure) {
final error = actionResult.getFailure().code; final error = actionResult.getFailure().code;

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
@ -111,26 +112,26 @@ class SettingsMenu extends StatelessWidget {
), ),
changeSelectedPage: changeSelectedPage, changeSelectedPage: changeSelectedPage,
), ),
// if (FeatureFlag.planBilling.isOn && if (FeatureFlag.planBilling.isOn &&
// userProfile.authenticator == userProfile.authenticator ==
// AuthenticatorPB.AppFlowyCloud && AuthenticatorPB.AppFlowyCloud &&
// member != null && member != null &&
// member!.role.isOwner) ...[ member!.role.isOwner) ...[
// SettingsMenuElement( SettingsMenuElement(
// page: SettingsPage.plan, page: SettingsPage.plan,
// selectedPage: currentPage, selectedPage: currentPage,
// label: LocaleKeys.settings_planPage_menuLabel.tr(), label: LocaleKeys.settings_planPage_menuLabel.tr(),
// icon: const FlowySvg(FlowySvgs.settings_plan_m), icon: const FlowySvg(FlowySvgs.settings_plan_m),
// changeSelectedPage: changeSelectedPage, changeSelectedPage: changeSelectedPage,
// ), ),
// SettingsMenuElement( SettingsMenuElement(
// page: SettingsPage.billing, page: SettingsPage.billing,
// selectedPage: currentPage, selectedPage: currentPage,
// label: LocaleKeys.settings_billingPage_menuLabel.tr(), label: LocaleKeys.settings_billingPage_menuLabel.tr(),
// icon: const FlowySvg(FlowySvgs.settings_billing_m), icon: const FlowySvg(FlowySvgs.settings_billing_m),
// changeSelectedPage: changeSelectedPage, changeSelectedPage: changeSelectedPage,
// ), ),
// ], ],
if (kDebugMode) if (kDebugMode)
SettingsMenuElement( SettingsMenuElement(
// no need to translate this page // no need to translate this page

View File

@ -1,5 +1,6 @@
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.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:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -52,6 +53,8 @@ class StackTraceError {
typedef void ErrorListener(); typedef void ErrorListener();
/// Receive error when Rust backend send error message back to the flutter frontend
///
class GlobalErrorCodeNotifier extends ChangeNotifier { class GlobalErrorCodeNotifier extends ChangeNotifier {
// Static instance with lazy initialization // Static instance with lazy initialization
static final GlobalErrorCodeNotifier _instance = static final GlobalErrorCodeNotifier _instance =
@ -107,3 +110,10 @@ class GlobalErrorCodeNotifier extends ChangeNotifier {
_instance.removeListener(listener); _instance.removeListener(listener);
} }
} }
extension FlowyErrorExtension on FlowyError {
bool get isAIResponseLimitExceeded =>
code == ErrorCode.AIResponseLimitExceeded;
bool get isStorageLimitExceeded => code == ErrorCode.FileStorageLimitExceeded;
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flowy_infra/size.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/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
@ -168,6 +169,37 @@ class FlowyTextButton extends StatelessWidget {
this.borderColor, 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 String text;
final FontWeight? fontWeight; final FontWeight? fontWeight;
final Color? fontColor; final Color? fontColor;

View File

@ -0,0 +1,15 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 16V14.5H14V16H6ZM9.25 13V5.875L7.0625 8.0625L6 7L10 3L14 7L12.9375 8.0625L10.75 5.875V13H9.25Z" fill="#E8EAED"/>
<path d="M6 16V14.5H14V16H6ZM9.25 13V5.875L7.0625 8.0625L6 7L10 3L14 7L12.9375 8.0625L10.75 5.875V13H9.25Z" fill="url(#paint0_linear_3646_112419)"/>
<path d="M6 16V14.5H14V16H6ZM9.25 13V5.875L7.0625 8.0625L6 7L10 3L14 7L12.9375 8.0625L10.75 5.875V13H9.25Z" fill="url(#paint1_linear_3646_112419)"/>
<defs>
<linearGradient id="paint0_linear_3646_112419" x1="10" y1="3" x2="10" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#8132FF" stop-opacity="0"/>
<stop offset="1" stop-color="#8132FF"/>
</linearGradient>
<linearGradient id="paint1_linear_3646_112419" x1="7.54546" y1="14.8182" x2="15.0845" y2="11.9942" gradientUnits="userSpaceOnUse">
<stop stop-color="#8032FF"/>
<stop offset="1" stop-color="#EF35FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View File

@ -283,11 +283,14 @@
"favoriteSpace": "Favorites", "favoriteSpace": "Favorites",
"RecentSpace": "Recent", "RecentSpace": "Recent",
"Spaces": "Spaces", "Spaces": "Spaces",
"upgradeToPro": "Upgrade to Pro Plan", "upgradeToPro": "Upgrade to Pro",
"upgradeToAIMax": "Unlock unlimited AI", "upgradeToAIMax": "Unlock unlimited AI",
"storageLimitDialogTitle": "You are running out of storage space. Upgrade to Pro Plan to get more storage", "storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage",
"aiResponseLitmitDialogTitle": "You are running out of AI responses. Upgrade to Pro Plan or AI Max to get more AI responses", "aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses",
"aiResponseLitmit": "You are running out of AI responses. Go to Settings -> Plan -> Click AI Max or Pro Plan to get more AI 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", "purchaseStorageSpace": "Purchase Storage Space",
"purchaseAIResponse": "Purchase ", "purchaseAIResponse": "Purchase ",
"upgradeToAILocal": "AI offline on your device" "upgradeToAILocal": "AI offline on your device"
@ -350,7 +353,10 @@
"signInDiscord": "Continue with Discord", "signInDiscord": "Continue with Discord",
"more": "More", "more": "More",
"create": "Create", "create": "Create",
"close": "Close" "close": "Close",
"next": "Next",
"previous": "Previous",
"submit": "Submit"
}, },
"label": { "label": {
"welcome": "Welcome!", "welcome": "Welcome!",
@ -638,7 +644,7 @@
"menuLabel": "AI Settings", "menuLabel": "AI Settings",
"keys": { "keys": {
"enableAISearchTitle": "AI Search", "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", "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", "llmModel": "Language Model",
"llmModelType": "Language Model Type", "llmModelType": "Language Model Type",
@ -704,14 +710,14 @@
"title": "AI Max", "title": "AI Max",
"description": "Unlock unlimited AI", "description": "Unlock unlimited AI",
"price": "{}", "price": "{}",
"priceInfo": "/user per month", "priceInfo": "per user per month",
"billingInfo": "billed annually or {} billed monthly" "billingInfo": "billed annually or {} billed monthly"
}, },
"aiOnDevice": { "aiOnDevice": {
"title": "AI On-device", "title": "AI On-device",
"description": "AI offline on your device", "description": "AI offline on your device",
"price": "{}", "price": "{}",
"priceInfo": "/user per month", "priceInfo": "per user per month",
"billingInfo": "billed annually or {} billed monthly" "billingInfo": "billed annually or {} billed monthly"
} }
}, },
@ -776,7 +782,6 @@
"actions": { "actions": {
"upgrade": "Upgrade", "upgrade": "Upgrade",
"downgrade": "Downgrade", "downgrade": "Downgrade",
"downgradeDisabledTooltip": "You will automatically downgrade at the end of the billing cycle",
"current": "Current" "current": "Current"
}, },
"freePlan": { "freePlan": {
@ -789,7 +794,7 @@
"title": "Pro", "title": "Pro",
"description": "For small teams to manage projects and team knowledge", "description": "For small teams to manage projects and team knowledge",
"price": "{}", "price": "{}",
"priceInfo": "/user per month billed annually\n\n{} billed monthly" "priceInfo": "per user per month \nbilled annually\n\n{} billed monthly"
}, },
"planLabels": { "planLabels": {
"itemOne": "Workspaces", "itemOne": "Workspaces",
@ -830,6 +835,43 @@
"downgradeLabel": "Downgrade plan" "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": { "common": {
"reset": "Reset" "reset": "Reset"
}, },
@ -1441,7 +1483,7 @@
"image": { "image": {
"copiedToPasteBoard": "The image link has been copied to the clipboard", "copiedToPasteBoard": "The image link has been copied to the clipboard",
"addAnImage": "Add an image", "addAnImage": "Add an image",
"imageUploadFailed": "Image upload failed", "imageUploadFailed": "Upload failed",
"errorCode": "Error code" "errorCode": "Error code"
}, },
"math": { "math": {

View File

@ -148,7 +148,12 @@ impl Chat {
}, },
Err(err) => { Err(err) => {
error!("[Chat] failed to stream answer: {}", err); error!("[Chat] failed to stream answer: {}", err);
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 _ = text_sink.send(format!("error:{}", err)).await;
}
let pb = ChatMessageErrorPB { let pb = ChatMessageErrorPB {
chat_id: chat_id.clone(), chat_id: chat_id.clone(),
error_message: err.to_string(), error_message: err.to_string(),