diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart index e611b7de3e..c0936b5b75 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart @@ -14,6 +14,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; class ChatEditDocumentService { + const ChatEditDocumentService._(); + static Future saveMessagesToNewPage( String chatPageName, String parentViewId, @@ -45,12 +47,13 @@ class ChatEditDocumentService { ).toNullable(); } - static Future addMessageToPage( + static Future addMessagesToPage( String documentId, - TextMessage message, + List messages, ) async { - if (message.text.isEmpty) { - Log.error('Message is empty'); + // Convert messages to markdown and trim the last empty newline. + final completeMessage = messages.map((m) => m.text).join('\n').trimRight(); + if (completeMessage.isEmpty) { return; } @@ -69,7 +72,7 @@ class ChatEditDocumentService { return; } - final messageDocument = customMarkdownToDocument(message.text); + final messageDocument = customMarkdownToDocument(completeMessage); if (messageDocument.isEmpty) { Log.error('Failed to convert message to document'); return; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart index d1d0898ccc..8718255cd9 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart @@ -12,13 +12,13 @@ class ChatMemberBloc extends Bloc { ChatMemberBloc() : super(const ChatMemberState()) { on( (event, emit) async { - event.when( + await event.when( receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) { final members = Map.from(state.members); members[id] = ChatMember(info: memberInfo); emit(state.copyWith(members: members)); }, - getMemberInfo: (String userId) { + getMemberInfo: (String userId) async { if (state.members.containsKey(userId)) { // Member info already exists. Debouncing refresh member info from backend would be better. return; @@ -27,19 +27,15 @@ class ChatMemberBloc extends Bloc { final payload = WorkspaceMemberIdPB( uid: Int64.parseInt(userId), ); - UserEventGetMemberInfo(payload).send().then((result) { - if (!isClosed) { - result.fold((member) { - add( - ChatMemberEvent.receiveMemberInfo( - userId, - member, - ), - ); - }, (err) { - Log.error("Error getting member info: $err"); - }); - } + await UserEventGetMemberInfo(payload).send().then((result) { + result.fold( + (member) { + if (!isClosed) { + add(ChatMemberEvent.receiveMemberInfo(userId, member)); + } + }, + (err) => Log.error("Error getting member info: $err"), + ); }); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart new file mode 100644 index 0000000000..75eb99fa9f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/util.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_select_message_bloc.freezed.dart'; + +class ChatSelectMessageBloc + extends Bloc { + ChatSelectMessageBloc({required this.viewNotifier}) + : super(ChatSelectMessageState.initial()) { + _dispatch(); + } + + final ViewPluginNotifier viewNotifier; + + void _dispatch() { + on( + (event, emit) { + event.when( + toggleSelectingMessages: () { + if (state.isSelectingMessages) { + emit(ChatSelectMessageState.initial()); + } else { + emit(state.copyWith(isSelectingMessages: true)); + } + }, + toggleSelectMessage: (Message message) { + if (state.selectedMessages.contains(message)) { + emit( + state.copyWith( + selectedMessages: state.selectedMessages + .where((m) => m != message) + .toList(), + ), + ); + } else { + emit( + state.copyWith( + selectedMessages: [...state.selectedMessages, message], + ), + ); + } + }, + selectAllMessages: (List messages) { + final filtered = messages.where(isAIMessage).toList(); + emit(state.copyWith(selectedMessages: filtered)); + }, + unselectAllMessages: () { + emit(state.copyWith(selectedMessages: const [])); + }, + saveAsPage: () { + emit(ChatSelectMessageState.initial()); + }, + ); + }, + ); + } + + bool isMessageSelected(String messageId) => + state.selectedMessages.any((m) => m.id == messageId); + + bool isAIMessage(Message message) { + return message.author.id == aiResponseUserId || + message.author.id == systemUserId || + message.author.id.startsWith("streamId:"); + } +} + +@freezed +class ChatSelectMessageEvent with _$ChatSelectMessageEvent { + const factory ChatSelectMessageEvent.toggleSelectingMessages() = + _ToggleSelectingMessages; + const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) = + _ToggleSelectMessage; + const factory ChatSelectMessageEvent.selectAllMessages( + List messages, + ) = _SelectAllMessages; + const factory ChatSelectMessageEvent.unselectAllMessages() = + _UnselectAllMessages; + const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage; +} + +@freezed +class ChatSelectMessageState with _$ChatSelectMessageState { + const factory ChatSelectMessageState({ + required bool isSelectingMessages, + required List selectedMessages, + }) = _ChatSelectMessageState; + + factory ChatSelectMessageState.initial() => const ChatSelectMessageState( + isSelectingMessages: false, + selectedMessages: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart index 72e6b8256a..d88d06345f 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart @@ -1,14 +1,22 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; import 'package:appflowy/plugins/ai_chat/chat_page.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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'; @@ -46,13 +54,16 @@ class AIChatPagePlugin extends Plugin { }) : notifier = ViewPluginNotifier(view: view); late final ViewInfoBloc _viewInfoBloc; + late final _chatMessageSelectorBloc = + ChatSelectMessageBloc(viewNotifier: notifier); @override final ViewPluginNotifier notifier; @override PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder( - bloc: _viewInfoBloc, + viewInfoBloc: _viewInfoBloc, + chatMessageSelectorBloc: _chatMessageSelectorBloc, notifier: notifier, ); @@ -71,6 +82,7 @@ class AIChatPagePlugin extends Plugin { @override void dispose() { _viewInfoBloc.close(); + _chatMessageSelectorBloc.close(); notifier.dispose(); } } @@ -78,11 +90,13 @@ class AIChatPagePlugin extends Plugin { class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { AIChatPagePluginWidgetBuilder({ - required this.bloc, + required this.viewInfoBloc, + required this.chatMessageSelectorBloc, required this.notifier, }); - final ViewInfoBloc bloc; + final ViewInfoBloc viewInfoBloc; + final ChatSelectMessageBloc chatMessageSelectorBloc; final ViewPluginNotifier notifier; int? deletedViewIndex; @@ -110,8 +124,11 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder return const SizedBox(); } - return BlocProvider.value( - value: bloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: chatMessageSelectorBloc), + BlocProvider.value(value: viewInfoBloc), + ], child: AIChatPage( userProfile: context.userProfile!, key: ValueKey(notifier.view.id), @@ -134,4 +151,51 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder @override EdgeInsets get contentPadding => EdgeInsets.zero; + + @override + Widget? get rightBarItem => MultiBlocProvider( + providers: [ + BlocProvider.value(value: viewInfoBloc), + BlocProvider.value(value: chatMessageSelectorBloc), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.isSelectingMessages) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ViewFavoriteButton( + key: ValueKey('favorite_button_${notifier.view.id}'), + view: notifier.view, + ), + const HSpace(4), + MoreViewActions( + key: ValueKey(notifier.view.id), + view: notifier.view, + customActions: [ + CustomViewAction( + view: notifier.view, + leftIcon: FlowySvgs.download_s, + label: LocaleKeys.moreAction_saveAsNewPage.tr(), + onTap: () { + chatMessageSelectorBloc.add( + const ChatSelectMessageEvent + .toggleSelectingMessages(), + ); + }, + ), + ViewAction( + type: ViewMoreActionType.divider, + view: notifier.view, + ), + ], + ), + ], + ); + }, + ), + ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index 4d6a6a1c57..067ba95b6b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -22,6 +23,7 @@ import 'application/ai_prompt_input_bloc.dart'; import 'application/chat_bloc.dart'; import 'application/chat_entity.dart'; import 'application/chat_member_bloc.dart'; +import 'application/chat_select_message_bloc.dart'; import 'application/chat_message_stream.dart'; import 'presentation/animated_chat_list.dart'; import 'presentation/chat_input/desktop_chat_input.dart'; @@ -106,20 +108,22 @@ class _ChatContentPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 784), - margin: UniversalPlatform.isDesktop - ? const EdgeInsets.symmetric(horizontal: 60.0) - : null, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: BlocBuilder( - builder: (context, state) { - return switch (state.loadingState) { - LoadChatMessageStatus.ready => Column( - children: [ - Expanded( + return BlocBuilder( + builder: (context, state) { + return switch (state.loadingState) { + LoadChatMessageStatus.ready => Column( + children: [ + ChatMessageSelectorBanner( + view: view, + allMessages: context.read().chatController.messages, + ), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: _wrapConstraints( + ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), child: Chat( chatController: context.read().chatController, @@ -135,15 +139,27 @@ class _ChatContentPage extends StatelessWidget { ), ), ), - _buildInput(context), - ], + ), ), - _ => const Center(child: CircularProgressIndicator.adaptive()), - }; - }, - ), - ), - ), + ), + _wrapConstraints( + _builtInput(context), + ), + ], + ), + _ => const Center(child: CircularProgressIndicator.adaptive()), + }; + }, + ); + } + + Widget _wrapConstraints(Widget child) { + return Container( + constraints: const BoxConstraints(maxWidth: 784), + margin: UniversalPlatform.isDesktop + ? const EdgeInsets.symmetric(horizontal: 60.0) + : null, + child: child, ); } @@ -193,31 +209,38 @@ class _ChatContentPage extends StatelessWidget { final refSourceJsonString = message.metadata?[messageRefSourceJsonStringKey] as String?; - return BlocBuilder( - builder: (context, state) { - final chatController = context.read().chatController; - final messages = chatController.messages - .where((e) => onetimeMessageTypeFromMeta(e.metadata) == null); - final isLastMessage = - messages.isEmpty ? false : messages.last.id == message.id; - return ChatAIMessageWidget( - user: message.author, - messageUserId: message.id, - message: message, - stream: stream is AnswerStream ? stream : null, - questionId: questionId, - chatId: view.id, - refSourceJsonString: refSourceJsonString, - isStreaming: state.promptResponseState != PromptResponseState.ready, - isLastMessage: isLastMessage, - onSelectedMetadata: (metadata) => - _onSelectMetadata(context, metadata), - onRegenerate: () => context - .read() - .add(ChatEvent.regenerateAnswer(message.id, null)), - onChangeFormat: (format) => context - .read() - .add(ChatEvent.regenerateAnswer(message.id, format)), + return BlocSelector( + selector: (state) => state.isSelectingMessages, + builder: (context, isSelectingMessages) { + return BlocBuilder( + builder: (context, state) { + final chatController = context.read().chatController; + final messages = chatController.messages + .where((e) => onetimeMessageTypeFromMeta(e.metadata) == null); + final isLastMessage = + messages.isEmpty ? false : messages.last.id == message.id; + return ChatAIMessageWidget( + user: message.author, + messageUserId: message.id, + message: message, + stream: stream is AnswerStream ? stream : null, + questionId: questionId, + chatId: view.id, + refSourceJsonString: refSourceJsonString, + isStreaming: + state.promptResponseState != PromptResponseState.ready, + isLastMessage: isLastMessage, + isSelectingMessages: isSelectingMessages, + onSelectedMetadata: (metadata) => + _onSelectMetadata(context, metadata), + onRegenerate: () => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, null)), + onChangeFormat: (format) => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, format)), + ); + }, ); }, ); @@ -265,74 +288,100 @@ class _ChatContentPage extends StatelessWidget { ); } - return ChatAnimatedListReversed( - scrollController: scrollController, - itemBuilder: itemBuilder, - onLoadPreviousMessages: () { - bloc.add(const ChatEvent.loadPreviousMessages()); + return BlocSelector( + selector: (state) => state.isSelectingMessages, + builder: (context, isSelectingMessages) { + return ChatAnimatedListReversed( + scrollController: scrollController, + itemBuilder: itemBuilder, + bottomPadding: isSelectingMessages + ? 48.0 + DesktopAIChatSizes.messageActionBarIconSize + : 8.0, + onLoadPreviousMessages: () { + bloc.add(const ChatEvent.loadPreviousMessages()); + }, + ); }, ); } - Widget _buildInput(BuildContext context) { - return Padding( - padding: AIChatUILayout.safeAreaInsets(context), - child: BlocSelector( - selector: (state) { - return state.promptResponseState == PromptResponseState.ready; - }, - builder: (context, canSendMessage) { - final chatBloc = context.read(); + Widget _builtInput(BuildContext context) { + return BlocSelector( + selector: (state) => state.isSelectingMessages, + builder: (context, isSelectingMessages) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ); + }, + child: isSelectingMessages + ? const SizedBox.shrink() + : Padding( + padding: AIChatUILayout.safeAreaInsets(context), + child: BlocSelector( + selector: (state) { + return state.promptResponseState == + PromptResponseState.ready; + }, + builder: (context, canSendMessage) { + final chatBloc = context.read(); - return UniversalPlatform.isDesktop - ? DesktopChatInput( - chatId: view.id, - isStreaming: !canSendMessage, - onStopStreaming: () { - chatBloc.add(const ChatEvent.stopStream()); - }, - onSubmitted: (text, format, metadata) { - chatBloc.add( - ChatEvent.sendMessage( - message: text, - format: format, - metadata: metadata, - ), - ); - }, - onUpdateSelectedSources: (ids) { - chatBloc.add( - ChatEvent.updateSelectedSources( - selectedSourcesIds: ids, - ), - ); - }, - ) - : MobileChatInput( - chatId: view.id, - isStreaming: !canSendMessage, - onStopStreaming: () { - chatBloc.add(const ChatEvent.stopStream()); - }, - onSubmitted: (text, format, metadata) { - chatBloc.add( - ChatEvent.sendMessage( - message: text, - format: format, - metadata: metadata, - ), - ); - }, - onUpdateSelectedSources: (ids) { - chatBloc.add( - ChatEvent.updateSelectedSources( - selectedSourcesIds: ids, - ), - ); - }, - ); - }, - ), + return UniversalPlatform.isDesktop + ? DesktopChatInput( + chatId: view.id, + isStreaming: !canSendMessage, + onStopStreaming: () { + chatBloc.add(const ChatEvent.stopStream()); + }, + onSubmitted: (text, format, metadata) { + chatBloc.add( + ChatEvent.sendMessage( + message: text, + format: format, + metadata: metadata, + ), + ); + }, + onUpdateSelectedSources: (ids) { + chatBloc.add( + ChatEvent.updateSelectedSources( + selectedSourcesIds: ids, + ), + ); + }, + ) + : MobileChatInput( + chatId: view.id, + isStreaming: !canSendMessage, + onStopStreaming: () { + chatBloc.add(const ChatEvent.stopStream()); + }, + onSubmitted: (text, format, metadata) { + chatBloc.add( + ChatEvent.sendMessage( + message: text, + format: format, + metadata: metadata, + ), + ); + }, + onUpdateSelectedSources: (ids) { + chatBloc.add( + ChatEvent.updateSelectedSources( + selectedSourcesIds: ids, + ), + ); + }, + ); + }, + ), + ), + ); + }, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart index 378d69d022..d5ecd09c38 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -10,9 +10,7 @@ import 'package:string_validator/string_validator.dart'; import 'layout_define.dart'; class ChatAIAvatar extends StatelessWidget { - const ChatAIAvatar({ - super.key, - }); + const ChatAIAvatar({super.key}); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart index c38e9dadc2..375fb79e12 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart @@ -118,7 +118,6 @@ class _DesktopChatInputState extends State { ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline, width: focusNode.hasFocus ? 1.5 : 1.0, - strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: const BorderRadius.all(Radius.circular(12.0)), ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart new file mode 100644 index 0000000000..4b25297d63 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart @@ -0,0 +1,314 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/prelude.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.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'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +import 'message/ai_message_action_bar.dart'; +import 'message/message_util.dart'; + +class ChatMessageSelectorBanner extends StatelessWidget { + const ChatMessageSelectorBanner({ + super.key, + required this.view, + this.allMessages = const [], + }); + + final ViewPB view; + final List allMessages; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (!state.isSelectingMessages) { + return const SizedBox.shrink(); + } + + final selectedAmount = state.selectedMessages.length; + final totalAmount = allMessages.length; + final allSelected = selectedAmount == totalAmount; + + return Container( + height: 48, + color: const Color(0xFF00BCF0), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + GestureDetector( + onTap: () { + if (selectedAmount > 0) { + _unselectAllMessages(context); + } else { + _selectAllMessages(context); + } + }, + child: FlowySvg( + allSelected + ? FlowySvgs.checkbox_ai_selected_s + : selectedAmount > 0 + ? FlowySvgs.checkbox_ai_minus_s + : FlowySvgs.checkbox_ai_empty_s, + blendMode: BlendMode.dstIn, + size: const Size.square(18), + ), + ), + const HSpace(8), + Expanded( + child: FlowyText.semibold( + allSelected + ? LocaleKeys.chat_selectBanner_allSelected.tr() + : selectedAmount > 0 + ? LocaleKeys.chat_selectBanner_nSelected + .tr(args: [selectedAmount.toString()]) + : LocaleKeys.chat_selectBanner_selectMessages.tr(), + figmaLineHeight: 16, + color: Colors.white, + ), + ), + SaveToPageButton( + view: view, + ), + const HSpace(8), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context.read().add( + const ChatSelectMessageEvent.toggleSelectingMessages(), + ), + child: const FlowySvg( + FlowySvgs.close_m, + color: Colors.white, + size: Size.square(24), + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _selectAllMessages(BuildContext context) => context + .read() + .add(ChatSelectMessageEvent.selectAllMessages(allMessages)); + + void _unselectAllMessages(BuildContext context) => context + .read() + .add(const ChatSelectMessageEvent.unselectAllMessages()); +} + +class SaveToPageButton extends StatefulWidget { + const SaveToPageButton({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => _SaveToPageButtonState(); +} + +class _SaveToPageButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + final userWorkspaceBloc = context.read(); + final userProfile = userWorkspaceBloc.userProfile; + final workspaceId = + userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add(const SpaceEvent.initial(openFirstPage: false)), + ), + BlocProvider( + create: (context) => ChatSettingsCubit(hideDisabled: true), + ), + ], + child: BlocSelector( + selector: (state) => state.currentSpace, + builder: (context, spaceView) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: const Offset(0, 18), + direction: PopoverDirection.bottomWithRightAligned, + constraints: const BoxConstraints.tightFor(width: 300, height: 400), + child: buildButton(context, spaceView), + popupBuilder: (_) => buildPopover(context), + ); + }, + ), + ); + } + + Widget buildButton(BuildContext context, ViewPB? spaceView) { + return BlocBuilder( + builder: (context, state) { + final selectedAmount = state.selectedMessages.length; + + return Opacity( + opacity: selectedAmount == 0 ? 0.5 : 1, + child: FlowyTextButton( + LocaleKeys.chat_selectBanner_saveButton.tr(), + onPressed: selectedAmount == 0 + ? null + : () async { + final documentId = getOpenedDocumentId(); + if (documentId != null) { + await onAddToExistingPage(context, documentId); + await forceReload(documentId); + await Future.delayed(const Duration(milliseconds: 500)); + await updateSelection(documentId); + } else { + if (spaceView != null) { + context + .read() + .refreshSources([spaceView], spaceView); + } + popoverController.show(); + } + }, + fontColor: Colors.white, + borderColor: Colors.white, + fillColor: Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 6.0, + ), + ), + ); + }, + ); + } + + Widget buildPopover(BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: SaveToPagePopoverContent( + onAddToNewPage: (parentViewId) async { + await addMessageToNewPage(context, parentViewId); + popoverController.close(); + }, + onAddToExistingPage: (documentId) async { + final view = await onAddToExistingPage(context, documentId); + + if (context.mounted) { + openPageFromMessage(context, view); + } + await Future.delayed(const Duration(milliseconds: 500)); + await updateSelection(documentId); + popoverController.close(); + }, + ), + ); + } + + Future onAddToExistingPage( + BuildContext context, + String documentId, + ) async { + final bloc = context.read(); + + final selectedMessages = [ + ...bloc.state.selectedMessages.whereType(), + ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + await ChatEditDocumentService.addMessagesToPage( + documentId, + selectedMessages, + ); + await Future.delayed(const Duration(milliseconds: 500)); + final view = await ViewBackendService.getView(documentId).toNullable(); + if (context.mounted) { + showSaveMessageSuccessToast(context, view); + } + + bloc.add(const ChatSelectMessageEvent.saveAsPage()); + + return view; + } + + Future addMessageToNewPage( + BuildContext context, + String parentViewId, + ) async { + final bloc = context.read(); + + final selectedMessages = [ + ...bloc.state.selectedMessages.whereType(), + ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + final newView = await ChatEditDocumentService.saveMessagesToNewPage( + widget.view.nameOrDefault, + parentViewId, + selectedMessages, + ); + + if (context.mounted) { + showSaveMessageSuccessToast(context, newView); + openPageFromMessage(context, newView); + } + bloc.add(const ChatSelectMessageEvent.saveAsPage()); + } + + Future forceReload(String documentId) async { + final bloc = DocumentBloc.findOpen(documentId); + if (bloc == null) { + return; + } + await bloc.forceReloadDocumentState(); + } + + Future updateSelection(String documentId) async { + final bloc = DocumentBloc.findOpen(documentId); + if (bloc == null) { + return; + } + await bloc.forceReloadDocumentState(); + final editorState = bloc.state.editorState; + final lastNodePath = editorState?.getLastSelectable()?.$1.path; + if (editorState == null || lastNodePath == null) { + return; + } + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: lastNodePath)), + ), + ); + } + + String? getOpenedDocumentId() { + final pageManager = getIt().state.currentPageManager; + if (!pageManager.showSecondaryPluginNotifier.value) { + return null; + } + return pageManager.secondaryNotifier.plugin.id; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart index 8fd3d615ca..6a1f897ee6 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart @@ -488,7 +488,7 @@ class _SaveToPageButtonState extends State { Widget buildPopover(BuildContext context) { return BlocProvider.value( value: context.read(), - child: _SaveToPagePopoverContent( + child: SaveToPagePopoverContent( onAddToNewPage: (parentViewId) { addMessageToNewPage(context, parentViewId); popoverController.close(); @@ -511,9 +511,9 @@ class _SaveToPageButtonState extends State { BuildContext context, String documentId, ) async { - await ChatEditDocumentService.addMessageToPage( + await ChatEditDocumentService.addMessagesToPage( documentId, - widget.textMessage, + [widget.textMessage], ); await Future.delayed(const Duration(milliseconds: 500)); final view = await ViewBackendService.getView(documentId).toNullable(); @@ -541,35 +541,6 @@ class _SaveToPageButtonState extends State { } } - void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) { - if (view == null) { - return; - } - showToastNotification( - context, - richMessage: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.chat_addToNewPageSuccessToast.tr(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: const Color(0xFFFFFFFF), - ), - ), - const TextSpan( - text: ' ', - ), - TextSpan( - text: view.nameOrDefault, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: const Color(0xFFFFFFFF), - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ); - } - Future forceReload(String documentId) async { final bloc = DocumentBloc.findOpen(documentId); if (bloc == null) { @@ -605,8 +576,9 @@ class _SaveToPageButtonState extends State { } } -class _SaveToPagePopoverContent extends StatelessWidget { - const _SaveToPagePopoverContent({ +class SaveToPagePopoverContent extends StatelessWidget { + const SaveToPagePopoverContent({ + super.key, required this.onAddToNewPage, required this.onAddToExistingPage, }); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart index 5362b1c1a4..586f43482d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -6,6 +6,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/startup/startup.dart'; @@ -15,6 +16,7 @@ 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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -37,6 +39,7 @@ class ChatAIMessageBubble extends StatelessWidget { required this.child, required this.showActions, this.isLastMessage = false, + this.isSelectingMessages = false, this.onRegenerate, this.onChangeFormat, }); @@ -45,27 +48,25 @@ class ChatAIMessageBubble extends StatelessWidget { final Widget child; final bool showActions; final bool isLastMessage; + final bool isSelectingMessages; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; @override Widget build(BuildContext context) { - final avatarAndMessage = Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ChatAIAvatar(), - const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), - Expanded(child: child), - ], + final messageWidget = _WrapIsSelectingMessage( + isSelectingMessages: isSelectingMessages, + message: message, + child: child, ); - return showActions + return !isSelectingMessages && showActions ? UniversalPlatform.isMobile - ? _wrapPopMenu(avatarAndMessage) + ? _wrapPopMenu(messageWidget) : isLastMessage - ? _wrapBottomActions(avatarAndMessage) - : _wrapHover(avatarAndMessage) - : avatarAndMessage; + ? _wrapBottomActions(messageWidget) + : _wrapHover(messageWidget) + : messageWidget; } Widget _wrapBottomActions(Widget child) { @@ -413,9 +414,9 @@ class ChatAIMessagePopup extends StatelessWidget { return; } - await ChatEditDocumentService.addMessageToPage( + await ChatEditDocumentService.addMessagesToPage( selectedView.id, - message as TextMessage, + [message as TextMessage], ); if (context.mounted) { @@ -429,3 +430,85 @@ class ChatAIMessagePopup extends StatelessWidget { ); } } + +class _WrapIsSelectingMessage extends StatelessWidget { + const _WrapIsSelectingMessage({ + required this.message, + required this.child, + this.isSelectingMessages = false, + }); + + final Message message; + final Widget child; + final bool isSelectingMessages; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final isSelected = + context.read().isMessageSelected(message.id); + return GestureDetector( + onTap: () { + if (isSelectingMessages) { + context + .read() + .add(ChatSelectMessageEvent.toggleSelectMessage(message)); + } + }, + behavior: isSelectingMessages ? HitTestBehavior.opaque : null, + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.tertiaryContainer + : null, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSelectingMessages) + ChatSelectMessageIndicator(isSelected: isSelected) + else + const ChatAIAvatar(), + const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), + Expanded( + child: IgnorePointer( + ignoring: isSelectingMessages, + child: child, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class ChatSelectMessageIndicator extends StatelessWidget { + const ChatSelectMessageIndicator({ + super.key, + required this.isSelected, + }); + + final bool isSelected; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: SizedBox.square( + dimension: DesktopAIChatSizes.avatarSize, + child: Center( + child: FlowySvg( + isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(20), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart index 5e9b3674b3..e095de5fca 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -38,6 +38,7 @@ class ChatAIMessageWidget extends StatelessWidget { this.onChangeFormat, this.isLastMessage = false, this.isStreaming = false, + this.isSelectingMessages = false, }); final User user; @@ -53,6 +54,7 @@ class ChatAIMessageWidget extends StatelessWidget { final void Function(PredefinedFormat)? onChangeFormat; final bool isStreaming; final bool isLastMessage; + final bool isSelectingMessages; @override Widget build(BuildContext context) { @@ -98,6 +100,7 @@ class ChatAIMessageWidget extends StatelessWidget { showActions: stream == null && state.text.isNotEmpty && !isStreaming, + isSelectingMessages: isSelectingMessages, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, child: Column( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart index 97e5e84b54..1b0084c77c 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart @@ -6,7 +6,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; /// Opens a message in the right hand sidebar on desktop, and push the page @@ -30,3 +30,32 @@ void openPageFromMessage(BuildContext context, ViewPB? view) { context.pushView(view); } } + +void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) { + if (view == null) { + return; + } + showToastNotification( + context, + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.chat_addToNewPageSuccessToast.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFFFFFFFF), + ), + ), + const TextSpan( + text: ' ', + ), + TextSpan( + text: view.nameOrDefault, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFFFFFFFF), + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart index 542938a574..09e6d7f2d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -288,7 +288,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { const HSpace(10), ViewFavoriteButton(view: view), const HSpace(4), - MoreViewActions(view: view, isDocument: false), + MoreViewActions(view: view), ], ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 375d8f4534..0f114d7ff4 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -70,6 +70,8 @@ extension ViewExtension on ViewPB { String get nameOrDefault => name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name; + bool get isDocument => pluginType == PluginType.document; + Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { ViewLayoutPB.Board => FlowySvgs.icon_board_s, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index 6c9c2faebf..f7a95f3103 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -294,7 +294,7 @@ class _SecondaryViewState extends State return CompositedTransformFollower( link: layerLink, followerAnchor: Alignment.topRight, - offset: const Offset(0.0, 80.0), + offset: const Offset(0.0, 120.0), child: Align( alignment: AlignmentDirectional.topEnd, child: AnimatedSwitcher( @@ -964,13 +964,16 @@ class NonClippingSizeTransition extends AnimatedWidget { @override Widget build(BuildContext context) { final AlignmentDirectional alignment; + final Edge edge; if (axis == Axis.vertical) { alignment = AlignmentDirectional(-1.0, axisAlignment); + edge = switch (axisAlignment) { -1.0 => Edge.bottom, _ => Edge.top }; } else { alignment = AlignmentDirectional(axisAlignment, -1.0); + edge = switch (axisAlignment) { -1.0 => Edge.right, _ => Edge.left }; } return ClipRect( - clipper: const EdgeRectClipper(edge: Edge.right, margin: 20), + clipper: EdgeRectClipper(edge: edge, margin: 20), child: Align( alignment: alignment, heightFactor: axis == Axis.vertical diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart index 17932423bc..86ca1a3018 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart @@ -4,6 +4,7 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; @@ -21,14 +22,16 @@ class MoreViewActions extends StatefulWidget { const MoreViewActions({ super.key, required this.view, - this.isDocument = true, + this.customActions = const [], }); /// The view to show the actions for. + /// final ViewPB view; - /// If false the view is a Database, otherwise it is a Document. - final bool isDocument; + /// Custom actions to show in the popover, will be laid out at the top. + /// + final List customActions; @override State createState() => _MoreViewActionsState(); @@ -49,8 +52,9 @@ class _MoreViewActionsState extends State { builder: (context, state) { return AppFlowyPopover( mutex: popoverMutex, - constraints: const BoxConstraints(maxWidth: 220), - offset: const Offset(0, 42), + constraints: const BoxConstraints(maxWidth: 245), + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 12), popupBuilder: (_) => _buildPopup(state), child: const _ThreeDots(), ); @@ -106,16 +110,21 @@ class _MoreViewActionsState extends State { final timeFormat = appearanceSettings.timeFormat; final viewMoreActionTypes = [ - if (widget.isDocument) ViewMoreActionType.divider, - ViewMoreActionType.duplicate, + if (widget.view.layout != ViewLayoutPB.Chat) ViewMoreActionType.duplicate, ViewMoreActionType.moveTo, ViewMoreActionType.delete, ViewMoreActionType.divider, ]; final actions = [ - if (widget.isDocument) ...[ + ...widget.customActions, + if (widget.view.isDocument) ...[ const FontSizeAction(), + ViewAction( + type: ViewMoreActionType.divider, + view: widget.view, + mutex: popoverMutex, + ), ], ...viewMoreActionTypes.map( (type) => ViewAction( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart index 5d0cf62f73..d2942adcf7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; @@ -11,6 +12,8 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.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:flutter_bloc/flutter_bloc.dart'; @@ -97,3 +100,38 @@ class ViewAction extends StatelessWidget { } } } + +class CustomViewAction extends StatelessWidget { + const CustomViewAction({ + super.key, + required this.view, + required this.leftIcon, + required this.label, + this.onTap, + this.mutex, + }); + + final ViewPB view; + final FlowySvgData leftIcon; + final String label; + final VoidCallback? onTap; + final PopoverMutex? mutex; + + @override + Widget build(BuildContext context) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyIconTextButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + onTap: onTap, + leftIconBuilder: (onHover) => FlowySvg(leftIcon), + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText( + label, + figmaLineHeight: 18.0, + ), + ), + ); + } +} diff --git a/frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg b/frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg new file mode 100644 index 0000000000..9b6bb59b26 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg b/frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg new file mode 100644 index 0000000000..4adc9445c1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/checkbox_ai_selected.svg b/frontend/resources/flowy_icons/16x/checkbox_ai_selected.svg new file mode 100644 index 0000000000..8f04722a89 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/checkbox_ai_selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 3bbca792a5..976ae437c9 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -150,7 +150,8 @@ "wordCountLabel": "Word count: ", "charCountLabel": "Character count: ", "createdAtLabel": "Created: ", - "syncedAtLabel": "Synced: " + "syncedAtLabel": "Synced: ", + "saveAsNewPage": "Save messages to page" }, "importPanel": { "textAndMarkdown": "Text & Markdown", @@ -238,6 +239,12 @@ "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", "tableWithImageDescription": "@:chat.changeFormat.table with image" + }, + "selectBanner": { + "saveButton": "Add to...", + "selectMessages": "Select messages", + "nSelected": "{} selected", + "allSelected": "All selected" } }, "trash": {