feat: AI chat (#5383)

* chore: ai type

* chore: use patch to fix version issue

* chore: update

* chore: update

* chore: integrate client api

* chore: add schema

* chore: setup event

* chore: add event test

* chore: add test

* chore: update test

* chore: load chat message

* chore: load chat message

* chore: chat ui

* chore: disable create chat

* chore: update client api

* chore: disable chat

* chore: ui theme

* chore: ui theme

* chore: copy message

* chore: fix test

* chore: show error

* chore: update bloc

* chore: update test

* chore: lint

* chore: icon

* chore: hover

* chore: show unsupported page

* chore: adjust mobile ui

* chore: adjust view title bar

* chore: return related question

* chore: error page

* chore: error page

* chore: code format

* chore: prompt

* chore: fix test

* chore: ui adjust

* chore: disable create chat

* chore: add loading page

* chore: fix test

* chore: disable chat action

* chore: add maximum text limit
This commit is contained in:
Nathan.fooo
2024-06-03 14:27:28 +08:00
committed by GitHub
parent 4d42c9ea68
commit aec7bc847e
114 changed files with 5473 additions and 282 deletions

View File

@ -0,0 +1,42 @@
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_ai_message_bloc.freezed.dart';
class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
ChatAIMessageBloc({
required Message message,
}) : super(ChatAIMessageState.initial(message)) {
on<ChatAIMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {},
update: (userProfile, deviceId, states) {},
);
},
);
}
}
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.update(
UserProfilePB userProfile,
String deviceId,
DocumentAwarenessStatesPB states,
) = Update;
}
@freezed
class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({
required Message message,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(Message message) =>
ChatAIMessageState(message: message);
}

View File

@ -0,0 +1,423 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_message_listener.dart';
part 'chat_bloc.freezed.dart';
const canRetryKey = "canRetry";
const sendMessageErrorKey = "sendMessageError";
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required ViewPB view,
required UserProfilePB userProfile,
}) : listener = ChatMessageListener(chatId: view.id),
chatId = view.id,
super(
ChatState.initial(view, userProfile),
) {
_dispatch();
listener.start(
chatMessageCallback: _handleChatMessage,
lastUserSentMessageCallback: (message) {
if (!isClosed) {
add(ChatEvent.didSentUserMessage(message));
}
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
final metadata = OnetimeShotType.serverStreamError.toMap();
if (state.lastSentMessage != null) {
metadata[canRetryKey] = "true";
}
final error = CustomMessage(
metadata: metadata,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.streaming([error]));
add(const ChatEvent.didFinishStreaming());
}
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createChatMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createChatMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishAnswerQuestionCallback: () {
if (!isClosed) {
add(const ChatEvent.didFinishStreaming());
if (state.lastSentMessage != null) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(
ChatEvent.didReceiveRelatedQuestion(list.items),
);
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
}
},
);
}
final ChatMessageListener listener;
final String chatId;
@override
Future<void> close() {
listener.stop();
return super.close();
}
void _dispatch() {
on<ChatEvent>(
(event, emit) async {
await event.when(
initialLoad: () {
final payload = LoadNextChatMessagePB(
chatId: state.view.id,
limit: Int64(10),
);
ChatEventLoadNextMessage(payload).send();
},
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
if (state.messages.isNotEmpty) {
beforeMessageId = Int64.parseInt(state.messages.last.id);
}
_loadPrevMessage(beforeMessageId);
emit(
state.copyWith(
loadingPreviousStatus: const LoadingState.loading(),
),
);
},
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
Log.debug("did load previous messages: ${messages.length}");
final uniqueMessages = {...state.messages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
emit(
state.copyWith(
messages: uniqueMessages,
loadingPreviousStatus: const LoadingState.finish(),
hasMorePrevMessage: hasMore,
),
);
},
didLoadLatestMessages: (List<Message> messages) {
final uniqueMessages = {...state.messages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
emit(
state.copyWith(
messages: uniqueMessages,
initialLoadingStatus: const LoadingState.finish(),
),
);
},
streaming: (List<Message> messages) {
final allMessages = _perminentMessages();
allMessages.insertAll(0, messages);
emit(state.copyWith(messages: allMessages));
},
didFinishStreaming: () {
emit(
state.copyWith(
answerQuestionStatus: const LoadingState.finish(),
),
);
},
sendMessage: (String message) async {
await _handleSentMessage(message, emit);
// Create a loading indicator
final loadingMessage =
_loadingMessage(state.userProfile.id.toString());
final allMessages = List<Message>.from(state.messages)
..insert(0, loadingMessage);
emit(
state.copyWith(
lastSentMessage: null,
messages: allMessages,
answerQuestionStatus: const LoadingState.loading(),
relatedQuestions: [],
),
);
},
retryGenerate: () {
if (state.lastSentMessage == null) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
ChatEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) => _handleChatMessage(answer),
(err) {
Log.error("Failed to get answer: $err");
},
);
}
});
},
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
final allMessages = _perminentMessages();
final message = CustomMessage(
metadata: OnetimeShotType.relatedQuestion.toMap(),
author: const User(id: "system"),
id: 'system',
);
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
relatedQuestions: questions,
),
);
},
clearReleatedQuestion: () {
emit(
state.copyWith(
relatedQuestions: [],
),
);
},
didSentUserMessage: (ChatMessagePB message) {
emit(
state.copyWith(
lastSentMessage: message,
),
);
},
);
},
);
}
// Returns the list of messages that are not include one-time messages.
List<Message> _perminentMessages() {
final allMessages = state.messages.where((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return allMessages;
}
void _loadPrevMessage(Int64? beforeMessageId) {
final payload = LoadPrevChatMessagePB(
chatId: state.view.id,
limit: Int64(10),
beforeMessageId: beforeMessageId,
);
ChatEventLoadPrevMessage(payload).send();
}
Future<void> _handleSentMessage(
String message,
Emitter<ChatState> emit,
) async {
final payload = SendChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
);
final result = await ChatEventSendMessage(payload).send();
result.fold(
(_) {},
(err) {
if (!isClosed) {
Log.error("Failed to send message: ${err.msg}");
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
metadata[sendMessageErrorKey] = err.msg;
final error = CustomMessage(
metadata: metadata,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.streaming([error]));
}
},
);
}
void _handleChatMessage(ChatMessagePB pb) {
if (!isClosed) {
final message = _createChatMessage(pb);
final messages = pb.hasFollowing
? [_loadingMessage(0.toString()), message]
: [message];
add(ChatEvent.streaming(messages));
}
}
Message _loadingMessage(String id) {
return CustomMessage(
author: User(id: id),
metadata: OnetimeShotType.loading.toMap(),
// fake id
id: nanoid(),
);
}
Message _createChatMessage(ChatMessagePB message) {
final messageId = message.messageId.toString();
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt(),
repliedMessage: _getReplyMessage(state.messages, messageId),
);
}
Message? _getReplyMessage(List<Message?> messages, String messageId) {
return messages.firstWhereOrNull((element) => element?.id == messageId);
}
}
@freezed
class ChatEvent with _$ChatEvent {
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
const factory ChatEvent.sendMessage(String message) = _SendMessage;
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages,
bool hasMore,
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.streaming(List<Message> messages) = _DidStreamMessage;
const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
const factory ChatEvent.retryGenerate() = _RetryGenerate;
const factory ChatEvent.didSentUserMessage(ChatMessagePB message) =
_DidSendUserMessage;
}
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required ViewPB view,
required List<Message> messages,
required UserProfilePB userProfile,
// When opening the chat, the initial loading status will be set as loading.
//After the initial loading is done, the status will be set as finished.
required LoadingState initialLoadingStatus,
// When loading previous messages, the status will be set as loading.
// After the loading is done, the status will be set as finished.
required LoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required LoadingState answerQuestionStatus,
// Indicate whether there are more previous messages to load.
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
}) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
ChatState(
view: view,
messages: [],
userProfile: userProfile,
initialLoadingStatus: const LoadingState.finish(),
loadingPreviousStatus: const LoadingState.finish(),
answerQuestionStatus: const LoadingState.finish(),
hasMorePrevMessage: true,
relatedQuestions: [],
);
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish() = _Finish;
}
enum OnetimeShotType {
unknown,
loading,
serverStreamError,
relatedQuestion,
invalidSendMesssage
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.loading':
return OnetimeShotType.loading;
case 'OnetimeShotType.serverStreamError':
return OnetimeShotType.serverStreamError;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, String> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}

View File

@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/rust_stream.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'chat_notification.dart';
typedef ChatMessageCallback = void Function(ChatMessagePB message);
typedef ChatErrorMessageCallback = void Function(ChatMessageErrorPB message);
typedef LatestMessageCallback = void Function(ChatMessageListPB list);
typedef PrevMessageCallback = void Function(ChatMessageListPB list);
class ChatMessageListener {
ChatMessageListener({required this.chatId}) {
_parser = ChatNotificationParser(id: chatId, callback: _callback);
_subscription = RustStreamReceiver.listen(
(observable) => _parser?.parse(observable),
);
}
final String chatId;
StreamSubscription<SubscribeObject>? _subscription;
ChatNotificationParser? _parser;
ChatMessageCallback? chatMessageCallback;
ChatMessageCallback? lastUserSentMessageCallback;
ChatErrorMessageCallback? chatErrorMessageCallback;
LatestMessageCallback? latestMessageCallback;
PrevMessageCallback? prevMessageCallback;
void Function()? finishAnswerQuestionCallback;
void start({
ChatMessageCallback? chatMessageCallback,
ChatErrorMessageCallback? chatErrorMessageCallback,
LatestMessageCallback? latestMessageCallback,
PrevMessageCallback? prevMessageCallback,
ChatMessageCallback? lastUserSentMessageCallback,
void Function()? finishAnswerQuestionCallback,
}) {
this.chatMessageCallback = chatMessageCallback;
this.chatErrorMessageCallback = chatErrorMessageCallback;
this.latestMessageCallback = latestMessageCallback;
this.prevMessageCallback = prevMessageCallback;
this.lastUserSentMessageCallback = lastUserSentMessageCallback;
this.finishAnswerQuestionCallback = finishAnswerQuestionCallback;
}
void _callback(
ChatNotification ty,
FlowyResult<Uint8List, FlowyError> result,
) {
result.map((r) {
switch (ty) {
case ChatNotification.DidReceiveChatMessage:
chatMessageCallback?.call(ChatMessagePB.fromBuffer(r));
break;
case ChatNotification.LastUserSentMessage:
lastUserSentMessageCallback?.call(ChatMessagePB.fromBuffer(r));
break;
case ChatNotification.StreamChatMessageError:
chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r));
break;
case ChatNotification.DidLoadLatestChatMessage:
latestMessageCallback?.call(ChatMessageListPB.fromBuffer(r));
break;
case ChatNotification.DidLoadPrevChatMessage:
prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r));
break;
case ChatNotification.FinishAnswerQuestion:
finishAnswerQuestionCallback?.call();
break;
default:
break;
}
});
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
}

View File

@ -0,0 +1,45 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy/core/notification/notification_helper.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart';
import 'package:appflowy_backend/rust_stream.dart';
import 'package:appflowy_result/appflowy_result.dart';
class ChatNotificationParser
extends NotificationParser<ChatNotification, FlowyError> {
ChatNotificationParser({
super.id,
required super.callback,
}) : super(
tyParser: (ty, source) =>
source == "Chat" ? ChatNotification.valueOf(ty) : null,
errorParser: (bytes) => FlowyError.fromBuffer(bytes),
);
}
typedef ChatNotificationHandler = Function(
ChatNotification ty,
FlowyResult<Uint8List, FlowyError> result,
);
class ChatNotificationListener {
ChatNotificationListener({
required String objectId,
required ChatNotificationHandler handler,
}) : _parser = ChatNotificationParser(id: objectId, callback: handler) {
_subscription =
RustStreamReceiver.listen((observable) => _parser?.parse(observable));
}
ChatNotificationParser? _parser;
StreamSubscription<SubscribeObject>? _subscription;
Future<void> stop() async {
_parser = null;
await _subscription?.cancel();
_subscription = null;
}
}

View File

@ -0,0 +1,103 @@
import 'package:appflowy/plugins/ai_chat/application/chat_message_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_related_question_bloc.freezed.dart';
class ChatRelatedMessageBloc
extends Bloc<ChatRelatedMessageEvent, ChatRelatedMessageState> {
ChatRelatedMessageBloc({
required String chatId,
}) : listener = ChatMessageListener(chatId: chatId),
super(ChatRelatedMessageState.initial()) {
on<ChatRelatedMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {
listener.start(
lastUserSentMessageCallback: (message) {
if (!isClosed) {
add(ChatRelatedMessageEvent.updateLastSentMessage(message));
}
},
);
},
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
Log.debug("Related questions: $questions");
emit(
state.copyWith(
relatedQuestions: questions,
),
);
},
updateLastSentMessage: (ChatMessagePB message) {
final payload =
ChatMessageIdPB(chatId: chatId, messageId: message.messageId);
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(
ChatRelatedMessageEvent.didReceiveRelatedQuestion(
list.items,
),
);
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
emit(
state.copyWith(
lastSentMessage: message,
relatedQuestions: [],
),
);
},
clear: () {
emit(
state.copyWith(
relatedQuestions: [],
),
);
},
);
},
);
}
final ChatMessageListener listener;
@override
Future<void> close() {
listener.stop();
return super.close();
}
}
@freezed
class ChatRelatedMessageEvent with _$ChatRelatedMessageEvent {
const factory ChatRelatedMessageEvent.initial() = Initial;
const factory ChatRelatedMessageEvent.updateLastSentMessage(
ChatMessagePB message,
) = _LastSentMessage;
const factory ChatRelatedMessageEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _RelatedQuestion;
const factory ChatRelatedMessageEvent.clear() = _Clear;
}
@freezed
class ChatRelatedMessageState with _$ChatRelatedMessageState {
const factory ChatRelatedMessageState({
ChatMessagePB? lastSentMessage,
@Default([]) List<RelatedQuestionPB> relatedQuestions,
}) = _ChatRelatedMessageState;
factory ChatRelatedMessageState.initial() => const ChatRelatedMessageState();
}

View File

@ -0,0 +1,44 @@
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_user_message_bloc.freezed.dart';
class ChatUserMessageBloc
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
ChatUserMessageBloc({
required Message message,
}) : super(ChatUserMessageState.initial(message)) {
on<ChatUserMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {},
update: (userProfile, deviceId, states) {},
);
},
);
}
}
@freezed
class ChatUserMessageEvent with _$ChatUserMessageEvent {
const factory ChatUserMessageEvent.initial() = Initial;
const factory ChatUserMessageEvent.update(
UserProfilePB userProfile,
String deviceId,
DocumentAwarenessStatesPB states,
) = Update;
}
@freezed
class ChatUserMessageState with _$ChatUserMessageState {
const factory ChatUserMessageState({
required Message message,
WorkspaceMemberPB? member,
}) = _ChatUserMessageState;
factory ChatUserMessageState.initial(Message message) =>
ChatUserMessageState(message: message);
}

View File

@ -0,0 +1,114 @@
import 'package:appflowy/generated/flowy_svgs.g.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_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.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/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AIChatPluginBuilder extends PluginBuilder {
@override
Plugin build(dynamic data) {
if (data is ViewPB) {
return AIChatPagePlugin(view: data);
}
throw FlowyPluginException.invalidData;
}
@override
String get menuName => "AIChat";
@override
FlowySvgData get icon => FlowySvgs.chat_ai_page_s;
@override
PluginType get pluginType => PluginType.chat;
@override
ViewLayoutPB get layoutType => ViewLayoutPB.Chat;
}
class AIChatPluginConfig implements PluginConfig {
@override
bool get creatable => false;
}
class AIChatPagePlugin extends Plugin {
AIChatPagePlugin({
required ViewPB view,
}) : notifier = ViewPluginNotifier(view: view);
late final ViewInfoBloc _viewInfoBloc;
@override
final ViewPluginNotifier notifier;
@override
PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder(
bloc: _viewInfoBloc,
notifier: notifier,
);
@override
PluginId get id => notifier.view.id;
@override
PluginType get pluginType => PluginType.chat;
@override
void init() {
_viewInfoBloc = ViewInfoBloc(view: notifier.view)
..add(const ViewInfoEvent.started());
}
}
class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
with NavigationItem {
AIChatPagePluginWidgetBuilder({
required this.bloc,
required this.notifier,
});
final ViewInfoBloc bloc;
final ViewPluginNotifier notifier;
int? deletedViewIndex;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
@override
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
@override
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
if (deletedView != null && deletedView.hasIndex()) {
deletedViewIndex = deletedView.index;
}
});
return BlocProvider<ViewInfoBloc>.value(
value: bloc,
child: AIChatPage(
userProfile: context.userProfile!,
key: ValueKey(notifier.view.id),
view: notifier.view,
onDeleted: () =>
context.onDeleted?.call(notifier.view, deletedViewIndex),
),
);
}
@override
List<NavigationItem> get navigationItems => [this];
}

View File

@ -0,0 +1,332 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_ai_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_streaming_error_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_user_message.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'presentation/chat_input.dart';
import 'presentation/chat_loading.dart';
import 'presentation/chat_popmenu.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart';
class AIChatPage extends StatefulWidget {
const AIChatPage({
super.key,
required this.view,
required this.onDeleted,
required this.userProfile,
});
final ViewPB view;
final VoidCallback onDeleted;
final UserProfilePB userProfile;
@override
State<AIChatPage> createState() => _AIChatPageState();
}
class _AIChatPageState extends State<AIChatPage> {
late types.User _user;
@override
void initState() {
super.initState();
_user = types.User(id: widget.userProfile.id.toString());
}
@override
Widget build(BuildContext context) {
if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
return buildChatWidget();
} else {
return Center(
child: FlowyText(
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
fontSize: 20,
),
);
}
}
Widget buildChatWidget() {
return SizedBox.expand(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 60),
child: BlocProvider(
create: (context) => ChatBloc(
view: widget.view,
userProfile: widget.userProfile,
)..add(const ChatEvent.initialLoad()),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (blocContext, state) {
return Chat(
messages: state.messages,
onAttachmentPressed: () {},
onSendPressed: (types.PartialText message) {
// We use custom bottom widget for chat input, so
// do not need to handle this event.
},
customBottomWidget: buildChatInput(blocContext),
user: _user,
theme: buildTheme(context),
customMessageBuilder: _customMessageBuilder,
onEndReached: () async {
if (state.hasMorePrevMessage &&
state.loadingPreviousStatus !=
const LoadingState.loading()) {
blocContext
.read<ChatBloc>()
.add(const ChatEvent.startLoadingPrevMessage());
}
},
emptyState: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return state.initialLoadingStatus ==
const LoadingState.finish()
? const ChatWelcomePage()
: const Center(
child: CircularProgressIndicator.adaptive(),
);
},
),
messageWidthRatio: isMobile ? 0.8 : 0.86,
bubbleBuilder: (
child, {
required message,
required nextMessageInGroup,
}) {
if (message.author.id == _user.id) {
return ChatUserMessageBubble(
message: message,
child: child,
);
} else {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.serverStreamError) {
return ChatStreamingError(
message: message,
onRetryPressed: () {
blocContext
.read<ChatBloc>()
.add(const ChatEvent.retryGenerate());
},
);
}
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
onQuestionSelected: (question) {
blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(question));
blocContext
.read<ChatBloc>()
.add(const ChatEvent.clearReleatedQuestion());
},
chatId: widget.view.id,
relatedQuestions: state.relatedQuestions,
);
}
return ChatAIMessageBubble(
message: message,
customMessageType: messageType,
child: child,
);
}
},
);
},
),
),
),
);
}
Widget buildBubble(Message message, Widget child) {
final isAuthor = message.author.id == _user.id;
const borderRadius = BorderRadius.all(Radius.circular(6));
final childWithPadding = isAuthor
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: child,
)
: Padding(
padding: const EdgeInsets.all(8),
child: child,
);
// If the message is from the author, we will decorate it with a different color
final decoratedChild = isAuthor
? DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: !isAuthor || message.type == types.MessageType.image
? AFThemeExtension.of(context).tint1
: Theme.of(context).colorScheme.secondary,
),
child: childWithPadding,
)
: childWithPadding;
// If the message is from the author, no further actions are needed
if (isAuthor) {
return ClipRRect(
borderRadius: borderRadius,
child: decoratedChild,
);
} else {
if (isMobile) {
return ChatPopupMenu(
onAction: (action) {
switch (action) {
case ChatMessageAction.copy:
if (message is TextMessage) {
Clipboard.setData(ClipboardData(text: message.text));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
}
break;
}
},
builder: (context) =>
ClipRRect(borderRadius: borderRadius, child: decoratedChild),
);
} else {
// Show hover effect only on desktop
return ClipRRect(
borderRadius: borderRadius,
child: ChatAIMessageHover(
message: message,
child: decoratedChild,
),
);
}
}
}
Widget _customMessageBuilder(
types.CustomMessage message, {
required int messageWidth,
}) {
// iteration custom message type
final messageType = onetimeMessageTypeFromMeta(message.metadata);
if (messageType == null) {
return const SizedBox.shrink();
}
switch (messageType) {
case OnetimeShotType.loading:
return const ChatAILoading();
default:
return const SizedBox.shrink();
}
}
Widget buildChatInput(BuildContext context) {
final query = MediaQuery.of(context);
final safeAreaInsets = isMobile
? EdgeInsets.fromLTRB(
query.padding.left,
0,
query.padding.right,
query.viewInsets.bottom + query.padding.bottom,
)
: EdgeInsets.zero;
return Column(
children: [
ClipRect(
child: Padding(
padding: safeAreaInsets,
child: ChatInput(
chatId: widget.view.id,
onSendPressed: (message) => onSendPressed(context, message.text),
),
),
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
),
],
);
}
AFDefaultChatTheme buildTheme(BuildContext context) {
return AFDefaultChatTheme(
backgroundColor: AFThemeExtension.of(context).background,
primaryColor: Theme.of(context).colorScheme.primary,
secondaryColor: AFThemeExtension.of(context).tint1,
receivedMessageDocumentIconColor: Theme.of(context).primaryColor,
receivedMessageCaptionTextStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
receivedMessageBodyTextStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
receivedMessageLinkTitleTextStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
receivedMessageBodyLinkTextStyle: const TextStyle(
color: Colors.lightBlue,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
sentMessageBodyTextStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
sentMessageBodyLinkTextStyle: const TextStyle(
color: Colors.blue,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
inputElevation: 2,
);
}
void onSendPressed(BuildContext context, String message) {
context.read<ChatBloc>().add(ChatEvent.sendMessage(message));
}
}

View File

@ -0,0 +1,197 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart';
import 'package:appflowy/workspace/presentation/home/toast.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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:styled_widget/styled_widget.dart';
const _leftPadding = 16.0;
class ChatAIMessageBubble extends StatelessWidget {
const ChatAIMessageBubble({
super.key,
required this.message,
required this.child,
this.customMessageType,
});
final Message message;
final Widget child;
final OnetimeShotType? customMessageType;
@override
Widget build(BuildContext context) {
const padding = EdgeInsets.symmetric(horizontal: _leftPadding);
final childWithPadding = Padding(padding: padding, child: child);
return BlocProvider(
create: (context) => ChatAIMessageBloc(message: message),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
final widget = isMobile
? _wrapPopMenu(childWithPadding)
: _wrapHover(childWithPadding);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChatBorderedCircleAvatar(
backgroundColor: Theme.of(context).colorScheme.secondary,
child: const FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(24),
),
),
Expanded(child: widget),
],
);
},
),
);
}
ChatAIMessageHover _wrapHover(Padding child) {
return ChatAIMessageHover(
message: message,
customMessageType: customMessageType,
child: child,
);
}
ChatPopupMenu _wrapPopMenu(Padding childWithPadding) {
return ChatPopupMenu(
onAction: (action) {
if (action == ChatMessageAction.copy && message is TextMessage) {
Clipboard.setData(ClipboardData(text: (message as TextMessage).text));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
}
},
builder: (context) => childWithPadding,
);
}
}
class ChatAIMessageHover extends StatefulWidget {
const ChatAIMessageHover({
super.key,
required this.child,
required this.message,
this.customMessageType,
});
final Widget child;
final Message message;
final bool autoShowHover = true;
final OnetimeShotType? customMessageType;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
}
class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
bool _isHover = false;
@override
void initState() {
super.initState();
_isHover = widget.autoShowHover ? false : true;
}
@override
Widget build(BuildContext context) {
final List<Widget> children = [
DecoratedBox(
decoration: const BoxDecoration(
color: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: widget.child,
),
),
];
if (_isHover) {
children.addAll(_buildOnHoverItems());
}
return MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) => setState(() {
if (widget.autoShowHover) {
_isHover = true;
}
}),
onExit: (p) => setState(() {
if (widget.autoShowHover) {
_isHover = false;
}
}),
child: Stack(
alignment: AlignmentDirectional.centerStart,
children: children,
),
);
}
List<Widget> _buildOnHoverItems() {
final List<Widget> children = [];
if (widget.customMessageType != null) {
//
} else {
if (widget.message is TextMessage) {
children.add(
CopyButton(
textMessage: widget.message as TextMessage,
).positioned(left: _leftPadding, bottom: 0),
);
}
}
return children;
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.textMessage,
});
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: 24,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
fillColor: Theme.of(context).cardColor,
icon: FlowySvg(
FlowySvgs.ai_copy_s,
size: const Size.square(14),
color: Theme.of(context).colorScheme.primary,
),
onPressed: () {
Clipboard.setData(ClipboardData(text: textMessage.text));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
},
),
);
}
}

View File

@ -0,0 +1,184 @@
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/built_in_svgs.dart';
import 'package:appflowy/util/color_generator/color_generator.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:string_validator/string_validator.dart';
class ChatChatUserAvatar extends StatelessWidget {
const ChatChatUserAvatar({required this.userId, super.key});
final String userId;
@override
Widget build(BuildContext context) {
return const ChatBorderedCircleAvatar();
}
}
class ChatBorderedCircleAvatar extends StatelessWidget {
const ChatBorderedCircleAvatar({
super.key,
this.border = const BorderSide(),
this.backgroundImage,
this.backgroundColor,
this.child,
});
final BorderSide border;
final ImageProvider<Object>? backgroundImage;
final Color? backgroundColor;
final Widget? child;
@override
Widget build(BuildContext context) {
return CircleAvatar(
backgroundColor: border.color,
child: ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: CircleAvatar(
backgroundImage: backgroundImage,
backgroundColor: backgroundColor,
child: child,
),
),
);
}
}
class ChatUserAvatar extends StatelessWidget {
const ChatUserAvatar({
super.key,
required this.iconUrl,
required this.name,
required this.size,
this.isHovering = false,
});
final String iconUrl;
final String name;
final double size;
// If true, a border will be applied on top of the avatar
final bool isHovering;
@override
Widget build(BuildContext context) {
if (iconUrl.isEmpty) {
return _buildEmptyAvatar(context);
} else if (isURL(iconUrl)) {
return _buildUrlAvatar(context);
} else {
return _buildEmojiAvatar(context);
}
}
Widget _buildEmptyAvatar(BuildContext context) {
final String nameOrDefault = _userName(name);
final Color color = ColorGenerator(name).toColor();
const initialsCount = 2;
// Taking the first letters of the name components and limiting to 2 elements
final nameInitials = nameOrDefault
.split(' ')
.where((element) => element.isNotEmpty)
.take(initialsCount)
.map((element) => element[0].toUpperCase())
.join();
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isHovering
? Border.all(
color: _darken(color),
width: 4,
)
: null,
),
child: FlowyText.regular(
nameInitials,
color: Colors.black,
),
);
}
Widget _buildUrlAvatar(BuildContext context) {
return SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isHovering
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 4,
)
: null,
),
child: ClipRRect(
borderRadius: Corners.s5Border,
child: CircleAvatar(
backgroundColor: Colors.transparent,
child: Image.network(
iconUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_buildEmptyAvatar(context),
),
),
),
),
);
}
Widget _buildEmojiAvatar(BuildContext context) {
return SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isHovering
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 4,
)
: null,
),
child: ClipRRect(
borderRadius: Corners.s5Border,
child: CircleAvatar(
backgroundColor: Colors.transparent,
child: builtInSVGIcons.contains(iconUrl)
? FlowySvg(
FlowySvgData('emoji/$iconUrl'),
blendMode: null,
)
: FlowyText.emoji(iconUrl),
),
),
),
);
}
/// Return the user name, if the user name is empty,
/// return the default user name.
///
String _userName(String name) =>
name.isEmpty ? LocaleKeys.defaultUsername.tr() : name;
/// Used to darken the generated color for the hover border effect.
/// The color is darkened by 15% - Hence the 0.15 value.
///
Color _darken(Color color) {
final hsl = HSLColor.fromColor(color);
return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor();
}
}

View File

@ -0,0 +1,257 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
class ChatInput extends StatefulWidget {
/// Creates [ChatInput] widget.
const ChatInput({
super.key,
this.isAttachmentUploading,
this.onAttachmentPressed,
required this.onSendPressed,
required this.chatId,
this.options = const InputOptions(),
});
final bool? isAttachmentUploading;
final VoidCallback? onAttachmentPressed;
final void Function(types.PartialText) onSendPressed;
final InputOptions options;
final String chatId;
@override
State<ChatInput> createState() => _ChatInputState();
}
/// [ChatInput] widget state.
class _ChatInputState extends State<ChatInput> {
late final _inputFocusNode = FocusNode(
onKeyEvent: (node, event) {
if (event.physicalKey == PhysicalKeyboardKey.enter &&
!HardwareKeyboard.instance.physicalKeysPressed.any(
(el) => <PhysicalKeyboardKey>{
PhysicalKeyboardKey.shiftLeft,
PhysicalKeyboardKey.shiftRight,
}.contains(el),
)) {
if (kIsWeb && _textController.value.isComposingRangeValid) {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
_handleSendPressed();
}
return KeyEventResult.handled;
} else {
return KeyEventResult.ignored;
}
},
);
bool _sendButtonVisible = false;
late TextEditingController _textController;
@override
void initState() {
super.initState();
_textController =
widget.options.textEditingController ?? InputTextFieldController();
_handleSendButtonVisibilityModeChange();
}
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
if (widget.options.sendButtonVisibilityMode ==
SendButtonVisibilityMode.hidden) {
_sendButtonVisible = false;
} else if (widget.options.sendButtonVisibilityMode ==
SendButtonVisibilityMode.editing) {
_sendButtonVisible = _textController.text.trim() != '';
_textController.addListener(_handleTextControllerChange);
} else {
_sendButtonVisible = true;
}
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
}
}
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() {
_sendButtonVisible = _textController.text.trim() != '';
});
}
Widget _inputBuilder() {
const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
const inputPadding = EdgeInsets.all(6);
return Focus(
autofocus: !widget.options.autofocus,
child: Padding(
padding: inputPadding,
child: Material(
borderRadius: BorderRadius.circular(12),
color: isMobile
? Theme.of(context).colorScheme.surfaceContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
elevation: 0.6,
child: Row(
children: [
if (widget.onAttachmentPressed != null)
AttachmentButton(
isLoading: widget.isAttachmentUploading ?? false,
onPressed: widget.onAttachmentPressed,
padding: buttonPadding,
),
Expanded(child: _inputTextField(textPadding)),
_sendButton(buttonPadding),
],
),
),
),
);
}
Padding _inputTextField(EdgeInsets textPadding) {
return Padding(
padding: textPadding,
child: TextField(
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
),
),
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
enabled: widget.options.enabled,
autocorrect: widget.options.autocorrect,
autofocus: widget.options.autofocus,
enableSuggestions: widget.options.enableSuggestions,
spellCheckConfiguration: const SpellCheckConfiguration(),
keyboardType: widget.options.keyboardType,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
onChanged: widget.options.onTextChanged,
onTap: widget.options.onTextFieldTap,
),
);
}
ConstrainedBox _sendButton(EdgeInsets buttonPadding) {
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: buttonPadding.bottom + buttonPadding.top + 24,
),
child: Visibility(
visible: _sendButtonVisible,
child: SendButton(
onPressed: _handleSendPressed,
padding: buttonPadding,
),
),
);
}
@override
void didUpdateWidget(covariant ChatInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.options.sendButtonVisibilityMode !=
oldWidget.options.sendButtonVisibilityMode) {
_handleSendButtonVisibilityModeChange();
}
}
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () => _inputFocusNode.requestFocus(),
child: _inputBuilder(),
);
}
@immutable
class InputOptions {
const InputOptions({
this.inputClearMode = InputClearMode.always,
this.keyboardType = TextInputType.multiline,
this.onTextChanged,
this.onTextFieldTap,
this.sendButtonVisibilityMode = SendButtonVisibilityMode.editing,
this.textEditingController,
this.autocorrect = true,
this.autofocus = false,
this.enableSuggestions = true,
this.enabled = true,
});
/// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always].
final InputClearMode inputClearMode;
/// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline].
final TextInputType keyboardType;
/// Will be called whenever the text inside [TextField] changes.
final void Function(String)? onTextChanged;
/// Will be called on [TextField] tap.
final VoidCallback? onTextFieldTap;
/// Controls the visibility behavior of the [SendButton] based on the
/// [TextField] state inside the [ChatInput] widget.
/// Defaults to [SendButtonVisibilityMode.editing].
final SendButtonVisibilityMode sendButtonVisibilityMode;
/// Custom [TextEditingController]. If not provided, defaults to the
/// [InputTextFieldController], which extends [TextEditingController] and has
/// additional fatures like markdown support. If you want to keep additional
/// features but still need some methods from the default [TextEditingController],
/// you can create your own [InputTextFieldController] (imported from this lib)
/// and pass it here.
final TextEditingController? textEditingController;
/// Controls the [TextInput] autocorrect behavior. Defaults to [true].
final bool autocorrect;
/// Whether [TextInput] should have focus. Defaults to [false].
final bool autofocus;
/// Controls the [TextInput] enableSuggestions behavior. Defaults to [true].
final bool enableSuggestions;
/// Controls the [TextInput] enabled behavior. Defaults to [true].
final bool enabled;
}
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;

View File

@ -0,0 +1,69 @@
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class ChatAILoading extends StatelessWidget {
const ChatAILoading({super.key});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: AFThemeExtension.of(context).lightGreyHover,
highlightColor:
AFThemeExtension.of(context).lightGreyHover.withOpacity(0.5),
period: const Duration(seconds: 3),
child: const ContentPlaceholder(),
);
}
}
class ContentPlaceholder extends StatelessWidget {
const ContentPlaceholder({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
const HSpace(10),
Container(
width: 100,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
],
),
Container(
width: 140,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
],
),
);
}
}

View File

@ -0,0 +1,70 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class ChatPopupMenu extends StatefulWidget {
const ChatPopupMenu({
super.key,
required this.onAction,
required this.builder,
});
final Function(ChatMessageAction) onAction;
final Widget Function(BuildContext context) builder;
@override
State<ChatPopupMenu> createState() => _ChatPopupMenuState();
}
class _ChatPopupMenuState extends State<ChatPopupMenu> {
@override
Widget build(BuildContext context) {
return PopoverActionList<ChatMessageActionWrapper>(
asBarrier: true,
actions: ChatMessageAction.values
.map((action) => ChatMessageActionWrapper(action))
.toList(),
buildChild: (controller) {
return GestureDetector(
onLongPress: () {
controller.show();
},
child: widget.builder(context),
);
},
onSelected: (action, controller) async {
widget.onAction(action.inner);
controller.close();
},
direction: PopoverDirection.bottomWithCenterAligned,
);
}
}
enum ChatMessageAction {
copy,
}
class ChatMessageActionWrapper extends ActionCell {
ChatMessageActionWrapper(this.inner);
final ChatMessageAction inner;
@override
Widget? leftIcon(Color iconColor) => null;
@override
String get name => inner.name;
}
extension ChatMessageActionExtension on ChatMessageAction {
String get name {
switch (this) {
case ChatMessageAction.copy:
return LocaleKeys.document_plugins_contextMenu_copy.tr();
}
}
}

View File

@ -0,0 +1,147 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_related_question_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:easy_localization/easy_localization.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';
class RelatedQuestionPage extends StatefulWidget {
const RelatedQuestionPage({
required this.chatId,
required this.onQuestionSelected,
super.key,
});
final String chatId;
final Function(String) onQuestionSelected;
@override
State<RelatedQuestionPage> createState() => _RelatedQuestionPageState();
}
class _RelatedQuestionPageState extends State<RelatedQuestionPage> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatRelatedMessageBloc(chatId: widget.chatId)
..add(
const ChatRelatedMessageEvent.initial(),
),
child: BlocBuilder<ChatRelatedMessageBloc, ChatRelatedMessageState>(
builder: (blocContext, state) {
return RelatedQuestionList(
chatId: widget.chatId,
onQuestionSelected: widget.onQuestionSelected,
relatedQuestions: state.relatedQuestions,
);
},
),
);
}
}
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
required this.chatId,
required this.onQuestionSelected,
required this.relatedQuestions,
super.key,
});
final String chatId;
final Function(String) onQuestionSelected;
final List<RelatedQuestionPB> relatedQuestions;
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length,
itemBuilder: (context, index) {
final question = relatedQuestions[index];
if (index == 0) {
return Column(
children: [
const Divider(height: 36),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const FlowySvg(
FlowySvgs.ai_summary_generate_s,
size: Size.square(24),
),
const HSpace(6),
FlowyText(
LocaleKeys.chat_relatedQuestion.tr(),
fontSize: 18,
),
],
),
),
const Divider(height: 6),
RelatedQuestionItem(
question: question,
onQuestionSelected: onQuestionSelected,
),
],
);
} else {
return RelatedQuestionItem(
question: question,
onQuestionSelected: onQuestionSelected,
);
}
},
);
}
}
class RelatedQuestionItem extends StatefulWidget {
const RelatedQuestionItem({
required this.question,
required this.onQuestionSelected,
super.key,
});
final RelatedQuestionPB question;
final Function(String) onQuestionSelected;
@override
State<RelatedQuestionItem> createState() => _RelatedQuestionItemState();
}
class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
title: Text(
widget.question.content,
style: TextStyle(
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
fontSize: 14,
),
),
onTap: () {
widget.onQuestionSelected(widget.question.content);
},
trailing: FlowySvg(
FlowySvgs.add_m,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}

View File

@ -0,0 +1,84 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_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_chat_types/flutter_chat_types.dart';
class ChatStreamingError extends StatelessWidget {
const ChatStreamingError({
required this.message,
required this.onRetryPressed,
super.key,
});
final void Function() onRetryPressed;
final Message message;
@override
Widget build(BuildContext context) {
final canRetry = message.metadata?[canRetryKey] != null;
if (canRetry) {
return Column(
children: [
const Divider(height: 4, thickness: 1),
const VSpace(16),
Center(
child: Column(
children: [
_aiUnvaliable(),
const VSpace(10),
_retryButton(),
],
),
),
],
);
} else {
return Center(
child: Column(
children: [
const Divider(height: 20, thickness: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_serverUnavailable.tr(),
fontSize: 14,
),
),
],
),
);
}
}
FlowyButton _retryButton() {
return FlowyButton(
radius: BorderRadius.circular(20),
useIntrinsicWidth: true,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText(
LocaleKeys.chat_regenerateAnswer.tr(),
fontSize: 14,
),
),
onTap: onRetryPressed,
iconPadding: 0,
leftIcon: const Icon(
Icons.refresh,
size: 20,
),
);
}
Padding _aiUnvaliable() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_aiServerUnavailable.tr(),
fontSize: 14,
),
);
}
}

View File

@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
// For internal usage only. Use values from theme itself.
/// See [ChatTheme.userAvatarNameColors].
const colors = [
Color(0xffff6767),
Color(0xff66e0da),
Color(0xfff5a2d9),
Color(0xfff0c722),
Color(0xff6a85e5),
Color(0xfffd9a6f),
Color(0xff92db6e),
Color(0xff73b8e5),
Color(0xfffd7590),
Color(0xffc78ae5),
];
/// Dark.
const dark = Color(0xff1f1c38);
/// Error.
const error = Color(0xffff6767);
/// N0.
const neutral0 = Color(0xff1d1c21);
/// N1.
const neutral1 = Color(0xff615e6e);
/// N2.
const neutral2 = Color(0xff9e9cab);
/// N7.
const neutral7 = Color(0xffffffff);
/// N7 with opacity.
const neutral7WithOpacity = Color(0x80ffffff);
/// Primary.
const primary = Color(0xff6f61e8);
/// Secondary.
const secondary = Color(0xfff5f5f7);
/// Secondary dark.
const secondaryDark = Color(0xff2b2250);
/// Default chat theme which extends [ChatTheme].
@immutable
class AFDefaultChatTheme extends ChatTheme {
/// Creates a default chat theme. Use this constructor if you want to
/// override only a couple of properties, otherwise create a new class
/// which extends [ChatTheme].
const AFDefaultChatTheme({
super.attachmentButtonIcon,
super.attachmentButtonMargin,
super.backgroundColor = neutral7,
super.bubbleMargin,
super.dateDividerMargin = const EdgeInsets.only(
bottom: 32,
top: 16,
),
super.dateDividerTextStyle = const TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
super.deliveredIcon,
super.documentIcon,
super.emptyChatPlaceholderTextStyle = const TextStyle(
color: neutral2,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.errorColor = error,
super.errorIcon,
super.inputBackgroundColor = neutral0,
super.inputSurfaceTintColor = neutral0,
super.inputElevation = 0,
super.inputBorderRadius = const BorderRadius.vertical(
top: Radius.circular(20),
),
super.inputContainerDecoration,
super.inputMargin = EdgeInsets.zero,
super.inputPadding = const EdgeInsets.fromLTRB(14, 20, 14, 20),
super.inputTextColor = neutral7,
super.inputTextCursorColor,
super.inputTextDecoration = const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isCollapsed: true,
),
super.inputTextStyle = const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.messageBorderRadius = 20,
super.messageInsetsHorizontal = 0,
super.messageInsetsVertical = 0,
super.messageMaxWidth = 1000,
super.primaryColor = primary,
super.receivedEmojiMessageTextStyle = const TextStyle(fontSize: 40),
super.receivedMessageBodyBoldTextStyle,
super.receivedMessageBodyCodeTextStyle,
super.receivedMessageBodyLinkTextStyle,
super.receivedMessageBodyTextStyle = const TextStyle(
color: neutral0,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.receivedMessageCaptionTextStyle = const TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.333,
),
super.receivedMessageDocumentIconColor = primary,
super.receivedMessageLinkDescriptionTextStyle = const TextStyle(
color: neutral0,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.428,
),
super.receivedMessageLinkTitleTextStyle = const TextStyle(
color: neutral0,
fontSize: 16,
fontWeight: FontWeight.w800,
height: 1.375,
),
super.secondaryColor = secondary,
super.seenIcon,
super.sendButtonIcon,
super.sendButtonMargin,
super.sendingIcon,
super.sentEmojiMessageTextStyle = const TextStyle(fontSize: 40),
super.sentMessageBodyBoldTextStyle,
super.sentMessageBodyCodeTextStyle,
super.sentMessageBodyLinkTextStyle,
super.sentMessageBodyTextStyle = const TextStyle(
color: neutral7,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.sentMessageCaptionTextStyle = const TextStyle(
color: neutral7WithOpacity,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.333,
),
super.sentMessageDocumentIconColor = neutral7,
super.sentMessageLinkDescriptionTextStyle = const TextStyle(
color: neutral7,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.428,
),
super.sentMessageLinkTitleTextStyle = const TextStyle(
color: neutral7,
fontSize: 16,
fontWeight: FontWeight.w800,
height: 1.375,
),
super.statusIconPadding = const EdgeInsets.symmetric(horizontal: 4),
super.systemMessageTheme = const SystemMessageTheme(
margin: EdgeInsets.only(
bottom: 24,
top: 8,
left: 8,
right: 8,
),
textStyle: TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
),
super.typingIndicatorTheme = const TypingIndicatorTheme(
animatedCirclesColor: neutral1,
animatedCircleSize: 5.0,
bubbleBorder: BorderRadius.all(Radius.circular(27.0)),
bubbleColor: neutral7,
countAvatarColor: primary,
countTextColor: secondary,
multipleUserTextStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: neutral2,
),
),
super.unreadHeaderTheme = const UnreadHeaderTheme(
color: secondary,
textStyle: TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.333,
),
),
super.userAvatarImageBackgroundColor = Colors.transparent,
super.userAvatarNameColors = colors,
super.userAvatarTextStyle = const TextStyle(
color: neutral7,
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
super.userNameTextStyle = const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
super.highlightMessageColor,
});
}

View File

@ -0,0 +1,31 @@
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
class ChatInvalidUserMessage extends StatelessWidget {
const ChatInvalidUserMessage({
required this.message,
super.key,
});
final Message message;
@override
Widget build(BuildContext context) {
final errorMessage = message.metadata?[sendMessageErrorKey] ?? "";
return Center(
child: Column(
children: [
const Divider(height: 20, thickness: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
errorMessage,
fontSize: 14,
),
),
],
),
);
}
}

View File

@ -0,0 +1,169 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:styled_widget/styled_widget.dart';
class ChatUserMessageBubble extends StatelessWidget {
const ChatUserMessageBubble({
super.key,
required this.message,
required this.child,
});
final Message message;
final Widget child;
@override
Widget build(BuildContext context) {
const borderRadius = BorderRadius.all(Radius.circular(6));
final backgroundColor = Theme.of(context).colorScheme.secondary;
return BlocProvider(
create: (context) => ChatUserMessageBloc(message: message),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _wrapHover(
Flexible(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: backgroundColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: child,
),
),
),
// ),
BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ChatUserAvatar(
iconUrl: state.member?.avatarUrl ?? "",
name: state.member?.name ?? "",
size: 36,
),
);
},
),
],
);
},
),
);
}
}
class ChatUserMessageHover extends StatefulWidget {
const ChatUserMessageHover({
super.key,
required this.child,
required this.message,
});
final Widget child;
final Message message;
final bool autoShowHover = true;
@override
State<ChatUserMessageHover> createState() => _ChatUserMessageHoverState();
}
class _ChatUserMessageHoverState extends State<ChatUserMessageHover> {
bool _isHover = false;
@override
void initState() {
super.initState();
_isHover = widget.autoShowHover ? false : true;
}
@override
Widget build(BuildContext context) {
final List<Widget> children = [
DecoratedBox(
decoration: const BoxDecoration(
color: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 30),
child: widget.child,
),
),
];
if (_isHover) {
if (widget.message is TextMessage) {
children.add(
EditButton(
textMessage: widget.message as TextMessage,
).positioned(right: 0, bottom: 0),
);
}
}
return MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) => setState(() {
if (widget.autoShowHover) {
_isHover = true;
}
}),
onExit: (p) => setState(() {
if (widget.autoShowHover) {
_isHover = false;
}
}),
child: Stack(
alignment: AlignmentDirectional.centerStart,
children: children,
),
);
}
}
class EditButton extends StatelessWidget {
const EditButton({
super.key,
required this.textMessage,
});
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: 24,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
fillColor: Theme.of(context).cardColor,
icon: FlowySvg(
FlowySvgs.ai_copy_s,
size: const Size.square(14),
color: Theme.of(context).colorScheme.primary,
),
onPressed: () {},
),
);
}
}

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class ChatWelcomePage extends StatelessWidget {
const ChatWelcomePage({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}

View File

@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
@ -20,6 +21,9 @@ class BlankPluginBuilder extends PluginBuilder {
@override
PluginType get pluginType => PluginType.blank;
@override
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
}
class BlankPluginConfig implements PluginConfig {
@ -47,7 +51,10 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
Widget tabBarItem(String pluginId) => leftBarItem;
@override
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) =>
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
}) =>
const BlankPage();
@override

View File

@ -25,7 +25,7 @@ class BoardPluginBuilder implements PluginBuilder {
PluginType get pluginType => PluginType.board;
@override
ViewLayoutPB? get layoutType => ViewLayoutPB.Board;
ViewLayoutPB get layoutType => ViewLayoutPB.Board;
}
class BoardPluginConfig implements PluginConfig {

View File

@ -25,7 +25,7 @@ class CalendarPluginBuilder extends PluginBuilder {
PluginType get pluginType => PluginType.calendar;
@override
ViewLayoutPB? get layoutType => ViewLayoutPB.Calendar;
ViewLayoutPB get layoutType => ViewLayoutPB.Calendar;
}
class CalendarPluginConfig implements PluginConfig {

View File

@ -25,7 +25,7 @@ class GridPluginBuilder implements PluginBuilder {
PluginType get pluginType => PluginType.grid;
@override
ViewLayoutPB? get layoutType => ViewLayoutPB.Grid;
ViewLayoutPB get layoutType => ViewLayoutPB.Grid;
}
class GridPluginConfig implements PluginConfig {

View File

@ -243,11 +243,14 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
@override
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
if (deletedView != null && deletedView.hasIndex()) {
context?.onDeleted(notifier.view, deletedView.index);
context.onDeleted?.call(notifier.view, deletedView.index);
}
});

View File

@ -54,6 +54,7 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
valueListenable: _layoutTypeChangeNotifier,
builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget(
shrinkWrap: widget.shrinkWrap,
context: PluginContext(),
),
);
}

View File

@ -48,6 +48,9 @@ class DatabaseDocumentPluginBuilder extends PluginBuilder {
@override
PluginType get pluginType => PluginType.databaseDocument;
@override
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
}
class DatabaseDocumentPlugin extends Plugin {
@ -98,7 +101,10 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
EdgeInsets get contentPadding => EdgeInsets.zero;
@override
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
}) {
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
builder: (_, state) => DatabaseDocumentPage(
key: ValueKey(documentId),

View File

@ -43,7 +43,7 @@ class DocumentPluginBuilder extends PluginBuilder {
PluginType get pluginType => PluginType.document;
@override
ViewLayoutPB? get layoutType => ViewLayoutPB.Document;
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
}
class DocumentPlugin extends Plugin {
@ -107,7 +107,10 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
EdgeInsets get contentPadding => EdgeInsets.zero;
@override
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
if (deletedView != null && deletedView.hasIndex()) {
@ -121,7 +124,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
builder: (_, state) => DocumentPage(
key: ValueKey(view.id),
view: view,
onDeleted: () => context?.onDeleted(view, deletedViewIndex),
onDeleted: () => context.onDeleted?.call(view, deletedViewIndex),
initialSelection: initialSelection,
),
),

View File

@ -5,6 +5,7 @@ export "./src/trash_header.dart";
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
@ -26,6 +27,9 @@ class TrashPluginBuilder extends PluginBuilder {
@override
PluginType get pluginType => PluginType.trash;
@override
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
}
class TrashPluginConfig implements PluginConfig {
@ -59,7 +63,10 @@ class TrashPluginDisplay extends PluginWidgetBuilder {
Widget? get rightBarItem => null;
@override
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) =>
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
}) =>
const TrashPage(
key: ValueKey('TrashPage'),
);