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

View File

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

View File

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

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

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/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart';
@ -7,19 +10,18 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
abstract class IEditableTranslateCellSkin {
@ -150,7 +152,22 @@ class TranslateCellAccessory extends StatelessWidget {
rowId: rowId,
fieldId: fieldId,
),
child: BlocBuilder<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) {
return const Row(
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/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:path/path.dart' as p;
@ -65,7 +65,7 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
return (s.url, null);
},
(err) {
if (err.code == ErrorCode.FileStorageLimitExceeded) {
if (err.isStorageLimitExceeded) {
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
} else {
return (null, err.msg);

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
@ -7,7 +9,6 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class OpenAIImageWidget extends StatefulWidget {
const OpenAIImageWidget({

View File

@ -1,35 +1,13 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class _AILimitDialog extends StatelessWidget {
const _AILimitDialog({
required this.message,
required this.onOkPressed,
});
final VoidCallback onOkPressed;
final String message;
@override
Widget build(BuildContext context) {
return NavigatorOkCancelDialog(
message: message,
okTitle: LocaleKeys.button_ok.tr(),
onOkPressed: onOkPressed,
titleUpperCase: false,
);
}
}
void showAILimitDialog(BuildContext context, String message) {
showDialog(
showConfirmDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) => _AILimitDialog(
message: message,
onOkPressed: () {},
),
title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(),
description: message,
);
}

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
@ -6,7 +8,6 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ShareMenuButton extends StatelessWidget {

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -91,7 +91,7 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
},
ready: (state) {
final billingPortalEnabled =
state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan;
state.subscriptionInfo.isBillingPortalEnabled;
return SettingsBody(
title: LocaleKeys.settings_billingPage_title.tr(),
@ -327,14 +327,9 @@ class _AITileState extends State<_AITile> {
: LocaleKeys.settings_billingPage_addons_addLabel.tr(),
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
onPressed: () {
if (widget.subscriptionInfo != null && isCanceled) {
// Show customer portal to renew
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.openCustomerPortal());
} else if (widget.subscriptionInfo != null) {
showConfirmDialog(
onPressed: () async {
if (widget.subscriptionInfo != null) {
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.settings_billingPage_addons_removeDialog_title
@ -343,11 +338,9 @@ class _AITileState extends State<_AITile> {
.settings_billingPage_addons_removeDialog_description
.tr(namedArgs: {"plan": widget.plan.label.tr()}),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
context.read<SettingsBillingBloc>().add(
SettingsBillingEvent.cancelSubscription(widget.plan),
);
},
onConfirm: () => context
.read<SettingsBillingBloc>()
.add(SettingsBillingEvent.cancelSubscription(widget.plan)),
);
} else {
// 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/workspace_subscription_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@ -141,7 +142,7 @@ class _SettingsPlanComparisonDialogState
children: [
const VSpace(30),
SizedBox(
height: 100,
height: 116,
child: FlowyText.semibold(
LocaleKeys
.settings_comparePlanDialog_planFeatures
@ -153,7 +154,7 @@ class _SettingsPlanComparisonDialogState
: const Color(0xFFE8E0FF),
),
),
const SizedBox(height: 96),
const SizedBox(height: 116),
const SizedBox(height: 56),
..._planLabels.map(
(e) => _ComparisonCell(
@ -184,17 +185,9 @@ class _SettingsPlanComparisonDialogState
cells: _freeLabels,
isCurrent:
currentInfo.plan == WorkspacePlanPB.FreePlan,
canDowngrade:
currentInfo.plan != WorkspacePlanPB.FreePlan,
currentCanceled: currentInfo.isCanceled ||
(context
.watch<SettingsPlanBloc>()
.state
.mapOrNull(
loading: (_) => true,
ready: (s) => s.downgradeProcessing,
) ??
false),
buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor(
currentInfo.plan,
),
onSelected: () async {
if (currentInfo.plan ==
WorkspacePlanPB.FreePlan ||
@ -202,6 +195,12 @@ class _SettingsPlanComparisonDialogState
return;
}
final reason =
await showCancelSurveyDialog(context);
if (reason == null || !context.mounted) {
return;
}
await showConfirmDialog(
context: context,
title: LocaleKeys
@ -216,8 +215,9 @@ class _SettingsPlanComparisonDialogState
style: ConfirmPopupStyle.cancelAndOk,
onConfirm: () =>
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent
.cancelSubscription(),
SettingsPlanEvent.cancelSubscription(
reason: reason,
),
),
);
},
@ -242,9 +242,9 @@ class _SettingsPlanComparisonDialogState
cells: _proLabels,
isCurrent:
currentInfo.plan == WorkspacePlanPB.ProPlan,
canUpgrade:
currentInfo.plan == WorkspacePlanPB.FreePlan,
currentCanceled: currentInfo.isCanceled,
buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor(
currentInfo.plan,
),
onSelected: () =>
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
@ -266,6 +266,35 @@ class _SettingsPlanComparisonDialogState
}
}
enum _PlanButtonType {
none,
upgrade,
downgrade;
bool get isDowngrade => this == downgrade;
bool get isUpgrade => this == upgrade;
}
extension _ButtonTypeFrom on WorkspacePlanPB {
/// Returns the button type for the given plan, taking the
/// current plan as [other].
///
_PlanButtonType buttonTypeFor(WorkspacePlanPB other) {
/// Current plan, no action
if (this == other) {
return _PlanButtonType.none;
}
// Free plan, can downgrade if not on the free plan
if (this == WorkspacePlanPB.FreePlan && other != WorkspacePlanPB.FreePlan) {
return _PlanButtonType.downgrade;
}
// Else we can assume it's an upgrade
return _PlanButtonType.upgrade;
}
}
class _PlanTable extends StatelessWidget {
const _PlanTable({
required this.title,
@ -275,9 +304,7 @@ class _PlanTable extends StatelessWidget {
required this.cells,
required this.isCurrent,
required this.onSelected,
this.canUpgrade = false,
this.canDowngrade = false,
this.currentCanceled = false,
this.buttonType = _PlanButtonType.none,
});
final String title;
@ -288,13 +315,11 @@ class _PlanTable extends StatelessWidget {
final List<_CellItem> cells;
final bool isCurrent;
final VoidCallback onSelected;
final bool canUpgrade;
final bool canDowngrade;
final bool currentCanceled;
final _PlanButtonType buttonType;
@override
Widget build(BuildContext context) {
final highlightPlan = !isCurrent && !canDowngrade && canUpgrade;
final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade;
final isLM = Theme.of(context).isLightMode;
return Container(
@ -336,37 +361,29 @@ class _PlanTable extends StatelessWidget {
title: price,
description: priceInfo,
isPrimary: !highlightPlan,
height: 96,
),
if (canUpgrade || canDowngrade) ...[
if (buttonType == _PlanButtonType.none) ...[
const SizedBox(height: 56),
] else ...[
Opacity(
opacity: canDowngrade && currentCanceled ? 0.5 : 1,
opacity: 1,
child: Padding(
padding: EdgeInsets.only(
left: 12 + (canUpgrade && !canDowngrade ? 12 : 0),
left: 12 + (buttonType.isUpgrade ? 12 : 0),
),
child: _ActionButton(
label: canUpgrade && !canDowngrade
label: buttonType.isUpgrade
? LocaleKeys.settings_comparePlanDialog_actions_upgrade
.tr()
: LocaleKeys
.settings_comparePlanDialog_actions_downgrade
.tr(),
onPressed: !canUpgrade && canDowngrade && currentCanceled
? null
: onSelected,
tooltip: !canUpgrade && canDowngrade && currentCanceled
? LocaleKeys
.settings_comparePlanDialog_actions_downgradeDisabledTooltip
.tr()
: null,
isUpgrade: canUpgrade && !canDowngrade,
useGradientBorder: !isCurrent && canUpgrade,
onPressed: onSelected,
isUpgrade: buttonType.isUpgrade,
useGradientBorder: buttonType.isUpgrade,
),
),
),
] else ...[
const SizedBox(height: 56),
],
...cells.map(
(cell) => _ComparisonCell(
@ -467,14 +484,12 @@ class _ComparisonCell extends StatelessWidget {
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.label,
this.tooltip,
required this.onPressed,
required this.isUpgrade,
this.useGradientBorder = false,
});
final String label;
final String? tooltip;
final VoidCallback? onPressed;
final bool isUpgrade;
final bool useGradientBorder;
@ -487,9 +502,7 @@ class _ActionButton extends StatelessWidget {
height: 56,
child: Row(
children: [
FlowyTooltip(
message: tooltip,
child: GestureDetector(
GestureDetector(
onTap: onPressed,
child: MouseRegion(
cursor: onPressed != null
@ -514,7 +527,6 @@ class _ActionButton extends StatelessWidget {
),
),
),
),
],
),
);
@ -538,10 +550,7 @@ class _ActionButton extends StatelessWidget {
shaderCallback: (bounds) => const LinearGradient(
transform: GradientRotation(-1.55),
stops: [0.4, 1],
colors: [
Color(0xFF251D37),
Color(0xFF7547C0),
],
colors: [Color(0xFF251D37), Color(0xFF7547C0)],
).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
child: child,
);
@ -579,19 +588,17 @@ class _Heading extends StatelessWidget {
required this.title,
this.description,
this.isPrimary = true,
this.height = 100,
});
final String title;
final String? description;
final bool isPrimary;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 185,
height: height,
height: 116,
child: Padding(
padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)),
child: Column(
@ -615,12 +622,14 @@ class _Heading extends StatelessWidget {
),
if (description != null && description!.isNotEmpty) ...[
const VSpace(4),
FlowyText.regular(
Flexible(
child: FlowyText.regular(
description!,
fontSize: 12,
maxLines: 3,
maxLines: 5,
lineHeight: 1.5,
),
),
],
],
),

View File

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

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

View File

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

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
@ -168,6 +169,37 @@ class FlowyTextButton extends StatelessWidget {
this.borderColor,
});
factory FlowyTextButton.primary({
required BuildContext context,
required String text,
VoidCallback? onPressed,
}) =>
FlowyTextButton(
text,
constraints: const BoxConstraints(minHeight: 32),
fillColor: Theme.of(context).colorScheme.primary,
hoverColor: const Color(0xFF005483),
fontColor: AFThemeExtension.of(context).strongText,
fontHoverColor: Colors.white,
onPressed: onPressed,
);
factory FlowyTextButton.secondary({
required BuildContext context,
required String text,
VoidCallback? onPressed,
}) =>
FlowyTextButton(
text,
constraints: const BoxConstraints(minHeight: 32),
fillColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.primary,
fontColor: Theme.of(context).colorScheme.primary,
borderColor: Theme.of(context).colorScheme.primary,
fontHoverColor: Colors.white,
onPressed: onPressed,
);
final String text;
final FontWeight? fontWeight;
final Color? fontColor;

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",
"RecentSpace": "Recent",
"Spaces": "Spaces",
"upgradeToPro": "Upgrade to Pro Plan",
"upgradeToPro": "Upgrade to Pro",
"upgradeToAIMax": "Unlock unlimited AI",
"storageLimitDialogTitle": "You are running out of storage space. Upgrade to Pro Plan to get more storage",
"aiResponseLitmitDialogTitle": "You are running out of AI responses. Upgrade to Pro Plan or AI Max to get more AI responses",
"aiResponseLitmit": "You are running out of AI responses. Go to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses",
"storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage",
"aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses",
"aiResponseLimitDialogTitle": "AI Responses limit reached",
"aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses",
"askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan",
"askOwnerToUpgradeToAIMax": "Your workspace is running out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons",
"purchaseStorageSpace": "Purchase Storage Space",
"purchaseAIResponse": "Purchase ",
"upgradeToAILocal": "AI offline on your device"
@ -350,7 +353,10 @@
"signInDiscord": "Continue with Discord",
"more": "More",
"create": "Create",
"close": "Close"
"close": "Close",
"next": "Next",
"previous": "Previous",
"submit": "Submit"
},
"label": {
"welcome": "Welcome!",
@ -638,7 +644,7 @@
"menuLabel": "AI Settings",
"keys": {
"enableAISearchTitle": "AI Search",
"aiSettingsDescription": "Select or configure Ai models used on @:appName. For best performance we recommend using the default model options",
"aiSettingsDescription": "Select or configure AI models used on @:appName. For best performance we recommend using the default model options",
"loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up",
"llmModel": "Language Model",
"llmModelType": "Language Model Type",
@ -704,14 +710,14 @@
"title": "AI Max",
"description": "Unlock unlimited AI",
"price": "{}",
"priceInfo": "/user per month",
"priceInfo": "per user per month",
"billingInfo": "billed annually or {} billed monthly"
},
"aiOnDevice": {
"title": "AI On-device",
"description": "AI offline on your device",
"price": "{}",
"priceInfo": "/user per month",
"priceInfo": "per user per month",
"billingInfo": "billed annually or {} billed monthly"
}
},
@ -776,7 +782,6 @@
"actions": {
"upgrade": "Upgrade",
"downgrade": "Downgrade",
"downgradeDisabledTooltip": "You will automatically downgrade at the end of the billing cycle",
"current": "Current"
},
"freePlan": {
@ -789,7 +794,7 @@
"title": "Pro",
"description": "For small teams to manage projects and team knowledge",
"price": "{}",
"priceInfo": "/user per month billed annually\n\n{} billed monthly"
"priceInfo": "per user per month \nbilled annually\n\n{} billed monthly"
},
"planLabels": {
"itemOne": "Workspaces",
@ -830,6 +835,43 @@
"downgradeLabel": "Downgrade plan"
}
},
"cancelSurveyDialog": {
"title": "Sorry to see you go",
"description": "We're sorry to see you go. We'd love to hear your feedback to help us improve @:appName. Please take a moment to answer a few questions.",
"commonOther": "Other",
"otherHint": "Write your answer here",
"questionOne": {
"question": "What prompted you to cancel your AppFlowy Pro subscription?",
"answerOne": "Cost too high",
"answerTwo": "Features did not meet expectations",
"answerThree": "Found a better alternative",
"answerFour": "Did not use it enough to justify the expense",
"answerFive": "Service issue or technical difficulties"
},
"questionTwo": {
"question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?",
"answerOne": "Very likely",
"answerTwo": "Somewhat likely",
"answerThree": "Not sure",
"answerFour": "Unlikely",
"answerFive": "Very unlikely"
},
"questionThree": {
"question": "Which Pro feature did you value the most during your subscription?",
"answerOne": "Multi-user collaboration",
"answerTwo": "Longer time version history",
"answerThree": "Unlimited AI responses",
"answerFour": "Access to local AI models"
},
"questionFour": {
"question": "How would you describe your overall experience with AppFlowy?",
"answerOne": "Great",
"answerTwo": "Good",
"answerThree": "Average",
"answerFour": "Below average",
"answerFive": "Unsatisfied"
}
},
"common": {
"reset": "Reset"
},
@ -1441,7 +1483,7 @@
"image": {
"copiedToPasteBoard": "The image link has been copied to the clipboard",
"addAnImage": "Add an image",
"imageUploadFailed": "Image upload failed",
"imageUploadFailed": "Upload failed",
"errorCode": "Error code"
},
"math": {
@ -2130,7 +2172,7 @@
"zero": "Publish {} selected view",
"one": "Publish {} selected views",
"many": "Publish {} selected views",
"other":"Publish {} selected views"
"other": "Publish {} selected views"
},
"mustSelectPrimaryDatabase": "The primary view must be selected",
"noDatabaseSelected": "No database selected, please select at least one database.",

View File

@ -148,7 +148,12 @@ impl Chat {
},
Err(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 pb = ChatMessageErrorPB {
chat_id: chat_id.clone(),
error_message: err.to_string(),