From 6e4206a8e2a51b37d8cac9a642539b712806bb71 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 20 Mar 2025 11:41:49 +0800 Subject: [PATCH 001/207] chore: completion stream v2 --- .../lib/ai/service/appflowy_ai_service.dart | 6 +++ frontend/rust-lib/Cargo.lock | 28 +++++----- frontend/rust-lib/Cargo.toml | 8 +-- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 12 ++--- frontend/rust-lib/flowy-ai/src/completion.rs | 52 +++++++++++++------ frontend/rust-lib/flowy-ai/src/entities.rs | 10 ++-- .../src/middleware/chat_service_mw.rs | 20 +++---- .../flowy-server/src/af_cloud/impls/chat.rs | 2 +- 8 files changed, 85 insertions(+), 53 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index 3c1d5c5a9d..723e11a17f 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -49,6 +49,9 @@ class AppFlowyAIService implements AIRepository { onProcess: onProcess, onEnd: onEnd, onError: onError, + onComment: (String text) async { + Log.info('Comment: $text'); + }, ); final records = history.map((record) => record.toPB()).toList(); @@ -80,12 +83,14 @@ abstract class CompletionStream { CompletionStream({ required this.onStart, required this.onProcess, + required this.onComment, required this.onEnd, required this.onError, }); final Future Function() onStart; final Future Function(String text) onProcess; + final Future Function(String text) onComment; final Future Function() onEnd; final void Function(AIError error) onError; } @@ -94,6 +99,7 @@ class AppFlowyCompletionStream extends CompletionStream { AppFlowyCompletionStream({ required super.onStart, required super.onProcess, + required super.onComment, required super.onEnd, required super.onError, }) { diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index bd95fd24c2..6ec630d09d 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "bytes", @@ -198,7 +198,7 @@ dependencies = [ [[package]] name = "appflowy-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=0136d80eeb4366913203db221b6a4aeae99dccd0#0136d80eeb4366913203db221b6a4aeae99dccd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=76d7e7dc885b4d0bd9752aea7ff835de1d48de4e#76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" dependencies = [ "anyhow", "appflowy-plugin", @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "appflowy-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=0136d80eeb4366913203db221b6a4aeae99dccd0#0136d80eeb4366913203db221b6a4aeae99dccd0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=76d7e7dc885b4d0bd9752aea7ff835de1d48de4e#76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" dependencies = [ "anyhow", "cfg-if", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "again", "anyhow", @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "collab-entity", "collab-rt-entity", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "futures-channel", "futures-util", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "bincode", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "async-trait", @@ -1546,7 +1546,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "bincode", "bytes", @@ -2979,7 +2979,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -2994,7 +2994,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "app-error", "jsonwebtoken", @@ -3609,7 +3609,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "bytes", @@ -6176,7 +6176,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1634d17dd48488f6b55a7e0b874390343f18ebbf#1634d17dd48488f6b55a7e0b874390343f18ebbf" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index eb94443c09..fd4b19f6ec 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "1634d17dd48488f6b55a7e0b874390343f18ebbf" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "1634d17dd48488f6b55a7e0b874390343f18ebbf" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9587c0680f70a58d45c9bb012811bc702df5d213" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9587c0680f70a58d45c9bb012811bc702df5d213" } [profile.dev] opt-level = 0 @@ -152,5 +152,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "0136d80eeb4366913203db221b6a4aeae99dccd0" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "0136d80eeb4366913203db221b6a4aeae99dccd0" } +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" } diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 2043c123c0..d0107831ee 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -1,8 +1,7 @@ -use bytes::Bytes; pub use client_api::entity::ai_dto::{ - AppFlowyOfflineAI, CompleteTextParams, CompletionMetadata, CompletionRecord, CompletionType, - CreateChatContext, LLMModel, LocalAIConfig, ModelInfo, ModelList, OutputContent, OutputLayout, - RelatedQuestion, RepeatedRelatedQuestion, ResponseFormat, StringOrMessage, + AppFlowyOfflineAI, CompleteTextParams, CompletionMessage, CompletionMetadata, CompletionType, + CreateChatContext, CustomPrompt, LLMModel, LocalAIConfig, ModelInfo, ModelList, OutputContent, + OutputLayout, RelatedQuestion, RepeatedRelatedQuestion, ResponseFormat, StringOrMessage, }; pub use client_api::entity::billing_dto::SubscriptionPlan; pub use client_api::entity::chat_dto::{ @@ -10,7 +9,8 @@ pub use client_api::entity::chat_dto::{ MessageCursor, RepeatedChatMessage, UpdateChatParams, }; pub use client_api::entity::QuestionStreamValue; -use client_api::error::AppResponseError; +pub use client_api::entity::*; +pub use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; use flowy_error::FlowyError; use futures::stream::BoxStream; use lib_infra::async_trait::async_trait; @@ -20,7 +20,7 @@ use std::path::Path; pub type ChatMessageStream = BoxStream<'static, Result>; pub type StreamAnswer = BoxStream<'static, Result>; -pub type StreamComplete = BoxStream<'static, Result>; +pub type StreamComplete = BoxStream<'static, Result>; #[async_trait] pub trait ChatCloudService: Send + Sync + 'static { async fn create_chat( diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 4ed073f0ca..fa79308b4f 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -4,7 +4,8 @@ use allo_isolate::Isolate; use dashmap::DashMap; use flowy_ai_pub::cloud::{ - ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionType, + ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionStreamValue, CompletionType, + CustomPrompt, }; use flowy_error::{FlowyError, FlowyResult}; @@ -37,6 +38,15 @@ impl AICompletion { &self, complete: CompleteTextPB, ) -> FlowyResult { + if matches!(complete.completion_type, CompletionTypePB::CustomPrompt) + && complete.custom_prompt.is_none() + { + return Err( + FlowyError::invalid_data() + .with_context("custom_prompt is required when completion_type is CustomPrompt"), + ); + } + let workspace_id = self .user_service .upgrade() @@ -95,6 +105,7 @@ impl CompletionTask { CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting, CompletionTypePB::ExplainSelected => CompletionType::Explain, CompletionTypePB::UserQuestion => CompletionType::UserQuestion, + CompletionTypePB::CustomPrompt => CompletionType::CustomPrompt, }; let _ = sink.send("start:".to_string()).await; @@ -103,12 +114,15 @@ impl CompletionTask { let params = CompleteTextParams { text: self.context.text, completion_type: Some(complete_type), - custom_prompt: None, metadata: Some(CompletionMetadata { object_id: self.context.object_id, workspace_id: Some(self.workspace_id.clone()), rag_ids: Some(self.context.rag_ids), completion_history, + custom_prompt: self + .context + .custom_prompt + .map(|v| CustomPrompt { system: v }), }), format, }; @@ -124,20 +138,26 @@ impl CompletionTask { return; }, result = stream.next() => { - match result { - Some(Ok(data)) => { - let s = String::from_utf8(data.to_vec()).unwrap_or_default(); - let _ = sink.send(format!("data:{}", s)).await; - }, - Some(Err(error)) => { - handle_error(&mut sink, error).await; - return; - }, - None => { - let _ = sink.send(format!("finish:{}", self.task_id)).await; - return; - }, - } + match result { + Some(Ok(data)) => { + match data { + CompletionStreamValue::Answer{ value } => { + let _ = sink.send(format!("data:{}", value)).await; + } + CompletionStreamValue::Comment{ value } => { + let _ = sink.send(format!("comment:{}", value)).await; + } + } + }, + Some(Err(error)) => { + handle_error(&mut sink, error).await; + return; + }, + None => { + let _ = sink.send(format!("finish:{}", self.task_id)).await; + return; + }, + } } } }, diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 4b5e91db11..70a8b5ff58 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use crate::local_ai::controller::LocalAISetting; use crate::local_ai::resource::PendingResource; use flowy_ai_pub::cloud::{ - ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionRecord, LLMModel, OutputContent, + ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; @@ -361,6 +361,9 @@ pub struct CompleteTextPB { #[pb(index = 7)] pub history: Vec, + + #[pb(index = 8, one_of)] + pub custom_prompt: Option, } #[derive(Default, ProtoBuf, Clone, Debug)] @@ -379,6 +382,7 @@ pub enum CompletionTypePB { ImproveWriting = 4, MakeShorter = 5, MakeLonger = 6, + CustomPrompt = 7, } #[derive(Default, ProtoBuf, Clone, Debug)] @@ -390,9 +394,9 @@ pub struct CompletionRecordPB { pub content: String, } -impl From<&CompletionRecordPB> for CompletionRecord { +impl From<&CompletionRecordPB> for CompletionMessage { fn from(value: &CompletionRecordPB) -> Self { - CompletionRecord { + CompletionMessage { role: match value.role { // Coerce ChatMessageTypePB::System to AI ChatMessageTypePB::System => "ai".to_string(), diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index d03b1d88c2..8e69f14d20 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -9,10 +9,10 @@ use appflowy_plugin::error::PluginError; use std::collections::HashMap; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, LocalAIConfig, MessageCursor, ModelList, RelatedQuestion, - RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, - SubscriptionPlan, UpdateChatParams, + AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, + ChatMessageType, ChatSettings, CompleteTextParams, CompletionStream, LocalAIConfig, + MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, + ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, }; use flowy_error::{FlowyError, FlowyResult}; use futures::{stream, Sink, StreamExt, TryStreamExt}; @@ -194,7 +194,6 @@ impl ChatCloudService for AICloudServiceMiddleware { let content = self.get_message_record(question_message_id)?.content; match self.local_ai.ask_question(chat_id, &content).await { Ok(answer) => { - // TODO(nathan): metadata let message = self .cloud_service .create_answer(workspace_id, chat_id, &answer, question_message_id, None) @@ -278,17 +277,20 @@ impl ChatCloudService for AICloudServiceMiddleware { if self.local_ai.is_running() { match self .local_ai - .complete_text( + .complete_text_v2( ¶ms.text, params.completion_type.unwrap() as u8, Some(json!(params.format)), + Some(json!(params.metadata)), ) .await { Ok(stream) => Ok( - stream - .map_err(|err| FlowyError::local_ai().with_context(err)) - .boxed(), + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) + .boxed(), ), Err(err) => { self.handle_plugin_error(err); diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index c7077dc061..5417f06a65 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -193,7 +193,7 @@ where let stream = self .inner .try_get_client()? - .stream_completion_text(workspace_id, params) + .stream_completion_v2(workspace_id, params) .await .map_err(FlowyError::from)? .map_err(FlowyError::from); From f413b9e070fc4a81ba8c4f1a96f4dce243af3ff7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 20 Mar 2025 11:44:02 +0800 Subject: [PATCH 002/207] chore: callback --- .../appflowy_flutter/lib/ai/service/appflowy_ai_service.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index 723e11a17f..e3093b6bbf 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -151,6 +151,10 @@ class AppFlowyCompletionStream extends CompletionStream { await onProcess(event.substring(5)); } + if (event.startsWith("comment:")) { + await onComment(event.substring(8)); + } + if (event.startsWith("finish:")) { await onEnd(); } From 566e7b2f40b1247f0844df412f3fb9f5c8f5cbbb Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 20 Mar 2025 11:50:25 +0800 Subject: [PATCH 003/207] feat: allow user scroll during generation (#7559) * chore: add keep alive to ai writer block component * chore: allow user scrolling during ai writer generation * chore: pull ai writer cubit upwards * test: fix unit tests * chore: clear selection --- .../lib/plugins/document/document_page.dart | 32 +- .../ai/ai_writer_block_component.dart | 150 ++---- .../ai_writer_block_operations.dart | 25 +- .../ai/operations/ai_writer_cubit.dart | 470 +++++++++--------- .../widgets/ai_writer_gesture_detector.dart | 5 +- .../ai/widgets/ai_writer_scroll_wrapper.dart | 223 +++++++++ .../base/markdown_text_robot.dart | 10 +- .../appflowy_flutter/lib/util/throttle.dart | 4 + .../ai_writer_test/ai_writer_bloc_test.dart | 51 +- 9 files changed, 580 insertions(+), 390 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 46ef2ca7f2..8716bb7ae2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; @@ -55,8 +56,6 @@ class _DocumentPageState extends State Selection? initialSelection; late final documentBloc = DocumentBloc(documentId: widget.view.id) ..add(const DocumentEvent.initial()); - late final viewBloc = ViewBloc(view: widget.view) - ..add(const ViewEvent.initial()); @override void initState() { @@ -68,7 +67,6 @@ class _DocumentPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); documentBloc.close(); - viewBloc.close(); super.dispose(); } @@ -93,7 +91,11 @@ class _DocumentPageState extends State value: ViewLockStatusBloc(view: widget.view) ..add(ViewLockStatusEvent.initial()), ), - BlocProvider.value(value: viewBloc), + BlocProvider( + create: (context) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + lazy: false, + ), ], child: BlocConsumer( listenWhen: (prev, curr) => curr.isLocked != prev.isLocked, @@ -126,14 +128,20 @@ class _DocumentPageState extends State return const SizedBox.shrink(); } - return BlocListener( - listener: (context, state) { - editorState.editable = !state.isLocked; - }, - child: - BlocListener( - listenWhen: (_, curr) => curr.action != null, - listener: onNotificationAction, + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) => + editorState.editable = !state.isLocked, + ), + BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: onNotificationAction, + ), + ], + child: AiWriterScrollWrapper( + viewId: widget.view.id, + editorState: editorState, child: buildEditorPage(context, state), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index aee2e2cb50..c1b533ccca 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -2,11 +2,8 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; -import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; @@ -19,7 +16,6 @@ import 'operations/ai_writer_cubit.dart'; import 'operations/ai_writer_entities.dart'; import 'operations/ai_writer_node_extension.dart'; import 'suggestion_action_bar.dart'; -import 'widgets/ai_writer_gesture_detector.dart'; class AiWriterBlockKeys { const AiWriterBlockKeys._(); @@ -98,18 +94,11 @@ class AiWriterBlockComponent extends BlockComponentStatefulWidget { } class _AIWriterBlockComponentState extends State { - final key = GlobalKey(); final textController = TextEditingController(); final overlayController = OverlayPortalController(); final layerLink = LayerLink(); late final editorState = context.read(); - late final aiWriterCubit = AiWriterCubit( - documentId: context.read().documentId, - editorState: editorState, - getAiWriterNode: () => widget.node, - initialCommand: widget.node.aiWriterCommand, - ); @override void initState() { @@ -118,7 +107,7 @@ class _AIWriterBlockComponentState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { overlayController.show(); if (!widget.node.isAiWriterInitialized) { - aiWriterCubit.init(); + context.read().register(widget.node); } }); } @@ -126,7 +115,6 @@ class _AIWriterBlockComponentState extends State { @override void dispose() { textController.dispose(); - aiWriterCubit.close(); super.dispose(); } @@ -136,85 +124,42 @@ class _AIWriterBlockComponentState extends State { return const SizedBox.shrink(); } - return MultiBlocProvider( - providers: [ - BlocProvider.value( - value: aiWriterCubit, - ), - BlocProvider( - create: (_) => AIPromptInputBloc( - predefinedFormat: null, - ), - ), - ], + return BlocProvider( + create: (_) => AIPromptInputBloc( + predefinedFormat: null, + ), child: LayoutBuilder( builder: (context, constraints) { - return BlocListener( - listener: (context, state) { - if (state is FailedContinueWritingAiWriterState) { - showConfirmDialog( - context: context, - title: LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), - description: LocaleKeys - .ai_continueWritingEmptyDocumentDescription - .tr(), - onConfirm: state.onConfirm, - ); - } else if (state is DiscardResponseAiWriterState) { - showConfirmDialog( - context: context, - title: LocaleKeys.button_discard.tr(), - description: LocaleKeys.document_plugins_discardResponse.tr(), - confirmLabel: LocaleKeys.button_discard.tr(), - style: ConfirmPopupStyle.cancelAndOk, - onConfirm: state.onDiscard, - onCancel: () {}, - ); - } - }, - child: OverlayPortal( - controller: overlayController, - overlayChildBuilder: (context) { - return Stack( - children: [ - BlocBuilder( - builder: (context, state) { - return AiWriterGestureDetector( - behavior: state is GeneratingAiWriterState - ? HitTestBehavior.opaque - : HitTestBehavior.translucent, - onPointerEvent: onTapOutside, - ); - }, + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return Center( + child: CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, + child: Container( + padding: const EdgeInsets.only( + left: 40.0, + bottom: 16.0, ), - CompositedTransformFollower( - link: layerLink, - showWhenUnlinked: false, - child: Container( - padding: const EdgeInsets.only( - left: 40.0, - bottom: 16.0, - ), - width: constraints.maxWidth, - child: OverlayContent( - editorState: editorState, - node: widget.node, - ), - ), + width: constraints.maxWidth, + child: OverlayContent( + editorState: editorState, + node: widget.node, ), - ], - ); - }, - child: CompositedTransformTarget( - link: layerLink, - child: BlocBuilder( - builder: (context, state) { - return SizedBox( - key: key, - width: double.infinity, - ); - }, + ), ), + ); + }, + child: CompositedTransformTarget( + link: layerLink, + child: BlocBuilder( + builder: (context, state) { + return SizedBox( + width: double.infinity, + height: 1.0, + ); + }, ), ), ); @@ -222,26 +167,6 @@ class _AIWriterBlockComponentState extends State { ), ); } - - void onTapOutside() { - if (aiWriterCubit.hasUnusedResponse()) { - showConfirmDialog( - context: context, - title: LocaleKeys.button_discard.tr(), - description: LocaleKeys.document_plugins_discardResponse.tr(), - confirmLabel: LocaleKeys.button_discard.tr(), - style: ConfirmPopupStyle.cancelAndOk, - onConfirm: () => aiWriterCubit - ..stopStream() - ..exit(), - onCancel: () {}, - ); - } else { - aiWriterCubit - ..stopStream() - ..exit(); - } - } } class OverlayContent extends StatelessWidget { @@ -258,6 +183,9 @@ class OverlayContent extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + if (state is IdleAiWriterState) { + return const SizedBox.shrink(); + } final selection = node.aiWriterSelection; final showSuggestionPopup = state is ReadyAiWriterState && !state.isFirstRun; @@ -337,7 +265,9 @@ class OverlayContent extends StatelessWidget { alignment: AlignmentDirectional.centerStart, child: FlowyText( - state.command.i18n, + (state as RegisteredAiWriter) + .command + .i18n, fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF666D76), @@ -421,7 +351,9 @@ class OverlayContent extends StatelessWidget { context.read().runCommand( command, - showPredefinedFormats ? predefinedFormat : null, + predefinedFormat: + showPredefinedFormats ? predefinedFormat : null, + isFirstRun: false, ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart index c82b39e241..c503042339 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart @@ -4,7 +4,26 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import '../ai_writer_block_component.dart'; import 'ai_writer_entities.dart'; -Future removeAiWriterNode(EditorState editorState, Node node) async { +Future setAiWriterNodeIsInitialized( + EditorState editorState, + Node node, +) async { + final transaction = editorState.transaction + ..updateNode(node, { + AiWriterBlockKeys.isInitialized: true, + }) + ..afterSelection = null; + + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); +} + +Future removeAiWriterNode( + EditorState editorState, + Node node, +) async { final transaction = editorState.transaction..deleteNode(node); await editorState.apply( transaction, @@ -88,10 +107,6 @@ Position ensurePreviousNodeIsEmptyParagraph( position = Position(path: previous.path); } - transaction.updateNode(aiWriterNode, { - AiWriterBlockKeys.isInitialized: true, - }); - return position; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 376757b1a7..133b0b5562 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -10,7 +10,6 @@ import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import '../../base/markdown_text_robot.dart'; import 'ai_writer_block_operations.dart'; @@ -21,28 +20,24 @@ class AiWriterCubit extends Cubit { AiWriterCubit({ required this.documentId, required this.editorState, - required this.getAiWriterNode, - required this.initialCommand, + this.onCreateNode, + this.onRemoveNode, + this.onAppendToDocument, AppFlowyAIService? aiService, }) : _aiService = aiService ?? AppFlowyAIService(), _textRobot = MarkdownTextRobot(editorState: editorState), selectedSourcesNotifier = ValueNotifier([documentId]), - super( - ReadyAiWriterState( - initialCommand, - isFirstRun: true, - ), - ) { - HardwareKeyboard.instance.addHandler(_cancelShortcutHandler); - editorState.service.keyboardService?.disableShortcuts(); - } + super(IdleAiWriterState()); final String documentId; final EditorState editorState; - final Node Function() getAiWriterNode; - final AiWriterCommand initialCommand; final AppFlowyAIService _aiService; final MarkdownTextRobot _textRobot; + final void Function()? onCreateNode; + final void Function()? onRemoveNode; + final void Function()? onAppendToDocument; + + Node? aiWriterNode; final List records = []; final ValueNotifier> selectedSourcesNotifier; @@ -52,85 +47,58 @@ class AiWriterCubit extends Cubit { @override Future close() async { selectedSourcesNotifier.dispose(); - HardwareKeyboard.instance.removeHandler(_cancelShortcutHandler); - editorState.service.keyboardService?.enableShortcuts(); await super.close(); } - void init() { - runCommand(initialCommand, null, isImmediateRun: true); + void register(Node node) async { + aiWriterNode = node; + onCreateNode?.call(); + + await setAiWriterNodeIsInitialized(editorState, node); + + final command = node.aiWriterCommand; + if (command == AiWriterCommand.userQuestion) { + emit(ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true)); + } else { + runCommand(command, isFirstRun: true); + } } - void submit( - String prompt, - PredefinedFormat? format, - ) async { - final command = AiWriterCommand.userQuestion; - final node = getAiWriterNode(); + Future exit() async { + await _textRobot.discard(); + _textRobot.reset(); + onRemoveNode?.call(); + emit(IdleAiWriterState()); - _previousPrompt = (prompt, format); - - final stream = await _aiService.streamCompletion( - objectId: documentId, - text: prompt, - format: format, - sourceIds: selectedSourcesNotifier.value, - completionType: command.toCompletionType(), - history: records, - onStart: () async { - final transaction = editorState.transaction; - final position = - ensurePreviousNodeIsEmptyParagraph(editorState, node, transaction); - transaction.afterSelection = null; - await editorState.apply( - transaction, - options: ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - ); - _textRobot.start(position: position); - records.add( - AiWriterRecord.user(content: prompt), - ); - }, - onProcess: (text) async { - await _textRobot.appendMarkdownText( - text, - attributes: ApplySuggestionFormatType.replace.attributes, - ); - }, - onEnd: () async { - await _textRobot.stop( - attributes: ApplySuggestionFormatType.replace.attributes, - ); - emit(ReadyAiWriterState(command, isFirstRun: false)); - records.add( - AiWriterRecord.ai(content: _textRobot.markdownText), - ); - }, - onError: (error) async { - emit(ErrorAiWriterState(state.command, error: error)); - records.add( - AiWriterRecord.ai(content: _textRobot.markdownText), - ); - }, - ); - - if (stream != null) { - emit( - GeneratingAiWriterState( - command, - taskId: stream.$1, - ), + if (aiWriterNode != null) { + final selection = aiWriterNode!.aiWriterSelection; + if (selection == null) { + return; + } + final transaction = editorState.transaction; + formatSelection( + editorState, + selection, + transaction, + ApplySuggestionFormatType.clear, ); + await editorState.apply( + transaction, + options: const ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + withUpdateSelection: false, + ); + await removeAiWriterNode(editorState, aiWriterNode!); + aiWriterNode = null; } } void runCommand( - AiWriterCommand command, - PredefinedFormat? predefinedFormat, { - bool isImmediateRun = false, + AiWriterCommand command, { + required bool isFirstRun, + PredefinedFormat? predefinedFormat, bool isRetry = false, }) async { switch (command) { @@ -138,7 +106,7 @@ class AiWriterCubit extends Cubit { await _startContinueWriting( command, predefinedFormat, - isImmediateRun: isImmediateRun, + isImmediateRun: isFirstRun, ); break; case AiWriterCommand.fixSpellingAndGrammar: @@ -158,96 +126,79 @@ class AiWriterCubit extends Cubit { } } - void stopStream() async { - if (state is! GeneratingAiWriterState) { - return; - } - await _textRobot.stop( - attributes: ApplySuggestionFormatType.replace.attributes, - ); - final generatingState = state as GeneratingAiWriterState; - await AIEventStopCompleteText( - CompleteTextTaskPB( - taskId: generatingState.taskId, - ), - ).send(); - emit( - ReadyAiWriterState( - state.command, - isFirstRun: false, - markdownText: generatingState.markdownText, - ), - ); - } + Future stopStream() async { + if (state is GeneratingAiWriterState) { + final generatingState = state as GeneratingAiWriterState; - void exit() async { - await _textRobot.discard(); - final selection = getAiWriterNode().aiWriterSelection; - if (selection == null) { - return; + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + + await AIEventStopCompleteText( + CompleteTextTaskPB( + taskId: generatingState.taskId, + ), + ).send(); + + emit( + ReadyAiWriterState( + generatingState.command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); } - final transaction = editorState.transaction; - formatSelection( - editorState, - selection, - transaction, - ApplySuggestionFormatType.clear, - ); - await editorState.apply( - transaction, - options: const ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - withUpdateSelection: false, - ); - await removeAiWriterNode(editorState, getAiWriterNode()); } void runResponseAction( SuggestionAction action, [ PredefinedFormat? predefinedFormat, ]) async { - if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) { - await _textRobot.discard(); - _textRobot.reset(); - runCommand(state.command, predefinedFormat, isRetry: true); + if (aiWriterNode == null) { return; } - final selection = getAiWriterNode().aiWriterSelection; + if (state is! RegisteredAiWriter) { + return; + } + + final command = (state as RegisteredAiWriter).command; + + if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) { + await _textRobot.discard(); + _textRobot.reset(); + runCommand( + command, + predefinedFormat: predefinedFormat, + isRetry: true, + isFirstRun: false, + ); + return; + } + + final selection = aiWriterNode!.aiWriterSelection; if (selection == null) { return; } if (action case SuggestionAction.discard || SuggestionAction.close) { - await _textRobot.discard(); + await exit(); + return; + } - final transaction = editorState.transaction; - formatSelection( - editorState, - selection, - transaction, - ApplySuggestionFormatType.clear, - ); + if (action case SuggestionAction.accept) { + await _textRobot.persist(); + final nodes = editorState.getNodesInSelection(selection); + final transaction = editorState.transaction..deleteNodes(nodes); await editorState.apply( transaction, options: const ApplyOptions(recordUndo: false), + withUpdateSelection: false, ); } - if (action case SuggestionAction.accept || SuggestionAction.keep) { + if (action case SuggestionAction.keep) { await _textRobot.persist(); - - if (acceptReplacesOriginal) { - final nodes = editorState.getNodesInSelection(selection); - final transaction = editorState.transaction..deleteNodes(nodes); - await editorState.apply( - transaction, - options: const ApplyOptions(recordUndo: false), - withUpdateSelection: false, - ); - } } if (action case SuggestionAction.insertBelow) { @@ -256,7 +207,7 @@ class AiWriterCubit extends Cubit { final transaction = editorState.transaction; final position = ensurePreviousNodeIsEmptyParagraph( editorState, - getAiWriterNode(), + aiWriterNode!, transaction, ); transaction.afterSelection = null; @@ -266,6 +217,7 @@ class AiWriterCubit extends Cubit { inMemoryUpdate: true, recordUndo: false, ), + withUpdateSelection: false, ); _textRobot.start(position: position); await _textRobot.persist(markdownText: readyState.markdownText); @@ -287,7 +239,9 @@ class AiWriterCubit extends Cubit { ); } - await removeAiWriterNode(editorState, getAiWriterNode()); + await removeAiWriterNode(editorState, aiWriterNode!); + aiWriterNode = null; + emit(IdleAiWriterState()); } bool hasUnusedResponse() { @@ -302,14 +256,87 @@ class AiWriterCubit extends Cubit { }; } + void submit( + String prompt, + PredefinedFormat? format, + ) async { + if (aiWriterNode == null) { + return; + } + final command = AiWriterCommand.userQuestion; + _previousPrompt = (prompt, format); + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + format: format, + history: records, + sourceIds: selectedSourcesNotifier.value, + completionType: command.toCompletionType(), + onStart: () async { + final transaction = editorState.transaction; + final position = ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + transaction, + ); + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + withUpdateSelection: false, + ); + _textRobot.start(position: position); + records.add( + AiWriterRecord.user(content: prompt), + ); + }, + onProcess: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + }, + onEnd: () async { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit(ReadyAiWriterState(command, isFirstRun: false)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + ); + + if (stream != null) { + emit( + GeneratingAiWriterState( + command, + taskId: stream.$1, + ), + ); + } + } + Future _startContinueWriting( AiWriterCommand command, PredefinedFormat? predefinedFormat, { required bool isImmediateRun, }) async { - final node = getAiWriterNode(); - - final cursorPosition = getAiWriterNode().aiWriterSelection?.start; + if (aiWriterNode == null) { + return; + } + final cursorPosition = aiWriterNode?.aiWriterSelection?.start; if (cursorPosition == null) { return; } @@ -320,28 +347,25 @@ class AiWriterCubit extends Cubit { String text = (await editorState.getMarkdownInSelection(selection)).trim(); if (text.isEmpty) { - if (state is! ReadyAiWriterState) { - return; - } final view = await ViewBackendService.getView(documentId).toNullable(); if (view == null || view.name.isEmpty || view.name == LocaleKeys.menuAppHeader_defaultNewPageName.tr()) { - final readyState = state as ReadyAiWriterState; + final stateCopy = state; emit( - FailedContinueWritingAiWriterState( + DocumentContentEmptyAiWriterState( command, onConfirm: () { if (isImmediateRun) { - removeAiWriterNode(editorState, node); + removeAiWriterNode(editorState, aiWriterNode!); } }, ), ); - emit(readyState); + emit(stateCopy); return; } else { - text += view.name; + text = view.name; } } @@ -352,8 +376,11 @@ class AiWriterCubit extends Cubit { history: records, onStart: () async { final transaction = editorState.transaction; - final position = - ensurePreviousNodeIsEmptyParagraph(editorState, node, transaction); + final position = ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + transaction, + ); transaction.afterSelection = null; await editorState.apply( transaction, @@ -361,14 +388,17 @@ class AiWriterCubit extends Cubit { inMemoryUpdate: true, recordUndo: false, ), + withUpdateSelection: false, ); _textRobot.start(position: position); }, onProcess: (text) async { await _textRobot.appendMarkdownText( text, + updateSelection: false, attributes: ApplySuggestionFormatType.replace.attributes, ); + onAppendToDocument?.call(); }, onEnd: () async { if (state case GeneratingAiWriterState _) { @@ -399,8 +429,10 @@ class AiWriterCubit extends Cubit { AiWriterCommand command, PredefinedFormat? predefinedFormat, ) async { - final node = getAiWriterNode(); - final selection = node.aiWriterSelection; + if (aiWriterNode == null) { + return; + } + final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } @@ -420,8 +452,11 @@ class AiWriterCubit extends Cubit { transaction, ApplySuggestionFormatType.original, ); - final position = - ensurePreviousNodeIsEmptyParagraph(editorState, node, transaction); + final position = ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + transaction, + ); transaction.afterSelection = null; await editorState.apply( transaction, @@ -429,14 +464,17 @@ class AiWriterCubit extends Cubit { inMemoryUpdate: true, recordUndo: false, ), + withUpdateSelection: false, ); _textRobot.start(position: position); }, onProcess: (text) async { await _textRobot.appendMarkdownText( text, + updateSelection: false, attributes: ApplySuggestionFormatType.replace.attributes, ); + onAppendToDocument?.call(); }, onEnd: () async { if (state is GeneratingAiWriterState) { @@ -472,8 +510,10 @@ class AiWriterCubit extends Cubit { AiWriterCommand command, PredefinedFormat? predefinedFormat, ) async { - final node = getAiWriterNode(); - final selection = node.aiWriterSelection; + if (aiWriterNode == null) { + return; + } + final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } @@ -524,101 +564,71 @@ class AiWriterCubit extends Cubit { ); } } +} - bool _cancelShortcutHandler(KeyEvent event) { - if (event is! KeyUpEvent) { - return false; - } - - switch (event.logicalKey) { - case LogicalKeyboardKey.escape: - if (state case GeneratingAiWriterState _) { - stopStream(); - } else if (hasUnusedResponse()) { - final saveState = state; - emit( - FailedContinueWritingAiWriterState( - state.command, - onConfirm: () { - stopStream(); - exit(); - }, - ), - ); - emit(saveState); - } else { - stopStream(); - exit(); - } - return true; - case LogicalKeyboardKey.keyC - when HardwareKeyboard.instance.logicalKeysPressed - .contains(LogicalKeyboardKey.controlLeft): - if (state case GeneratingAiWriterState _) { - stopStream(); - } - return true; - default: - break; - } - - return false; - } +mixin RegisteredAiWriter { + AiWriterCommand get command; } sealed class AiWriterState { - const AiWriterState(this.command); - - final AiWriterCommand command; + const AiWriterState(); } -class ReadyAiWriterState extends AiWriterState { +class IdleAiWriterState extends AiWriterState { + const IdleAiWriterState(); +} + +class ReadyAiWriterState extends AiWriterState with RegisteredAiWriter { const ReadyAiWriterState( - super.command, { + this.command, { required this.isFirstRun, this.markdownText = '', }); + @override + final AiWriterCommand command; + final bool isFirstRun; final String markdownText; } -class GeneratingAiWriterState extends AiWriterState { +class GeneratingAiWriterState extends AiWriterState with RegisteredAiWriter { const GeneratingAiWriterState( - super.command, { + this.command, { required this.taskId, this.progress = '', this.markdownText = '', }); + @override + final AiWriterCommand command; + final String taskId; final String progress; final String markdownText; } -class ErrorAiWriterState extends AiWriterState { +class ErrorAiWriterState extends AiWriterState with RegisteredAiWriter { const ErrorAiWriterState( - super.command, { + this.command, { required this.error, }); + @override + final AiWriterCommand command; + final AIError error; } -class FailedContinueWritingAiWriterState extends AiWriterState { - const FailedContinueWritingAiWriterState( - super.command, { +class DocumentContentEmptyAiWriterState extends AiWriterState + with RegisteredAiWriter { + const DocumentContentEmptyAiWriterState( + this.command, { required this.onConfirm, }); + @override + final AiWriterCommand command; + final void Function() onConfirm; } - -class DiscardResponseAiWriterState extends AiWriterState { - const DiscardResponseAiWriterState( - super.command, { - required this.onDiscard, - }); - - final void Function() onDiscard; -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart index 84f785335b..8a691acdfc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart @@ -21,7 +21,10 @@ class AiWriterGestureDetector extends StatelessWidget { TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), - (instance) => instance..onTapDown = (_) => onPointerEvent(), + (instance) => instance + ..onTapDown = ((_) => onPointerEvent()) + ..onSecondaryTapDown = ((_) => onPointerEvent()) + ..onTertiaryTapDown = ((_) => onPointerEvent()), ), ImmediateMultiDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart new file mode 100644 index 0000000000..4c04baf557 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -0,0 +1,223 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/throttle.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../operations/ai_writer_cubit.dart'; +import 'ai_writer_gesture_detector.dart'; + +class AiWriterScrollWrapper extends StatefulWidget { + const AiWriterScrollWrapper({ + super.key, + required this.viewId, + required this.editorState, + required this.child, + }); + + final String viewId; + final EditorState editorState; + final Widget child; + + @override + State createState() => _AiWriterScrollWrapperState(); +} + +class _AiWriterScrollWrapperState extends State { + final overlayController = OverlayPortalController(); + late final throttler = Throttler(); + late final aiWriterCubit = AiWriterCubit( + documentId: widget.viewId, + editorState: widget.editorState, + onCreateNode: () { + aiWriterRegistered = true; + widget.editorState.service.keyboardService?.disableShortcuts(); + HardwareKeyboard.instance.addHandler(cancelShortcutHandler); + }, + onRemoveNode: () { + aiWriterRegistered = false; + HardwareKeyboard.instance.removeHandler(cancelShortcutHandler); + widget.editorState.service.keyboardService?.enableShortcuts(); + }, + onAppendToDocument: onAppendToDocument, + ); + + bool userHasScrolled = false; + bool aiWriterRegistered = false; + + @override + void initState() { + super.initState(); + overlayController.show(); + } + + @override + void dispose() { + aiWriterCubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: aiWriterCubit, + child: NotificationListener( + onNotification: handleScrollNotification, + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state is DocumentContentEmptyAiWriterState) { + showConfirmDialog( + context: context, + title: LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), + description: LocaleKeys + .ai_continueWritingEmptyDocumentDescription + .tr(), + onConfirm: state.onConfirm, + ); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous is GeneratingAiWriterState && + current is ReadyAiWriterState, + listener: (context, state) { + widget.editorState.updateSelectionWithReason(null); + }, + ), + ], + child: OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return BlocBuilder( + builder: (context, state) { + return AiWriterGestureDetector( + behavior: state is RegisteredAiWriter + ? HitTestBehavior.translucent + : HitTestBehavior.deferToChild, + onPointerEvent: () => onTapOutside(context), + ); + }, + ); + }, + child: widget.child, + ), + ), + ), + ); + } + + bool handleScrollNotification(ScrollNotification notification) { + if (!aiWriterRegistered) { + return false; + } + + if (notification is UserScrollNotification) { + debounceResetUserHasScrolled(); + userHasScrolled = true; + throttler.cancel(); + } + + return false; + } + + void debounceResetUserHasScrolled() { + Debounce.debounce( + 'user_has_scrolled', + const Duration(seconds: 3), + () => userHasScrolled = false, + ); + } + + void onTapOutside(BuildContext context) { + final aiWriterCubit = context.read(); + + if (aiWriterCubit.hasUnusedResponse()) { + showConfirmDialog( + context: context, + title: LocaleKeys.button_discard.tr(), + description: LocaleKeys.document_plugins_discardResponse.tr(), + confirmLabel: LocaleKeys.button_discard.tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: () { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + }, + onCancel: () {}, + ); + } else { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + } + } + + bool cancelShortcutHandler(KeyEvent event) { + if (event is! KeyUpEvent) { + return false; + } + + switch (event.logicalKey) { + case LogicalKeyboardKey.escape: + if (aiWriterCubit.state case GeneratingAiWriterState _) { + aiWriterCubit.stopStream(); + } else if (aiWriterCubit.hasUnusedResponse()) { + showConfirmDialog( + context: context, + title: LocaleKeys.button_discard.tr(), + description: LocaleKeys.document_plugins_discardResponse.tr(), + confirmLabel: LocaleKeys.button_discard.tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: () { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + }, + onCancel: () {}, + ); + } else { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + } + return true; + case LogicalKeyboardKey.keyC + when HardwareKeyboard.instance.logicalKeysPressed + .contains(LogicalKeyboardKey.controlLeft): + if (aiWriterCubit.state case GeneratingAiWriterState _) { + aiWriterCubit.stopStream(); + } + return true; + default: + break; + } + + return false; + } + + void onAppendToDocument() { + if (!aiWriterRegistered || userHasScrolled) { + return; + } + + throttler.call(() { + if (aiWriterCubit.aiWriterNode != null) { + final path = aiWriterCubit.aiWriterNode!.path; + widget.editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + ); + } + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 58c4deb1b1..1904b10934 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -70,6 +70,7 @@ class MarkdownTextRobot { /// The text will be inserted into the document but only in memory Future appendMarkdownText( String text, { + bool updateSelection = true, Map? attributes, }) async { _markdownText += text; @@ -77,6 +78,7 @@ class MarkdownTextRobot { await _lock.synchronized(() async { await _refresh( inMemoryUpdate: true, + updateSelection: updateSelection, attributes: attributes, ); }); @@ -95,7 +97,6 @@ class MarkdownTextRobot { await _lock.synchronized(() async { await _refresh( inMemoryUpdate: true, - updateSelection: false, attributes: attributes, ); }); @@ -155,7 +156,7 @@ class MarkdownTextRobot { Future _refresh({ required bool inMemoryUpdate, - bool updateSelection = true, + bool updateSelection = false, Map? attributes, }) async { final position = _insertPosition; @@ -203,10 +204,6 @@ class MarkdownTextRobot { offset: lastDelta.length, ), ); - - if (!updateSelection) { - insertTransaction.afterSelection = null; - } } await editorState.apply( @@ -215,6 +212,7 @@ class MarkdownTextRobot { inMemoryUpdate: inMemoryUpdate, recordUndo: !inMemoryUpdate, ), + withUpdateSelection: updateSelection, ); _insertedNodes = newNodes; diff --git a/frontend/appflowy_flutter/lib/util/throttle.dart b/frontend/appflowy_flutter/lib/util/throttle.dart index c8c6dcf0ca..0aaa9f2d3a 100644 --- a/frontend/appflowy_flutter/lib/util/throttle.dart +++ b/frontend/appflowy_flutter/lib/util/throttle.dart @@ -16,6 +16,10 @@ class Throttler { }); } + void cancel() { + _timer?.cancel(); + } + void dispose() { _timer?.cancel(); _timer = null; diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart index aab5de8169..2abd4ffb97 100644 --- a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -162,20 +162,22 @@ void main() { ); final editorState = EditorState(document: document) ..selection = selection; - final command = AiWriterCommand.explain; - final node = aiWriterNode( - command: command, - selection: selection, - ); return AiWriterCubit( documentId: '', - getAiWriterNode: () => node, editorState: editorState, - initialCommand: command, aiService: _MockAIRepository(), ); }, - act: (bloc) => bloc.init(), + act: (bloc) => bloc.register( + aiWriterNode( + command: AiWriterCommand.explain, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ), + ), + ), + wait: Duration(seconds: 1), expect: () => [ isA() .having((s) => s.markdownText, 'result', isEmpty), @@ -216,19 +218,22 @@ void main() { ); final editorState = EditorState(document: document) ..selection = selection; - final node = aiWriterNode( - command: AiWriterCommand.explain, - selection: selection, - ); return AiWriterCubit( documentId: '', - getAiWriterNode: () => node, editorState: editorState, - initialCommand: AiWriterCommand.explain, aiService: _MockErrorRepository(), ); }, - act: (bloc) => bloc.init(), + act: (bloc) => bloc.register( + aiWriterNode( + command: AiWriterCommand.explain, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ), + ), + ), + wait: Duration(seconds: 1), expect: () => [ isA() .having((s) => s.markdownText, 'result', isEmpty), @@ -264,12 +269,10 @@ void main() { final aiNode = editorState.getNodeAtPath([3])!; final bloc = AiWriterCubit( documentId: '', - getAiWriterNode: () => aiNode, editorState: editorState, - initialCommand: AiWriterCommand.improveWriting, aiService: _MockAIRepository(), ); - bloc.init(); + bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); @@ -314,12 +317,10 @@ void main() { final aiNode = editorState.getNodeAtPath([3])!; final bloc = AiWriterCubit( documentId: '', - getAiWriterNode: () => aiNode, editorState: editorState, - initialCommand: AiWriterCommand.improveWriting, aiService: _MockAIRepository(), ); - bloc.init(); + bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.discard); await blocResponseFuture(); @@ -355,12 +356,10 @@ void main() { final aiNode = editorState.getNodeAtPath([3])!; final bloc = AiWriterCubit( documentId: '', - getAiWriterNode: () => aiNode, editorState: editorState, - initialCommand: AiWriterCommand.improveWriting, aiService: _MockAIRepositoryLess(), ); - bloc.init(); + bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); @@ -394,12 +393,10 @@ void main() { final aiNode = editorState.getNodeAtPath([3])!; final bloc = AiWriterCubit( documentId: '', - getAiWriterNode: () => aiNode, editorState: editorState, - initialCommand: AiWriterCommand.improveWriting, aiService: _MockAIRepositoryMore(), ); - bloc.init(); + bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); From aacd795ae040ef476837375f712e93a115084b7d Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 20 Mar 2025 12:33:51 +0800 Subject: [PATCH 004/207] chore: ai writer more actions (#7576) * feat: add more button to ai writer input box * chore: adjust button padding * chore: adjust icon color --- .../desktop_prompt_text_field.dart | 12 + .../prompt_input/select_sources_menu.dart | 4 +- .../ai/ai_writer_block_component.dart | 264 ++++++------------ .../operations/ai_writer_node_extension.dart | 2 +- .../ai/suggestion_action_bar.dart | 63 ----- .../ai_writer_prompt_input_more_button.dart | 175 ++++++++++++ .../widgets/ai_writer_suggestion_actions.dart | 110 ++++++++ frontend/resources/translations/en.json | 3 +- 8 files changed, 394 insertions(+), 239 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/suggestion_action_bar.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index de641d479c..c446e30f60 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -22,6 +22,7 @@ class DesktopPromptInput extends StatefulWidget { required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, this.hideDecoration = false, + this.extraBottomActionButton, }); final bool isStreaming; @@ -31,6 +32,7 @@ class DesktopPromptInput extends StatefulWidget { final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; final bool hideDecoration; + final Widget? extraBottomActionButton; @override State createState() => _DesktopPromptInputState(); @@ -178,6 +180,8 @@ class _DesktopPromptInputState extends State { widget.selectedSourcesNotifier, onUpdateSelectedSources: widget.onUpdateSelectedSources, + extraBottomActionButton: + widget.extraBottomActionButton, ), ), ), @@ -565,6 +569,7 @@ class _PromptBottomActions extends StatelessWidget { required this.onStopStreaming, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, + this.extraBottomActionButton, }); final bool showPredefinedFormats; @@ -575,6 +580,7 @@ class _PromptBottomActions extends StatelessWidget { final void Function() onStopStreaming; final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; + final Widget? extraBottomActionButton; @override Widget build(BuildContext context) { @@ -599,6 +605,12 @@ class _PromptBottomActions extends StatelessWidget { DesktopAIChatSizes.inputActionBarButtonSpacing, ), ], + if (extraBottomActionButton != null) ...[ + extraBottomActionButton!, + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], // _mentionButton(context), // const HSpace( // DesktopAIPromptSizes.actionBarButtonSpacing, diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart index d7c920c49c..51357e6a0b 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart @@ -145,7 +145,7 @@ class _IndicatorButton extends StatelessWidget { children: [ FlowySvg( FlowySvgs.ai_page_s, - color: Theme.of(context).iconTheme.color, + color: Theme.of(context).hintColor, ), const HSpace(2.0), ValueListenableBuilder( @@ -170,7 +170,7 @@ class _IndicatorButton extends StatelessWidget { FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, - size: const Size.square(10), + size: const Size.square(8), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index c1b533ccca..1e0bac83c5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -6,7 +6,6 @@ import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -15,7 +14,8 @@ import 'package:universal_platform/universal_platform.dart'; import 'operations/ai_writer_cubit.dart'; import 'operations/ai_writer_entities.dart'; import 'operations/ai_writer_node_extension.dart'; -import 'suggestion_action_bar.dart'; +import 'widgets/ai_writer_suggestion_actions.dart'; +import 'widgets/ai_writer_prompt_input_more_button.dart'; class AiWriterBlockKeys { const AiWriterBlockKeys._(); @@ -169,7 +169,7 @@ class _AIWriterBlockComponentState extends State { } } -class OverlayContent extends StatelessWidget { +class OverlayContent extends StatefulWidget { const OverlayContent({ super.key, required this.editorState, @@ -179,6 +179,19 @@ class OverlayContent extends StatelessWidget { final EditorState editorState; final Node node; + @override + State createState() => _OverlayContentState(); +} + +class _OverlayContentState extends State { + final showCommandsToggle = ValueNotifier(false); + + @override + void dispose() { + showCommandsToggle.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -186,10 +199,11 @@ class OverlayContent extends StatelessWidget { if (state is IdleAiWriterState) { return const SizedBox.shrink(); } - final selection = node.aiWriterSelection; + final selection = widget.node.aiWriterSelection; final showSuggestionPopup = state is ReadyAiWriterState && !state.isFirstRun; - final showActionPopup = state is ReadyAiWriterState && state.isFirstRun; + final isInitialReadyState = + state is ReadyAiWriterState && state.isFirstRun; final markdownText = switch (state) { final ReadyAiWriterState ready => ready.markdownText, final GeneratingAiWriterState generating => generating.markdownText, @@ -200,10 +214,6 @@ class OverlayContent extends StatelessWidget { final isLightMode = Theme.of(context).isLightMode; final darkBorderColor = isLightMode ? Color(0x1F1F2329) : Color(0xFF505469); - final lightBorderColor = - Theme.of(context).brightness == Brightness.light - ? ColorSchemeConstants.lightBorderColor - : ColorSchemeConstants.darkBorderColor; return Column( mainAxisSize: MainAxisSize.min, @@ -220,10 +230,8 @@ class OverlayContent extends StatelessWidget { borderColor: darkBorderColor, ), child: SuggestionActionBar( - actions: _getSuggestedActions( - currentCommand: state.command, - hasSelection: hasSelection, - ), + currentCommand: state.command, + hasSelection: hasSelection, onTap: (action) { _onSelectSuggestionAction(context, action); }, @@ -288,10 +296,8 @@ class OverlayContent extends StatelessWidget { if (showSuggestionPopup) ...[ const VSpace(4.0), SuggestionActionBar( - actions: _getSuggestedActions( - currentCommand: state.command, - hasSelection: hasSelection, - ), + currentCommand: state.command, + hasSelection: hasSelection, onTap: (action) { _onSelectSuggestionAction(context, action); }, @@ -310,16 +316,43 @@ class OverlayContent extends StatelessWidget { decoration: markdownText.isNotEmpty ? _getInputChildDecoration(context) : _getSingleChildDeocoration(context), - child: MainContentArea(), + child: MainContentArea( + isDocumentEmpty: _isDocumentEmpty(), + isInitialReadyState: isInitialReadyState, + showCommandsToggle: showCommandsToggle, + ), ), ], ), ), - ..._bottomActions( - context, - showActionPopup, - hasSelection, - lightBorderColor, + ValueListenableBuilder( + valueListenable: showCommandsToggle, + builder: (context, value, child) { + if (!value || !isInitialReadyState) { + return const SizedBox.shrink(); + } + return Align( + alignment: AlignmentDirectional.centerEnd, + child: BottomCommandButtons( + hasSelection: hasSelection, + editorState: widget.editorState, + onSelectCommand: (command) { + final promptInputBloc = context.read(); + final showPredefinedFormats = + promptInputBloc.state.showPredefinedFormats; + final predefinedFormat = + promptInputBloc.state.predefinedFormat; + + context.read().runCommand( + command, + predefinedFormat: + showPredefinedFormats ? predefinedFormat : null, + isFirstRun: true, + ); + }, + ), + ); + }, ), ], ); @@ -327,41 +360,6 @@ class OverlayContent extends StatelessWidget { ); } - Widget _bottomButton(AiWriterCommand command) { - return Builder( - builder: (context) { - return SizedBox( - height: 30.0, - child: FlowyButton( - leftIcon: FlowySvg( - command.icon, - size: const Size.square(16), - color: Theme.of(context).iconTheme.color, - ), - margin: const EdgeInsets.all(6.0), - text: FlowyText( - command.i18n, - figmaLineHeight: 20, - ), - onTap: () { - final aiInputBloc = context.read(); - final showPredefinedFormats = - aiInputBloc.state.showPredefinedFormats; - final predefinedFormat = aiInputBloc.state.predefinedFormat; - - context.read().runCommand( - command, - predefinedFormat: - showPredefinedFormats ? predefinedFormat : null, - isFirstRun: false, - ); - }, - ), - ); - }, - ); - } - BoxDecoration _getModalDecoration( BuildContext context, { required Color? color, @@ -406,119 +404,6 @@ class OverlayContent extends StatelessWidget { ); } - List _bottomActions( - BuildContext context, - bool showActionPopup, - bool hasSelection, - Color borderColor, - ) { - if (!showActionPopup) { - return []; - } - - if (editorState.isEmptyForContinueWriting()) { - final documentContext = editorState.document.root.context; - if (documentContext == null) { - return []; - } - final view = documentContext.read().state.view; - if (view.name.isEmpty) { - return []; - } - } - - return [ - // add one here to take into account the border of the main message box. - // It is configured to be on the outside to hide some graphical - // artifacts. - const VSpace(4.0 + 1.0), - Container( - padding: EdgeInsets.all(8.0), - constraints: BoxConstraints(minWidth: 240.0), - decoration: _getModalDecoration( - context, - color: Theme.of(context).colorScheme.surface, - borderColor: borderColor, - borderRadius: BorderRadius.all(Radius.circular(8.0)), - ), - child: IntrinsicWidth( - child: SeparatedColumn( - separatorBuilder: () => const VSpace(4.0), - crossAxisAlignment: CrossAxisAlignment.start, - children: _getCommands( - hasSelection: hasSelection, - ), - ), - ), - ), - ]; - } - - List _getCommands({required bool hasSelection}) { - if (hasSelection) { - return [ - _bottomButton(AiWriterCommand.improveWriting), - _bottomButton(AiWriterCommand.fixSpellingAndGrammar), - _bottomButton(AiWriterCommand.explain), - const Divider(height: 1.0, thickness: 1.0), - _bottomButton(AiWriterCommand.makeLonger), - _bottomButton(AiWriterCommand.makeShorter), - ]; - } else { - return [ - _bottomButton(AiWriterCommand.continueWriting), - ]; - } - } - - List _getSuggestedActions({ - required AiWriterCommand currentCommand, - required bool hasSelection, - }) { - if (hasSelection) { - return switch (currentCommand) { - AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ - SuggestionAction.keep, - SuggestionAction.discard, - SuggestionAction.rewrite, - ], - AiWriterCommand.explain => [ - SuggestionAction.insertBelow, - SuggestionAction.tryAgain, - SuggestionAction.close, - ], - AiWriterCommand.fixSpellingAndGrammar || - AiWriterCommand.improveWriting || - AiWriterCommand.makeShorter || - AiWriterCommand.makeLonger => - [ - SuggestionAction.accept, - SuggestionAction.discard, - SuggestionAction.insertBelow, - SuggestionAction.rewrite, - ], - }; - } else { - return switch (currentCommand) { - AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ - SuggestionAction.keep, - SuggestionAction.discard, - SuggestionAction.rewrite, - ], - AiWriterCommand.explain => [ - SuggestionAction.insertBelow, - SuggestionAction.tryAgain, - SuggestionAction.close, - ], - _ => [ - SuggestionAction.keep, - SuggestionAction.discard, - SuggestionAction.rewrite, - ], - }; - } - } - void _onSelectSuggestionAction( BuildContext context, SuggestionAction action, @@ -530,10 +415,33 @@ class OverlayContent extends StatelessWidget { predefinedFormat, ); } + + bool _isDocumentEmpty() { + if (widget.editorState.isEmptyForContinueWriting()) { + final documentContext = widget.editorState.document.root.context; + if (documentContext == null) { + return true; + } + final view = documentContext.read().state.view; + if (view.name.isEmpty) { + return true; + } + } + return false; + } } class MainContentArea extends StatelessWidget { - const MainContentArea({super.key}); + const MainContentArea({ + super.key, + required this.isInitialReadyState, + required this.isDocumentEmpty, + required this.showCommandsToggle, + }); + + final bool isInitialReadyState; + final bool isDocumentEmpty; + final ValueNotifier showCommandsToggle; @override Widget build(BuildContext context) { @@ -553,6 +461,18 @@ class MainContentArea extends StatelessWidget { ...sources, ]; }, + extraBottomActionButton: isInitialReadyState + ? ValueListenableBuilder( + valueListenable: showCommandsToggle, + builder: (context, value, _) { + return AiWriterPromptMoreButton( + isEnabled: !isDocumentEmpty, + isSelected: value, + onTap: () => showCommandsToggle.value = !value, + ); + }, + ) + : null, ); } if (state is GeneratingAiWriterState) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart index 1119917e62..168854a34d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart @@ -104,7 +104,7 @@ extension AiWriterNodeExtension on EditorState { start: Position(path: [0]), end: selection?.normalized.end ?? this.selection?.normalized.end ?? - Position(path: [0]), + Position(path: getLastSelectable()?.$1.path ?? [0]), ); // if the selected nodes are not entirely selected, slice the nodes diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/suggestion_action_bar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/suggestion_action_bar.dart deleted file mode 100644 index a16fc44641..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/suggestion_action_bar.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; - -import 'operations/ai_writer_entities.dart'; - -class SuggestionActionBar extends StatelessWidget { - const SuggestionActionBar({ - super.key, - required this.actions, - required this.onTap, - }); - - final List actions; - final void Function(SuggestionAction) onTap; - - @override - Widget build(BuildContext context) { - return SeparatedRow( - mainAxisSize: MainAxisSize.min, - separatorBuilder: () => const HSpace(4.0), - children: actions - .map( - (action) => SuggestionActionButton( - action: action, - onTap: () => onTap(action), - ), - ) - .toList(), - ); - } -} - -class SuggestionActionButton extends StatelessWidget { - const SuggestionActionButton({ - super.key, - required this.action, - required this.onTap, - }); - - final SuggestionAction action; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 28, - child: FlowyButton( - text: FlowyText( - action.i18n, - figmaLineHeight: 20, - ), - leftIcon: action.buildIcon(context), - iconPadding: 4.0, - margin: const EdgeInsets.symmetric( - horizontal: 6.0, - vertical: 4.0, - ), - onTap: onTap, - useIntrinsicWidth: true, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart new file mode 100644 index 0000000000..1e2641d796 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -0,0 +1,175 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../operations/ai_writer_entities.dart'; + +class AiWriterPromptMoreButton extends StatelessWidget { + const AiWriterPromptMoreButton({ + super.key, + required this.isEnabled, + required this.isSelected, + required this.onTap, + }); + + final bool isEnabled; + final bool isSelected; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: !isEnabled, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + isSelected: () => isSelected, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_sparks_s, + color: isEnabled + ? Theme.of(context).hintColor + : Theme.of(context).disabledColor, + ), + const HSpace(2.0), + FlowyText( + LocaleKeys.ai_more.tr(), + fontSize: 12, + figmaLineHeight: 16, + color: isEnabled + ? Theme.of(context).hintColor + : Theme.of(context).disabledColor, + ), + const HSpace(2.0), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class BottomCommandButtons extends StatelessWidget { + const BottomCommandButtons({ + super.key, + required this.hasSelection, + required this.editorState, + required this.onSelectCommand, + }); + + final EditorState editorState; + final bool hasSelection; + final void Function(AiWriterCommand) onSelectCommand; + + @override + Widget build(BuildContext context) { + return Container( + // add one here to take into account the border of the main message box. + // It is configured to be on the outside to hide some graphical + // artifacts. + margin: EdgeInsets.only(top: 4.0 + 1.0), + padding: EdgeInsets.all(8.0), + constraints: BoxConstraints(minWidth: 240.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? ColorSchemeConstants.lightBorderColor + : ColorSchemeConstants.darkBorderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + boxShadow: const [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 20, + color: Color(0x1A1F2329), + ), + ], + ), + child: IntrinsicWidth( + child: Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: _getCommands( + hasSelection: hasSelection, + ), + ), + ), + ); + } + + List _getCommands({required bool hasSelection}) { + if (hasSelection) { + return [ + _bottomButton(AiWriterCommand.improveWriting), + _bottomButton(AiWriterCommand.fixSpellingAndGrammar), + _bottomButton(AiWriterCommand.explain), + const Divider(height: 1.0, thickness: 1.0), + _bottomButton(AiWriterCommand.makeLonger), + _bottomButton(AiWriterCommand.makeShorter), + ]; + } else { + return [ + _bottomButton(AiWriterCommand.continueWriting), + ]; + } + } + + Widget _bottomButton(AiWriterCommand command) { + return Builder( + builder: (context) { + return FlowyButton( + leftIcon: FlowySvg( + command.icon, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: FlowyText( + command.i18n, + figmaLineHeight: 20, + ), + onTap: () { + final aiInputBloc = context.read(); + final showPredefinedFormats = + aiInputBloc.state.showPredefinedFormats; + final predefinedFormat = aiInputBloc.state.predefinedFormat; + + context.read().runCommand( + command, + predefinedFormat: + showPredefinedFormats ? predefinedFormat : null, + isFirstRun: false, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart new file mode 100644 index 0000000000..d39ede2608 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart @@ -0,0 +1,110 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../operations/ai_writer_entities.dart'; + +class SuggestionActionBar extends StatelessWidget { + const SuggestionActionBar({ + super.key, + required this.currentCommand, + required this.hasSelection, + required this.onTap, + }); + + final AiWriterCommand currentCommand; + final bool hasSelection; + final void Function(SuggestionAction) onTap; + + @override + Widget build(BuildContext context) { + return SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4.0), + children: _getSuggestedActions() + .map( + (action) => SuggestionActionButton( + action: action, + onTap: () => onTap(action), + ), + ) + .toList(), + ); + } + + List _getSuggestedActions() { + if (hasSelection) { + return switch (currentCommand) { + AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + AiWriterCommand.explain => [ + SuggestionAction.insertBelow, + SuggestionAction.tryAgain, + SuggestionAction.close, + ], + AiWriterCommand.fixSpellingAndGrammar || + AiWriterCommand.improveWriting || + AiWriterCommand.makeShorter || + AiWriterCommand.makeLonger => + [ + SuggestionAction.accept, + SuggestionAction.discard, + SuggestionAction.insertBelow, + SuggestionAction.rewrite, + ], + }; + } else { + return switch (currentCommand) { + AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + AiWriterCommand.explain => [ + SuggestionAction.insertBelow, + SuggestionAction.tryAgain, + SuggestionAction.close, + ], + _ => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + }; + } + } +} + +class SuggestionActionButton extends StatelessWidget { + const SuggestionActionButton({ + super.key, + required this.action, + required this.onTap, + }); + + final SuggestionAction action; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: FlowyButton( + text: FlowyText( + action.i18n, + figmaLineHeight: 20, + ), + leftIcon: action.buildIcon(context), + iconPadding: 4.0, + margin: const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 4.0, + ), + onTap: onTap, + useIntrinsicWidth: true, + ), + ); + } +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 55cc15d3ac..7d8cba8b13 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -3160,7 +3160,8 @@ "editing": "Editing", "analyzing": "Analyzing", "continueWritingEmptyDocumentTitle": "Continue writing error", - "continueWritingEmptyDocumentDescription": "We are having trouble expanding on the content in your document. Write a small intro and we can take it from there!" + "continueWritingEmptyDocumentDescription": "We are having trouble expanding on the content in your document. Write a small intro and we can take it from there!", + "more": "More" }, "autoUpdate": { "criticalUpdateTitle": "Update required to continue", From 10f19069c6877691fb50a9179de8d61f00d61ffa Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 20 Mar 2025 13:30:42 +0800 Subject: [PATCH 005/207] chore: implement ui --- .../lib/ai/service/appflowy_ai_service.dart | 44 +++++------ .../ai/operations/ai_writer_cubit.dart | 77 +++++++++++++++---- .../ai_writer_test/ai_writer_bloc_test.dart | 14 ++-- .../flowy-server/src/af_cloud/impls/chat.rs | 1 + 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index e3093b6bbf..18b1c71029 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -24,7 +24,8 @@ abstract class AIRepository { List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, }); @@ -40,18 +41,17 @@ class AppFlowyAIService implements AIRepository { List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { final stream = AppFlowyCompletionStream( onStart: onStart, - onProcess: onProcess, + processMessage: processMessage, + processAssistMessage: processAssistMessage, + processError: onError, onEnd: onEnd, - onError: onError, - onComment: (String text) async { - Log.info('Comment: $text'); - }, ); final records = history.map((record) => record.toPB()).toList(); @@ -82,26 +82,26 @@ class AppFlowyAIService implements AIRepository { abstract class CompletionStream { CompletionStream({ required this.onStart, - required this.onProcess, - required this.onComment, + required this.processMessage, + required this.processAssistMessage, + required this.processError, required this.onEnd, - required this.onError, }); final Future Function() onStart; - final Future Function(String text) onProcess; - final Future Function(String text) onComment; + final Future Function(String text) processMessage; + final Future Function(String text) processAssistMessage; + final void Function(AIError error) processError; final Future Function() onEnd; - final void Function(AIError error) onError; } class AppFlowyCompletionStream extends CompletionStream { AppFlowyCompletionStream({ required super.onStart, - required super.onProcess, - required super.onComment, + required super.processMessage, + required super.processAssistMessage, + required super.processError, required super.onEnd, - required super.onError, }) { _startListening(); } @@ -116,7 +116,7 @@ class AppFlowyCompletionStream extends CompletionStream { _subscription = _controller.stream.listen( (event) async { if (event == "AI_RESPONSE_LIMIT") { - onError( + processError( AIError( message: LocaleKeys.ai_textLimitReachedDescription.tr(), code: AIErrorCode.aiResponseLimitExceeded, @@ -125,7 +125,7 @@ class AppFlowyCompletionStream extends CompletionStream { } if (event == "AI_IMAGE_RESPONSE_LIMIT") { - onError( + processError( AIError( message: LocaleKeys.ai_imageLimitReachedDescription.tr(), code: AIErrorCode.aiImageResponseLimitExceeded, @@ -135,7 +135,7 @@ class AppFlowyCompletionStream extends CompletionStream { if (event.startsWith("AI_MAX_REQUIRED:")) { final msg = event.substring(16); - onError( + processError( AIError( message: msg, code: AIErrorCode.other, @@ -148,11 +148,11 @@ class AppFlowyCompletionStream extends CompletionStream { } if (event.startsWith("data:")) { - await onProcess(event.substring(5)); + await processMessage(event.substring(5)); } if (event.startsWith("comment:")) { - await onComment(event.substring(8)); + await processAssistMessage(event.substring(8)); } if (event.startsWith("finish:")) { @@ -160,7 +160,7 @@ class AppFlowyCompletionStream extends CompletionStream { } if (event.startsWith("error:")) { - onError( + processError( AIError(message: event.substring(6), code: AIErrorCode.other), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 133b0b5562..3521c2c63d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -293,7 +293,7 @@ class AiWriterCubit extends Cubit { AiWriterRecord.user(content: prompt), ); }, - onProcess: (text) async { + processMessage: (text) async { await _textRobot.appendMarkdownText( text, updateSelection: false, @@ -301,14 +301,33 @@ class AiWriterCubit extends Cubit { ); onAppendToDocument?.call(); }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, onEnd: () async { - await _textRobot.stop( - attributes: ApplySuggestionFormatType.replace.attributes, - ); - emit(ReadyAiWriterState(command, isFirstRun: false)); - records.add( - AiWriterRecord.ai(content: _textRobot.markdownText), - ); + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + } }, onError: (error) async { emit(ErrorAiWriterState(command, error: error)); @@ -392,7 +411,7 @@ class AiWriterCubit extends Cubit { ); _textRobot.start(position: position); }, - onProcess: (text) async { + processMessage: (text) async { await _textRobot.appendMarkdownText( text, updateSelection: false, @@ -400,12 +419,29 @@ class AiWriterCubit extends Cubit { ); onAppendToDocument?.call(); }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, onEnd: () async { - if (state case GeneratingAiWriterState _) { + if (state case final GeneratingAiWriterState generatingState) { await _textRobot.stop( attributes: ApplySuggestionFormatType.replace.attributes, ); - emit(ReadyAiWriterState(command, isFirstRun: false)); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); } records.add( AiWriterRecord.ai(content: _textRobot.markdownText), @@ -468,7 +504,7 @@ class AiWriterCubit extends Cubit { ); _textRobot.start(position: position); }, - onProcess: (text) async { + processMessage: (text) async { await _textRobot.appendMarkdownText( text, updateSelection: false, @@ -476,8 +512,19 @@ class AiWriterCubit extends Cubit { ); onAppendToDocument?.call(); }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, onEnd: () async { - if (state is GeneratingAiWriterState) { + if (state case final GeneratingAiWriterState generatingState) { await _textRobot.stop( attributes: ApplySuggestionFormatType.replace.attributes, ); @@ -485,6 +532,7 @@ class AiWriterCubit extends Cubit { ReadyAiWriterState( command, isFirstRun: false, + markdownText: generatingState.markdownText, ), ); records.add( @@ -524,7 +572,7 @@ class AiWriterCubit extends Cubit { completionType: command.toCompletionType(), history: records, onStart: () async {}, - onProcess: (text) async { + processMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { emit( GeneratingAiWriterState( @@ -535,6 +583,7 @@ class AiWriterCubit extends Cubit { ); } }, + processAssistMessage: (_) async {}, onEnd: () async { if (state case final GeneratingAiWriterState generatingState) { emit( diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart index 2abd4ffb97..8b674c0873 100644 --- a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -26,7 +26,7 @@ class _MockAIRepository extends Mock implements AppFlowyAIService { List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { @@ -37,7 +37,7 @@ class _MockAIRepository extends Mock implements AppFlowyAIService { final lines = text.split('\n'); for (final line in lines) { if (line.isNotEmpty) { - await onProcess('$_aiResponse $line\n\n'); + await processMessage('$_aiResponse $line\n\n'); } } await onEnd(); @@ -57,7 +57,7 @@ class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { @@ -66,7 +66,7 @@ class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { Future(() async { await onStart(); // only return 1 line. - await onProcess('Hello World'); + await processMessage('Hello World'); await onEnd(); }), ); @@ -84,7 +84,7 @@ class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { @@ -94,7 +94,7 @@ class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { await onStart(); // return 10 lines for (var i = 0; i < 10; i++) { - await onProcess('Hello World\n\n'); + await processMessage('Hello World\n\n'); } await onEnd(); }), @@ -113,7 +113,7 @@ class _MockErrorRepository extends Mock implements AppFlowyAIService { List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 5417f06a65..e4e468f46e 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -197,6 +197,7 @@ where .await .map_err(FlowyError::from)? .map_err(FlowyError::from); + Ok(stream.boxed()) } From db2270c8d813ed550cbb74437b1ae9e2d3d4b2cd Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 20 Mar 2025 15:13:14 +0800 Subject: [PATCH 006/207] chore: update local ai commit --- frontend/rust-lib/Cargo.lock | 4 ++-- frontend/rust-lib/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 6ec630d09d..a4c37c53f5 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ [[package]] name = "appflowy-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=76d7e7dc885b4d0bd9752aea7ff835de1d48de4e#76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8#1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" dependencies = [ "anyhow", "appflowy-plugin", @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "appflowy-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=76d7e7dc885b4d0bd9752aea7ff835de1d48de4e#76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8#1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index fd4b19f6ec..fee2d93f9e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,5 +152,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "76d7e7dc885b4d0bd9752aea7ff835de1d48de4e" } +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" } From 6fd250d4d1e6cc287c87a9b7b279d9560539e984 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 21 Mar 2025 10:04:51 +0800 Subject: [PATCH 007/207] chore: update client api --- frontend/rust-lib/Cargo.lock | 24 ++++++++++++------------ frontend/rust-lib/Cargo.toml | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index a4c37c53f5..530c0a60fd 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "bytes", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "again", "anyhow", @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "collab-entity", "collab-rt-entity", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "futures-channel", "futures-util", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "bincode", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "async-trait", @@ -1546,7 +1546,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "bincode", "bytes", @@ -2979,7 +2979,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -2994,7 +2994,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "app-error", "jsonwebtoken", @@ -3609,7 +3609,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "bytes", @@ -6176,7 +6176,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9587c0680f70a58d45c9bb012811bc702df5d213#9587c0680f70a58d45c9bb012811bc702df5d213" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index fee2d93f9e..365bd0ed45 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9587c0680f70a58d45c9bb012811bc702df5d213" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9587c0680f70a58d45c9bb012811bc702df5d213" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "1d06321789482dd31a44d515eefa06e00871d8ad" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "1d06321789482dd31a44d515eefa06e00871d8ad" } [profile.dev] opt-level = 0 From a5c0ad599861bd5ad81f1f87441a8c4cbdc31bde Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 21 Mar 2025 10:07:27 +0800 Subject: [PATCH 008/207] chore: fix test --- frontend/rust-lib/event-integration-test/src/chat_event.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/rust-lib/event-integration-test/src/chat_event.rs b/frontend/rust-lib/event-integration-test/src/chat_event.rs index f12f3d200a..36fdb411a0 100644 --- a/frontend/rust-lib/event-integration-test/src/chat_event.rs +++ b/frontend/rust-lib/event-integration-test/src/chat_event.rs @@ -101,6 +101,7 @@ impl EventIntegrationTest { rag_ids: vec![], format: None, history: vec![], + custom_prompt: None, }; EventBuilder::new(self.clone()) .event(AIEvent::CompleteText) From f1b2f51a06f2d86bf2dac77e8da9fa65f2200f0e Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 21 Mar 2025 10:20:47 +0800 Subject: [PATCH 009/207] chore: fix test --- frontend/appflowy_flutter/macos/Podfile.lock | 46 +++++++++---------- .../ai_writer_test/ai_writer_bloc_test.dart | 4 ++ 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index b4a1a3d20d..30ee626f09 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 - auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c - irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 - local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart index 8b674c0873..bffe27e985 100644 --- a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -27,6 +27,7 @@ class _MockAIRepository extends Mock implements AppFlowyAIService { required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { @@ -58,6 +59,7 @@ class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { @@ -85,6 +87,7 @@ class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { @@ -114,6 +117,7 @@ class _MockErrorRepository extends Mock implements AppFlowyAIService { required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, }) async { From 182101023be878d97acbad009f80eedc693ed97d Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 21 Mar 2025 10:37:10 +0800 Subject: [PATCH 010/207] chore: remove actions in explanation when not explain --- .../editor_plugins/ai/ai_writer_block_component.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 1e0bac83c5..b3e325fedb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -293,7 +293,8 @@ class _OverlayContentState extends State { ), ), ), - if (showSuggestionPopup) ...[ + if (showSuggestionPopup && + state.command == AiWriterCommand.explain) ...[ const VSpace(4.0), SuggestionActionBar( currentCommand: state.command, From c79d014305a5001d34d0ea9592b881faae1ab764 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 21 Mar 2025 10:49:41 +0800 Subject: [PATCH 011/207] chore: revert podfile changes and update error --- frontend/appflowy_flutter/macos/Podfile.lock | 46 ++++++++++---------- frontend/rust-lib/flowy-error/src/code.rs | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 30ee626f09..b4a1a3d20d 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 - appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 - auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 - connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 - file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 - irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c - window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index dad5f84e38..28ff1a5a20 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -357,7 +357,7 @@ pub enum ErrorCode { #[error("Requested namespace has one or more invalid characters")] CustomNamespaceInvalidCharacter = 122, - #[error("Requested namespace has one or more invalid characters")] + #[error("AI Service is unavailable")] AIServiceUnavailable = 123, #[error("AI Image Response limit exceeded")] From deb019aa4a5bc5907327f986522d77236eaab336 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:14:49 +0800 Subject: [PATCH 012/207] chore: don't allow double register two ai nodes --- .../editor_plugins/ai/operations/ai_writer_cubit.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 3521c2c63d..3b0e04e828 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -51,6 +51,10 @@ class AiWriterCubit extends Cubit { } void register(Node node) async { + if (aiWriterNode != null && node.id != aiWriterNode!.id) { + await removeAiWriterNode(editorState, node); + return; + } aiWriterNode = node; onCreateNode?.call(); From 87015f71332251110a1c92f98b31e7bfe19c609e Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 21 Mar 2025 11:46:58 +0800 Subject: [PATCH 013/207] chore: upgrade lai commit --- frontend/rust-lib/Cargo.lock | 4 ++-- frontend/rust-lib/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 530c0a60fd..60d65be5cc 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ [[package]] name = "appflowy-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8#1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=cd991507870d225e76df41164deee356cd9921a7#cd991507870d225e76df41164deee356cd9921a7" dependencies = [ "anyhow", "appflowy-plugin", @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "appflowy-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8#1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=cd991507870d225e76df41164deee356cd9921a7#cd991507870d225e76df41164deee356cd9921a7" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 365bd0ed45..515bd1d673 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,5 +152,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "1d5f16d07506dfc05a9fbca673b94d0a51a4a9f8" } +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "cd991507870d225e76df41164deee356cd9921a7" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "cd991507870d225e76df41164deee356cd9921a7" } From 05949d2f87f129b5a77014f1b9b167fc15f7502f Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 23 Mar 2025 21:53:05 +0800 Subject: [PATCH 014/207] chore: support switch ai model in chat or ai writer --- .../lib/ai/service/ai_input_control.dart | 138 ++++++++++++++ .../lib/ai/service/ai_prompt_input_bloc.dart | 74 +++----- .../lib/ai/service/select_model_bloc.dart | 82 +++++++++ .../desktop_prompt_text_field.dart | 170 +++++++++++++++++- .../application/ai_model_switch_listener.dart | 53 ++++++ .../lib/plugins/ai_chat/chat_page.dart | 1 + .../ai/ai_writer_block_component.dart | 4 + .../settings/ai/settings_ai_bloc.dart | 2 +- frontend/resources/translations/en.json | 4 +- frontend/rust-lib/Cargo.lock | 43 ++--- frontend/rust-lib/Cargo.toml | 4 +- .../flowy-codegen/src/ts_event/mod.rs | 4 +- .../event-integration-test/src/chat_event.rs | 27 +-- .../tests/chat/ai_tool_test.rs | 19 -- .../event-integration-test/tests/chat/mod.rs | 1 - frontend/rust-lib/flowy-ai-pub/Cargo.toml | 1 + frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 20 +++ frontend/rust-lib/flowy-ai/src/ai_manager.rs | 168 +++++++++++++++-- frontend/rust-lib/flowy-ai/src/chat.rs | 26 ++- frontend/rust-lib/flowy-ai/src/completion.rs | 26 ++- frontend/rust-lib/flowy-ai/src/entities.rs | 75 +++++++- .../rust-lib/flowy-ai/src/event_handler.rs | 43 +++-- frontend/rust-lib/flowy-ai/src/event_map.rs | 28 ++- frontend/rust-lib/flowy-ai/src/lib.rs | 1 + .../flowy-ai/src/local_ai/controller.rs | 11 ++ .../src/middleware/chat_service_mw.rs | 81 ++++++--- .../rust-lib/flowy-ai/src/notification.rs | 1 + frontend/rust-lib/flowy-ai/src/util.rs | 3 + frontend/rust-lib/flowy-core/Cargo.toml | 16 +- .../src/deps_resolve/cloud_service_impl.rs | 16 +- .../flowy-server/src/af_cloud/impls/chat.rs | 23 ++- .../rust-lib/flowy-server/src/default_impl.rs | 8 +- 32 files changed, 938 insertions(+), 235 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart create mode 100644 frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart delete mode 100644 frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs create mode 100644 frontend/rust-lib/flowy-ai/src/util.rs diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart new file mode 100644 index 0000000000..942c27a3c9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart @@ -0,0 +1,138 @@ +import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AIModelStateNotifier { + AIModelStateNotifier({required this.objectId}) + : _isDesktop = UniversalPlatform.isDesktop, + _localAIListener = + UniversalPlatform.isDesktop ? LocalAIStateListener() : null, + _aiModelSwitchListener = AIModelSwitchListener(chatId: objectId); + + final String objectId; + final bool _isDesktop; + final LocalAIStateListener? _localAIListener; + final AIModelSwitchListener _aiModelSwitchListener; + LocalAIPB? _localAIState; + AvailableModelsPB? _availableModels; + + // callbacks + void Function(AiType, bool, String)? onChanged; + void Function(AvailableModelsPB)? onAvailableModelsChanged; + String hintText() { + final aiType = getCurrentAiType(); + if (aiType.isLocal) { + return isEditable() + ? LocaleKeys.chat_inputLocalAIMessageHint.tr() + : LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(); + } + return LocaleKeys.chat_inputMessageHint.tr(); + } + + AiType getCurrentAiType() { + // On non-desktop platforms, always return cloud type + if (!_isDesktop) { + return AiType.cloud; + } + + return _availableModels?.selectedModel.isLocal == true + ? AiType.local + : AiType.cloud; + } + + bool isEditable() { + // On non-desktop platforms, always editable (cloud-only) + if (!_isDesktop) { + return true; + } + + return getCurrentAiType().isLocal + ? _localAIState?.state == RunningStatePB.Running + : true; + } + + void _notifyStateChanged() { + onChanged?.call(getCurrentAiType(), isEditable(), hintText()); + } + + Future init() async { + await _loadAvailableModels(); + } + + Future _loadAvailableModels() async { + final payload = AvailableModelsQueryPB(source: objectId); + final result = await AIEventGetAvailableModels(payload).send(); + result.fold( + (models) { + _availableModels = models; + onAvailableModelsChanged?.call(models); + _notifyStateChanged(); + }, + (err) { + Log.error("Failed to get available models: $err"); + }, + ); + } + + void startListening({ + void Function(AiType, bool, String)? onChanged, + void Function(AvailableModelsPB)? onAvailableModelsChanged, + }) { + this.onChanged = onChanged; + this.onAvailableModelsChanged = onAvailableModelsChanged; + + // Only start local AI listener on desktop platforms + if (_isDesktop) { + _localAIListener?.start( + stateCallback: (state) { + _localAIState = state; + + if (state.state == RunningStatePB.Running || + state.state == RunningStatePB.Stopped) { + _loadAvailableModels(); + } + }, + ); + } + + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) { + if (_availableModels != null) { + final updatedModels = _availableModels!.deepCopy() + ..selectedModel = model; + _availableModels = updatedModels; + onAvailableModelsChanged?.call(updatedModels); + } + + if (model.isLocal && _isDesktop) { + AIEventGetLocalAIState().send().fold( + (localAIState) { + _localAIState = localAIState; + _notifyStateChanged(); + }, + (error) { + Log.error("Failed to get local AI state: $error"); + _notifyStateChanged(); + }, + ); + } else { + _notifyStateChanged(); + } + }, + ); + } + + Future stop() async { + onChanged = null; + await _localAIListener?.stop(); + await _aiModelSwitchListener.stop(); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart index 178265b20a..e2acda9a1e 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart @@ -1,14 +1,8 @@ import 'dart:async'; -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/ai/service/ai_input_control.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -18,19 +12,19 @@ part 'ai_prompt_input_bloc.freezed.dart'; class AIPromptInputBloc extends Bloc { AIPromptInputBloc({ + required String objectId, required PredefinedFormat? predefinedFormat, - }) : _listener = LocalAIStateListener(), - super(AIPromptInputState.initial(predefinedFormat)) { + }) : _aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), + super(AIPromptInputState.initial(objectId, predefinedFormat)) { _dispatch(); - _startListening(); _init(); } - final LocalAIStateListener _listener; + final AIModelStateNotifier _aiModelStateNotifier; @override Future close() async { - await _listener.stop(); + await _aiModelStateNotifier.stop(); return super.close(); } @@ -38,29 +32,11 @@ class AIPromptInputBloc extends Bloc { on( (event, emit) { event.when( - updateAIState: (localAIState) { - final aiType = localAIState.enabled ? AiType.local : AiType.cloud; - // final supportChatWithFile = - // aiType.isLocal && localAIState.state == RunningStatePB.Running; - // If local ai is enabled, user can only send messages when the AI is running - final editable = localAIState.enabled - ? localAIState.state == RunningStatePB.Running - : true; - - var hintText = aiType.isLocal - ? LocaleKeys.chat_inputLocalAIMessageHint.tr() - : LocaleKeys.chat_inputMessageHint.tr(); - - if (editable == false && aiType.isLocal) { - hintText = - LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(); - } - + updateAIState: (aiType, editable, hintText) { emit( state.copyWith( aiType: aiType, supportChatWithFile: false, - localAIState: localAIState, editable: editable, hintText: hintText, ), @@ -127,25 +103,16 @@ class AIPromptInputBloc extends Bloc { ); } - void _startListening() { - _listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(AIPromptInputEvent.updateAIState(pluginState)); - } - }, - ); - } - void _init() { - AIEventGetLocalAIState().send().fold( - (localAIState) { + _aiModelStateNotifier.startListening( + onChanged: (aiType, editable, hintText) { if (!isClosed) { - add(AIPromptInputEvent.updateAIState(localAIState)); + add(AIPromptInputEvent.updateAIState(aiType, editable, hintText)); } }, - Log.error, ); + + _aiModelStateNotifier.init(); } Map consumeMetadata() { @@ -164,8 +131,12 @@ class AIPromptInputBloc extends Bloc { @freezed class AIPromptInputEvent with _$AIPromptInputEvent { - const factory AIPromptInputEvent.updateAIState(LocalAIPB localAIState) = - _UpdateAIState; + const factory AIPromptInputEvent.updateAIState( + AiType aiType, + bool editable, + String hintText, + ) = _UpdateAIState; + const factory AIPromptInputEvent.toggleShowPredefinedFormat() = _ToggleShowPredefinedFormat; const factory AIPromptInputEvent.updatePredefinedFormat( @@ -184,24 +155,27 @@ class AIPromptInputEvent with _$AIPromptInputEvent { @freezed class AIPromptInputState with _$AIPromptInputState { const factory AIPromptInputState({ + required String objectId, required AiType aiType, required bool supportChatWithFile, required bool showPredefinedFormats, required PredefinedFormat? predefinedFormat, - required LocalAIPB? localAIState, required List attachedFiles, required List mentionedPages, required bool editable, required String hintText, }) = _AIPromptInputState; - factory AIPromptInputState.initial(PredefinedFormat? format) => + factory AIPromptInputState.initial( + String objectId, + PredefinedFormat? format, + ) => AIPromptInputState( + objectId: objectId, aiType: AiType.cloud, supportChatWithFile: false, showPredefinedFormats: format != null, predefinedFormat: format, - localAIState: null, attachedFiles: [], mentionedPages: [], editable: true, diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart new file mode 100644 index 0000000000..665533bd40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:appflowy/ai/service/ai_input_control.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'select_model_bloc.freezed.dart'; + +class SelectModelBloc extends Bloc { + SelectModelBloc({ + required this.objectId, + }) : _aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), + super(const SelectModelState()) { + _aiModelStateNotifier.init(); + _aiModelStateNotifier.startListening( + onAvailableModelsChanged: (models) { + if (!isClosed) { + add(SelectModelEvent.didLoadModels(models)); + } + }, + ); + + on( + (event, emit) async { + await event.when( + selectModel: (AIModelPB model) async { + await AIEventUpdateSelectedModel( + UpdateSelectedModelPB( + source: objectId, + selectedModel: model, + ), + ).send(); + + state.availableModels?.freeze(); + final newAvailableModels = state.availableModels?.rebuild((m) { + m.selectedModel = model; + }); + + emit( + state.copyWith( + availableModels: newAvailableModels, + ), + ); + }, + didLoadModels: (AvailableModelsPB models) { + emit(state.copyWith(availableModels: models)); + }, + ); + }, + ); + } + + final String objectId; + final AIModelStateNotifier _aiModelStateNotifier; + + @override + Future close() async { + await _aiModelStateNotifier.stop(); + await super.close(); + } +} + +@freezed +class SelectModelEvent with _$SelectModelEvent { + const factory SelectModelEvent.selectModel( + AIModelPB model, + ) = _SelectModel; + + const factory SelectModelEvent.didLoadModels( + AvailableModelsPB models, + ) = _DidLoadModels; +} + +@freezed +class SelectModelState with _$SelectModelState { + const factory SelectModelState({ + AvailableModelsPB? availableModels, + }) = _SelectModelState; +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index c446e30f60..2cd74b5944 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -1,14 +1,17 @@ import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/ai/service/select_model_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:extended_text_field/extended_text_field.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -165,6 +168,7 @@ class _DesktopPromptInputState extends State { top: null, child: TextFieldTapRegion( child: _PromptBottomActions( + objectId: state.objectId, showPredefinedFormats: state.showPredefinedFormats, onTogglePredefinedFormatSection: () => @@ -561,6 +565,7 @@ class PromptInputTextField extends StatelessWidget { class _PromptBottomActions extends StatelessWidget { const _PromptBottomActions({ + required this.objectId, required this.sendButtonState, required this.showPredefinedFormats, required this.onTogglePredefinedFormatSection, @@ -572,6 +577,7 @@ class _PromptBottomActions extends StatelessWidget { this.extraBottomActionButton, }); + final String objectId; final bool showPredefinedFormats; final void Function() onTogglePredefinedFormatSection; final void Function() onStartMention; @@ -589,15 +595,10 @@ class _PromptBottomActions extends StatelessWidget { margin: DesktopAIChatSizes.inputActionBarMargin, child: BlocBuilder( builder: (context, state) { - if (state.localAIState == null) { - return Align( - alignment: AlignmentDirectional.centerEnd, - child: _sendButton(), - ); - } return Row( children: [ _predefinedFormatButton(), + SelectModelButton(objectId: objectId), const Spacer(), if (state.aiType.isCloud) ...[ _selectSourcesButton(context), @@ -683,3 +684,160 @@ class _PromptBottomActions extends StatelessWidget { ); } } + +class SelectModelButton extends StatefulWidget { + const SelectModelButton({ + super.key, + required this.objectId, + }); + + final String objectId; + + @override + State createState() => _SelectModelButtonState(); +} + +class _SelectModelButtonState extends State { + final popoverController = PopoverController(); + late SelectModelBloc bloc; + + @override + void initState() { + super.initState(); + bloc = SelectModelBloc(objectId: widget.objectId); + } + + @override + void dispose() { + popoverController.close(); + bloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + // constraints: BoxConstraints.loose(const Size(250, 200)), + offset: const Offset(0.0, -10.0), + direction: PopoverDirection.topWithLeftAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () {}, + onClose: () {}, + popupBuilder: (_) { + return BlocProvider.value( + value: bloc, + child: _PopoverSelectModel( + onClose: () => popoverController.close(), + ), + ); + }, + child: _CurrentModelButton( + modelName: state.availableModels?.selectedModel.name ?? "", + onTap: () => popoverController.show(), + ), + ); + }, + ), + ); + } +} + +class _PopoverSelectModel extends StatelessWidget { + const _PopoverSelectModel({ + required this.onClose, + }); + + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ListView.builder( + shrinkWrap: true, + itemCount: state.availableModels?.models.length ?? 0, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), + itemBuilder: (context, index) { + return _ModelItem( + model: state.availableModels!.models[index], + onTap: () { + context.read().add( + SelectModelEvent.selectModel( + state.availableModels!.models[index], + ), + ); + onClose(); + }, + ); + }, + ); + }, + ); + } +} + +class _ModelItem extends StatelessWidget { + const _ModelItem({ + required this.model, + required this.onTap, + }); + + final AIModelPB model; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + var modelName = model.name; + if (model.isLocal) { + modelName += " (${LocaleKeys.chat_changeFormat_localModel.tr()})"; + } + return FlowyTextButton( + modelName, + fillColor: Colors.transparent, + onPressed: onTap, + ); + } +} + +class _CurrentModelButton extends StatelessWidget { + const _CurrentModelButton({ + required this.modelName, + required this.onTap, + }); + + final String modelName; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_changeFormat_switchModel.tr(), + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), + child: FlowyText( + modelName, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart new file mode 100644 index 0000000000..f25ea4deca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/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'; + +typedef OnUpdateSelectedModel = void Function(AIModelPB model); + +class AIModelSwitchListener { + AIModelSwitchListener({required this.chatId}) { + _parser = ChatNotificationParser(id: chatId, callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + final String chatId; + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + void start({ + OnUpdateSelectedModel? onUpdateSelectedModel, + }) { + this.onUpdateSelectedModel = onUpdateSelectedModel; + } + + OnUpdateSelectedModel? onUpdateSelectedModel; + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.DidUpdateSelectedModel: + onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r)); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} 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 ce84f923d2..a666039ef2 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -73,6 +73,7 @@ class AIChatPage extends StatelessWidget { /// [AIPromptInputBloc] is used to handle the user prompt BlocProvider( create: (_) => AIPromptInputBloc( + objectId: view.id, predefinedFormat: PredefinedFormat( imageFormat: ImageFormat.text, textFormat: TextFormat.bulletList, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index b3e325fedb..1d25723854 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -2,6 +2,7 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -124,9 +125,12 @@ class _AIWriterBlockComponentState extends State { return const SizedBox.shrink(); } + final documentId = context.read()?.documentId; + return BlocProvider( create: (_) => AIPromptInputBloc( predefinedFormat: null, + objectId: documentId ?? editorState.document.root.id, ), child: LayoutBuilder( builder: (context, constraints) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index ec6970ab7b..74a61fe96c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -173,7 +173,7 @@ class SettingsAIBloc extends Bloc { } void _loadModelList() { - AIEventGetAvailableModels().send().then((result) { + AIEventGetServerAvailableModels().send().then((result) { result.fold((config) { if (!isClosed) { add(SettingsAIEvent.didLoadAvailableModels(config.models)); diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 7d8cba8b13..7c215e83f9 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -247,6 +247,8 @@ "table": "Table", "blankDescription": "Format response", "defaultDescription": "Auto mode", + "localModel": "Local Model", + "switchModel": "Switch model", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", @@ -3191,4 +3193,4 @@ "rewrite": "Rewrite", "insertBelow": "Insert below" } -} +} \ No newline at end of file diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 60d65be5cc..4e1c67233d 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "bytes", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "again", "anyhow", @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "collab-entity", "collab-rt-entity", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "futures-channel", "futures-util", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "bincode", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "async-trait", @@ -1398,7 +1398,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1546,7 +1546,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "bincode", "bytes", @@ -2072,6 +2072,7 @@ dependencies = [ "flowy-error", "futures", "lib-infra", + "serde", "serde_json", ] @@ -2979,7 +2980,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -2994,7 +2995,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "app-error", "jsonwebtoken", @@ -3609,7 +3610,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "bytes", @@ -4646,7 +4647,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -4666,7 +4667,6 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -4734,19 +4734,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", - "proc-macro2", - "quote", - "syn 2.0.94", -] - [[package]] name = "phf_shared" version = "0.8.0" @@ -6176,7 +6163,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=1d06321789482dd31a44d515eefa06e00871d8ad#1d06321789482dd31a44d515eefa06e00871d8ad" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 515bd1d673..6b524c41fd 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "1d06321789482dd31a44d515eefa06e00871d8ad" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "1d06321789482dd31a44d515eefa06e00871d8ad" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d77dacae32bc25440ca61f675900b80fba6cc9a2" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d77dacae32bc25440ca61f675900b80fba6cc9a2" } [profile.dev] opt-level = 0 diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs index ff51ff952b..da8c05f360 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs @@ -153,8 +153,8 @@ pub fn parse_event_crate(event_crate: &TsEventCrate) -> Vec { attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) - .enumerate() - .map(|(_index, variant)| EventASTContext::from(&variant.attrs)) + .into_iter() + .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, _ => vec![], diff --git a/frontend/rust-lib/event-integration-test/src/chat_event.rs b/frontend/rust-lib/event-integration-test/src/chat_event.rs index 36fdb411a0..c8c638bc85 100644 --- a/frontend/rust-lib/event-integration-test/src/chat_event.rs +++ b/frontend/rust-lib/event-integration-test/src/chat_event.rs @@ -1,8 +1,8 @@ use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; use flowy_ai::entities::{ - ChatMessageListPB, ChatMessageTypePB, CompleteTextPB, CompleteTextTaskPB, CompletionTypePB, - LoadNextChatMessagePB, LoadPrevChatMessagePB, SendChatPayloadPB, + ChatMessageListPB, ChatMessageTypePB, LoadNextChatMessagePB, LoadPrevChatMessagePB, + SendChatPayloadPB, }; use flowy_ai::event_map::AIEvent; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; @@ -87,27 +87,4 @@ impl EventIntegrationTest { .await .parse::() } - - pub async fn complete_text( - &self, - text: &str, - completion_type: CompletionTypePB, - ) -> CompleteTextTaskPB { - let payload = CompleteTextPB { - text: text.to_string(), - completion_type, - stream_port: 0, - object_id: "".to_string(), - rag_ids: vec![], - format: None, - history: vec![], - custom_prompt: None, - }; - EventBuilder::new(self.clone()) - .event(AIEvent::CompleteText) - .payload(payload) - .async_send() - .await - .parse::() - } } diff --git a/frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs deleted file mode 100644 index f8c2f08b50..0000000000 --- a/frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs +++ /dev/null @@ -1,19 +0,0 @@ -use event_integration_test::user_event::use_localhost_af_cloud; -use event_integration_test::EventIntegrationTest; -use flowy_ai::entities::CompletionTypePB; - -use std::time::Duration; - -#[tokio::test] -async fn af_cloud_complete_text_test() { - use_localhost_af_cloud().await; - let test = EventIntegrationTest::new().await; - test.af_cloud_sign_up().await; - - let _workspace_id = test.get_current_workspace().await.id; - let _task = test - .complete_text("hello world", CompletionTypePB::MakeLonger) - .await; - - tokio::time::sleep(Duration::from_secs(6)).await; -} diff --git a/frontend/rust-lib/event-integration-test/tests/chat/mod.rs b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs index 21c16131e9..773bdab81f 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs @@ -1,2 +1 @@ -mod ai_tool_test; mod chat_message_test; diff --git a/frontend/rust-lib/flowy-ai-pub/Cargo.toml b/frontend/rust-lib/flowy-ai-pub/Cargo.toml index 3afd04d530..09ca8dc2d4 100644 --- a/frontend/rust-lib/flowy-ai-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-ai-pub/Cargo.toml @@ -12,3 +12,4 @@ client-api = { workspace = true } bytes.workspace = true futures.workspace = true serde_json.workspace = true +serde.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index d0107831ee..4639a93501 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -14,6 +14,7 @@ pub use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; use flowy_error::FlowyError; use futures::stream::BoxStream; use lib_infra::async_trait::async_trait; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; @@ -21,6 +22,22 @@ use std::path::Path; pub type ChatMessageStream = BoxStream<'static, Result>; pub type StreamAnswer = BoxStream<'static, Result>; pub type StreamComplete = BoxStream<'static, Result>; + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)] +pub struct AIModel { + pub name: String, + pub is_local: bool, +} + +impl Default for AIModel { + fn default() -> Self { + Self { + name: "default".to_string(), + is_local: false, + } + } +} + #[async_trait] pub trait ChatCloudService: Send + Sync + 'static { async fn create_chat( @@ -55,6 +72,7 @@ pub trait ChatCloudService: Send + Sync + 'static { chat_id: &str, message_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result; async fn get_answer( @@ -90,6 +108,7 @@ pub trait ChatCloudService: Send + Sync + 'static { &self, workspace_id: &str, params: CompleteTextParams, + ai_model: Option, ) -> Result; async fn embed_file( @@ -121,4 +140,5 @@ pub trait ChatCloudService: Send + Sync + 'static { ) -> Result<(), FlowyError>; async fn get_available_models(&self, workspace_id: &str) -> Result; + async fn get_workspace_default_model(&self, workspace_id: &str) -> Result; } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 96301a07b0..535a3bf8c8 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -1,7 +1,7 @@ use crate::chat::Chat; use crate::entities::{ - ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, FilePB, PredefinedFormatPB, - RepeatedRelatedQuestionPB, StreamMessageParams, + AIModelPB, AvailableModelsPB, ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, + FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, }; use crate::local_ai::controller::LocalAIController; use crate::middleware::chat_service_mw::AICloudServiceMiddleware; @@ -10,12 +10,13 @@ use std::collections::HashMap; use appflowy_plugin::manager::PluginManager; use dashmap::DashMap; -use flowy_ai_pub::cloud::{ChatCloudService, ChatSettings, ModelList, UpdateChatParams}; +use flowy_ai_pub::cloud::{AIModel, ChatCloudService, ChatSettings, UpdateChatParams}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; use crate::notification::{chat_notification_builder, ChatNotification}; +use crate::util::ai_available_models_key; use collab_integrate::persistence::collab_metadata_sql::{ batch_insert_collab_metadata, batch_select_collab_metadata, AFCollabMetadata, }; @@ -24,6 +25,7 @@ use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; use std::path::PathBuf; use std::sync::{Arc, Weak}; +use tokio::sync::RwLock; use tracing::{error, info, trace}; pub trait AIUserService: Send + Sync + 'static { @@ -59,7 +61,8 @@ pub struct AIManager { pub external_service: Arc, chats: Arc>>, pub local_ai: Arc, - store_preferences: Arc, + pub store_preferences: Arc, + server_models: Arc>>, } impl AIManager { @@ -99,6 +102,7 @@ impl AIManager { local_ai, external_service, store_preferences, + server_models: Arc::new(Default::default()), } } @@ -114,6 +118,7 @@ impl AIManager { chat_id.to_string(), self.user_service.clone(), self.cloud_service_wm.clone(), + self.store_preferences.clone(), )) }); if self.local_ai.is_running() { @@ -205,24 +210,25 @@ impl AIManager { save_chat(self.user_service.sqlite_connection(*uid)?, chat_id)?; let chat = Arc::new(Chat::new( - self.user_service.user_id().unwrap(), + self.user_service.user_id()?, chat_id.to_string(), self.user_service.clone(), self.cloud_service_wm.clone(), + self.store_preferences.clone(), )); self.chats.insert(chat_id.to_string(), chat.clone()); Ok(chat) } - pub async fn stream_chat_message<'a>( - &'a self, - params: &'a StreamMessageParams<'a>, + pub async fn stream_chat_message( + &self, + params: StreamMessageParams, ) -> Result { - let chat = self.get_or_create_chat_instance(params.chat_id).await?; - let question = chat.stream_chat_message(params).await?; + let chat = self.get_or_create_chat_instance(¶ms.chat_id).await?; + let question = chat.stream_chat_message(¶ms).await?; let _ = self .external_service - .notify_did_send_message(params.chat_id, params.message) + .notify_did_send_message(¶ms.chat_id, ¶ms.message) .await; Ok(question) } @@ -238,19 +244,149 @@ impl AIManager { let question_message_id = chat .get_question_id_from_answer_id(answer_message_id) .await?; + + let preferred_model = self + .store_preferences + .get_object::(&ai_available_models_key(chat_id)); chat - .stream_regenerate_response(question_message_id, answer_stream_port, format) + .stream_regenerate_response( + question_message_id, + answer_stream_port, + format, + preferred_model, + ) .await?; Ok(()) } - pub async fn get_available_models(&self) -> FlowyResult { + pub async fn get_workspace_select_model(&self) -> FlowyResult { let workspace_id = self.user_service.workspace_id()?; + let model = self + .cloud_service_wm + .get_workspace_default_model(&workspace_id) + .await?; + Ok(model) + } + + pub async fn get_server_available_models(&self) -> FlowyResult> { + let workspace_id = self.user_service.workspace_id()?; + + // First, try reading from the cache. + { + let cached_models = self.server_models.read().await; + if !cached_models.is_empty() { + return Ok(cached_models.clone()); + } + } + + // Cache miss: fetch from the cloud. let list = self .cloud_service_wm .get_available_models(&workspace_id) .await?; - Ok(list) + let models = list + .models + .into_iter() + .map(|m| m.name) + .collect::>(); + + // Update the cache. + *self.server_models.write().await = models.clone(); + Ok(models) + } + + pub async fn update_selected_model(&self, source: String, model: AIModelPB) -> FlowyResult<()> { + let source_key = ai_available_models_key(&source); + self + .store_preferences + .set_object::(&source_key, &model.clone().into())?; + + chat_notification_builder(&source, ChatNotification::DidUpdateSelectedModel) + .payload(model) + .send(); + Ok(()) + } + + pub async fn get_available_models(&self, source: String) -> FlowyResult { + // Build the models list from server models and mark them as non-local. + let mut models: Vec = self + .get_server_available_models() + .await? + .into_iter() + .map(|name| AIModelPB { + name, + is_local: false, + }) + .collect(); + + // Optionally add the local plugin model. + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + models.push(AIModelPB { + name: local_model, + is_local: true, + }); + } + + if models.is_empty() { + return Ok(AvailableModelsPB { + models, + selected_model: None, + }); + } + + let source_key = ai_available_models_key(&source); + + // Retrieve stored selected model, if any. + let stored_selected = self.store_preferences.get_object::(&source_key); + + // Get workspace default model once. + let workspace_default = self.get_workspace_select_model().await.ok(); + + // Determine the effective selected model. + let effective_selected = stored_selected.unwrap_or_else(|| { + if let Some(ws_name) = workspace_default.clone() { + let model = AIModel { + name: ws_name, + is_local: false, + }; + // Store the default if not present. + let _ = self.store_preferences.set_object(&source_key, &model); + model + } else { + AIModel::default() + } + }); + + // Find a matching model in the available list. + let used_model = models + .iter() + .find(|m| m.name == effective_selected.name) + .cloned() + .or_else(|| { + // If no match, try to use the workspace default if available. + if let Some(ws_name) = workspace_default { + Some(AIModelPB { + name: ws_name, + is_local: false, + }) + } else { + models.first().cloned() + } + }); + + // Update the stored preference if a different model is used. + if let Some(ref used) = used_model { + if used.name != effective_selected.name { + self + .store_preferences + .set_object::(&source_key, &AIModel::from(used.clone()))?; + } + } + + Ok(AvailableModelsPB { + models, + selected_model: used_model, + }) } pub async fn get_or_create_chat_instance(&self, chat_id: &str) -> Result, FlowyError> { @@ -258,10 +394,11 @@ impl AIManager { match chat { None => { let chat = Arc::new(Chat::new( - self.user_service.user_id().unwrap(), + self.user_service.user_id()?, chat_id.to_string(), self.user_service.clone(), self.cloud_service_wm.clone(), + self.store_preferences.clone(), )); self.chats.insert(chat_id.to_string(), chat.clone()); Ok(chat) @@ -363,7 +500,6 @@ impl AIManager { pub async fn update_rag_ids(&self, chat_id: &str, rag_ids: Vec) -> FlowyResult<()> { info!("[Chat] update chat:{} rag ids: {:?}", chat_id, rag_ids); - let workspace_id = self.user_service.workspace_id()?; let update_setting = UpdateChatParams { name: None, diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index 2ed7a584d0..ea553758df 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -10,11 +10,13 @@ use crate::persistence::{ ChatMessageTable, }; use crate::stream_message::StreamMessage; +use crate::util::ai_available_models_key; use allo_isolate::Isolate; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, + AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, }; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; @@ -39,6 +41,7 @@ pub struct Chat { latest_message_id: Arc, stop_stream: Arc, stream_buffer: Arc>, + store_preferences: Arc, } impl Chat { @@ -47,6 +50,7 @@ impl Chat { chat_id: String, user_service: Arc, chat_service: Arc, + store_preferences: Arc, ) -> Chat { Chat { uid, @@ -57,6 +61,7 @@ impl Chat { latest_message_id: Default::default(), stop_stream: Arc::new(AtomicBool::new(false)), stream_buffer: Arc::new(Mutex::new(StringBuffer::default())), + store_preferences, } } @@ -81,9 +86,9 @@ impl Chat { } #[instrument(level = "info", skip_all, err)] - pub async fn stream_chat_message<'a>( - &'a self, - params: &'a StreamMessageParams<'a>, + pub async fn stream_chat_message( + &self, + params: &StreamMessageParams, ) -> Result { trace!( "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, metadata={:?}, format={:?}", @@ -113,7 +118,7 @@ impl Chat { .create_question( &workspace_id, &self.chat_id, - params.message, + ¶ms.message, params.message_type.clone(), &[], ) @@ -138,7 +143,9 @@ impl Chat { // Save message to disk save_and_notify_message(uid, &self.chat_id, &self.user_service, question.clone())?; let format = params.format.clone().map(Into::into).unwrap_or_default(); - + let preferred_ai_model = self + .store_preferences + .get_object::(&ai_available_models_key(&self.chat_id)); self.stream_response( params.answer_stream_port, answer_stream_buffer, @@ -146,6 +153,7 @@ impl Chat { workspace_id, question.message_id, format, + preferred_ai_model, ); let question_pb = ChatMessagePB::from(question); @@ -158,6 +166,7 @@ impl Chat { question_id: i64, answer_stream_port: i64, format: Option, + ai_model: Option, ) -> FlowyResult<()> { trace!( "[Chat] regenerate and stream chat message: chat_id={}", @@ -183,11 +192,13 @@ impl Chat { workspace_id, question_id, format, + ai_model, ); Ok(()) } + #[allow(clippy::too_many_arguments)] fn stream_response( &self, answer_stream_port: i64, @@ -196,6 +207,7 @@ impl Chat { workspace_id: String, question_id: i64, format: ResponseFormat, + ai_model: Option, ) { let stop_stream = self.stop_stream.clone(); let chat_id = self.chat_id.clone(); @@ -204,7 +216,7 @@ impl Chat { tokio::spawn(async move { let mut answer_sink = IsolateSink::new(Isolate::new(answer_stream_port)); match cloud_service - .stream_answer(&workspace_id, &chat_id, question_id, format) + .stream_answer(&workspace_id, &chat_id, question_id, format, ai_model) .await { Ok(mut stream) => { diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index fa79308b4f..38dea5f5e4 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -4,14 +4,16 @@ use allo_isolate::Isolate; use dashmap::DashMap; use flowy_ai_pub::cloud::{ - ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionStreamValue, CompletionType, - CustomPrompt, + AIModel, ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionStreamValue, + CompletionType, CustomPrompt, }; use flowy_error::{FlowyError, FlowyResult}; use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; +use crate::util::ai_available_models_key; +use flowy_sqlite::kv::KVStorePreferences; use std::sync::{Arc, Weak}; use tokio::select; use tracing::info; @@ -20,17 +22,20 @@ pub struct AICompletion { tasks: Arc>>, cloud_service: Weak, user_service: Weak, + store_preferences: Arc, } impl AICompletion { pub fn new( cloud_service: Weak, user_service: Weak, + store_preferences: Arc, ) -> Self { Self { tasks: Arc::new(DashMap::new()), cloud_service, user_service, + store_preferences, } } @@ -53,7 +58,17 @@ impl AICompletion { .ok_or_else(FlowyError::internal)? .workspace_id()?; let (tx, rx) = tokio::sync::mpsc::channel(1); - let task = CompletionTask::new(workspace_id, complete, self.cloud_service.clone(), rx); + let preferred_model = self + .store_preferences + .get_object::(&ai_available_models_key(&complete.object_id)); + + let task = CompletionTask::new( + workspace_id, + complete, + preferred_model, + self.cloud_service.clone(), + rx, + ); let task_id = task.task_id.clone(); self.tasks.insert(task_id.clone(), tx); @@ -74,12 +89,14 @@ pub struct CompletionTask { stop_rx: tokio::sync::mpsc::Receiver<()>, context: CompleteTextPB, cloud_service: Weak, + preferred_model: Option, } impl CompletionTask { pub fn new( workspace_id: String, context: CompleteTextPB, + preferred_model: Option, cloud_service: Weak, stop_rx: tokio::sync::mpsc::Receiver<()>, ) -> Self { @@ -89,6 +106,7 @@ impl CompletionTask { context, cloud_service, stop_rx, + preferred_model, } } @@ -129,7 +147,7 @@ impl CompletionTask { info!("start completion: {:?}", params); match cloud_service - .stream_complete(&self.workspace_id, params) + .stream_complete(&self.workspace_id, params, self.preferred_model) .await { Ok(mut stream) => loop { diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 70a8b5ff58..355e8d27b5 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -4,8 +4,9 @@ use std::collections::HashMap; use crate::local_ai::controller::LocalAISetting; use crate::local_ai::resource::PendingResource; use flowy_ai_pub::cloud::{ - ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, OutputContent, - OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, + AIModel, ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, + OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, + ResponseFormat, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use lib_infra::validator_fn::required_not_empty_str; @@ -76,9 +77,9 @@ pub struct StreamChatPayloadPB { } #[derive(Default, Debug)] -pub struct StreamMessageParams<'a> { - pub chat_id: &'a str, - pub message: &'a str, +pub struct StreamMessageParams { + pub chat_id: String, + pub message: String, pub message_type: ChatMessageType, pub answer_stream_port: i64, pub question_stream_port: i64, @@ -182,12 +183,65 @@ pub struct ChatMessageListPB { pub total: i64, } -#[derive(Default, ProtoBuf, Validate, Clone, Debug)] -pub struct ModelConfigPB { +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct ServerAvailableModelsPB { #[pb(index = 1)] pub models: String, } +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct AvailableModelsQueryPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub source: String, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct UpdateSelectedModelPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub source: String, + + #[pb(index = 2)] + pub selected_model: AIModelPB, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AvailableModelsPB { + #[pb(index = 1)] + pub models: Vec, + + #[pb(index = 2, one_of)] + pub selected_model: Option, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AIModelPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub is_local: bool, +} + +impl From for AIModelPB { + fn from(model: AIModel) -> Self { + Self { + name: model.name, + is_local: model.is_local, + } + } +} + +impl From for AIModel { + fn from(value: AIModelPB) -> Self { + AIModel { + name: value.name, + is_local: value.is_local, + } + } +} + impl From for ChatMessageListPB { fn from(repeated_chat_message: RepeatedChatMessage) -> Self { let messages = repeated_chat_message @@ -471,14 +525,14 @@ pub struct PendingResourcePB { pub enum PendingResourceTypePB { #[default] LocalAIAppRes = 0, - AIModel = 1, + ModelRes = 1, } impl From for PendingResourceTypePB { fn from(value: PendingResource) -> Self { match value { PendingResource::PluginExecutableNotReady { .. } => PendingResourceTypePB::LocalAIAppRes, - _ => PendingResourceTypePB::AIModel, + _ => PendingResourceTypePB::ModelRes, } } } @@ -559,6 +613,9 @@ pub struct UpdateChatSettingsPB { #[pb(index = 2)] pub rag_ids: Vec, + + #[pb(index = 3)] + pub chat_model: String, } #[derive(Debug, Default, Clone, ProtoBuf)] diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 5a8018ae4b..1418e84058 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -69,8 +69,8 @@ pub(crate) async fn stream_chat_message_handler( trace!("Stream chat message with metadata: {:?}", metadata); let params = StreamMessageParams { - chat_id: &chat_id, - message: &message, + chat_id, + message, message_type, answer_stream_port, question_stream_port, @@ -79,7 +79,7 @@ pub(crate) async fn stream_chat_message_handler( }; let ai_manager = upgrade_ai_manager(ai_manager)?; - let result = ai_manager.stream_chat_message(¶ms).await?; + let result = ai_manager.stream_chat_message(params).await?; data_result_ok(result) } @@ -103,19 +103,36 @@ pub(crate) async fn regenerate_response_handler( } #[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_model_list_handler( +pub(crate) async fn get_server_model_list_handler( ai_manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; - let available_models = ai_manager.get_available_models().await?; - let models = available_models - .models - .into_iter() - .map(|m| m.name) - .collect::>(); - + let models = ai_manager.get_server_available_models().await?; let models = serde_json::to_string(&json!({"models": models}))?; - data_result_ok(ModelConfigPB { models }) + data_result_ok(ServerAvailableModelsPB { models }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_chat_models_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + let models = ai_manager.get_available_models(data.source).await?; + data_result_ok(models) +} + +pub(crate) async fn update_selected_model_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager + .update_selected_model(data.source, data.selected_model) + .await?; + Ok(()) } #[tracing::instrument(level = "debug", skip_all, err)] diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs index 7875e7c3c9..84c2266f0b 100644 --- a/frontend/rust-lib/flowy-ai/src/event_map.rs +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -10,9 +10,14 @@ use crate::ai_manager::AIManager; use crate::event_handler::*; pub fn init(ai_manager: Weak) -> AFPlugin { - let user_service = Arc::downgrade(&ai_manager.upgrade().unwrap().user_service); - let cloud_service = Arc::downgrade(&ai_manager.upgrade().unwrap().cloud_service_wm); - let ai_tools = Arc::new(AICompletion::new(cloud_service, user_service)); + let strong_ai_manager = ai_manager.upgrade().unwrap(); + let user_service = Arc::downgrade(&strong_ai_manager.user_service); + let cloud_service = Arc::downgrade(&strong_ai_manager.cloud_service_wm); + let ai_tools = Arc::new(AICompletion::new( + cloud_service, + user_service, + strong_ai_manager.store_preferences.clone(), + )); AFPlugin::new() .name("flowy-ai") .state(ai_manager) @@ -35,12 +40,17 @@ pub fn init(ai_manager: Weak) -> AFPlugin { AIEvent::UpdateLocalAISetting, update_local_ai_setting_handler, ) - .event(AIEvent::GetAvailableModels, get_model_list_handler) + .event( + AIEvent::GetServerAvailableModels, + get_server_model_list_handler, + ) .event(AIEvent::CreateChatContext, create_chat_context_handler) .event(AIEvent::GetChatInfo, create_chat_context_handler) .event(AIEvent::GetChatSettings, get_chat_settings_handler) .event(AIEvent::UpdateChatSettings, update_chat_settings_handler) .event(AIEvent::RegenerateResponse, regenerate_response_handler) + .event(AIEvent::GetAvailableModels, get_chat_models_handler) + .event(AIEvent::UpdateSelectedModel, update_selected_model_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -105,12 +115,18 @@ pub enum AIEvent { #[event(input = "RegenerateResponsePB")] RegenerateResponse = 27, - #[event(output = "ModelConfigPB")] - GetAvailableModels = 28, + #[event(output = "ServerAvailableModelsPB")] + GetServerAvailableModels = 28, #[event(output = "LocalAISettingPB")] GetLocalAISetting = 29, #[event(input = "LocalAISettingPB")] UpdateLocalAISetting = 30, + + #[event(input = "AvailableModelsQueryPB", output = "AvailableModelsPB")] + GetAvailableModels = 31, + + #[event(input = "UpdateSelectedModelPB")] + UpdateSelectedModel = 32, } diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index be6c743d86..884c859660 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -11,3 +11,4 @@ pub mod notification; mod persistence; mod protobuf; mod stream_message; +mod util; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index da24b70145..04d73831b9 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -193,6 +193,10 @@ impl LocalAIController { /// AppFlowy store the value in local storage isolated by workspace id. Each workspace can have /// different settings. pub fn is_enabled(&self) -> bool { + if !get_operating_system().is_desktop() { + return false; + } + if let Ok(key) = self .user_service .workspace_id() @@ -204,6 +208,13 @@ impl LocalAIController { } } + pub fn get_plugin_chat_model(&self) -> Option { + if !self.is_enabled() { + return None; + } + Some(self.resource.get_llm_setting().chat_model_name) + } + pub fn open_chat(&self, chat_id: &str) { if !self.is_enabled() { return; diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 8e69f14d20..37bf5b5daf 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -9,7 +9,7 @@ use appflowy_plugin::error::PluginError; use std::collections::HashMap; use flowy_ai_pub::cloud::{ - AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, CompletionStream, LocalAIConfig, MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, @@ -25,7 +25,7 @@ use futures_util::SinkExt; use serde_json::{json, Value}; use std::path::Path; use std::sync::{Arc, Weak}; -use tracing::trace; +use tracing::{info, trace}; pub struct AICloudServiceMiddleware { cloud_service: Arc, @@ -156,12 +156,19 @@ impl ChatCloudService for AICloudServiceMiddleware { &self, workspace_id: &str, chat_id: &str, - question_id: i64, + message_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result { - if self.local_ai.is_enabled() { + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + info!("stream_answer use model: {:?}", ai_model); + if use_local_ai { if self.local_ai.is_running() { - let row = self.get_message_record(question_id)?; + let row = self.get_message_record(message_id)?; match self .local_ai .stream_question(chat_id, &row.content, Some(json!(format)), json!({})) @@ -179,7 +186,7 @@ impl ChatCloudService for AICloudServiceMiddleware { } else { self .cloud_service - .stream_answer(workspace_id, chat_id, question_id, format) + .stream_answer(workspace_id, chat_id, message_id, format, ai_model) .await } } @@ -273,34 +280,45 @@ impl ChatCloudService for AICloudServiceMiddleware { &self, workspace_id: &str, params: CompleteTextParams, + ai_model: Option, ) -> Result { - if self.local_ai.is_running() { - match self - .local_ai - .complete_text_v2( - ¶ms.text, - params.completion_type.unwrap() as u8, - Some(json!(params.format)), - Some(json!(params.metadata)), - ) - .await - { - Ok(stream) => Ok( - CompletionStream::new( - stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + info!("stream_complete use model: {:?}", ai_model); + if use_local_ai { + if self.local_ai.is_running() { + match self + .local_ai + .complete_text_v2( + ¶ms.text, + params.completion_type.unwrap() as u8, + Some(json!(params.format)), + Some(json!(params.metadata)), ) - .map_err(FlowyError::from) - .boxed(), - ), - Err(err) => { - self.handle_plugin_error(err); - Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) - }, + .await + { + Ok(stream) => Ok( + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) + .boxed(), + ), + Err(err) => { + self.handle_plugin_error(err); + Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) + }, + } + } else { + Err(FlowyError::local_ai_not_ready()) } } else { self .cloud_service - .stream_complete(workspace_id, params) + .stream_complete(workspace_id, params, ai_model) .await } } @@ -364,4 +382,11 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn get_available_models(&self, workspace_id: &str) -> Result { self.cloud_service.get_available_models(workspace_id).await } + + async fn get_workspace_default_model(&self, workspace_id: &str) -> Result { + self + .cloud_service + .get_workspace_default_model(workspace_id) + .await + } } diff --git a/frontend/rust-lib/flowy-ai/src/notification.rs b/frontend/rust-lib/flowy-ai/src/notification.rs index 81d1bb92d6..97cc0e9631 100644 --- a/frontend/rust-lib/flowy-ai/src/notification.rs +++ b/frontend/rust-lib/flowy-ai/src/notification.rs @@ -15,6 +15,7 @@ pub enum ChatNotification { UpdateLocalAIState = 6, DidUpdateChatSettings = 7, LocalAIResourceUpdated = 8, + DidUpdateSelectedModel = 9, } impl std::convert::From for i32 { diff --git a/frontend/rust-lib/flowy-ai/src/util.rs b/frontend/rust-lib/flowy-ai/src/util.rs new file mode 100644 index 0000000000..d1667ae98b --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/util.rs @@ -0,0 +1,3 @@ +pub fn ai_available_models_key(object_id: &str) -> String { + format!("ai_available_models_{}", object_id) +} diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index d1668524cb..7977c33d4f 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -74,14 +74,14 @@ dart = [ "flowy-ai/dart", "flowy-storage/dart", ] -ts = [ - "flowy-user/tauri_ts", - "flowy-folder/tauri_ts", - "flowy-search/tauri_ts", - "flowy-database2/ts", - "flowy-ai/tauri_ts", - "flowy-storage/tauri_ts", -] +#ts = [ +# "flowy-user/tauri_ts", +# "flowy-folder/tauri_ts", +# "flowy-search/tauri_ts", +# "flowy-database2/ts", +# "flowy-ai/tauri_ts", +# "flowy-storage/tauri_ts", +#] openssl_vendored = ["flowy-sqlite/openssl_vendored"] # Enable/Disable AppFlowy Verbose Log Configuration diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 80c22642a4..68adc45c0e 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -21,7 +21,7 @@ use collab_integrate::collab_builder::{ CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, }; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, + AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, LocalAIConfig, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, }; @@ -705,13 +705,14 @@ impl ChatCloudService for ServerProvider { chat_id: &str, message_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result { let workspace_id = workspace_id.to_string(); let chat_id = chat_id.to_string(); let server = self.get_server()?; server .chat_service() - .stream_answer(&workspace_id, &chat_id, message_id, format) + .stream_answer(&workspace_id, &chat_id, message_id, format, ai_model) .await } @@ -772,12 +773,13 @@ impl ChatCloudService for ServerProvider { &self, workspace_id: &str, params: CompleteTextParams, + ai_model: Option, ) -> Result { let workspace_id = workspace_id.to_string(); let server = self.get_server()?; server .chat_service() - .stream_complete(&workspace_id, params) + .stream_complete(&workspace_id, params, ai_model) .await } @@ -846,6 +848,14 @@ impl ChatCloudService for ServerProvider { .get_available_models(workspace_id) .await } + + async fn get_workspace_default_model(&self, workspace_id: &str) -> Result { + self + .get_server()? + .chat_service() + .get_workspace_default_model(workspace_id) + .await + } } #[async_trait] diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index e4e468f46e..e14f247d88 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -7,8 +7,8 @@ use client_api::entity::chat_dto::{ RepeatedChatMessage, }; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, LocalAIConfig, - ModelList, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, + LocalAIConfig, ModelList, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, }; use flowy_error::FlowyError; use futures_util::{StreamExt, TryStreamExt}; @@ -101,12 +101,14 @@ where chat_id: &str, message_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result { trace!( - "stream_answer: workspace_id={}, chat_id={}, format={:?}", + "stream_answer: workspace_id={}, chat_id={}, format={:?}, model: {:?}", workspace_id, chat_id, - format + format, + ai_model, ); let try_get_client = self.inner.try_get_client(); let result = try_get_client? @@ -117,6 +119,7 @@ where question_id: message_id, format, }, + ai_model.map(|v| v.name), ) .await; @@ -189,11 +192,12 @@ where &self, workspace_id: &str, params: CompleteTextParams, + ai_model: Option, ) -> Result { let stream = self .inner .try_get_client()? - .stream_completion_v2(workspace_id, params) + .stream_completion_v2(workspace_id, params, ai_model.map(|v| v.name)) .await .map_err(FlowyError::from)? .map_err(FlowyError::from); @@ -280,4 +284,13 @@ where .await?; Ok(list) } + + async fn get_workspace_default_model(&self, workspace_id: &str) -> Result { + let setting = self + .inner + .try_get_client()? + .get_workspace_settings(workspace_id) + .await?; + Ok(setting.ai_model) + } } diff --git a/frontend/rust-lib/flowy-server/src/default_impl.rs b/frontend/rust-lib/flowy-server/src/default_impl.rs index d1a16159c4..7dca08754b 100644 --- a/frontend/rust-lib/flowy-server/src/default_impl.rs +++ b/frontend/rust-lib/flowy-server/src/default_impl.rs @@ -1,6 +1,6 @@ use client_api::entity::ai_dto::{LocalAIConfig, RepeatedRelatedQuestion}; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, + AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, }; @@ -52,6 +52,7 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { _chat_id: &str, _message_id: i64, _format: ResponseFormat, + _ai_model: Option, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } @@ -97,6 +98,7 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { &self, _workspace_id: &str, _params: CompleteTextParams, + _ai_model: Option, ) -> Result { Err(FlowyError::not_support().with_context("complete text is not supported in local server.")) } @@ -148,4 +150,8 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { async fn get_available_models(&self, _workspace_id: &str) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } + + async fn get_workspace_default_model(&self, _workspace_id: &str) -> Result { + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + } } From 44c9d572c813741672026584d6703e523e5d687c Mon Sep 17 00:00:00 2001 From: Morn Date: Mon, 24 Mar 2025 09:54:45 +0800 Subject: [PATCH 015/207] fix: using date picker with @ menu with some errors (#7580) --- .../mention/mention_date_block.dart | 9 ------ .../appflowy_date_picker_base.dart | 29 +++++++++---------- .../date_picker/desktop_date_picker.dart | 1 + .../widgets/date_picker_dialog.dart | 14 +++++++-- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart index e1df115e15..6c5ee7f527 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -60,8 +60,6 @@ class MentionDateBlock extends StatefulWidget { } class _MentionDateBlockState extends State { - final PopoverMutex mutex = PopoverMutex(); - late bool _includeTime = widget.includeTime; late DateTime? parsedDate = DateTime.tryParse(widget.date); @@ -71,12 +69,6 @@ class _MentionDateBlockState extends State { super.didUpdateWidget(oldWidget); } - @override - void dispose() { - mutex.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { if (parsedDate == null) { @@ -105,7 +97,6 @@ class _MentionDateBlockState extends State { final options = DatePickerOptions( focusedDay: parsedDate, - popoverMutex: mutex, selectedDay: parsedDate, includeTime: _includeTime, dateFormat: appearance.dateFormat, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart index 8bfc187422..d965670f77 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart @@ -23,6 +23,7 @@ abstract class AppFlowyDatePicker extends StatefulWidget { this.onIncludeTimeChanged, this.onIsRangeChanged, this.onReminderSelected, + this.enableDidUpdate = true, }); final DateTime? dateTime; @@ -55,6 +56,7 @@ abstract class AppFlowyDatePicker extends StatefulWidget { final ReminderOption reminderOption; final OnReminderSelected? onReminderSelected; + final bool enableDidUpdate; } abstract class AppFlowyDatePickerState @@ -75,34 +77,31 @@ abstract class AppFlowyDatePickerState @override void initState() { super.initState(); - - dateTime = widget.dateTime; - startDateTime = widget.isRange ? widget.dateTime : null; - endDateTime = widget.isRange ? widget.endDateTime : null; - includeTime = widget.includeTime; - isRange = widget.isRange; - reminderOption = widget.reminderOption; + initData(); focusedDateTime = widget.dateTime ?? DateTime.now(); } @override void didUpdateWidget(covariant oldWidget) { - dateTime = widget.dateTime; - if (widget.isRange) { - startDateTime = widget.dateTime; - endDateTime = widget.endDateTime; - } else { - startDateTime = endDateTime = null; + if (widget.enableDidUpdate) { + initData(); } - includeTime = widget.includeTime; - isRange = widget.isRange; if (oldWidget.reminderOption != widget.reminderOption) { reminderOption = widget.reminderOption; } super.didUpdateWidget(oldWidget); } + void initData() { + dateTime = widget.dateTime; + startDateTime = widget.isRange ? widget.dateTime : null; + endDateTime = widget.isRange ? widget.endDateTime : null; + includeTime = widget.includeTime; + isRange = widget.isRange; + reminderOption = widget.reminderOption; + } + void onDateSelectedFromDatePicker( DateTime? newStartDateTime, DateTime? newEndDateTime, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart index c404f576b1..fada23e994 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart @@ -32,6 +32,7 @@ class DesktopAppFlowyDatePicker extends AppFlowyDatePicker { super.onIncludeTimeChanged, super.onIsRangeChanged, super.onReminderSelected, + super.enableDidUpdate, this.popoverMutex, this.options = const [], }); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart index 301fd038ee..54fc2fac2a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart @@ -16,7 +16,6 @@ import 'package:flutter/services.dart'; class DatePickerOptions { DatePickerOptions({ DateTime? focusedDay, - this.popoverMutex, this.selectedDay, this.includeTime = false, this.isRange = false, @@ -31,7 +30,6 @@ class DatePickerOptions { }) : focusedDay = focusedDay ?? DateTime.now(); final DateTime focusedDay; - final PopoverMutex? popoverMutex; final DateTime? selectedDay; final bool includeTime; final bool isRange; @@ -48,6 +46,7 @@ class DatePickerOptions { abstract class DatePickerService { void show(Offset offset, {required DatePickerOptions options}); + void dismiss(); } @@ -60,6 +59,7 @@ class DatePickerMenu extends DatePickerService { final BuildContext context; final EditorState editorState; + PopoverMutex? popoverMutex; OverlayEntry? _menuEntry; @@ -67,6 +67,9 @@ class DatePickerMenu extends DatePickerService { void dismiss() { _menuEntry?.remove(); _menuEntry = null; + popoverMutex?.close(); + popoverMutex?.dispose(); + popoverMutex = null; } @override @@ -97,6 +100,7 @@ class DatePickerMenu extends DatePickerService { } } + popoverMutex = PopoverMutex(); _menuEntry = OverlayEntry( builder: (_) => Material( type: MaterialType.transparency, @@ -119,6 +123,7 @@ class DatePickerMenu extends DatePickerService { offset: Offset(offsetX, offsetY), showBelow: showBelow, options: options, + popoverMutex: popoverMutex, ), ], ), @@ -137,11 +142,13 @@ class _AnimatedDatePicker extends StatelessWidget { required this.offset, required this.showBelow, required this.options, + this.popoverMutex, }); final Offset offset; final bool showBelow; final DatePickerOptions options; + final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { @@ -165,11 +172,12 @@ class _AnimatedDatePicker extends StatelessWidget { dateFormat: options.dateFormat.simplified, timeFormat: options.timeFormat.simplified, dateTime: options.selectedDay, - popoverMutex: options.popoverMutex, + popoverMutex: popoverMutex, reminderOption: options.selectedReminderOption ?? ReminderOption.none, onDaySelected: options.onDaySelected, onRangeSelected: options.onRangeSelected, onReminderSelected: options.onReminderSelected, + enableDidUpdate: false, ), ), ); From 6f031d0c7e1ab2efee52e41f2ee4de3e41e476b7 Mon Sep 17 00:00:00 2001 From: Morn Date: Mon, 24 Mar 2025 09:55:23 +0800 Subject: [PATCH 016/207] feat: revamp toolbar link (#7578) * feat: revamp toolbar link * fix: some review issues * chore: add integration test for toolbar link --- .../document/document_toolbar_test.dart | 151 ++++++ .../document/presentation/editor_page.dart | 3 +- .../desktop_floating_toolbar.dart | 18 +- .../link/link_create_menu.dart | 164 +++++++ .../desktop_toolbar/link/link_edit_menu.dart | 429 ++++++++++++++++++ .../desktop_toolbar/link/link_hover_menu.dart | 322 +++++++++++++ .../link/link_search_text_field.dart | 326 +++++++++++++ .../desktop_toolbar/link/link_styles.dart | 34 ++ .../desktop_toolbar/toolbar_cubit.dart | 17 + .../header/emoji_icon_widget.dart | 3 + .../mention/mention_page_block.dart | 12 +- .../custom_link_toolbar_item.dart | 171 ++++++- .../document/presentation/editor_style.dart | 69 ++- frontend/appflowy_flutter/macos/Podfile.lock | 46 +- .../src/flowy_overlay/appflowy_popover.dart | 14 +- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- .../flowy_icons/20x/toolbar_link_earth.svg | 3 + .../flowy_icons/20x/toolbar_link_edit.svg | 3 + .../flowy_icons/20x/toolbar_link_unlink.svg | 3 + frontend/resources/translations/en.json | 7 +- 21 files changed, 1750 insertions(+), 51 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart create mode 100644 frontend/resources/flowy_icons/20x/toolbar_link_earth.svg create mode 100644 frontend/resources/flowy_icons/20x/toolbar_link_edit.svg create mode 100644 frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart index abfee17d8e..aaecd0ff10 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart @@ -1,10 +1,19 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -176,4 +185,146 @@ void main() { ); }); }); + + group('document toolbar: link', () { + String? getLinkFromNode(Node node) { + for (final insert in node.delta!) { + final link = insert.attributes?.href; + if (link != null) return link; + } + return null; + } + + bool isPageLink(Node node) { + for (final insert in node.delta!) { + final isPage = insert.attributes?.isPage; + if (isPage == true) return true; + } + return false; + } + + String getNodeText(Node node) { + for (final insert in node.delta!) { + if (insert is TextInsert) return insert.text; + } + return ''; + } + + testWidgets('insert link and remove link', (tester) async { + const text = 'insert link', link = 'https://test.appflowy.cloud'; + await prepareForToolbar(tester, text); + + final toolbar = find.byType(DesktopFloatingToolbar); + expect(toolbar, findsOneWidget); + + /// tap link button to show CreateLinkMenu + final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(linkButton); + final createLinkMenu = find.byType(LinkCreateMenu); + expect(createLinkMenu, findsOneWidget); + + /// test esc to close + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + expect(toolbar, findsNothing); + + /// show toolbar again + await selectText(tester, text); + await tester.tapButton(linkButton); + + /// insert link + final textField = find.descendant( + of: createLinkMenu, + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, link); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + Node node = tester.editor.getNodeAtPath([0]); + expect(getLinkFromNode(node), link); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + + /// hover link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + final hoverMenu = find.byType(LinkHoverMenu); + expect(hoverMenu, findsOneWidget); + + /// copy link + final copyButton = find.descendant( + of: hoverMenu, + matching: find.byFlowySvg(FlowySvgs.toolbar_link_m), + ); + await tester.tapButton(copyButton); + final clipboardContent = await getIt().getData(); + final plainText = clipboardContent.plainText; + expect(plainText, link); + + /// remove link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m)); + node = tester.editor.getNodeAtPath([0]); + expect(getLinkFromNode(node), null); + }); + + testWidgets('insert link and edit link', (tester) async { + const text = 'edit link', + link = 'https://test.appflowy.cloud', + afterText = '$text after'; + await prepareForToolbar(tester, text); + + /// tap link button to show CreateLinkMenu + final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(linkButton); + + /// search for page and select it + final textField = find.descendant( + of: find.byType(LinkCreateMenu), + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, gettingStarted); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + + Node node = tester.editor.getNodeAtPath([0]); + expect(isPageLink(node), true); + expect(getLinkFromNode(node) == link, false); + + /// hover link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + + /// click edit button to show LinkEditMenu + final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m); + await tester.tapButton(editButton); + final linkEditMenu = find.byType(LinkEditMenu); + expect(linkEditMenu, findsOneWidget); + + /// change the link text + final titleField = find.descendant( + of: linkEditMenu, + matching: find.byType(TextFormField), + ); + await tester.enterText(titleField, afterText); + await tester.pumpAndSettle(); + await tester.tapButton( + find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)), + ); + final linkField = find.ancestor( + of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()), + matching: find.byType(TextFormField), + ); + await tester.enterText(linkField, link); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + /// apply the change + final applyButton = + find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr()); + await tester.tapButton(applyButton); + + node = tester.editor.getNodeAtPath([0]); + expect(isPageLink(node), false); + expect(getLinkFromNode(node), link); + expect(getNodeText(node), afterText); + }); + }); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 09685aef5c..f445e698aa 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -443,8 +443,9 @@ class _AppFlowyEditorPageState extends State color: Theme.of(context).cardColor, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), - toolbarBuilder: (context, child) => DesktopFloatingToolbar( + toolbarBuilder: (context, child, onDismiss) => DesktopFloatingToolbar( editorState: editorState, + onDismiss: onDismiss, child: child, ), placeHolderBuilder: (_) => customPlaceholderItem, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart index 4932eb8274..2cc9d700e2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -1,15 +1,20 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'toolbar_cubit.dart'; class DesktopFloatingToolbar extends StatefulWidget { const DesktopFloatingToolbar({ super.key, required this.editorState, required this.child, + required this.onDismiss, }); final EditorState editorState; final Widget child; + final VoidCallback onDismiss; @override State createState() => _DesktopFloatingToolbarState(); @@ -35,11 +40,14 @@ class _DesktopFloatingToolbarState extends State { @override Widget build(BuildContext context) { if (position == null) return Container(); - return Positioned( - left: position!.left, - top: position!.top, - right: position!.right, - child: widget.child, + return BlocProvider( + create: (_) => ToolbarCubit(widget.onDismiss), + child: Positioned( + left: position!.left, + top: position!.top, + right: position!.right, + child: widget.child, + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart new file mode 100644 index 0000000000..4d3ef71cce --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -0,0 +1,164 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.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 'link_search_text_field.dart'; + +class LinkCreateMenu extends StatefulWidget { + const LinkCreateMenu({ + super.key, + required this.editorState, + required this.onSubmitted, + required this.onDismiss, + required this.alignment, + }); + + final EditorState editorState; + final void Function(String link, bool isPage) onSubmitted; + final VoidCallback onDismiss; + final LinkMenuAlignment alignment; + + @override + State createState() => _LinkCreateMenuState(); +} + +class _LinkCreateMenuState extends State { + late LinkSearchTextField searchTextField = LinkSearchTextField( + onEnter: () { + searchTextField.onSearchResult( + onLink: () => onSubmittedLink(), + onRecentViews: () => + onSubmittedPageLink(searchTextField.currentRecentView), + onSearchViews: () => + onSubmittedPageLink(searchTextField.currentSearchedView), + onEmpty: () {}, + ); + }, + onEscape: widget.onDismiss, + onDataRefresh: () { + if (mounted) setState(() {}); + }, + ); + + bool get isButtonEnable => searchText.isNotEmpty; + + String get searchText => searchTextField.searchText; + + bool get showAtTop => widget.alignment.isTop; + + @override + void initState() { + super.initState(); + searchTextField.requestFocus(); + searchTextField.searchRecentViews(); + } + + @override + void dispose() { + searchTextField.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 320, + child: Column( + children: showAtTop + ? [ + searchTextField.buildResultContainer( + margin: EdgeInsets.only(bottom: 2), + context: context, + onLinkSelected: onSubmittedLink, + onPageLinkSelected: onSubmittedPageLink, + ), + buildSearchContainer(), + ] + : [ + buildSearchContainer(), + searchTextField.buildResultContainer( + margin: EdgeInsets.only(top: 2), + context: context, + onLinkSelected: onSubmittedLink, + onPageLinkSelected: onSubmittedPageLink, + ), + ], + ), + ); + } + + Widget buildSearchContainer() { + return Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.all(8), + child: ValueListenableBuilder( + valueListenable: searchTextField.textEditingController, + builder: (context, _, __) { + return Row( + children: [ + Expanded(child: searchTextField.buildTextField()), + HSpace(8), + FlowyTextButton( + LocaleKeys.document_toolbar_insert.tr(), + mainAxisAlignment: MainAxisAlignment.center, + padding: EdgeInsets.zero, + constraints: BoxConstraints(maxWidth: 72, minHeight: 32), + fontSize: 14, + fontColor: + isButtonEnable ? Colors.white : LinkStyle.textTertiary, + fillColor: isButtonEnable + ? LinkStyle.fillThemeThick + : LinkStyle.borderColor, + hoverColor: LinkStyle.fillThemeThick, + lineHeight: 20 / 14, + fontWeight: FontWeight.w600, + onPressed: isButtonEnable ? () => onSubmittedLink() : null, + ), + ], + ); + }, + ), + ); + } + + void onSubmittedLink() => widget.onSubmitted(searchText, false); + + void onSubmittedPageLink(ViewPB view) async { + final workspaceId = context + .read() + ?.state + .currentWorkspace + ?.workspaceId ?? + ''; + final link = ShareConstants.buildShareUrl( + workspaceId: workspaceId, + viewId: view.id, + ); + widget.onSubmitted(link, true); + } +} + +ShapeDecoration buildToolbarLinkDecoration(BuildContext context) => + ShapeDecoration( + color: Theme.of(context).cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: [ + const BoxShadow( + color: LinkStyle.shadowMedium, + blurRadius: 24, + offset: Offset(0, 4), + ), + ], + ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart new file mode 100644 index 0000000000..e10b46411b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart @@ -0,0 +1,429 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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 'link_create_menu.dart'; +import 'link_search_text_field.dart'; +import 'link_styles.dart'; + +class LinkEditMenu extends StatefulWidget { + const LinkEditMenu({ + super.key, + required this.linkInfo, + required this.onDismiss, + required this.onApply, + required this.onRemoveLink, + }); + + final LinkInfo linkInfo; + final ValueChanged onApply; + final VoidCallback onRemoveLink; + final VoidCallback onDismiss; + + @override + State createState() => _LinkEditMenuState(); +} + +class _LinkEditMenuState extends State { + ValueChanged get onApply => widget.onApply; + + VoidCallback get onRemoveLink => widget.onRemoveLink; + + VoidCallback get onDismiss => widget.onDismiss; + + late TextEditingController linkNameController = + TextEditingController(text: linkInfo.name); + final textFocusNode = FocusNode(); + late LinkInfo linkInfo = widget.linkInfo; + late LinkSearchTextField searchTextField; + bool isShowingSearchResult = false; + ViewPB? currentView; + + bool get enableApply => + linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty; + + @override + void initState() { + super.initState(); + final isPageLink = linkInfo.isPage; + if (isPageLink) getPageView(); + searchTextField = LinkSearchTextField( + initialSearchText: isPageLink ? '' : linkInfo.link, + onEnter: () { + searchTextField.onSearchResult( + onLink: onLinkSelected, + onRecentViews: () => + onPageSelected(searchTextField.currentRecentView), + onSearchViews: () => + onPageSelected(searchTextField.currentSearchedView), + onEmpty: () { + searchTextField.unfocus(); + }, + ); + }, + onEscape: () { + if (isShowingSearchResult) { + hideSearchResult(); + } else { + onDismiss(); + } + }, + onDataRefresh: () { + if (mounted) setState(() {}); + }, + )..searchRecentViews(); + } + + @override + void dispose() { + linkNameController.dispose(); + textFocusNode.dispose(); + searchTextField.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final showingRecent = + searchTextField.showingRecent && isShowingSearchResult; + return GestureDetector( + onTap: onDismiss, + child: Container( + width: 400, + height: 250 + (showingRecent ? 32 : 0), + color: Colors.white.withAlpha(1), + child: Stack( + children: [ + GestureDetector( + onTap: hideSearchResult, + child: Container( + width: 400, + height: 192, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.fromLTRB(20, 16, 20, 16), + ), + ), + Positioned( + top: 16, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_pageOrURL.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Positioned( + top: 80, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_linkName.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Positioned( + top: 152, + left: 20, + child: buildButtons(), + ), + Positioned( + top: 108, + left: 20, + child: buildNameTextField(), + ), + Positioned( + top: 36, + left: 20, + child: buildLinkField(), + ), + ], + ), + ), + ); + } + + Widget buildLinkField() { + final showPageView = linkInfo.isPage && !isShowingSearchResult; + if (showPageView) return buildPageView(); + if (!isShowingSearchResult) return buildLinkView(); + return SizedBox( + width: 360, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 360, + height: 32, + child: searchTextField.buildTextField( + autofocus: true, + ), + ), + VSpace(6), + searchTextField.buildResultContainer( + context: context, + width: 360, + onPageLinkSelected: onPageSelected, + onLinkSelected: onLinkSelected, + ), + ], + ), + ); + } + + Widget buildButtons() { + return GestureDetector( + onTap: hideSearchResult, + child: SizedBox( + width: 360, + height: 32, + child: Row( + children: [ + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), + width: 32, + height: 32, + tooltipText: LocaleKeys.editor_removeLink.tr(), + preferBelow: false, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all(color: LinkStyle.borderColor), + ), + onPressed: onRemoveLink, + ), + Spacer(), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all(color: LinkStyle.borderColor), + ), + child: FlowyTextButton( + LocaleKeys.button_cancel.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + fontColor: LinkStyle.textPrimary, + fillColor: Colors.transparent, + fontWeight: FontWeight.w400, + onPressed: onDismiss, + ), + ), + HSpace(12), + ValueListenableBuilder( + valueListenable: linkNameController, + builder: (context, _, __) { + return FlowyTextButton( + LocaleKeys.settings_appearance_documentSettings_apply.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + fontColor: + enableApply ? Colors.white : LinkStyle.textTertiary, + fillColor: enableApply + ? LinkStyle.fillThemeThick + : LinkStyle.borderColor, + fontWeight: FontWeight.w400, + onPressed: + enableApply ? () => widget.onApply.call(linkInfo) : null, + ); + }, + ), + ], + ), + ), + ); + } + + Widget buildNameTextField() { + return SizedBox( + width: 360, + height: 32, + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + focusNode: textFocusNode, + textAlign: TextAlign.left, + controller: linkNameController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + onChanged: (text) { + linkInfo = LinkInfo( + name: text, + link: linkInfo.link, + isPage: linkInfo.isPage, + ); + }, + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_toolbar_linkNameHint.tr(), + ), + ), + ); + } + + Widget buildPageView() { + late Widget child; + final view = currentView; + if (view == null) { + child = Center( + child: SizedBox.fromSize( + size: Size(10, 10), + child: CircularProgressIndicator(), + ), + ); + } else { + child = GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + height: 32, + padding: EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + searchTextField.buildIcon(view), + HSpace(8), + Flexible( + child: FlowyText.regular( + view.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } + return Container( + width: 360, + height: 32, + decoration: buildDecoration(), + child: child, + ); + } + + Widget buildLinkView() { + return Container( + width: 360, + height: 32, + decoration: buildDecoration(), + child: GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + FlowySvg(FlowySvgs.toolbar_link_earth_m), + HSpace(8), + Flexible( + child: FlowyText.regular( + linkInfo.link, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Future getPageView() async { + if (!linkInfo.isPage) return; + final link = linkInfo.link; + final viewId = link.split('/').lastOrNull ?? ''; + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (mounted) { + setState(() { + currentView = view; + }); + } + } + + void showSearchResult() { + setState(() { + if (linkInfo.isPage) searchTextField.updateText(''); + isShowingSearchResult = true; + searchTextField.requestFocus(); + }); + } + + void hideSearchResult() { + setState(() { + isShowingSearchResult = false; + searchTextField.unfocus(); + textFocusNode.unfocus(); + }); + } + + void onLinkSelected() { + if (mounted) { + linkInfo = LinkInfo( + name: linkInfo.name, + link: searchTextField.searchText, + ); + hideSearchResult(); + } + } + + Future onPageSelected(ViewPB view) async { + currentView = view; + final link = ShareConstants.buildShareUrl( + workspaceId: await UserBackendService.getCurrentWorkspace().fold( + (s) => s.id, + (f) => '', + ), + viewId: view.id, + ); + linkInfo = LinkInfo( + name: linkInfo.name, + link: link, + isPage: true, + ); + searchTextField.updateText(linkInfo.link); + if (mounted) { + setState(() { + isShowingSearchResult = false; + searchTextField.unfocus(); + }); + } + } + + BoxDecoration buildDecoration() => BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: LinkStyle.borderColor), + ); +} + +class LinkInfo { + LinkInfo({this.isPage = false, required this.name, required this.link}); + + final bool isPage; + final String name; + final String link; + + Attributes toAttribute() => + {AppFlowyRichTextKeys.href: link, kIsPageLink: isPage}; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart new file mode 100644 index 0000000000..e5950b5e79 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -0,0 +1,322 @@ +import 'dart:math'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_editor/appflowy_editor.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/services.dart'; + +import 'link_create_menu.dart'; +import 'link_edit_menu.dart'; +import 'link_styles.dart'; + +class LinkHoverTrigger extends StatefulWidget { + const LinkHoverTrigger({ + super.key, + required this.editorState, + required this.selection, + required this.node, + required this.attribute, + required this.size, + this.delayToShow = const Duration(milliseconds: 50), + this.delayToHide = const Duration(milliseconds: 300), + }); + + final EditorState editorState; + final Selection selection; + final Node node; + final Attributes attribute; + final Size size; + final Duration delayToShow; + final Duration delayToHide; + + @override + State createState() => _LinkHoverTriggerState(); +} + +class _LinkHoverTriggerState extends State { + final hoverMenuController = PopoverController(); + final editMenuController = PopoverController(); + bool isHoverMenuShowing = false; + bool isHoverMenuHovering = false; + bool isHoverTriggerHovering = false; + + Size get size => widget.size; + + EditorState get editorState => widget.editorState; + + Selection get selection => widget.selection; + + Attributes get attribute => widget.attribute; + + @override + void dispose() { + hoverMenuController.close(); + editMenuController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (v) { + isHoverTriggerHovering = true; + Future.delayed(widget.delayToShow, () { + if (isHoverTriggerHovering && !isHoverMenuShowing) { + showLinkHoverMenu(); + } + }); + }, + onExit: (v) { + isHoverTriggerHovering = false; + tryToDismissLinkHoverMenu(); + }, + child: buildHoverPopover( + buildEditPopover( + Container( + color: Colors.black.withAlpha(1), + width: size.width, + height: size.height, + ), + ), + ), + ); + } + + Widget buildHoverPopover(Widget child) { + return AppFlowyPopover( + controller: hoverMenuController, + direction: PopoverDirection.topWithLeftAligned, + offset: Offset(0, size.height), + onOpen: () { + keepEditorFocusNotifier.increase(); + isHoverMenuShowing = true; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + isHoverMenuShowing = false; + }, + margin: EdgeInsets.zero, + constraints: BoxConstraints( + maxWidth: max(320, size.width), + maxHeight: 48 + size.height, + ), + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + popupBuilder: (context) => LinkHoverMenu( + attribute: widget.attribute, + triggerSize: size, + onEnter: (_) { + isHoverMenuHovering = true; + }, + onExit: (_) { + isHoverMenuHovering = false; + tryToDismissLinkHoverMenu(); + }, + onOpenLink: openLink, + onCopyLink: copyLink, + onEditLink: showLinkEditMenu, + onRemoveLink: () => removeLink(editorState, selection, true), + ), + child: child, + ); + } + + Widget buildEditPopover(Widget child) { + final href = attribute.href ?? '', + isPage = attribute.isPage, + title = editorState.getTextInSelection(selection).join(); + return AppFlowyPopover( + controller: editMenuController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, 0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + margin: EdgeInsets.zero, + asBarrier: true, + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + constraints: BoxConstraints( + maxWidth: 400, + minHeight: 282, + ), + popupBuilder: (context) => LinkEditMenu( + linkInfo: LinkInfo(name: title, link: href, isPage: isPage), + onDismiss: () => editMenuController.close(), + onApply: (info) async { + final transaction = editorState.transaction; + transaction.replaceText( + widget.node, + selection.startIndex, + selection.length, + info.name, + attributes: info.toAttribute(), + ); + editMenuController.close(); + await editorState.apply(transaction); + }, + onRemoveLink: () => removeLink(editorState, selection, true), + ), + child: child, + ); + } + + void showLinkHoverMenu() { + if (isHoverMenuShowing) return; + keepEditorFocusNotifier.increase(); + hoverMenuController.show(); + } + + void showLinkEditMenu() { + keepEditorFocusNotifier.increase(); + hoverMenuController.close(); + editMenuController.show(); + } + + void tryToDismissLinkHoverMenu() { + Future.delayed(widget.delayToHide, () { + if (isHoverMenuHovering || isHoverTriggerHovering) { + return; + } + hoverMenuController.close(); + }); + } + + Future openLink() async { + final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage; + + if (isPage) { + final viewId = href.split('/').lastOrNull ?? ''; + if (viewId.isEmpty) { + await afLaunchUrlString(href); + } else { + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (view != null) { + await handleMentionBlockTap(context, widget.editorState, view); + } + } + } else { + await afLaunchUrlString(href); + } + } + + Future copyLink() async { + final href = widget.attribute.href ?? ''; + if (href.isEmpty) return; + await getIt() + .setData(ClipboardServiceData(plainText: href)); + hoverMenuController.close(); + } +} + +class LinkHoverMenu extends StatelessWidget { + const LinkHoverMenu({ + super.key, + required this.attribute, + required this.onEnter, + required this.onExit, + required this.triggerSize, + required this.onCopyLink, + required this.onOpenLink, + required this.onEditLink, + required this.onRemoveLink, + }); + + final Attributes attribute; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final Size triggerSize; + final VoidCallback onCopyLink; + final VoidCallback onOpenLink; + final VoidCallback onEditLink; + final VoidCallback onRemoveLink; + + @override + Widget build(BuildContext context) { + final href = attribute.href ?? ''; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onEnter: onEnter, + onExit: onExit, + child: SizedBox( + width: max(320, triggerSize.width), + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: FlowyText.regular( + href, + fontSize: 14, + figmaLineHeight: 20, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + height: 20, + width: 1, + color: LinkStyle.borderColor, + margin: EdgeInsets.symmetric(horizontal: 6), + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_m), + tooltipText: LocaleKeys.editor_copyLink.tr(), + width: 36, + height: 32, + onPressed: onCopyLink, + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_edit_m), + tooltipText: LocaleKeys.editor_editLink.tr(), + width: 36, + height: 32, + onPressed: onEditLink, + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), + tooltipText: LocaleKeys.editor_removeLink.tr(), + width: 36, + height: 32, + onPressed: onRemoveLink, + ), + ], + ), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: onEnter, + onExit: onExit, + child: GestureDetector( + onTap: onOpenLink, + child: Container( + width: triggerSize.width, + height: triggerSize.height, + color: Colors.black.withAlpha(1), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart new file mode 100644 index 0000000000..4fcd9e3b8b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart @@ -0,0 +1,326 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.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/services.dart'; + +import 'link_create_menu.dart'; +import 'link_styles.dart'; + +class LinkSearchTextField { + LinkSearchTextField({ + this.onEscape, + this.onEnter, + this.onDataRefresh, + String? initialSearchText, + }) : textEditingController = TextEditingController( + text: initialSearchText ?? '', + ); + + final TextEditingController textEditingController; + final ItemScrollController searchController = ItemScrollController(); + late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); + final List searchedViews = []; + final List recentViews = []; + int selectedIndex = 0; + + final VoidCallback? onEscape; + final VoidCallback? onEnter; + final VoidCallback? onDataRefresh; + + String get searchText => textEditingController.text; + + bool get isButtonEnable => searchText.isNotEmpty; + + bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty; + + ViewPB get currentSearchedView => searchedViews[selectedIndex]; + + ViewPB get currentRecentView => recentViews[selectedIndex]; + + void dispose() { + textEditingController.dispose(); + focusNode.dispose(); + searchedViews.clear(); + recentViews.clear(); + } + + Widget buildTextField({bool autofocus = false}) { + return TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: autofocus, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: textEditingController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + onChanged: (text) { + if (text.isEmpty) { + searchedViews.clear(); + selectedIndex = 0; + onDataRefresh?.call(); + } else { + searchViews(text); + } + }, + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_toolbar_linkInputHint.tr(), + ), + ); + } + + Widget buildResultContainer({ + EdgeInsetsGeometry? margin, + required BuildContext context, + VoidCallback? onLinkSelected, + ValueChanged? onPageLinkSelected, + double width = 320.0, + }) { + return onSearchResult( + onEmpty: () => SizedBox.shrink(), + onLink: () => Container( + height: 48, + width: width, + padding: EdgeInsets.all(8), + margin: margin, + decoration: buildToolbarLinkDecoration(context), + child: FlowyButton( + leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m), + isSelected: true, + text: FlowyText.regular( + searchText, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 20, + ), + onTap: onLinkSelected, + ), + ), + onRecentViews: () => Container( + width: width, + height: recentViews.length.clamp(1, 5) * 32.0 + 48, + margin: margin, + padding: EdgeInsets.all(8), + decoration: buildToolbarLinkDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 32, + padding: EdgeInsets.all(8), + child: FlowyText.semibold( + LocaleKeys.inlineActions_recentPages.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Flexible( + child: ListView.builder( + itemBuilder: (context, index) { + final currentView = recentViews[index]; + return buildPageItem( + currentView, + index == selectedIndex, + onPageLinkSelected, + ); + }, + itemCount: recentViews.length, + ), + ), + ], + ), + ), + onSearchViews: () => Container( + width: width, + height: searchedViews.length.clamp(1, 5) * 32.0 + 16, + margin: margin, + decoration: buildToolbarLinkDecoration(context), + child: ScrollablePositionedList.builder( + padding: EdgeInsets.all(8), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + itemCount: searchedViews.length, + itemScrollController: searchController, + initialScrollIndex: max(0, selectedIndex), + itemBuilder: (context, index) { + final currentView = searchedViews[index]; + return buildPageItem( + currentView, + index == selectedIndex, + onPageLinkSelected, + ); + }, + ), + ), + ); + } + + Widget buildPageItem( + ViewPB view, + bool isSelected, + ValueChanged? onSubmittedPageLink, + ) { + return SizedBox( + height: 32, + child: FlowyButton( + isSelected: isSelected, + leftIcon: buildIcon(view), + text: FlowyText.regular( + view.name, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 20, + ), + onTap: () => onSubmittedPageLink?.call(view), + ), + ); + } + + Widget buildIcon(ViewPB view) { + if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20)); + final iconData = view.icon.toEmojiIconData(); + return RawEmojiIconWidget( + emoji: iconData, + emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20, + lineHeight: 1, + ); + } + + void requestFocus() => focusNode.requestFocus(); + + void unfocus() => focusNode.unfocus(); + + void updateText(String text) => textEditingController.text = text; + + T onSearchResult({ + required ValueGetter onLink, + required ValueGetter onRecentViews, + required ValueGetter onSearchViews, + required ValueGetter onEmpty, + }) { + if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) { + return onEmpty.call(); + } + if (searchedViews.isEmpty && searchText.isNotEmpty) { + return onLink.call(); + } + if (searchedViews.isEmpty) return onRecentViews.call(); + return onSearchViews.call(); + } + + KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + int index = selectedIndex; + if (key.logicalKey == LogicalKeyboardKey.escape) { + onEscape?.call(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.arrowUp) { + index = onSearchResult( + onLink: () => 0, + onRecentViews: () { + int result = index - 1; + if (result < 0) result = recentViews.length - 1; + return result; + }, + onSearchViews: () { + int result = index - 1; + if (result < 0) result = searchedViews.length - 1; + searchController.scrollTo( + index: result, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + return result; + }, + onEmpty: () => 0, + ); + refreshIndex(index); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.arrowDown) { + index = onSearchResult( + onLink: () => 0, + onRecentViews: () { + int result = index + 1; + if (result >= recentViews.length) result = 0; + return result; + }, + onSearchViews: () { + int result = index + 1; + if (result >= searchedViews.length) result = 0; + searchController.scrollTo( + index: result, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + return result; + }, + onEmpty: () => 0, + ); + refreshIndex(index); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.enter) { + onEnter?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future searchRecentViews() async { + final recentService = getIt(); + final sectionViews = await recentService.recentViews(); + final views = sectionViews + .unique((e) => e.item.id) + .map((e) => e.item) + .take(5) + .toList(); + recentViews.clear(); + recentViews.addAll(views); + selectedIndex = 0; + onDataRefresh?.call(); + } + + Future searchViews(String search) async { + final viewResult = await ViewBackendService.getAllViews(); + final allViews = viewResult + .toNullable() + ?.items + .where( + (view) => + view.name.toLowerCase().contains(search.toLowerCase()) || + (view.name.isEmpty && search.isEmpty) || + (view.name.isEmpty && + LocaleKeys.menuAppHeader_defaultNewPageName + .tr() + .toLowerCase() + .contains(search.toLowerCase())), + ) + .take(10) + .toList(); + searchedViews.clear(); + searchedViews.addAll(allViews ?? []); + selectedIndex = 0; + onDataRefresh?.call(); + } + + void refreshIndex(int index) { + selectedIndex = index; + onDataRefresh?.call(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart new file mode 100644 index 0000000000..ee63269dfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class LinkStyle { + static const borderColor = Color(0xFFE8ECF3); + static const textTertiary = Color(0xFF99A1A8); + static const fillThemeThick = Color(0xFF00B5FF); + static const shadowMedium = Color(0x1F22251F); + static const textPrimary = Color(0xFF1F2329); + + static InputDecoration buildLinkTextFieldInputDecoration(String hintText) { + const border = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: LinkStyle.borderColor), + ); + final enableBorder = border.copyWith( + borderSide: BorderSide(color: LinkStyle.fillThemeThick), + ); + const hintStyle = TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + color: LinkStyle.textTertiary, + ); + return InputDecoration( + hintText: hintText, + hintStyle: hintStyle, + contentPadding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + isDense: true, + border: border, + enabledBorder: border, + focusedBorder: enableBorder, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart new file mode 100644 index 0000000000..8e114bf4c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart @@ -0,0 +1,17 @@ +import 'dart:ui'; + +import 'package:bloc/bloc.dart'; + +class ToolbarCubit extends Cubit { + ToolbarCubit(this.onDismissCallback) : super(ToolbarState._()); + + final VoidCallback onDismissCallback; + + void dismiss() { + onDismissCallback.call(); + } +} + +class ToolbarState { + const ToolbarState._(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart index 33333a3e92..91ae1af354 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart @@ -71,11 +71,13 @@ class RawEmojiIconWidget extends StatefulWidget { required this.emoji, required this.emojiSize, this.enableColor = true, + this.lineHeight, }); final EmojiIconData emoji; final double emojiSize; final bool enableColor; + final double? lineHeight; @override State createState() => _RawEmojiIconWidgetState(); @@ -115,6 +117,7 @@ class _RawEmojiIconWidgetState extends State { emoji: widget.emoji.emoji, fontSize: widget.emojiSize, textAlign: TextAlign.justify, + lineHeight: widget.lineHeight, ); case FlowyIconType.icon: IconsData iconData = diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index 398d158e9a..23c05b89eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -118,7 +118,7 @@ class _MentionPageBlockState extends State { view: view, content: state.blockContent, textStyle: widget.textStyle, - handleTap: () => _handleTap( + handleTap: () => handleMentionBlockTap( context, widget.editorState, view, @@ -138,7 +138,7 @@ class _MentionPageBlockState extends State { content: state.blockContent, textStyle: widget.textStyle, showTrashHint: state.isInTrash, - handleTap: () => _handleTap( + handleTap: () => handleMentionBlockTap( context, widget.editorState, view, @@ -221,7 +221,8 @@ class _MentionSubPageBlockState extends State { view: view, showTrashHint: state.isInTrash, textStyle: widget.textStyle, - handleTap: () => _handleTap(context, widget.editorState, view), + handleTap: () => + handleMentionBlockTap(context, widget.editorState, view), isChildPage: true, content: '', handleDoubleTap: () => _handleDoubleTap( @@ -239,7 +240,8 @@ class _MentionSubPageBlockState extends State { content: null, textStyle: widget.textStyle, isChildPage: true, - handleTap: () => _handleTap(context, widget.editorState, view), + handleTap: () => + handleMentionBlockTap(context, widget.editorState, view), ); } }, @@ -321,7 +323,7 @@ Path? _findNodePathByBlockId(EditorState editorState, String blockId) { return null; } -Future _handleTap( +Future handleMentionBlockTap( BuildContext context, EditorState editorState, ViewPB view, { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index c7fa1a34cc..355e7b3b7a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -1,11 +1,16 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'toolbar_id_enum.dart'; +const kIsPageLink = 'is_page_link'; + final customLinkItem = ToolbarItem( id: ToolbarId.link.id, group: 4, @@ -22,6 +27,7 @@ final customLinkItem = ToolbarItem( final hoverColor = isHref ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); + final toolbarCubit = context.read(); final child = FlowyIconButton( width: 36, @@ -33,7 +39,14 @@ final customLinkItem = ToolbarItem( size: Size.square(20.0), color: Theme.of(context).iconTheme.color, ), - onPressed: () => showLinkMenu(context, editorState, selection, isHref), + onPressed: () { + toolbarCubit?.dismiss(); + if (isHref) { + removeLink(editorState, selection, isHref); + } else { + _showLinkMenu(context, editorState, selection, isHref); + } + }, ); if (tooltipBuilder != null) { @@ -48,3 +61,159 @@ final customLinkItem = ToolbarItem( return child; }, ); + +void removeLink( + EditorState editorState, + Selection selection, + bool isHref, +) { + if (!isHref) return; + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..formatText( + node, + index, + length, + { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); +} + +void _showLinkMenu( + BuildContext context, + EditorState editorState, + Selection selection, + bool isHref, +) { + final (left, top, right, bottom, alignment) = _getPosition(editorState); + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: top, + bottom: bottom, + left: left, + right: right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkCreateMenu( + alignment: alignment, + editorState: editorState, + onSubmitted: (link, isPage) async { + await editorState.formatDelta(selection, { + BuiltInAttributeKey.href: link, + kIsPageLink: isPage, + }); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +extension AttributeExtension on Attributes { + bool get isPage { + if (this[kIsPageLink] is bool) { + return this[kIsPageLink]; + } + return false; + } +} + +// get a proper position for link menu +( + double? left, + double? top, + double? right, + double? bottom, + LinkMenuAlignment alignment, +) _getPosition( + EditorState editorState, +) { + final rect = editorState.selectionRects().first; + const menuHeight = 222.0, menuWidth = 320.0; + + double? left, right, top, bottom; + LinkMenuAlignment alignment = LinkMenuAlignment.topLeft; + final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero), + editorSize = editorState.renderBox!.size; + final editorBottom = editorSize.height + editorOffset.dy, + editorRight = editorSize.width + editorOffset.dx; + final overflowBottom = rect.bottom + menuHeight > editorBottom, + overflowTop = rect.top - menuHeight < 0, + overflowLeft = rect.left - menuWidth < 0, + overflowRight = rect.right + menuWidth > editorRight; + + if (overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else if (overflowBottom && !overflowTop) { + /// show at top + bottom = editorBottom - rect.top; + } else if (!overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else { + top = 0; + } + + if (overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else if (overflowRight && !overflowLeft) { + /// show at left + right = editorRight - rect.right; + } else if (!overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else { + left = 0; + } + + if (left != null && top != null) { + alignment = LinkMenuAlignment.bottomRight; + } else if (left != null && bottom != null) { + alignment = LinkMenuAlignment.topRight; + } else if (right != null && top != null) { + alignment = LinkMenuAlignment.bottomLeft; + } else if (right != null && bottom != null) { + alignment = LinkMenuAlignment.topLeft; + } + + return (left, top, right, bottom, alignment); +} + +enum LinkMenuAlignment { + topLeft, + topRight, + bottomLeft, + bottomRight, +} + +extension LinkMenuAlignmentExtension on LinkMenuAlignment { + bool get isTop => + this == LinkMenuAlignment.topLeft || this == LinkMenuAlignment.topRight; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 4b4b2d135a..c602ade2c4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -28,6 +28,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; class EditorStyleCustomizer { @@ -134,6 +135,7 @@ class EditorStyleCustomizer { textSpanDecorator: customizeAttributeDecorator, textScaleFactor: context.watch().state.textScaleFactor, + textSpanOverlayBuilder: _buildTextSpanOverlay, ); } @@ -468,14 +470,15 @@ class EditorStyleCustomizer { ); } - return defaultTextSpanDecoratorForAttribute( - context, - node, - index, - text, - before, - after, - ); + if (href != null) { + return TextSpan( + style: before.style, + text: text.text, + mouseCursor: SystemMouseCursors.click, + ); + } else { + return before; + } } Widget buildToolbarItemTooltip( @@ -590,4 +593,54 @@ class EditorStyleCustomizer { _ => style, }; } + + List _buildTextSpanOverlay( + BuildContext context, + Node node, + SelectableMixin delegate, + ) { + final delta = node.delta; + if (delta == null) { + return []; + } + final widgets = []; + final textInserts = delta.whereType(); + int index = 0; + final editorState = context.read(); + for (final textInsert in textInserts) { + if (textInsert.attributes?.href != null) { + final nodeSelection = Selection( + start: Position(path: node.path, offset: index), + end: Position( + path: node.path, + offset: index + textInsert.length, + ), + ); + final rectList = delegate.getRectsInSelection(nodeSelection); + if (rectList.isNotEmpty) { + for (final rect in rectList) { + widgets.add( + Positioned( + left: rect.left, + top: rect.top, + child: SizedBox( + width: rect.width, + height: rect.height, + child: LinkHoverTrigger( + editorState: editorState, + selection: nodeSelection, + attribute: textInsert.attributes!, + node: node, + size: rect.size, + ), + ), + ), + ); + } + } + } + index += textInsert.length; + } + return widgets; + } } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index b4a1a3d20d..30ee626f09 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 - auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c - irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 - local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 9d25672622..0ef500d967 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -25,6 +25,7 @@ class AppFlowyPopover extends StatelessWidget { this.skipTraversal = false, this.decorationColor, this.borderRadius, + this.popoverDecoration, this.animationDuration = const Duration(), this.slideDistance = 5.0, this.beginScaleFactor = 0.9, @@ -56,6 +57,7 @@ class AppFlowyPopover extends StatelessWidget { final double endScaleFactor; final double beginOpacity; final double endOpacity; + final Decoration? popoverDecoration; /// The widget that will be used to trigger the popover. /// @@ -102,6 +104,7 @@ class AppFlowyPopover extends StatelessWidget { popupBuilder: (context) => _PopoverContainer( constraints: constraints, margin: margin, + decoration: popoverDecoration, decorationColor: decorationColor, borderRadius: borderRadius, child: popupBuilder(context), @@ -116,6 +119,7 @@ class _PopoverContainer extends StatelessWidget { const _PopoverContainer({ this.decorationColor, this.borderRadius, + this.decoration, required this.child, required this.margin, required this.constraints, @@ -126,6 +130,7 @@ class _PopoverContainer extends StatelessWidget { final EdgeInsets margin; final Color? decorationColor; final BorderRadius? borderRadius; + final Decoration? decoration; @override Widget build(BuildContext context) { @@ -133,10 +138,11 @@ class _PopoverContainer extends StatelessWidget { type: MaterialType.transparency, child: Container( padding: margin, - decoration: context.getPopoverDecoration( - color: decorationColor, - borderRadius: borderRadius, - ), + decoration: decoration ?? + context.getPopoverDecoration( + color: decorationColor, + borderRadius: borderRadius, + ), constraints: constraints, child: child, ), diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 4d580a4fd9..dcaefebed4 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -90,8 +90,8 @@ packages: dependency: "direct main" description: path: "." - ref: "5070212" - resolved-ref: "5070212ee0f02182a8acdd760b4d7b42264baec4" + ref: "50f9724" + resolved-ref: "50f9724190ee47eac3d7dbe323a3ce39d19ea883" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 25ce68a289..19e30fa4a2 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -180,7 +180,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "5070212" + ref: "50f9724" appflowy_editor_plugins: git: diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg new file mode 100644 index 0000000000..57cb67da9a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg new file mode 100644 index 0000000000..fc8765fa5b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg new file mode 100644 index 0000000000..e1061b914a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 7d8cba8b13..2c4dfa4371 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2120,7 +2120,12 @@ "inlineCode": "Inline code", "suggestions": "Suggestions", "turnInto": "Turn into", - "equation": "Equation" + "equation": "Equation", + "insert": "Insert", + "linkInputHint": "Paste link or search pages", + "pageOrURL": "Page or URL", + "linkName": "Link Name", + "linkNameHint": "Input link name" }, "errorBlock": { "theBlockIsNotSupported": "Unable to parse the block content", From 910c45e45741844d4027bbf7b85882d3a97851d5 Mon Sep 17 00:00:00 2001 From: Morn Date: Mon, 24 Mar 2025 09:55:58 +0800 Subject: [PATCH 017/207] chore: change some mobile slash menu icons (#7579) --- .../slash_menu_items/image_item.dart | 25 +++++----- .../slash_menu_items/mobile_items.dart | 6 +-- .../slash_menu_items/sub_page_item.dart | 49 ++++++++++--------- .../flowy_icons/20x/slash_menu_image.svg | 5 ++ 4 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 frontend/resources/flowy_icons/20x/slash_menu_image.svg diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart index f0ce852e41..844b73c3e1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart @@ -17,17 +17,20 @@ final _keywords = [ ]; /// Image menu item -final imageSlashMenuItem = SelectionMenuItem( - getName: () => LocaleKeys.document_slashMenu_name_image.tr(), - keywords: _keywords, - handler: (editorState, _, __) async => editorState.insertImageBlock(), - nameBuilder: slashMenuItemNameBuilder, - icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_image_s, - isSelected: isSelected, - style: style, - ), -); +final imageSlashMenuItem = buildImageSlashMenuItem(); + +SelectionMenuItem buildImageSlashMenuItem({FlowySvgData? svg}) => + SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_image.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertImageBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: svg ?? FlowySvgs.slash_menu_icon_image_s, + isSelected: isSelected, + style: style, + ), + ); extension on EditorState { Future insertImageBlock() async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart index b69e74bd74..b71b54ad40 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart @@ -15,7 +15,7 @@ final List mobileItems = [ mobileTableSlashMenuItem, visualsMobileSlashMenuItem, dateOrReminderSlashMenuItem, - subPageSlashMenuItem, + buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), advancedMobileSlashMenuItem, ]; @@ -26,7 +26,7 @@ final List mobileItemsInTale = [ fileAndMediaMobileSlashMenuItem, visualsMobileSlashMenuItem, dateOrReminderSlashMenuItem, - subPageSlashMenuItem, + buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), advancedMobileSlashMenuItem, ]; @@ -93,7 +93,7 @@ MobileSelectionMenuItem fileAndMediaMobileSlashMenuItem = ), nameBuilder: slashMenuItemNameBuilder, children: [ - imageSlashMenuItem, + buildImageSlashMenuItem(svg: FlowySvgs.slash_menu_image_m), photoGallerySlashMenuItem, fileSlashMenuItem, ], diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart index bc7f7e46b4..1052dbbe3e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart @@ -22,26 +22,29 @@ final _keywords = [ ]; // Sub-page menu item -SelectionMenuItem subPageSlashMenuItem = SelectionMenuItem.node( - getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(), - keywords: _keywords, - updateSelection: (editorState, path, __, ___) { - final context = editorState.document.root.context; - if (context != null) { - final isInDatabase = - context.read().isInDatabaseRowPage; - if (isInDatabase) { - Navigator.of(context).pop(); - } - } - return Selection.collapsed(Position(path: path)); - }, - replace: (_, node) => node.delta?.isEmpty ?? false, - nodeBuilder: (_, __) => subPageNode(), - nameBuilder: slashMenuItemNameBuilder, - iconBuilder: (_, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.insert_document_s, - isSelected: isSelected, - style: style, - ), -); +SelectionMenuItem subPageSlashMenuItem = buildSubpageSlashMenuItem(); + +SelectionMenuItem buildSubpageSlashMenuItem({FlowySvgData? svg}) => + SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(), + keywords: _keywords, + updateSelection: (editorState, path, __, ___) { + final context = editorState.document.root.context; + if (context != null) { + final isInDatabase = + context.read().isInDatabaseRowPage; + if (isInDatabase) { + Navigator.of(context).pop(); + } + } + return Selection.collapsed(Position(path: path)); + }, + replace: (_, node) => node.delta?.isEmpty ?? false, + nodeBuilder: (_, __) => subPageNode(), + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: svg ?? FlowySvgs.insert_document_s, + isSelected: isSelected, + style: style, + ), + ); diff --git a/frontend/resources/flowy_icons/20x/slash_menu_image.svg b/frontend/resources/flowy_icons/20x/slash_menu_image.svg new file mode 100644 index 0000000000..f5b7917ad3 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/slash_menu_image.svg @@ -0,0 +1,5 @@ + + + + + From 08d1d3602ebb9e13a46af07e6e334156333ea1d1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 10:44:20 +0800 Subject: [PATCH 018/207] chore: implement MCP client --- frontend/rust-lib/Cargo.lock | 158 ++++++++++++++++++ frontend/rust-lib/flowy-ai/Cargo.toml | 1 + frontend/rust-lib/flowy-ai/src/lib.rs | 1 + .../flowy-ai/src/mcp/client_manager.rs | 11 ++ frontend/rust-lib/flowy-ai/src/mcp/mod.rs | 1 + 5 files changed, 172 insertions(+) create mode 100644 frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs create mode 100644 frontend/rust-lib/flowy-ai/src/mcp/mod.rs diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 60d65be5cc..4d19fb2c2f 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -154,6 +154,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + [[package]] name = "anyhow" version = "1.0.94" @@ -257,6 +306,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -764,6 +819,12 @@ dependencies = [ "phf_codegen 0.11.2", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -785,6 +846,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.0", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + [[package]] name = "client-api" version = "0.2.0" @@ -1181,6 +1282,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2041,6 +2148,7 @@ dependencies = [ "lib-dispatch", "lib-infra", "log", + "mcpr", "md5", "notify", "pin-project", @@ -3088,6 +3196,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.2" @@ -3681,6 +3795,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" version = "0.10.5" @@ -4088,6 +4208,25 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" +[[package]] +name = "mcpr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a434a4b9dfa1c5dcef97ee84fdf4c600bab479df9a24259b9e5474e26f6cd47" +dependencies = [ + "anyhow", + "clap", + "log", + "rand 0.8.5", + "reqwest 0.12.9", + "serde", + "serde_json", + "thiserror 1.0.64", + "tiny_http", + "tungstenite", + "url", +] + [[package]] name = "md-5" version = "0.10.5" @@ -5609,6 +5748,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.7", @@ -6879,6 +7019,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -7525,6 +7677,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.10.0" diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index 2db562aa0d..1aa8fcbdb3 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -50,6 +50,7 @@ collab-integrate.workspace = true [target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] notify = "6.1.1" +mcpr = "0.2.3" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.55" diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index be6c743d86..7b26dc1b1d 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -6,6 +6,7 @@ mod chat; mod completion; pub mod entities; mod local_ai; +mod mcp; mod middleware; pub mod notification; mod persistence; diff --git a/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs b/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs new file mode 100644 index 0000000000..80eae6a9bd --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs @@ -0,0 +1,11 @@ +use dashmap::DashMap; +use mcpr::transport::Transport; +use mcpr::{client::Client, transport::sse::SSETransport, transport::stdio::StdioTransport}; +use std::sync::Arc; + +pub struct MCPClientManager { + stdio_clients: Arc>>, + http_client: Arc>>, +} + +impl MCPClientManager {} diff --git a/frontend/rust-lib/flowy-ai/src/mcp/mod.rs b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs new file mode 100644 index 0000000000..62badbf1f9 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs @@ -0,0 +1 @@ +mod client_manager; From 949556e2fa3be43df6263f373fdbdb2babaa1105 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 12:03:42 +0800 Subject: [PATCH 019/207] chore: remove tauri feature --- frontend/rust-lib/Cargo.lock | 42 ++++++++++++------- frontend/rust-lib/Cargo.toml | 4 +- frontend/rust-lib/flowy-ai/Cargo.toml | 2 - frontend/rust-lib/flowy-ai/build.rs | 16 ------- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 36 ++++++++++++---- frontend/rust-lib/flowy-core/Cargo.toml | 8 ---- frontend/rust-lib/flowy-database2/Cargo.toml | 1 - frontend/rust-lib/flowy-database2/build.rs | 30 ++++++------- frontend/rust-lib/flowy-date/Cargo.toml | 1 - frontend/rust-lib/flowy-date/build.rs | 16 ------- frontend/rust-lib/flowy-document/Cargo.toml | 5 --- frontend/rust-lib/flowy-document/build.rs | 16 ------- frontend/rust-lib/flowy-error/Cargo.toml | 2 - frontend/rust-lib/flowy-error/build.rs | 14 ------- frontend/rust-lib/flowy-folder/Cargo.toml | 2 - frontend/rust-lib/flowy-folder/build.rs | 16 ------- .../rust-lib/flowy-notification/Cargo.toml | 2 - frontend/rust-lib/flowy-notification/build.rs | 14 ------- frontend/rust-lib/flowy-search/Cargo.toml | 11 +++-- frontend/rust-lib/flowy-search/build.rs | 11 +---- frontend/rust-lib/flowy-storage/Cargo.toml | 1 - frontend/rust-lib/flowy-storage/build.rs | 16 ------- frontend/rust-lib/flowy-user/Cargo.toml | 1 - frontend/rust-lib/flowy-user/build.rs | 16 ------- 24 files changed, 80 insertions(+), 203 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 4e1c67233d..6b6af73c8f 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "bytes", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "again", "anyhow", @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "collab-entity", "collab-rt-entity", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "futures-channel", "futures-util", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "bincode", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "async-trait", @@ -1398,7 +1398,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1546,7 +1546,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "bincode", "bytes", @@ -2980,7 +2980,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -2995,7 +2995,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "app-error", "jsonwebtoken", @@ -3610,7 +3610,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "bytes", @@ -4647,7 +4647,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros", + "phf_macros 0.8.0", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -4667,6 +4667,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -4734,6 +4735,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "phf_shared" version = "0.8.0" @@ -6163,7 +6177,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d77dacae32bc25440ca61f675900b80fba6cc9a2#d77dacae32bc25440ca61f675900b80fba6cc9a2" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 6b524c41fd..a660ebcb5e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d77dacae32bc25440ca61f675900b80fba6cc9a2" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d77dacae32bc25440ca61f675900b80fba6cc9a2" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "dfd44f8fb7152ace03eb03e46391cf76266948b9" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "dfd44f8fb7152ace03eb03e46391cf76266948b9" } [profile.dev] opt-level = 0 diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index 2db562aa0d..6564b967e4 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -67,5 +67,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] -web_ts = ["flowy-codegen/ts", "flowy-notification/web_ts"] diff --git a/frontend/rust-lib/flowy-ai/build.rs b/frontend/rust-lib/flowy-ai/build.rs index e9230d3d6d..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-ai/build.rs +++ b/frontend/rust-lib/flowy-ai/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 535a3bf8c8..a904bd2c7b 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -55,6 +55,20 @@ pub trait AIExternalService: Send + Sync + 'static { async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError>; } +struct ServerModelsCache { + models: Vec, + timestamp: Option, +} + +impl Default for ServerModelsCache { + fn default() -> Self { + Self { + models: Vec::new(), + timestamp: None, + } + } +} + pub struct AIManager { pub cloud_service_wm: Arc, pub user_service: Arc, @@ -62,7 +76,7 @@ pub struct AIManager { chats: Arc>>, pub local_ai: Arc, pub store_preferences: Arc, - server_models: Arc>>, + server_models: Arc>, } impl AIManager { @@ -270,16 +284,22 @@ impl AIManager { pub async fn get_server_available_models(&self) -> FlowyResult> { let workspace_id = self.user_service.workspace_id()?; + let now = timestamp(); - // First, try reading from the cache. + // First, try reading from the cache with expiration check { let cached_models = self.server_models.read().await; - if !cached_models.is_empty() { - return Ok(cached_models.clone()); + if !cached_models.models.is_empty() { + if let Some(timestamp) = cached_models.timestamp { + // Cache is valid if less than 5 minutes (300 seconds) old + if now - timestamp < 300 { + return Ok(cached_models.models.clone()); + } + } } } - // Cache miss: fetch from the cloud. + // Cache miss or expired: fetch from the cloud. let list = self .cloud_service_wm .get_available_models(&workspace_id) @@ -290,8 +310,10 @@ impl AIManager { .map(|m| m.name) .collect::>(); - // Update the cache. - *self.server_models.write().await = models.clone(); + // Update the cache with new timestamp + let mut cache = self.server_models.write().await; + cache.models = models.clone(); + cache.timestamp = Some(now); Ok(models) } diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 7977c33d4f..8c33996046 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -74,14 +74,6 @@ dart = [ "flowy-ai/dart", "flowy-storage/dart", ] -#ts = [ -# "flowy-user/tauri_ts", -# "flowy-folder/tauri_ts", -# "flowy-search/tauri_ts", -# "flowy-database2/ts", -# "flowy-ai/tauri_ts", -# "flowy-storage/tauri_ts", -#] openssl_vendored = ["flowy-sqlite/openssl_vendored"] # Enable/Disable AppFlowy Verbose Log Configuration diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index a6b676512d..d7bee42420 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -62,5 +62,4 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] verbose_log = ["collab-database/verbose_log"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database2/build.rs b/frontend/rust-lib/flowy-database2/build.rs index aeaaee42f3..e10aed7956 100644 --- a/frontend/rust-lib/flowy-database2/build.rs +++ b/frontend/rust-lib/flowy-database2/build.rs @@ -5,19 +5,19 @@ fn main() { flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - #[cfg(feature = "ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } + // #[cfg(feature = "ts")] + // { + // flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + // flowy_codegen::protobuf_file::ts_gen( + // env!("CARGO_PKG_NAME"), + // env!("CARGO_PKG_NAME"), + // flowy_codegen::Project::Tauri, + // ); + // flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); + // flowy_codegen::protobuf_file::ts_gen( + // env!("CARGO_PKG_NAME"), + // env!("CARGO_PKG_NAME"), + // flowy_codegen::Project::TauriApp, + // ); + // } } diff --git a/frontend/rust-lib/flowy-date/Cargo.toml b/frontend/rust-lib/flowy-date/Cargo.toml index 40015cad77..d04dfd8416 100644 --- a/frontend/rust-lib/flowy-date/Cargo.toml +++ b/frontend/rust-lib/flowy-date/Cargo.toml @@ -24,4 +24,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-date/build.rs b/frontend/rust-lib/flowy-date/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-date/build.rs +++ b/frontend/rust-lib/flowy-date/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-document/Cargo.toml b/frontend/rust-lib/flowy-document/Cargo.toml index 77aa321d3c..aaaef4938e 100644 --- a/frontend/rust-lib/flowy-document/Cargo.toml +++ b/frontend/rust-lib/flowy-document/Cargo.toml @@ -50,10 +50,5 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = [ - "flowy-codegen/ts", -] - # search "Enable/Disable AppFlowy Verbose Log" to find the place that can enable verbose log verbose_log = ["collab-document/verbose_log"] diff --git a/frontend/rust-lib/flowy-document/build.rs b/frontend/rust-lib/flowy-document/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-document/build.rs +++ b/frontend/rust-lib/flowy-document/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index d521e26f4d..7502370a0f 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -54,8 +54,6 @@ impl_from_tantivy = ["tantivy"] impl_from_sqlite = ["flowy-sqlite", "r2d2"] impl_from_appflowy_cloud = ["client-api"] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = ["flowy-codegen/ts"] [build-dependencies] flowy-codegen = { workspace = true, features = ["proto_gen"] } diff --git a/frontend/rust-lib/flowy-error/build.rs b/frontend/rust-lib/flowy-error/build.rs index 81f0556ae3..8dfda67156 100644 --- a/frontend/rust-lib/flowy-error/build.rs +++ b/frontend/rust-lib/flowy-error/build.rs @@ -1,18 +1,4 @@ fn main() { #[cfg(feature = "dart")] flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-folder/Cargo.toml b/frontend/rust-lib/flowy-folder/Cargo.toml index 1a36ecce2c..998fcb84f5 100644 --- a/frontend/rust-lib/flowy-folder/Cargo.toml +++ b/frontend/rust-lib/flowy-folder/Cargo.toml @@ -50,6 +50,4 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] -web_ts = ["flowy-codegen/ts", "flowy-notification/web_ts"] test_helper = [] diff --git a/frontend/rust-lib/flowy-folder/build.rs b/frontend/rust-lib/flowy-folder/build.rs index e9230d3d6d..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-folder/build.rs +++ b/frontend/rust-lib/flowy-folder/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-notification/Cargo.toml b/frontend/rust-lib/flowy-notification/Cargo.toml index b7a96898ff..3851546541 100644 --- a/frontend/rust-lib/flowy-notification/Cargo.toml +++ b/frontend/rust-lib/flowy-notification/Cargo.toml @@ -25,5 +25,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-notification/build.rs b/frontend/rust-lib/flowy-notification/build.rs index 81f0556ae3..8dfda67156 100644 --- a/frontend/rust-lib/flowy-notification/build.rs +++ b/frontend/rust-lib/flowy-notification/build.rs @@ -1,18 +1,4 @@ fn main() { #[cfg(feature = "dart")] flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-search/Cargo.toml b/frontend/rust-lib/flowy-search/Cargo.toml index 2769f55479..f4d6207783 100644 --- a/frontend/rust-lib/flowy-search/Cargo.toml +++ b/frontend/rust-lib/flowy-search/Cargo.toml @@ -11,11 +11,11 @@ collab-folder = { workspace = true } flowy-derive.workspace = true flowy-error = { workspace = true, features = [ - "impl_from_sqlite", - "impl_from_dispatch_error", - "impl_from_collab_document", - "impl_from_tantivy", - "impl_from_serde", + "impl_from_sqlite", + "impl_from_dispatch_error", + "impl_from_collab_document", + "impl_from_tantivy", + "impl_from_serde", ] } flowy-notification.workspace = true flowy-sqlite.workspace = true @@ -52,4 +52,3 @@ tempfile = "3.10.0" [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-search/build.rs b/frontend/rust-lib/flowy-search/build.rs index 2600d32fb7..482ac05272 100644 --- a/frontend/rust-lib/flowy-search/build.rs +++ b/frontend/rust-lib/flowy-search/build.rs @@ -1,8 +1,5 @@ -#[cfg(feature = "tauri_ts")] -use flowy_codegen::Project; - fn main() { - #[cfg(any(feature = "dart", feature = "tauri_ts"))] + #[cfg(any(feature = "dart"))] let crate_name = env!("CARGO_PKG_NAME"); #[cfg(feature = "dart")] @@ -10,10 +7,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(crate_name); flowy_codegen::dart_event::gen(crate_name); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen(crate_name, crate_name, Project::Tauri); - flowy_codegen::ts_event::gen(crate_name, Project::Tauri); - } } diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index 405faed1ba..c57cfde484 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -36,7 +36,6 @@ rand = { version = "0.8", features = ["std_rng"] } [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] [build-dependencies] flowy-codegen.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage/build.rs b/frontend/rust-lib/flowy-storage/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-storage/build.rs +++ b/frontend/rust-lib/flowy-storage/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 2a02043f38..5ad579a0fb 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -60,7 +60,6 @@ quickcheck_macros = "1.0" [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] [build-dependencies] flowy-codegen.workspace = true diff --git a/frontend/rust-lib/flowy-user/build.rs b/frontend/rust-lib/flowy-user/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-user/build.rs +++ b/frontend/rust-lib/flowy-user/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } From 2cbcb320fe706aca1d45a399cf3428f82dd7cdeb Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 12:11:12 +0800 Subject: [PATCH 020/207] chore: add timeout --- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 77 ++++++++++++++------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index a904bd2c7b..81ab36bed6 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -284,37 +284,70 @@ impl AIManager { pub async fn get_server_available_models(&self) -> FlowyResult> { let workspace_id = self.user_service.workspace_id()?; - let now = timestamp(); + + let now = timestamp(); // This is safer than using SystemTime which could fail // First, try reading from the cache with expiration check - { + let should_fetch = { let cached_models = self.server_models.read().await; - if !cached_models.models.is_empty() { - if let Some(timestamp) = cached_models.timestamp { - // Cache is valid if less than 5 minutes (300 seconds) old - if now - timestamp < 300 { - return Ok(cached_models.models.clone()); - } - } - } + cached_models.models.is_empty() || cached_models.timestamp.map_or(true, |ts| now - ts >= 300) + }; + + if !should_fetch { + // Cache is still valid, return cached data + let cached_models = self.server_models.read().await; + return Ok(cached_models.models.clone()); } // Cache miss or expired: fetch from the cloud. - let list = self + match self .cloud_service_wm .get_available_models(&workspace_id) - .await?; - let models = list - .models - .into_iter() - .map(|m| m.name) - .collect::>(); + .await + { + Ok(list) => { + let models = list + .models + .into_iter() + .map(|m| m.name) + .collect::>(); - // Update the cache with new timestamp - let mut cache = self.server_models.write().await; - cache.models = models.clone(); - cache.timestamp = Some(now); - Ok(models) + // Update the cache with new timestamp - handle potential errors + if let Err(err) = self.update_models_cache(&models, now).await { + error!("Failed to update models cache: {}", err); + // Still return the fetched models even if caching failed + } + + Ok(models) + }, + Err(err) => { + error!("Failed to fetch available models: {}", err); + + // Return cached data if available, even if expired + let cached_models = self.server_models.read().await; + if !cached_models.models.is_empty() { + info!("Returning expired cached models due to fetch failure"); + return Ok(cached_models.models.clone()); + } + + // If no cached data, return empty list + Ok(Vec::new()) + }, + } + } + + async fn update_models_cache(&self, models: &[String], timestamp: i64) -> FlowyResult<()> { + match self.server_models.try_write() { + Ok(mut cache) => { + cache.models = models.to_vec(); + cache.timestamp = Some(timestamp); + Ok(()) + }, + Err(_) => { + // Handle lock acquisition failure + Err(FlowyError::internal().with_context("Failed to acquire write lock for models cache")) + }, + } } pub async fn update_selected_model(&self, source: String, model: AIModelPB) -> FlowyResult<()> { From 37085042f8a818575657fc96a6ea67f9fc652eb0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 12:40:29 +0800 Subject: [PATCH 021/207] chore: clippy --- .../build-tool/flowy-codegen/src/protobuf_file/mod.rs | 2 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 10 +--------- frontend/rust-lib/flowy-search/build.rs | 7 ++----- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs index 1ddb1bdeb0..8baa78c096 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs @@ -77,7 +77,7 @@ pub fn dart_gen(crate_name: &str) { } #[allow(unused_variables)] -pub fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { +fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { // 1. generate the proto files to proto_file_dir #[cfg(feature = "proto_gen")] let proto_crates = gen_proto_files(crate_name); diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 81ab36bed6..e1e6a2724a 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -55,20 +55,12 @@ pub trait AIExternalService: Send + Sync + 'static { async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError>; } +#[derive(Debug, Default)] struct ServerModelsCache { models: Vec, timestamp: Option, } -impl Default for ServerModelsCache { - fn default() -> Self { - Self { - models: Vec::new(), - timestamp: None, - } - } -} - pub struct AIManager { pub cloud_service_wm: Arc, pub user_service: Arc, diff --git a/frontend/rust-lib/flowy-search/build.rs b/frontend/rust-lib/flowy-search/build.rs index 482ac05272..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-search/build.rs +++ b/frontend/rust-lib/flowy-search/build.rs @@ -1,10 +1,7 @@ fn main() { - #[cfg(any(feature = "dart"))] - let crate_name = env!("CARGO_PKG_NAME"); - #[cfg(feature = "dart")] { - flowy_codegen::protobuf_file::dart_gen(crate_name); - flowy_codegen::dart_event::gen(crate_name); + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } } From dfb5a6629f8bd33777d589c77262cbd12ba42179 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:27:31 +0800 Subject: [PATCH 022/207] chore: pass ai writer context (#7596) * chore: pass ai writer context * chore: maintain selection after starting ai writer * chore: improve ui of additional comments * chore: revert podfile.lock changes * chore: code readability * chore: revert podfile.lock changes * fix: accept shouldn't try to unformat --- .../desktop_prompt_text_field.dart | 30 +- .../lib/plugins/ai_chat/chat_page.dart | 16 +- .../chat_input/mobile_chat_input.dart | 4 +- .../document/presentation/editor_page.dart | 2 +- .../ai/ai_writer_block_component.dart | 213 +++++++------ .../ai_writer_block_operations.dart | 22 +- .../ai/operations/ai_writer_cubit.dart | 298 +++++++++++------- .../ai/operations/ai_writer_entities.dart | 13 +- .../ai_writer_prompt_input_more_button.dart | 20 +- .../ai/widgets/ai_writer_scroll_wrapper.dart | 31 +- .../base/markdown_text_robot.dart | 2 +- 11 files changed, 388 insertions(+), 263 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index 2cd74b5944..0fbdeafee4 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -20,6 +20,7 @@ class DesktopPromptInput extends StatefulWidget { const DesktopPromptInput({ super.key, required this.isStreaming, + required this.textController, required this.onStopStreaming, required this.onSubmitted, required this.selectedSourcesNotifier, @@ -29,6 +30,7 @@ class DesktopPromptInput extends StatefulWidget { }); final bool isStreaming; + final TextEditingController textController; final void Function() onStopStreaming; final void Function(String, PredefinedFormat?, Map) onSubmitted; @@ -47,7 +49,6 @@ class _DesktopPromptInputState extends State { final overlayController = OverlayPortalController(); final inputControlCubit = ChatInputControlCubit(); final focusNode = FocusNode(); - final textController = TextEditingController(); late SendButtonState sendButtonState; bool isComposing = false; @@ -56,7 +57,7 @@ class _DesktopPromptInputState extends State { void initState() { super.initState(); - textController.addListener(handleTextControllerChanged); + widget.textController.addListener(handleTextControllerChanged); focusNode.addListener( () { if (!widget.hideDecoration) { @@ -84,7 +85,7 @@ class _DesktopPromptInputState extends State { @override void dispose() { focusNode.dispose(); - textController.dispose(); + widget.textController.removeListener(handleTextControllerChanged); inputControlCubit.close(); super.dispose(); } @@ -109,7 +110,7 @@ class _DesktopPromptInputState extends State { overlayChildBuilder: (context) { return PromptInputMentionPageMenu( anchor: PromptInputAnchor(textFieldKey, layerLink), - textController: textController, + textController: widget.textController, onPageSelected: handlePageSelected, ); }, @@ -224,12 +225,12 @@ class _DesktopPromptInputState extends State { if (!focusNode.hasFocus) { focusNode.requestFocus(); } - textController.text += '@'; + widget.textController.text += '@'; WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { context .read() - .startSearching(textController.value); + .startSearching(widget.textController.value); overlayController.show(); } }); @@ -245,7 +246,7 @@ class _DesktopPromptInputState extends State { void updateSendButtonState() { if (widget.isStreaming) { sendButtonState = SendButtonState.streaming; - } else if (textController.text.trim().isEmpty) { + } else if (widget.textController.text.trim().isEmpty) { sendButtonState = SendButtonState.disabled; } else { sendButtonState = SendButtonState.enabled; @@ -257,9 +258,9 @@ class _DesktopPromptInputState extends State { return; } final trimmedText = inputControlCubit.formatIntputText( - textController.text.trim(), + widget.textController.text.trim(), ); - textController.clear(); + widget.textController.clear(); if (trimmedText.isEmpty) { return; } @@ -282,7 +283,7 @@ class _DesktopPromptInputState extends State { setState(() { // update whether send button is clickable updateSendButtonState(); - isComposing = !textController.value.composing.isCollapsed; + isComposing = !widget.textController.value.composing.isCollapsed; }); if (isComposing) { @@ -300,6 +301,7 @@ class _DesktopPromptInputState extends State { } // handle cases where mention a page is cancelled + final textController = widget.textController; final textSelection = textController.value.selection; final isSelectingMultipleCharacters = !textSelection.isCollapsed; final isCaretBeforeStartOfRange = @@ -348,7 +350,7 @@ class _DesktopPromptInputState extends State { KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { if (event.character == '@') { WidgetsBinding.instance.addPostFrameCallback((_) { - inputControlCubit.startSearching(textController.value); + inputControlCubit.startSearching(widget.textController.value); overlayController.show(); }); } @@ -356,12 +358,12 @@ class _DesktopPromptInputState extends State { } void handlePageSelected(ViewPB view) { - final newText = textController.text.replaceRange( + final newText = widget.textController.text.replaceRange( inputControlCubit.filterStartPosition, inputControlCubit.filterEndPosition, view.id, ); - textController.value = TextEditingValue( + widget.textController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: inputControlCubit.filterStartPosition + view.id.length, @@ -386,7 +388,7 @@ class _DesktopPromptInputState extends State { key: textFieldKey, editable: state.editable, cubit: inputControlCubit, - textController: textController, + textController: widget.textController, textFieldFocusNode: focusNode, contentPadding: calculateContentPadding(state.showPredefinedFormats), 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 a666039ef2..cbc4929f56 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -385,13 +385,26 @@ class _ChatContentPage extends StatelessWidget { } } -class _Input extends StatelessWidget { +class _Input extends StatefulWidget { const _Input({ required this.view, }); final ViewPB view; + @override + State<_Input> createState() => _InputState(); +} + +class _InputState extends State<_Input> { + final textController = TextEditingController(); + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocSelector( @@ -421,6 +434,7 @@ class _Input extends StatelessWidget { return UniversalPlatform.isDesktop ? DesktopPromptInput( isStreaming: !canSendMessage, + textController: textController, onStopStreaming: () { chatBloc.add(const ChatEvent.stopStream()); }, diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart index 0aa7465dfb..76d1af7134 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart @@ -41,7 +41,7 @@ class _MobileChatInputState extends State { void initState() { super.initState(); - textController.addListener(handleTextControllerChange); + textController.addListener(handleTextControllerChanged); // focusNode.onKeyEvent = handleKeyEvent; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -197,7 +197,7 @@ class _MobileChatInputState extends State { ); } - void handleTextControllerChange() { + void handleTextControllerChanged() { if (textController.value.isComposingRangeValid) { return; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index f445e698aa..b14d930d65 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -393,7 +393,7 @@ class _AppFlowyEditorPageState extends State }, child: SizedBox( width: double.infinity, - height: UniversalPlatform.isDesktopOrWeb ? 300 : 400, + height: UniversalPlatform.isDesktopOrWeb ? 600 : 400, ), ), dropTargetStyle: AppFlowyDropTargetStyle( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 1d25723854..a35d0cfbf8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -107,9 +107,7 @@ class _AIWriterBlockComponentState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { overlayController.show(); - if (!widget.node.isAiWriterInitialized) { - context.read().register(widget.node); - } + context.read().register(widget.node); }); } @@ -150,6 +148,7 @@ class _AIWriterBlockComponentState extends State { child: OverlayContent( editorState: editorState, node: widget.node, + textController: textController, ), ), ), @@ -178,10 +177,12 @@ class OverlayContent extends StatefulWidget { super.key, required this.editorState, required this.node, + required this.textController, }); final EditorState editorState; final Node node; + final TextEditingController textController; @override State createState() => _OverlayContentState(); @@ -200,31 +201,40 @@ class _OverlayContentState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - if (state is IdleAiWriterState) { + if (state is IdleAiWriterState || + state is DocumentContentEmptyAiWriterState) { return const SizedBox.shrink(); } + + final command = (state as RegisteredAiWriter).command; + final selection = widget.node.aiWriterSelection; - final showSuggestionPopup = - state is ReadyAiWriterState && !state.isFirstRun; - final isInitialReadyState = - state is ReadyAiWriterState && state.isFirstRun; + final hasSelection = selection != null && !selection.isCollapsed; + final markdownText = switch (state) { final ReadyAiWriterState ready => ready.markdownText, final GeneratingAiWriterState generating => generating.markdownText, _ => '', }; - final hasSelection = selection != null && !selection.isCollapsed; - final isLightMode = Theme.of(context).isLightMode; - final darkBorderColor = - isLightMode ? Color(0x1F1F2329) : Color(0xFF505469); + final showSuggestedActions = + state is ReadyAiWriterState && !state.isFirstRun; + final isInitialReadyState = + state is ReadyAiWriterState && state.isFirstRun; + final showSuggestedActionsPopup = + showSuggestedActions && markdownText.isEmpty; + final showSuggestedActionsWithin = + showSuggestedActions && markdownText.isNotEmpty; + + final darkBorderColor = Theme.of(context).isLightMode + ? Color(0x1F1F2329) + : Color(0xFF505469); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showSuggestionPopup && - state.command != AiWriterCommand.explain) ...[ + if (showSuggestedActionsPopup) ...[ Container( padding: EdgeInsets.all(4.0), decoration: _getModalDecoration( @@ -234,7 +244,7 @@ class _OverlayContentState extends State { borderColor: darkBorderColor, ), child: SuggestionActionBar( - currentCommand: state.command, + currentCommand: command, hasSelection: hasSelection, onTap: (action) { _onSelectSuggestionAction(context, action); @@ -243,85 +253,40 @@ class _OverlayContentState extends State { ), const VSpace(4.0 + 1.0), ], - DecoratedBox( + Container( decoration: _getModalDecoration( context, color: null, borderColor: darkBorderColor, borderRadius: BorderRadius.all(Radius.circular(12.0)), ), + constraints: BoxConstraints(maxHeight: 400), child: Column( + mainAxisSize: MainAxisSize.min, children: [ if (markdownText.isNotEmpty) ...[ - DecoratedBox( - decoration: _getHelperChildDecoration(context), - child: Container( - constraints: BoxConstraints(maxHeight: 140), - width: double.infinity, - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SingleChildScrollView( - physics: ClampingScrollPhysics(), - padding: EdgeInsets.only(top: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 24.0, - padding: - EdgeInsets.symmetric(horizontal: 6.0), - alignment: - AlignmentDirectional.centerStart, - child: FlowyText( - (state as RegisteredAiWriter) - .command - .i18n, - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFF666D76), - ), - ), - const VSpace(4.0), - Padding( - padding: - EdgeInsets.symmetric(horizontal: 6.0), - child: AIMarkdownText( - markdown: markdownText, - ), - ), - ], - ), - ), - ), - if (showSuggestionPopup && - state.command == AiWriterCommand.explain) ...[ - const VSpace(4.0), - SuggestionActionBar( - currentCommand: state.command, - hasSelection: hasSelection, - onTap: (action) { - _onSelectSuggestionAction(context, action); - }, - ), - ], - const VSpace(8.0), - ], + Flexible( + child: DecoratedBox( + decoration: _secondaryContentDecoration(context), + child: SecondaryContentArea( + markdownText: markdownText, + onSelectSuggestionAction: (action) { + _onSelectSuggestionAction(context, action); + }, + command: command, + showSuggestionActions: showSuggestedActionsWithin, + hasSelection: hasSelection, ), ), ), - Divider( - height: 1.0, - ), + Divider(height: 1.0), ], DecoratedBox( decoration: markdownText.isNotEmpty - ? _getInputChildDecoration(context) + ? _mainContentDecoration(context) : _getSingleChildDeocoration(context), child: MainContentArea( + textController: widget.textController, isDocumentEmpty: _isDocumentEmpty(), isInitialReadyState: isInitialReadyState, showCommandsToggle: showCommandsToggle, @@ -338,21 +303,19 @@ class _OverlayContentState extends State { } return Align( alignment: AlignmentDirectional.centerEnd, - child: BottomCommandButtons( + child: MoreAiWriterCommands( hasSelection: hasSelection, editorState: widget.editorState, onSelectCommand: (command) { - final promptInputBloc = context.read(); - final showPredefinedFormats = - promptInputBloc.state.showPredefinedFormats; - final predefinedFormat = - promptInputBloc.state.predefinedFormat; + final state = context.read().state; + final showPredefinedFormats = state.showPredefinedFormats; + final predefinedFormat = state.predefinedFormat; + final text = widget.textController.text; context.read().runCommand( command, - predefinedFormat: - showPredefinedFormats ? predefinedFormat : null, - isFirstRun: true, + text, + showPredefinedFormats ? predefinedFormat : null, ); }, ), @@ -395,14 +358,14 @@ class _OverlayContentState extends State { ); } - BoxDecoration _getHelperChildDecoration(BuildContext context) { + BoxDecoration _secondaryContentDecoration(BuildContext context) { return BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)), ); } - BoxDecoration _getInputChildDecoration(BuildContext context) { + BoxDecoration _mainContentDecoration(BuildContext context) { return BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.vertical(bottom: Radius.circular(12.0)), @@ -436,14 +399,83 @@ class _OverlayContentState extends State { } } +class SecondaryContentArea extends StatelessWidget { + const SecondaryContentArea({ + super.key, + required this.command, + required this.markdownText, + required this.showSuggestionActions, + required this.hasSelection, + required this.onSelectSuggestionAction, + }); + + final AiWriterCommand command; + final String markdownText; + final bool showSuggestionActions; + final bool hasSelection; + final void Function(SuggestionAction) onSelectSuggestionAction; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: SingleChildScrollView( + physics: ClampingScrollPhysics(), + padding: EdgeInsets.only(top: 8.0, left: 14.0, right: 14.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 24.0, + alignment: AlignmentDirectional.centerStart, + child: FlowyText( + command.i18n, + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF666D76), + ), + ), + const VSpace(4.0), + AIMarkdownText( + markdown: markdownText, + ), + ], + ), + ), + ), + if (showSuggestionActions) ...[ + const VSpace(4.0), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: SuggestionActionBar( + currentCommand: command, + hasSelection: hasSelection, + onTap: onSelectSuggestionAction, + ), + ), + ], + const VSpace(8.0), + ], + ), + ); + } +} + class MainContentArea extends StatelessWidget { const MainContentArea({ super.key, + required this.textController, required this.isInitialReadyState, required this.isDocumentEmpty, required this.showCommandsToggle, }); + final TextEditingController textController; final bool isInitialReadyState; final bool isDocumentEmpty; final ValueNotifier showCommandsToggle; @@ -458,7 +490,10 @@ class MainContentArea extends StatelessWidget { return DesktopPromptInput( isStreaming: false, hideDecoration: true, - onSubmitted: (message, format, _) => cubit.submit(message, format), + textController: textController, + onSubmitted: (message, format, _) { + cubit.runCommand(state.command, message, format); + }, onStopStreaming: () => cubit.stopStream(), selectedSourcesNotifier: cubit.selectedSourcesNotifier, onUpdateSelectedSources: (sources) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart index c503042339..d48c655e56 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import '../ai_writer_block_component.dart'; import 'ai_writer_entities.dart'; +import 'ai_writer_node_extension.dart'; Future setAiWriterNodeIsInitialized( EditorState editorState, @@ -11,13 +14,26 @@ Future setAiWriterNodeIsInitialized( final transaction = editorState.transaction ..updateNode(node, { AiWriterBlockKeys.isInitialized: true, - }) - ..afterSelection = null; + }); await editorState.apply( transaction, - options: const ApplyOptions(recordUndo: false), + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + withUpdateSelection: false, ); + + final selection = node.aiWriterSelection; + if (selection != null && !selection.isCollapsed) { + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: {selectionExtraInfoDisableToolbar: true}, + ), + ); + } } Future removeAiWriterNode( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 3b0e04e828..079a3a2749 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -1,14 +1,13 @@ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import '../../base/markdown_text_robot.dart'; @@ -41,8 +40,6 @@ class AiWriterCubit extends Cubit { final List records = []; final ValueNotifier> selectedSourcesNotifier; - (String, PredefinedFormat?)? _previousPrompt; - bool acceptReplacesOriginal = false; @override Future close() async { @@ -50,31 +47,20 @@ class AiWriterCubit extends Cubit { await super.close(); } - void register(Node node) async { - if (aiWriterNode != null && node.id != aiWriterNode!.id) { - await removeAiWriterNode(editorState, node); - return; + Future exit({ + bool withDiscard = true, + bool withUnformat = true, + }) async { + if (withDiscard) { + await _textRobot.discard(); } - aiWriterNode = node; - onCreateNode?.call(); - - await setAiWriterNodeIsInitialized(editorState, node); - - final command = node.aiWriterCommand; - if (command == AiWriterCommand.userQuestion) { - emit(ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true)); - } else { - runCommand(command, isFirstRun: true); - } - } - - Future exit() async { - await _textRobot.discard(); _textRobot.reset(); onRemoveNode?.call(); + records.clear(); + selectedSourcesNotifier.value = [documentId]; emit(IdleAiWriterState()); - if (aiWriterNode != null) { + if (withUnformat) { final selection = aiWriterNode!.aiWriterSelection; if (selection == null) { return; @@ -94,43 +80,96 @@ class AiWriterCubit extends Cubit { ), withUpdateSelection: false, ); + } + if (aiWriterNode != null) { await removeAiWriterNode(editorState, aiWriterNode!); aiWriterNode = null; } } + void register(Node node) async { + if (node.isAiWriterInitialized) { + return; + } + if (aiWriterNode != null && node.id != aiWriterNode!.id) { + await removeAiWriterNode(editorState, node); + return; + } + + aiWriterNode = node; + onCreateNode?.call(); + + await setAiWriterNodeIsInitialized(editorState, node); + + final command = node.aiWriterCommand; + final (run, prompt) = await _addSelectionTextToRecords(command); + + if (!run) { + await exit(); + return; + } + + runCommand(command, prompt, null); + } + void runCommand( - AiWriterCommand command, { - required bool isFirstRun, + AiWriterCommand command, + String prompt, PredefinedFormat? predefinedFormat, - bool isRetry = false, - }) async { + ) async { + if (aiWriterNode == null) { + return; + } + switch (command) { case AiWriterCommand.continueWriting: await _startContinueWriting( command, predefinedFormat, - isImmediateRun: isFirstRun, ); break; case AiWriterCommand.fixSpellingAndGrammar: case AiWriterCommand.improveWriting: case AiWriterCommand.makeLonger: case AiWriterCommand.makeShorter: - await _startSuggestingEdits(command, predefinedFormat); + await _startSuggestingEdits(command, prompt, predefinedFormat); break; case AiWriterCommand.explain: - await _startInforming(command, predefinedFormat); + await _startInforming(command, prompt, predefinedFormat); + break; + case AiWriterCommand.userQuestion when prompt.isNotEmpty: + _startAskingQuestion(prompt, predefinedFormat); break; case AiWriterCommand.userQuestion: - if (isRetry && _previousPrompt != null) { - submit(_previousPrompt!.$1, _previousPrompt!.$2); - } + emit( + ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true), + ); break; } } + void _retry({ + required PredefinedFormat? predefinedFormat, + }) async { + final lastQuestion = + records.lastWhereOrNull((record) => record.role == AiRole.user); + + if (lastQuestion != null && state is RegisteredAiWriter) { + await _textRobot.discard(); + _textRobot.reset(); + runCommand( + (state as RegisteredAiWriter).command, + lastQuestion.content, + lastQuestion.format, + ); + } + } + Future stopStream() async { + if (aiWriterNode == null) { + return; + } + if (state is GeneratingAiWriterState) { final generatingState = state as GeneratingAiWriterState; @@ -138,6 +177,10 @@ class AiWriterCubit extends Cubit { attributes: ApplySuggestionFormatType.replace.attributes, ); + if (_textRobot.hasAnyResult) { + records.add(AiWriterRecord.ai(content: _textRobot.markdownText)); + } + await AIEventStopCompleteText( CompleteTextTaskPB( taskId: generatingState.taskId, @@ -162,34 +205,20 @@ class AiWriterCubit extends Cubit { return; } - if (state is! RegisteredAiWriter) { - return; - } - - final command = (state as RegisteredAiWriter).command; - if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) { - await _textRobot.discard(); - _textRobot.reset(); - runCommand( - command, - predefinedFormat: predefinedFormat, - isRetry: true, - isFirstRun: false, - ); + _retry(predefinedFormat: predefinedFormat); return; } - - final selection = aiWriterNode!.aiWriterSelection; - if (selection == null) { - return; - } - if (action case SuggestionAction.discard || SuggestionAction.close) { await exit(); return; } + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + if (action case SuggestionAction.accept) { await _textRobot.persist(); final nodes = editorState.getNodesInSelection(selection); @@ -199,6 +228,8 @@ class AiWriterCubit extends Cubit { options: const ApplyOptions(recordUndo: false), withUpdateSelection: false, ); + await exit(withDiscard: false, withUnformat: false); + return; } if (action case SuggestionAction.keep) { @@ -206,8 +237,12 @@ class AiWriterCubit extends Cubit { } if (action case SuggestionAction.insertBelow) { - if (state case final ReadyAiWriterState readyState - when readyState.markdownText.isNotEmpty) { + if (state is! ReadyAiWriterState) { + return; + } + final command = (state as ReadyAiWriterState).command; + final markdownText = (state as ReadyAiWriterState).markdownText; + if (command == AiWriterCommand.explain && markdownText.isNotEmpty) { final transaction = editorState.transaction; final position = ensurePreviousNodeIsEmptyParagraph( editorState, @@ -224,8 +259,8 @@ class AiWriterCubit extends Cubit { withUpdateSelection: false, ); _textRobot.start(position: position); - await _textRobot.persist(markdownText: readyState.markdownText); - } else { + await _textRobot.persist(markdownText: markdownText); + } else if (_textRobot.hasAnyResult) { await _textRobot.persist(); } @@ -243,9 +278,7 @@ class AiWriterCubit extends Cubit { ); } - await removeAiWriterNode(editorState, aiWriterNode!); - aiWriterNode = null; - emit(IdleAiWriterState()); + await exit(withDiscard: false); } bool hasUnusedResponse() { @@ -260,7 +293,56 @@ class AiWriterCubit extends Cubit { }; } - void submit( + Future<(bool, String)> _addSelectionTextToRecords( + AiWriterCommand command, + ) async { + final node = aiWriterNode; + if (node == null) { + return (false, ''); + } + final selection = node.aiWriterSelection?.normalized; + if (selection == null) { + return (false, ''); + } + + if (command == AiWriterCommand.continueWriting) { + return (true, ''); + } else { + if (selection.isCollapsed) { + return (true, ''); + } else { + final selectionText = + await editorState.getMarkdownInSelection(selection); + + if (command == AiWriterCommand.userQuestion) { + records.add( + AiWriterRecord.user(content: selectionText, format: null), + ); + return (true, ''); + } else { + return (true, selectionText); + } + } + } + } + + Future _getDocumentContentFromTopToPosition(Position position) async { + final beginningToCursorSelection = Selection( + start: Position(path: [0]), + end: position, + ).normalized; + + final documentText = + (await editorState.getMarkdownInSelection(beginningToCursorSelection)) + .trim(); + + final view = await ViewBackendService.getView(documentId).toNullable(); + final viewName = view?.name ?? ''; + + return "$viewName\n$documentText".trim(); + } + + void _startAskingQuestion( String prompt, PredefinedFormat? format, ) async { @@ -268,7 +350,6 @@ class AiWriterCubit extends Cubit { return; } final command = AiWriterCommand.userQuestion; - _previousPrompt = (prompt, format); final stream = await _aiService.streamCompletion( objectId: documentId, @@ -294,7 +375,10 @@ class AiWriterCubit extends Cubit { ); _textRobot.start(position: position); records.add( - AiWriterRecord.user(content: prompt), + AiWriterRecord.user( + content: prompt, + format: format, + ), ); }, processMessage: (text) async { @@ -353,43 +437,19 @@ class AiWriterCubit extends Cubit { Future _startContinueWriting( AiWriterCommand command, - PredefinedFormat? predefinedFormat, { - required bool isImmediateRun, - }) async { - if (aiWriterNode == null) { + PredefinedFormat? predefinedFormat, + ) async { + final position = aiWriterNode?.aiWriterSelection?.start; + if (position == null) { return; } - final cursorPosition = aiWriterNode?.aiWriterSelection?.start; - if (cursorPosition == null) { - return; - } - final selection = Selection( - start: Position(path: [0]), - end: cursorPosition, - ).normalized; + final text = await _getDocumentContentFromTopToPosition(position); - String text = (await editorState.getMarkdownInSelection(selection)).trim(); if (text.isEmpty) { - final view = await ViewBackendService.getView(documentId).toNullable(); - if (view == null || - view.name.isEmpty || - view.name == LocaleKeys.menuAppHeader_defaultNewPageName.tr()) { - final stateCopy = state; - emit( - DocumentContentEmptyAiWriterState( - command, - onConfirm: () { - if (isImmediateRun) { - removeAiWriterNode(editorState, aiWriterNode!); - } - }, - ), - ); - emit(stateCopy); - return; - } else { - text = view.name; - } + final stateCopy = state; + emit(DocumentContentEmptyAiWriterState(command, onConfirm: exit)); + emit(stateCopy); + return; } final stream = await _aiService.streamCompletion( @@ -397,6 +457,7 @@ class AiWriterCubit extends Cubit { text: text, completionType: command.toCompletionType(), history: records, + sourceIds: selectedSourcesNotifier.value, onStart: () async { final transaction = editorState.transaction; final position = ensurePreviousNodeIsEmptyParagraph( @@ -414,6 +475,12 @@ class AiWriterCubit extends Cubit { withUpdateSelection: false, ); _textRobot.start(position: position); + records.add( + AiWriterRecord.user( + content: text, + format: predefinedFormat, + ), + ); }, processMessage: (text) async { await _textRobot.appendMarkdownText( @@ -467,23 +534,23 @@ class AiWriterCubit extends Cubit { Future _startSuggestingEdits( AiWriterCommand command, + String prompt, PredefinedFormat? predefinedFormat, ) async { - if (aiWriterNode == null) { - return; - } final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } - - acceptReplacesOriginal = true; + if (prompt.isEmpty) { + prompt = records.removeAt(0).content; + } final stream = await _aiService.streamCompletion( objectId: documentId, - text: await editorState.getMarkdownInSelection(selection), + text: prompt, completionType: command.toCompletionType(), history: records, + sourceIds: selectedSourcesNotifier.value, onStart: () async { final transaction = editorState.transaction; formatSelection( @@ -507,6 +574,12 @@ class AiWriterCubit extends Cubit { withUpdateSelection: false, ); _textRobot.start(position: position); + records.add( + AiWriterRecord.user( + content: prompt, + format: predefinedFormat, + ), + ); }, processMessage: (text) async { await _textRobot.appendMarkdownText( @@ -560,22 +633,31 @@ class AiWriterCubit extends Cubit { Future _startInforming( AiWriterCommand command, + String prompt, PredefinedFormat? predefinedFormat, ) async { - if (aiWriterNode == null) { - return; - } final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } + if (prompt.isEmpty) { + prompt = records.removeAt(0).content; + } final stream = await _aiService.streamCompletion( objectId: documentId, - text: await editorState.getMarkdownInSelection(selection), + text: prompt, completionType: command.toCompletionType(), history: records, - onStart: () async {}, + sourceIds: selectedSourcesNotifier.value, + onStart: () async { + records.add( + AiWriterRecord.user( + content: prompt, + format: predefinedFormat, + ), + ); + }, processMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { emit( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart index 7dc8ffc04e..f15c2e6d7f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; @@ -129,24 +130,22 @@ enum AiRole { } class AiWriterRecord extends Equatable { - const AiWriterRecord({ - required this.role, - required this.content, - }); - const AiWriterRecord.user({ required this.content, + required this.format, }) : role = AiRole.user; const AiWriterRecord.ai({ required this.content, - }) : role = AiRole.ai; + }) : role = AiRole.ai, + format = null; final AiRole role; final String content; + final PredefinedFormat? format; @override - List get props => [role, content]; + List get props => [role, content, format]; CompletionRecordPB toPB() { return CompletionRecordPB( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart index 1e2641d796..eefb122a20 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -1,14 +1,12 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import '../operations/ai_writer_entities.dart'; @@ -74,8 +72,8 @@ class AiWriterPromptMoreButton extends StatelessWidget { } } -class BottomCommandButtons extends StatelessWidget { - const BottomCommandButtons({ +class MoreAiWriterCommands extends StatelessWidget { + const MoreAiWriterCommands({ super.key, required this.hasSelection, required this.editorState, @@ -155,19 +153,7 @@ class BottomCommandButtons extends StatelessWidget { command.i18n, figmaLineHeight: 20, ), - onTap: () { - final aiInputBloc = context.read(); - final showPredefinedFormats = - aiInputBloc.state.showPredefinedFormats; - final predefinedFormat = aiInputBloc.state.predefinedFormat; - - context.read().runCommand( - command, - predefinedFormat: - showPredefinedFormats ? predefinedFormat : null, - isFirstRun: false, - ); - }, + onTap: () => onSelectCommand(command), ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart index 4c04baf557..8a054de1c0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -145,19 +145,11 @@ class _AiWriterScrollWrapperState extends State { description: LocaleKeys.document_plugins_discardResponse.tr(), confirmLabel: LocaleKeys.button_discard.tr(), style: ConfirmPopupStyle.cancelAndOk, - onConfirm: () { - Future(() async { - await aiWriterCubit.stopStream(); - await aiWriterCubit.exit(); - }); - }, + onConfirm: stopAndExit, onCancel: () {}, ); } else { - Future(() async { - await aiWriterCubit.stopStream(); - await aiWriterCubit.exit(); - }); + stopAndExit(); } } @@ -177,19 +169,11 @@ class _AiWriterScrollWrapperState extends State { description: LocaleKeys.document_plugins_discardResponse.tr(), confirmLabel: LocaleKeys.button_discard.tr(), style: ConfirmPopupStyle.cancelAndOk, - onConfirm: () { - Future(() async { - await aiWriterCubit.stopStream(); - await aiWriterCubit.exit(); - }); - }, + onConfirm: stopAndExit, onCancel: () {}, ); } else { - Future(() async { - await aiWriterCubit.stopStream(); - await aiWriterCubit.exit(); - }); + stopAndExit(); } return true; case LogicalKeyboardKey.keyC @@ -220,4 +204,11 @@ class _AiWriterScrollWrapperState extends State { } }); } + + void stopAndExit() { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 1904b10934..2eafb0af7c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -140,7 +140,7 @@ class MarkdownTextRobot { await editorState.apply( transaction, - options: const ApplyOptions(recordUndo: false), + options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true), ); if (_enableDebug) { From 682a50da533739a07550d21656e8ec140f217dfb Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:15:20 +0800 Subject: [PATCH 023/207] chore: replace ai response every time (#7597) --- .../editor_plugins/ai/operations/ai_writer_cubit.dart | 5 +++-- .../editor_plugins/base/markdown_text_robot.dart | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 079a3a2749..0784ade2cd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -121,6 +121,9 @@ class AiWriterCubit extends Cubit { return; } + await _textRobot.discard(); + _textRobot.reset(); + switch (command) { case AiWriterCommand.continueWriting: await _startContinueWriting( @@ -155,8 +158,6 @@ class AiWriterCubit extends Cubit { records.lastWhereOrNull((record) => record.role == AiRole.user); if (lastQuestion != null && state is RegisteredAiWriter) { - await _textRobot.discard(); - _textRobot.reset(); runCommand( (state as RegisteredAiWriter).command, lastQuestion.content, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 2eafb0af7c..62c024b31a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -151,7 +151,6 @@ class MarkdownTextRobot { void reset() { _markdownText = ''; _insertedNodes = []; - _insertPosition = null; } Future _refresh({ From 66ce786726eed1fb06a4e6281f7b13a4267e19e9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 16:38:37 +0800 Subject: [PATCH 024/207] chore: add client --- .../flowy-ai/src/mcp/client_manager.rs | 141 +++++++++++++++++- frontend/rust-lib/flowy-error/src/code.rs | 3 + 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs b/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs index 80eae6a9bd..e48af10071 100644 --- a/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/mcp/client_manager.rs @@ -1,11 +1,144 @@ +use anyhow::Context; use dashmap::DashMap; +use flowy_error::{ErrorCode, FlowyError}; +use mcpr::error::MCPError; +use mcpr::schema::JSONRPCRequest; use mcpr::transport::Transport; use mcpr::{client::Client, transport::sse::SSETransport, transport::stdio::StdioTransport}; +use serde_json::Value; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; use std::sync::Arc; +use std::thread; +use tracing::{debug, info}; -pub struct MCPClientManager { - stdio_clients: Arc>>, - http_client: Arc>>, +pub struct MCPServerConfig { + server_cmd: String, + args: Vec, } -impl MCPClientManager {} +impl MCPServerConfig { + pub fn is_sse_server(&self) -> bool { + self.server_cmd.starts_with("http") + } +} + +pub struct MCPClient { + client: Client, + process: Option, +} + +impl MCPClient +where + T: Transport, +{ + pub fn initialize(&mut self) -> Result<(), FlowyError> { + self + .client + .initialize() + .map(|err| FlowyError::new(ErrorCode::MCPError).with_context(err))?; + Ok(()) + } +} + +impl Drop for MCPClient { + fn drop(&mut self) { + if let Some(process) = &mut self.process { + let _ = process.kill(); + } + } +} + +pub struct MCPClientManager { + stdio_clients: Arc>>, + sse_clients: Arc>>, +} + +impl MCPClientManager { + pub fn new() -> MCPClientManager { + Self { + stdio_clients: Arc::new(DashMap::new()), + sse_clients: Arc::new(DashMap::new()), + } + } + + pub async fn connect_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { + if config.is_sse_server() { + let context = connect_to_http_server(&config.server_cmd, &config.args).await?; + self.sse_clients.insert(config.server_cmd, context); + } else { + let context = connect_to_stdio_server(&config.server_cmd, &config.args).await?; + self.stdio_clients.insert(config.server_cmd, context); + } + Ok(()) + } + + pub async fn remove_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { + if config.is_sse_server() { + self.sse_clients.remove(&config.server_cmd); + } else { + self.stdio_clients.remove(&config.server_cmd); + } + Ok(()) + } +} + +async fn connect_to_http_server( + command: &str, + args: &[String], +) -> Result, FlowyError> { + info!( + "Connecting to running server with command: {} {}", + command, + args.join(" ") + ); + + let transport = SSETransport::new_server(&command); + let client = Client::new(transport); + Ok(MCPClient { + client, + process: None, + }) +} + +async fn connect_to_stdio_server( + command: &str, + args: &[String], +) -> Result, FlowyError> { + info!( + "Connecting to running server with command: {} {}", + command, + args.join(" ") + ); + + // Start a new process that will connect to the server + let mut process = Command::new(command) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to spawn process")?; + + if let Some(stderr) = process.stderr.take() { + let stderr_reader = BufReader::new(stderr); + thread::spawn(move || { + for line in stderr_reader.lines() { + if let Ok(line) = line { + debug!("Server stderr: {}", line); + } + } + }); + } + + let transport = StdioTransport::with_reader_writer( + Box::new(process.stdout.take().context("Failed to get stdout")?), + Box::new(process.stdin.take().context("Failed to get stdin")?), + ); + + let client = Client::new(transport); + Ok(MCPClient { + client, + process: Some(process), + }) +} diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index 28ff1a5a20..b16099013b 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -374,6 +374,9 @@ pub enum ErrorCode { #[error("Local AI is not ready")] LocalAINotReady = 128, + + #[error("MCP error")] + MCPError = 129, } impl ErrorCode { From 7463e4e3eba830caf1cd3ab01ea8dc2e98c38b79 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 24 Mar 2025 16:50:16 +0800 Subject: [PATCH 025/207] fix: pass response format in ai writer (#7599) --- .../editor_plugins/ai/operations/ai_writer_cubit.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 0784ade2cd..07d5d84ae9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -459,6 +459,7 @@ class AiWriterCubit extends Cubit { completionType: command.toCompletionType(), history: records, sourceIds: selectedSourcesNotifier.value, + format: predefinedFormat, onStart: () async { final transaction = editorState.transaction; final position = ensurePreviousNodeIsEmptyParagraph( @@ -549,6 +550,7 @@ class AiWriterCubit extends Cubit { final stream = await _aiService.streamCompletion( objectId: documentId, text: prompt, + format: predefinedFormat, completionType: command.toCompletionType(), history: records, sourceIds: selectedSourcesNotifier.value, @@ -651,6 +653,7 @@ class AiWriterCubit extends Cubit { completionType: command.toCompletionType(), history: records, sourceIds: selectedSourcesNotifier.value, + format: predefinedFormat, onStart: () async { records.add( AiWriterRecord.user( From 35081fd31133a767a2fa4e1c155b9ee2471e3f0c Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 21:59:42 +0800 Subject: [PATCH 026/207] chore: remove default model name --- .../desktop_prompt_text_field.dart | 2 + .../setting/ai/ai_settings_group.dart | 6 +-- .../settings/ai/settings_ai_bloc.dart | 49 +++++++------------ .../setting_ai_view/model_selection.dart | 4 +- frontend/appflowy_flutter/macos/Podfile.lock | 46 ++++++++--------- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 27 +++++----- frontend/rust-lib/flowy-ai/src/entities.rs | 25 +++++++++- .../rust-lib/flowy-ai/src/event_handler.rs | 6 ++- .../flowy-user/src/entities/user_profile.rs | 5 +- 9 files changed, 90 insertions(+), 80 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index 0fbdeafee4..ba2d035183 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -739,6 +739,7 @@ class _SelectModelButtonState extends State { ); }, child: _CurrentModelButton( + key: ValueKey(state.availableModels?.selectedModel.name), modelName: state.availableModels?.selectedModel.name ?? "", onTap: () => popoverController.show(), ), @@ -810,6 +811,7 @@ class _CurrentModelButton extends StatelessWidget { const _CurrentModelButton({ required this.modelName, required this.onTap, + super.key, }); final String modelName; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart index f67cc9e6b8..3b75b08543 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart @@ -87,13 +87,13 @@ class AiSettingsGroup extends StatelessWidget { children: availableModels .mapIndexed( (index, model) => FlowyOptionTile.checkbox( - text: model, + text: model.name, showTopBorder: index == 0, - isSelected: state.selectedAIModel == model, + isSelected: state.selectedAIModel == model.name, onTap: () { context .read() - .add(SettingsAIEvent.selectModel(model)); + .add(SettingsAIEvent.selectModel(model.name)); context.pop(); }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 74a61fe96c..6dcd52b294 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -1,9 +1,8 @@ -import 'dart:convert'; - import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -95,32 +94,22 @@ class SettingsAIBloc extends Bloc { ), ); }, - didLoadAvailableModels: (String models) { - final dynamic decodedJson = jsonDecode(models); - Log.info("Available models: $decodedJson"); - if (decodedJson is Map) { - final models = ModelList.fromJson(decodedJson).models; - if (models.isEmpty) { - // If available models is empty, then we just show the - // Default - emit(state.copyWith(availableModels: ["Default"])); - return; - } - - if (!models.contains(state.selectedAIModel)) { - // Use first model as default model if current selected model - // is not available - final selectedModel = models[0]; - _updateUserWorkspaceSetting(model: selectedModel); - emit( - state.copyWith( - availableModels: models, - selectedAIModel: selectedModel, - ), - ); - } else { - emit(state.copyWith(availableModels: models)); - } + didLoadAvailableModels: (List models) { + if (state.selectedAIModel.isEmpty) { + final m = models.firstWhere((model) => model.isDefault); + _updateUserWorkspaceSetting(model: m.name); + emit( + state.copyWith( + availableModels: models, + selectedAIModel: m.name, + ), + ); + } else { + emit( + state.copyWith( + availableModels: models, + ), + ); } }, refreshMember: (member) { @@ -203,7 +192,7 @@ class SettingsAIEvent with _$SettingsAIEvent { ) = _DidReceiveUserProfile; const factory SettingsAIEvent.didLoadAvailableModels( - String models, + List models, ) = _DidLoadAvailableModels; } @@ -214,7 +203,7 @@ class SettingsAIState with _$SettingsAIState { UseAISettingPB? aiSettings, @Default("Default") String selectedAIModel, AFRolePB? currentWorkspaceMemberRole, - @Default(["Default"]) List availableModels, + @Default([]) List availableModels, @Default(true) bool enableSearchIndexing, }) = _SettingsAIState; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index dfc53e4f08..8cd2f31114 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -38,8 +38,8 @@ class AIModelSelection extends StatelessWidget { .map( (model) => buildDropdownMenuEntry( context, - value: model, - label: model, + value: model.name, + label: model.name, ), ) .toList(), diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 30ee626f09..b4a1a3d20d 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 - appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 - auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 - connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 - file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 - irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c - window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index e1e6a2724a..2486637ef8 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -20,6 +20,7 @@ use crate::util::ai_available_models_key; use collab_integrate::persistence::collab_metadata_sql::{ batch_insert_collab_metadata, batch_select_collab_metadata, AFCollabMetadata, }; +use flowy_ai_pub::cloud::ai_dto::AvailableModel; use flowy_storage_pub::storage::StorageService; use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; @@ -57,7 +58,7 @@ pub trait AIExternalService: Send + Sync + 'static { #[derive(Debug, Default)] struct ServerModelsCache { - models: Vec, + models: Vec, timestamp: Option, } @@ -274,10 +275,9 @@ impl AIManager { Ok(model) } - pub async fn get_server_available_models(&self) -> FlowyResult> { + pub async fn get_server_available_models(&self) -> FlowyResult> { let workspace_id = self.user_service.workspace_id()?; - - let now = timestamp(); // This is safer than using SystemTime which could fail + let now = timestamp(); // First, try reading from the cache with expiration check let should_fetch = { @@ -298,16 +298,9 @@ impl AIManager { .await { Ok(list) => { - let models = list - .models - .into_iter() - .map(|m| m.name) - .collect::>(); - - // Update the cache with new timestamp - handle potential errors + let models = list.models; if let Err(err) = self.update_models_cache(&models, now).await { error!("Failed to update models cache: {}", err); - // Still return the fetched models even if caching failed } Ok(models) @@ -328,7 +321,11 @@ impl AIManager { } } - async fn update_models_cache(&self, models: &[String], timestamp: i64) -> FlowyResult<()> { + async fn update_models_cache( + &self, + models: &[AvailableModel], + timestamp: i64, + ) -> FlowyResult<()> { match self.server_models.try_write() { Ok(mut cache) => { cache.models = models.to_vec(); @@ -360,8 +357,8 @@ impl AIManager { .get_server_available_models() .await? .into_iter() - .map(|name| AIModelPB { - name, + .map(|m| AIModelPB { + name: m.name, is_local: false, }) .collect(); diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 355e8d27b5..ace7e3d807 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use crate::local_ai::controller::LocalAISetting; use crate::local_ai::resource::PendingResource; +use flowy_ai_pub::cloud::ai_dto::AvailableModel; use flowy_ai_pub::cloud::{ AIModel, ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, @@ -186,7 +187,29 @@ pub struct ChatMessageListPB { #[derive(Default, ProtoBuf, Clone, Debug)] pub struct ServerAvailableModelsPB { #[pb(index = 1)] - pub models: String, + pub models: Vec, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AvailableModelPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub is_default: bool, +} + +impl From for AvailableModelPB { + fn from(value: AvailableModel) -> Self { + let is_default = value + .metadata + .and_then(|v| v.get("is_default").map(|v| v.as_bool().unwrap_or(false))) + .unwrap_or(false); + Self { + name: value.name, + is_default, + } + } } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 1418e84058..2bc182b9b1 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -7,7 +7,6 @@ use crate::entities::*; use flowy_ai_pub::cloud::{ChatMessageMetadata, ChatMessageType, ChatRAGData, ContextLoader}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; -use serde_json::json; use std::sync::{Arc, Weak}; use tracing::trace; use validator::Validate; @@ -108,7 +107,10 @@ pub(crate) async fn get_server_model_list_handler( ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; let models = ai_manager.get_server_available_models().await?; - let models = serde_json::to_string(&json!({"models": models}))?; + let models = models + .into_iter() + .map(AvailableModelPB::from) + .collect::>(); data_result_ok(ServerAvailableModelsPB { models }) } diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index aa9d38a9cd..36d5232bd2 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -77,10 +77,7 @@ impl From for UserProfilePB { EncryptionType::NoEncryption => ("".to_string(), EncryptionTypePB::NoEncryption), EncryptionType::SelfEncryption(sign) => (sign, EncryptionTypePB::Symmetric), }; - let mut ai_model = user_profile.ai_model; - if ai_model.is_empty() { - ai_model = "Default".to_string(); - } + let ai_model = user_profile.ai_model; Self { id: user_profile.uid, email: user_profile.email, From 4c39908748faed8763cad58b65a3b707e1420b24 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Mar 2025 22:21:44 +0800 Subject: [PATCH 027/207] chore: separate model --- .../desktop_prompt_text_field.dart | 103 ++++++++++++++---- frontend/resources/translations/en.json | 1 + 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index ba2d035183..bf21a59ad0 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -761,29 +761,94 @@ class _PopoverSelectModel extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return ListView.builder( - shrinkWrap: true, - itemCount: state.availableModels?.models.length ?? 0, + if (state.availableModels == null || + state.availableModels!.models.isEmpty) { + return const SizedBox.shrink(); + } + + // Separate models into local and cloud models + final localModels = state.availableModels!.models + .where((model) => model.isLocal) + .toList(); + + final cloudModels = state.availableModels!.models + .where((model) => !model.isLocal) + .toList(); + + return Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), - itemBuilder: (context, index) { - return _ModelItem( - model: state.availableModels!.models[index], - onTap: () { - context.read().add( - SelectModelEvent.selectModel( - state.availableModels!.models[index], - ), - ); - onClose(); - }, - ); - }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Local AI Models Section + if (localModels.isNotEmpty) ...[ + _ModelSectionHeader( + title: LocaleKeys.chat_changeFormat_localModel.tr(), + ), + const SizedBox(height: 4), + ...localModels.map( + (model) => _ModelItem( + model: model, + onTap: () { + context.read().add( + SelectModelEvent.selectModel(model), + ); + onClose(); + }, + ), + ), + const SizedBox(height: 8), + ], + + // Cloud AI Models Section + if (cloudModels.isNotEmpty) ...[ + if (localModels.isNotEmpty) + _ModelSectionHeader( + title: LocaleKeys.chat_changeFormat_cloudModel.tr(), + ), + const VSpace(4), + ...cloudModels.map( + (model) => _ModelItem( + model: model, + onTap: () { + context.read().add( + SelectModelEvent.selectModel(model), + ); + onClose(); + }, + ), + ), + ], + ], + ), ); }, ); } } +class _ModelSectionHeader extends StatelessWidget { + const _ModelSectionHeader({ + required this.title, + }); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: FlowyText( + title, + fontSize: 12, + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w500, + ), + ); + } +} + class _ModelItem extends StatelessWidget { const _ModelItem({ required this.model, @@ -795,10 +860,8 @@ class _ModelItem extends StatelessWidget { @override Widget build(BuildContext context) { - var modelName = model.name; - if (model.isLocal) { - modelName += " (${LocaleKeys.chat_changeFormat_localModel.tr()})"; - } + final modelName = model.name; + return FlowyTextButton( modelName, fillColor: Colors.transparent, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 12ae84cc8e..62de8be000 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -248,6 +248,7 @@ "blankDescription": "Format response", "defaultDescription": "Auto mode", "localModel": "Local Model", + "cloudModel": "Cloud Model", "switchModel": "Switch model", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", From 72d660f1acb3e9a3bda480970866a24d914a19ec Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:07:46 +0800 Subject: [PATCH 028/207] fix: number cell update flashes with old content (#7605) --- .../database/application/cell/bloc/number_cell_bloc.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart index df159b817b..73b2d2977b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart @@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc { if (state.content != text) { emit(state.copyWith(content: text)); await cellController.saveCellData(text); - - // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. - // So for every cell data that will be formatted in the backend. - // It needs to get the formatted data after saving. - add( - NumberCellEvent.didReceiveCellUpdate( - cellController.getCellData(), - ), - ); } }, ); From 0dc23639627613927236b5136de2b7d955b5d8ff Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 25 Mar 2025 13:05:18 +0800 Subject: [PATCH 029/207] chore: fix incorrect local ai state --- .../lib/ai/service/ai_input_control.dart | 58 +++++++++---------- .../lib/startup/tasks/device_info_task.dart | 3 + 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart index 942c27a3c9..8e4b42d1ce 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart @@ -5,7 +5,6 @@ import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.da import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:protobuf/protobuf.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -21,12 +20,14 @@ class AIModelStateNotifier { final bool _isDesktop; final LocalAIStateListener? _localAIListener; final AIModelSwitchListener _aiModelSwitchListener; + LocalAIPB? _localAIState; AvailableModelsPB? _availableModels; - // callbacks + // Callbacks void Function(AiType, bool, String)? onChanged; void Function(AvailableModelsPB)? onAvailableModelsChanged; + String hintText() { final aiType = getCurrentAiType(); if (aiType.isLocal) { @@ -38,22 +39,16 @@ class AIModelStateNotifier { } AiType getCurrentAiType() { - // On non-desktop platforms, always return cloud type - if (!_isDesktop) { - return AiType.cloud; - } - - return _availableModels?.selectedModel.isLocal == true + // On non-desktop platforms, always return cloud type. + if (!_isDesktop) return AiType.cloud; + return (_availableModels?.selectedModel.isLocal ?? false) ? AiType.local : AiType.cloud; } bool isEditable() { - // On non-desktop platforms, always editable (cloud-only) - if (!_isDesktop) { - return true; - } - + // On non-desktop platforms, always editable. + if (!_isDesktop) return true; return getCurrentAiType().isLocal ? _localAIState?.state == RunningStatePB.Running : true; @@ -64,7 +59,11 @@ class AIModelStateNotifier { } Future init() async { - await _loadAvailableModels(); + // Load both available models and local state concurrently. + await Future.wait([ + _loadAvailableModels(), + _loadLocalAIState(), + ]); } Future _loadAvailableModels() async { @@ -76,8 +75,20 @@ class AIModelStateNotifier { onAvailableModelsChanged?.call(models); _notifyStateChanged(); }, - (err) { - Log.error("Failed to get available models: $err"); + (err) => Log.error("Failed to get available models: $err"), + ); + } + + Future _loadLocalAIState() async { + final result = await AIEventGetLocalAIState().send(); + result.fold( + (state) { + _localAIState = state; + _notifyStateChanged(); + }, + (error) { + Log.error("Failed to get local AI state: $error"); + _notifyStateChanged(); }, ); } @@ -89,12 +100,11 @@ class AIModelStateNotifier { this.onChanged = onChanged; this.onAvailableModelsChanged = onAvailableModelsChanged; - // Only start local AI listener on desktop platforms + // Only start local AI listener on desktop platforms. if (_isDesktop) { _localAIListener?.start( stateCallback: (state) { _localAIState = state; - if (state.state == RunningStatePB.Running || state.state == RunningStatePB.Stopped) { _loadAvailableModels(); @@ -111,18 +121,8 @@ class AIModelStateNotifier { _availableModels = updatedModels; onAvailableModelsChanged?.call(updatedModels); } - if (model.isLocal && _isDesktop) { - AIEventGetLocalAIState().send().fold( - (localAIState) { - _localAIState = localAIState; - _notifyStateChanged(); - }, - (error) { - Log.error("Failed to get local AI state: $error"); - _notifyStateChanged(); - }, - ); + _loadLocalAIState(); } else { _notifyStateChanged(); } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart index 81464f59d7..2c90afbdda 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart @@ -29,6 +29,9 @@ class ApplicationInfo { // If the latest version is greater than the current version, it means there is an update available static bool get isUpdateAvailable { try { + if (latestVersion.isEmpty) { + return false; + } return Version.parse(latestVersion) > Version.parse(applicationVersion); } catch (e) { return false; From ed44c20281a1aceeefa2063d8a4ee3194e082c6a Mon Sep 17 00:00:00 2001 From: ixicut Date: Tue, 25 Mar 2025 11:19:11 +0200 Subject: [PATCH 030/207] fix: correct translation inconsistencies --- frontend/resources/translations/uk-UA.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index 45cc93fd43..8227fc4c53 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -64,7 +64,7 @@ "settings": "Налаштування", "magicLinkSent": "Magic Link надіслано!", "invalidEmail": "Будь ласка, введіть дійсну адресу електронної пошти", - "alreadyHaveAnAccount": "Вже є аккаунт?", + "alreadyHaveAnAccount": "Вже є акаунт?", "logIn": "Авторизуватися", "generalError": "Щось пішло не так. Будь ласка спробуйте пізніше", "limitRateError": "З міркувань безпеки ви можете запитувати чарівне посилання лише кожні 60 секунд", @@ -362,7 +362,7 @@ "helpCenter": "Центр допомоги", "add": "Додати", "yes": "Так", - "no": "Немає", + "no": "Ні", "clear": "Очистити", "remove": "Видалити", "dontRemove": "Не видаляйте", @@ -810,7 +810,7 @@ "description": "Ви впевнені, що хочете видалити {plan}? Ви негайно втратите доступ до функцій і переваг {plan}." } }, - "currentPeriodBadge": "ПОТОМ", + "currentPeriodBadge": "ПОТОЧНИЙ", "changePeriod": "Період зміни", "planPeriod": "{} період", "monthlyInterval": "Щомісяця", From eb4b015de8d8fb80de747a559f489a59b5833bb7 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:37:15 +0800 Subject: [PATCH 031/207] chore: bump editor plugin ref (#7610) --- frontend/appflowy_flutter/pubspec.lock | 4 ++-- frontend/appflowy_flutter/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index dcaefebed4..2acfec76bb 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -99,8 +99,8 @@ packages: dependency: "direct main" description: path: "packages/appflowy_editor_plugins" - ref: ca8289099e40e0d6ad0605fbbe01fde3091538bb - resolved-ref: ca8289099e40e0d6ad0605fbbe01fde3091538bb + ref: "4efcff7" + resolved-ref: "4efcff720ed01dd4d0f5f88a9f1ff6f79f423caa" url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git" source: git version: "0.0.6" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 19e30fa4a2..344232897f 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -186,7 +186,7 @@ dependency_overrides: git: url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git path: "packages/appflowy_editor_plugins" - ref: "ca8289099e40e0d6ad0605fbbe01fde3091538bb" + ref: "4efcff7" sheet: git: From 039c191d1f653667e5d05c7b61d7b40b1900210c Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 25 Mar 2025 22:41:35 +0800 Subject: [PATCH 032/207] fix: ai text insertion (#7615) * fix: dont scroll to ai writer node if path not found * chore: rename text robot clear method and add reset * fix: insert position is off if using ai writer multiple times * chore: reorganize code * fix: undo not working after accept --- .../ai_writer_block_operations.dart | 30 ++++- .../ai/operations/ai_writer_cubit.dart | 117 +++++------------- .../ai/widgets/ai_writer_scroll_wrapper.dart | 8 +- .../base/markdown_text_robot.dart | 8 +- 4 files changed, 65 insertions(+), 98 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart index d48c655e56..1b495a5b23 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart @@ -48,16 +48,16 @@ Future removeAiWriterNode( ); } -void formatSelection( +Future formatSelection( EditorState editorState, Selection selection, - Transaction transaction, ApplySuggestionFormatType formatType, -) { +) async { final nodes = editorState.getNodesInSelection(selection).toList(); if (nodes.isEmpty) { return; } + final transaction = editorState.transaction; if (nodes.length == 1) { final node = nodes.removeAt(0); @@ -103,25 +103,43 @@ void formatSelection( } transaction.compose(); + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + withUpdateSelection: false, + ); } -Position ensurePreviousNodeIsEmptyParagraph( +Future ensurePreviousNodeIsEmptyParagraph( EditorState editorState, Node aiWriterNode, - Transaction transaction, -) { +) async { final previous = aiWriterNode.previous; final needsEmptyParagraphNode = previous == null || previous.type != ParagraphBlockKeys.type || (previous.delta?.toPlainText().isNotEmpty ?? false); final Position position; + final transaction = editorState.transaction; + if (needsEmptyParagraphNode) { position = Position(path: aiWriterNode.path); transaction.insertNode(aiWriterNode.path, paragraphNode()); } else { position = Position(path: previous.path); } + transaction.afterSelection = Selection.collapsed(position); + + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + ); return position; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 07d5d84ae9..20247bf3a3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -54,6 +54,7 @@ class AiWriterCubit extends Cubit { if (withDiscard) { await _textRobot.discard(); } + _textRobot.clear(); _textRobot.reset(); onRemoveNode?.call(); records.clear(); @@ -65,21 +66,11 @@ class AiWriterCubit extends Cubit { if (selection == null) { return; } - final transaction = editorState.transaction; - formatSelection( + await formatSelection( editorState, selection, - transaction, ApplySuggestionFormatType.clear, ); - await editorState.apply( - transaction, - options: const ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - withUpdateSelection: false, - ); } if (aiWriterNode != null) { await removeAiWriterNode(editorState, aiWriterNode!); @@ -122,7 +113,7 @@ class AiWriterCubit extends Cubit { } await _textRobot.discard(); - _textRobot.reset(); + _textRobot.clear(); switch (command) { case AiWriterCommand.continueWriting: @@ -222,11 +213,15 @@ class AiWriterCubit extends Cubit { if (action case SuggestionAction.accept) { await _textRobot.persist(); + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); final nodes = editorState.getNodesInSelection(selection); final transaction = editorState.transaction..deleteNodes(nodes); await editorState.apply( transaction, - options: const ApplyOptions(recordUndo: false), withUpdateSelection: false, ); await exit(withDiscard: false, withUnformat: false); @@ -235,6 +230,8 @@ class AiWriterCubit extends Cubit { if (action case SuggestionAction.keep) { await _textRobot.persist(); + await exit(withDiscard: false); + return; } if (action case SuggestionAction.insertBelow) { @@ -244,20 +241,9 @@ class AiWriterCubit extends Cubit { final command = (state as ReadyAiWriterState).command; final markdownText = (state as ReadyAiWriterState).markdownText; if (command == AiWriterCommand.explain && markdownText.isNotEmpty) { - final transaction = editorState.transaction; - final position = ensurePreviousNodeIsEmptyParagraph( + final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, - transaction, - ); - transaction.afterSelection = null; - await editorState.apply( - transaction, - options: ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - withUpdateSelection: false, ); _textRobot.start(position: position); await _textRobot.persist(markdownText: markdownText); @@ -265,21 +251,13 @@ class AiWriterCubit extends Cubit { await _textRobot.persist(); } - final transaction = editorState.transaction; - formatSelection( + await formatSelection( editorState, selection, - transaction, ApplySuggestionFormatType.clear, ); - await editorState.apply( - transaction, - options: const ApplyOptions(recordUndo: false), - withUpdateSelection: false, - ); + await exit(withDiscard: false); } - - await exit(withDiscard: false); } bool hasUnusedResponse() { @@ -308,22 +286,20 @@ class AiWriterCubit extends Cubit { if (command == AiWriterCommand.continueWriting) { return (true, ''); - } else { - if (selection.isCollapsed) { - return (true, ''); - } else { - final selectionText = - await editorState.getMarkdownInSelection(selection); + } + if (selection.isCollapsed) { + return (true, ''); + } - if (command == AiWriterCommand.userQuestion) { - records.add( - AiWriterRecord.user(content: selectionText, format: null), - ); - return (true, ''); - } else { - return (true, selectionText); - } - } + final selectionText = await editorState.getMarkdownInSelection(selection); + + if (command == AiWriterCommand.userQuestion) { + records.add( + AiWriterRecord.user(content: selectionText, format: null), + ); + return (true, ''); + } else { + return (true, selectionText); } } @@ -360,19 +336,9 @@ class AiWriterCubit extends Cubit { sourceIds: selectedSourcesNotifier.value, completionType: command.toCompletionType(), onStart: () async { - final transaction = editorState.transaction; - final position = ensurePreviousNodeIsEmptyParagraph( + final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, - transaction, - ); - await editorState.apply( - transaction, - options: ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - withUpdateSelection: false, ); _textRobot.start(position: position); records.add( @@ -461,20 +427,9 @@ class AiWriterCubit extends Cubit { sourceIds: selectedSourcesNotifier.value, format: predefinedFormat, onStart: () async { - final transaction = editorState.transaction; - final position = ensurePreviousNodeIsEmptyParagraph( + final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, - transaction, - ); - transaction.afterSelection = null; - await editorState.apply( - transaction, - options: ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - withUpdateSelection: false, ); _textRobot.start(position: position); records.add( @@ -555,26 +510,14 @@ class AiWriterCubit extends Cubit { history: records, sourceIds: selectedSourcesNotifier.value, onStart: () async { - final transaction = editorState.transaction; - formatSelection( + await formatSelection( editorState, selection, - transaction, ApplySuggestionFormatType.original, ); - final position = ensurePreviousNodeIsEmptyParagraph( + final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, - transaction, - ); - transaction.afterSelection = null; - await editorState.apply( - transaction, - options: ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - withUpdateSelection: false, ); _textRobot.start(position: position); records.add( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart index 8a054de1c0..326d1a44ab 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -198,9 +198,11 @@ class _AiWriterScrollWrapperState extends State { throttler.call(() { if (aiWriterCubit.aiWriterNode != null) { final path = aiWriterCubit.aiWriterNode!.path; - widget.editorState.updateSelectionWithReason( - Selection.collapsed(Position(path: path)), - ); + if (path.isNotEmpty) { + widget.editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + ); + } } }); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 62c024b31a..378c4d88cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -58,7 +58,7 @@ class MarkdownTextRobot { void start({ Position? position, }) { - _insertPosition ??= position ?? editorState.selection?.start; + _insertPosition = position ?? editorState.selection?.start; if (_enableDebug) { Log.info( @@ -148,11 +148,15 @@ class MarkdownTextRobot { } } - void reset() { + void clear() { _markdownText = ''; _insertedNodes = []; } + void reset() { + _insertPosition = null; + } + Future _refresh({ required bool inMemoryUpdate, bool updateSelection = false, From 5a0478ad562728e30984bb169f466c31cbe7e359 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:00:28 +0800 Subject: [PATCH 033/207] fix(mobile): settings page crashes (#7616) --- .../settings/ai/settings_ai_bloc.dart | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 6dcd52b294..44558b13b7 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -7,6 +7,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_ai_bloc.freezed.dart'; @@ -96,14 +97,17 @@ class SettingsAIBloc extends Bloc { }, didLoadAvailableModels: (List models) { if (state.selectedAIModel.isEmpty) { - final m = models.firstWhere((model) => model.isDefault); - _updateUserWorkspaceSetting(model: m.name); - emit( - state.copyWith( - availableModels: models, - selectedAIModel: m.name, - ), - ); + final defaultModel = + models.firstWhereOrNull((model) => model.isDefault); + if (defaultModel != null) { + _updateUserWorkspaceSetting(model: defaultModel.name); + emit( + state.copyWith( + availableModels: models, + selectedAIModel: defaultModel.name, + ), + ); + } } else { emit( state.copyWith( From 1d437fb81ef3e9a335a802fe8a627c96ba39b6e5 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:44:00 +0800 Subject: [PATCH 034/207] chore: adjust ai writer popover content area (#7618) --- .../ai/ai_writer_block_component.dart | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index a35d0cfbf8..3e1316a58f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -423,28 +423,25 @@ class SecondaryContentArea extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const VSpace(8.0), + Container( + height: 24.0, + padding: EdgeInsets.symmetric(horizontal: 14.0), + alignment: AlignmentDirectional.centerStart, + child: FlowyText( + command.i18n, + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF666D76), + ), + ), + const VSpace(4.0), Flexible( child: SingleChildScrollView( physics: ClampingScrollPhysics(), - padding: EdgeInsets.only(top: 8.0, left: 14.0, right: 14.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 24.0, - alignment: AlignmentDirectional.centerStart, - child: FlowyText( - command.i18n, - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFF666D76), - ), - ), - const VSpace(4.0), - AIMarkdownText( - markdown: markdownText, - ), - ], + padding: EdgeInsets.symmetric(horizontal: 14.0), + child: AIMarkdownText( + markdown: markdownText, ), ), ), From c212c568e9f6876a168bdc97c61c910e8cbd3648 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:00:41 +0800 Subject: [PATCH 035/207] chore: bump editor ref (#7621) --- frontend/appflowy_flutter/pubspec.lock | 4 ++-- frontend/appflowy_flutter/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 2acfec76bb..d61c5338f5 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -90,8 +90,8 @@ packages: dependency: "direct main" description: path: "." - ref: "50f9724" - resolved-ref: "50f9724190ee47eac3d7dbe323a3ce39d19ea883" + ref: f46e991 + resolved-ref: f46e991d0a9c5a95bd14be4cc96e68171c9ed9bc url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 344232897f..e005f7153d 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -180,7 +180,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "50f9724" + ref: "f46e991" appflowy_editor_plugins: git: From 1db6da7024ea247b7d15ac0be96d45ec91da8d99 Mon Sep 17 00:00:00 2001 From: Morn Date: Wed, 26 Mar 2025 13:22:47 +0800 Subject: [PATCH 036/207] fix: undo not working for toggle heading (#7598) --- .../editor_plugins/actions/block_action_option_cubit.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart index 35e92d7170..abed98136d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart @@ -469,7 +469,7 @@ class BlockActionOptionCubit extends Cubit { blockComponentDelta: newDelta.toJson(), }, children: [ - ...node.children, + ...node.children.map((e) => e.deepCopy()), ...insertedNodes.map((e) => e.deepCopy()), ], ); From cfca70ae140aab41494bea7f90e70c9524a12c69 Mon Sep 17 00:00:00 2001 From: Ametero <101631790+Ameterius@users.noreply.github.com> Date: Tue, 25 Mar 2025 22:23:25 -0700 Subject: [PATCH 037/207] =?UTF-8?q?chore:=20update=20translations=20with?= =?UTF-8?q?=20Fink=20=F0=9F=90=A6=20(#7584)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/resources/translations/ar-SA.json | 4 +- frontend/resources/translations/ru-RU.json | 93 +++++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 243cf0c177..62708e664f 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -2255,11 +2255,11 @@ "layoutDateField": "تقويم التخطيط بواسطة", "changeLayoutDateField": "تغيير حقل التخطيط", "noDateTitle": "بدون تاريخ", - "noDateHint": "ستظهر الأحداث غير المجدولة هنا", "unscheduledEventsTitle": "الأحداث غير المجدولة", "clickToAdd": "انقر للإضافة إلى التقويم", "name": "تخطيط التقويم", - "clickToOpen": "انقر لفتح السجل" + "clickToOpen": "انقر لفتح السجل", + "noDateHint": "ستظهر الأحداث غير المجدولة هنا" }, "referencedCalendarPrefix": "نظرا ل", "quickJumpYear": "انتقل إلى", diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index b89b26f0c5..6ca29ffc03 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -76,9 +76,14 @@ }, "workspace": { "chooseWorkspace": "Выберите рабочее пространство", + "defaultName": "Моё рабочее пространство", "create": "Создать рабочее пространство", + "new": "Новое рабочее пространство", + "importFromNotion": "Импортировать с Notion", + "learnMore": "Узнать больше", "reset": "Сбросить рабочее пространство", "renameWorkspace": "Переименовать рабочее пространство", + "workspaceNameCannotBeEmpty": "Название рабочего пространства не может быть пустым", "resetWorkspacePrompt": "Сброс рабочего пространства приведет к удалению всех страниц и данных внутри него. Вы уверены, что хотите сбросить рабочее пространство? В качестве альтернативы вы можете обратиться в службу поддержки для восстановления рабочего пространства", "hint": "рабочее пространство", "notFoundError": "Рабочее пространство не найдено.", @@ -122,7 +127,9 @@ "visitSite": "Посетить сайт", "exportAsTab": "Экспортировать как", "publishTab": "Опубликовать", - "shareTab": "Поделиться" + "shareTab": "Поделиться", + "publishOnAppFlowy": "Выложить на AppFlowy", + "shareTabTitle": "Пригласить к сотрудничеству" }, "moreAction": { "small": "маленький", @@ -144,6 +151,11 @@ "csv": "CSV", "database": "База данных" }, + "emojiIconPicker": { + "iconUploader": { + "change": "Изменить" + } + }, "disclosureAction": { "rename": "Переименовать", "delete": "Удалить", @@ -155,7 +167,8 @@ "addToFavorites": "Добавить в избранное", "copyLink": "Скопировать ссылку", "changeIcon": "Изменить иконку", - "collapseAllPages": "Свернуть все подстраницы" + "collapseAllPages": "Свернуть все подстраницы", + "lockPage": "Заблокировать страницу" }, "blankPageTitle": "Пустая страница", "newPageText": "Новая страница", @@ -170,17 +183,39 @@ "relatedQuestion": "Связано", "serverUnavailable": "Сервис временно недоступен. Пожалуйста, повторите попытку позже.", "aiServerUnavailable": "🌈 Ой-ой! 🌈. Единорог съел наш ответ. Пожалуйста, повторите попытку!", + "retry": "Повторить", "clickToRetry": "Нажмите, чтобы повторить попытку", "regenerateAnswer": "Повторно сгенерировать", "question1": "Как использовать канбан для управления задачами.", "question2": "Объясните метод GTD.", "question3": "Зачем использовать Rust.", "question4": "Рецепт из того, что есть у меня на кухне.", - "aiMistakePrompt": "ИИ может ошибаться. Проверяйте важную информацию." + "aiMistakePrompt": "ИИ может ошибаться. Проверяйте важную информацию.", + "inputActionNoPages": "Нет результатов на странице", + "currentPage": "Текущая страница", + "regenerate": "Попробуйте ещё раз", + "addToNewPage": "Создать новую страницу", + "openPagePreviewFailedToast": "Не удалось открыть страницу", + "changeFormat": { + "actionButton": "Изменить формат", + "textOnly": "Текст", + "imageOnly": "Только изображение", + "textAndImage": "Текст и изображение", + "text": "Параграф", + "bullet": "Список маркеров", + "number": "Нумерованный список", + "defaultDescription": "Автоматический режим" + }, + "selectBanner": { + "selectMessages": "Выбрать сообщения", + "allSelected": "Все выбрано" + }, + "stopTooltip": "Остановить генерацию" }, "trash": { "text": "Корзина", "restoreAll": "Восстановить всё", + "restore": "Восстановить", "deleteAll": "Удалить всё", "pageHeader": { "fileName": "Имя файла", @@ -195,6 +230,9 @@ "title": "Вы уверены, что хотите восстановить все страницы в корзине?", "caption": "Это действие не может быть отменено." }, + "restorePage": { + "caption": "Вы уверены, что хотите восстановить эту страницу?" + }, "mobile": { "actions": "Действия с корзиной", "empty": "Корзина пуста", @@ -289,7 +327,10 @@ "removeSuccess": "Удалено успешно", "favoriteSpace": "Избранное", "RecentSpace": "Недавнее", - "Spaces": "Пространства" + "Spaces": "Пространства", + "upgradeToPro": "Обновление до Pro", + "upgradeToAIMax": "Разблокируйте неограниченный ИИ", + "purchaseAIResponse": "Покупка " }, "notifications": { "export": { @@ -323,6 +364,7 @@ "upload": "Загрузить", "edit": "Редактировать", "delete": "Удалить", + "copy": "Копировать", "duplicate": "Дублировать", "putback": "Вернуть", "update": "Обновить", @@ -334,6 +376,7 @@ "helpCenter": "Центр помощи", "add": "Добавить", "yes": "Да", + "no": "Нет", "clear": "Очистить", "remove": "Удалить", "dontRemove": "Не удалять", @@ -349,6 +392,16 @@ "more": "Больше", "create": "Создать", "close": "Закрыть", + "next": "Следующий", + "previous": "Предыдущий", + "submit": "Представить", + "download": "Скачать", + "backToHome": "Вернуться на главную", + "viewing": "Просмотр", + "editing": "Редактирование", + "gotIt": "Понятно", + "retry": "Повторить попытку", + "uploadFailed": "Загрузка не удалась.", "tryAGain": "Попробовать ещё раз", "Done": "Готово", "Cancel": "Отмена", @@ -376,6 +429,28 @@ }, "settings": { "title": "Настройки", + "popupMenuItem": { + "settings": "Настройки", + "members": "Участники", + "helpAndSupport": "Помощь и поддержка" + }, + "sites": { + "namespaceTitle": "Пространство имен", + "namespaceDescription": "Управляйте своим пространством имен и домашней страницей", + "namespaceHeader": "Пространство имен", + "homepageHeader": "Домашняя страница", + "updateNamespace": "Обновить пространство имен", + "removeHomepage": "Удалить домашнюю страницу", + "selectHomePage": "Выберите страницу", + "clearHomePage": "Очистить домашнюю страницу для этого пространства имен", + "customUrl": "Пользовательский URL-адрес", + "namespace": { + "description": "Это изменение будет применено ко всем опубликованным страницам, размещенным в этом пространстве имен." + }, + "publishedPage": { + "page": "Страница" + } + }, "accountPage": { "menuLabel": "Мой аккаунт", "title": "Мой аккаунт", @@ -2183,5 +2258,15 @@ "privacyPolicy": "Политика конфиденциальности", "signInError": "Ошибка входа", "login": "Зарегистрироваться или войти" + }, + "ai": { + "limitReachedAction": { + "upgrade": "улучшить", + "proPlan": "план Pro", + "aiAddon": "Дополнение ИИ" + }, + "editing": "Редактирование", + "analyzing": "Анализ", + "more": "Более" } } From 24bb1b58a057c2876cd7f827b49afdf727c00615 Mon Sep 17 00:00:00 2001 From: Morn Date: Wed, 26 Mar 2025 13:24:16 +0800 Subject: [PATCH 038/207] feat: support use ":" keyword to create emojis (#7582) * feat: add ability to use : keyword to create emojis(#2797) * fix: emoji position error * chore: add integration test * chore: dismiss emoji picker while starting searching with space --- .../uncategorized/emoji_shortcut_test.dart | 112 +++++ .../uncategorized_test_runner_1.dart | 1 - .../shortcuts/character_shortcuts.dart | 5 + .../plugins/emoji/emoji_actions_command.dart | 44 ++ .../lib/plugins/emoji/emoji_handler.dart | 382 ++++++++++++++++++ .../lib/plugins/emoji/emoji_menu.dart | 204 ++++++++++ 6 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart index 554a6eecbf..f539835b9b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:appflowy/plugins/emoji/emoji_handler.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -39,4 +41,114 @@ void main() { expect(find.byType(EmojiSelectionMenu), findsOneWidget); }); }); + + group('insert emoji by colon', () { + Future createNewDocumentAndShowEmojiList(WidgetTester tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(); + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(':'); + await tester.pumpAndSettle(Duration(seconds: 1)); + } + + testWidgets('insert with click', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + final emojiButtons = + find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); + final firstTextFinder = find.descendant( + of: emojiButtons.first, + matching: find.byType(FlowyText), + ); + final emojiText = + (firstTextFinder.evaluate().first.widget as FlowyText).text; + + /// click first emoji item + await tester.tapButton(emojiButtons.first); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(emojiText.contains(firstNode.delta!.toPlainText()), true); + }); + + testWidgets('insert with arrow and enter', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + final emojiButtons = + find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); + + /// tap arrow down and arrow up + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown); + + final firstTextFinder = find.descendant( + of: emojiButtons.first, + matching: find.byType(FlowyText), + ); + final emojiText = + (firstTextFinder.evaluate().first.widget as FlowyText).text; + + /// tap enter + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(emojiText.contains(firstNode.delta!.toPlainText()), true); + }); + + testWidgets('insert with searching', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// search for `smiling eyes`, IME is not working, use keyboard input + final searchText = [ + LogicalKeyboardKey.keyS, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyG, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyY, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyS, + ]; + + for (final key in searchText) { + await tester.simulateKeyEvent(key); + } + + /// tap enter + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(firstNode.delta!.toPlainText().contains('😄'), true); + }); + + testWidgets('start searching with sapce', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + + /// input space + await tester.simulateKeyEvent(LogicalKeyboardKey.space); + + /// emoji list is dismissed + expect(emojiHandler, findsNothing); + }); + }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart index f7d94e8b4a..836cfe4ccd 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart @@ -13,7 +13,6 @@ void main() { hotkeys_test.main(); emoji_shortcut_test.main(); hotkeys_test.main(); - emoji_shortcut_test.main(); share_markdown_test.main(); import_files_test.main(); zoom_in_out_test.main(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart index 49178ca12b..13b2fea5ee 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/plugins/emoji/emoji_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -82,5 +83,9 @@ List buildCharacterShortcutEvents( documentBloc.documentId, styleCustomizer.inlineActionsMenuStyleBuilder(), ), + + /// show emoji list + /// - Using `:` + emojiCommand(context), ]; } diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart new file mode 100644 index 0000000000..1942a1fd98 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart @@ -0,0 +1,44 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'emoji_menu.dart'; + +const emojiCharacter = ':'; + +CharacterShortcutEvent emojiCommand(BuildContext context) => + CharacterShortcutEvent( + key: 'Opens Emoji Menu', + character: emojiCharacter, + handler: (editorState) { + emojiMenuService ??= EmojiMenu( + context: context, + editorState: editorState, + ); + return emojiCommandHandler(editorState, context); + }, + ); + +EmojiMenuService? emojiMenuService; + +Future emojiCommandHandler( + EditorState editorState, + BuildContext context, +) async { + final selection = editorState.selection; + + if (UniversalPlatform.isMobile || selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + await editorState.insertTextAtPosition( + emojiCharacter, + position: selection.start, + ); + emojiMenuService?.show(); + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart new file mode 100644 index 0000000000..ec86de7e8d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart @@ -0,0 +1,382 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.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/services.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +import 'emoji_menu.dart'; + +class EmojiHandler extends StatefulWidget { + const EmojiHandler({ + super.key, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.onSelectionUpdate, + required this.onEmojiSelect, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final EditorState editorState; + final EmojiMenuService menuService; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + final SelectEmojiItemHandler onEmojiSelect; + final int startCharAmount; + final bool Function()? cancelBySpaceHandler; + + @override + State createState() => _EmojiHandlerState(); +} + +class _EmojiHandlerState extends State { + final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler'); + final ItemScrollController controller = ItemScrollController(); + late EmojiData emojiData; + final List searchedEmojis = []; + bool loaded = false; + int invalidCounter = 0; + late int startOffset; + String _search = ''; + + set search(String search) { + _search = search; + _doSearch(); + } + + final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); + + startOffset = widget.editorState.selection?.endIndex ?? 0; + + if (kCachedEmojiData != null) { + loadEmojis(kCachedEmojiData!); + } else { + EmojiData.builtIn().then( + (value) { + kCachedEmojiData = value; + loadEmojis(value); + }, + ); + } + } + + @override + void dispose() { + _focusNode.dispose(); + _selectedIndexNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final noEmojis = searchedEmojis.isEmpty; + return Focus( + focusNode: _focusNode, + onKeyEvent: onKeyEvent, + child: Container( + constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withAlpha(25), + ), + ], + ), + child: noEmojis + ? SizedBox( + width: 400, + height: 40, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ), + ), + ) + : ScrollablePositionedList.builder( + itemCount: searchedEmojis.length, + itemScrollController: controller, + padding: EdgeInsets.all(8), + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemBuilder: (ctx, index) { + return ValueListenableBuilder( + valueListenable: _selectedIndexNotifier, + builder: (context, value, __) { + final selectedEmoji = searchedEmojis[index]; + final displayedEmoji = + emojiData.getEmojiById(selectedEmoji.id); + final isSelected = value == index; + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium( + '$displayedEmoji ${selectedEmoji.name}', + lineHeight: 1.0, + overflow: TextOverflow.ellipsis, + ), + isSelected: isSelected, + onTap: () => onSelect(index), + ), + ); + }, + ); + }, + ), + ), + ); + } + + void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index; + + void loadEmojis(EmojiData data) { + emojiData = data; + searchedEmojis.clear(); + searchedEmojis.addAll(emojiData.emojis.values); + if (mounted) { + setState(() { + loaded = true; + }); + } + } + + Future _doSearch() async { + if (!loaded) return; + if (_search.startsWith(' ')) { + widget.onDismiss.call(); + return; + } + final searchEmojiData = emojiData.filterByKeyword(_search); + setState(() { + searchedEmojis.clear(); + searchedEmojis.addAll(searchEmojiData.emojis.values); + changeSelectedIndex(0); + }); + if (searchedEmojis.isEmpty) { + widget.onDismiss.call(); + } + } + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + const moveKeys = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + ]; + + if (event.logicalKey == LogicalKeyboardKey.enter) { + onSelect(_selectedIndexNotifier.value); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + // Workaround to bring focus back to editor + widget.editorState + .updateSelectionWithReason(widget.editorState.selection); + widget.onDismiss.call(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_search.isEmpty) { + if (_canDeleteLastCharacter()) { + widget.editorState.deleteBackward(); + } else { + // Workaround for editor regaining focus + widget.editorState.apply( + widget.editorState.transaction + ..afterSelection = widget.editorState.selection, + ); + } + widget.onDismiss.call(); + } else { + widget.onSelectionUpdate(); + widget.editorState.deleteBackward(); + _deleteCharacterAtSelection(); + } + + return KeyEventResult.handled; + } else if (event.character != null && + ![ + ...moveKeys, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ].contains(event.logicalKey)) { + /// Prevents dismissal of context menu by notifying the parent + /// that the selection change occurred from the handler. + widget.onSelectionUpdate(); + + if (event.logicalKey == LogicalKeyboardKey.space) { + final cancelBySpaceHandler = widget.cancelBySpaceHandler; + if (cancelBySpaceHandler != null && cancelBySpaceHandler()) { + return KeyEventResult.handled; + } + } + + // Interpolation to avoid having a getter for private variable + _insertCharacter(event.character!); + return KeyEventResult.handled; + } else if (moveKeys.contains(event.logicalKey)) { + _moveSelection(event.logicalKey); + return KeyEventResult.handled; + } + + if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight] + .contains(event.logicalKey)) { + widget.onSelectionUpdate(); + + event.logicalKey == LogicalKeyboardKey.arrowLeft + ? widget.editorState.moveCursorForward() + : widget.editorState.moveCursorBackward(SelectionMoveRange.character); + + /// If cursor moves before @ then dismiss menu + /// If cursor moves after @search.length then dismiss menu + final selection = widget.editorState.selection; + if (selection != null && + (selection.endIndex < startOffset || + selection.endIndex > (startOffset + _search.length))) { + widget.onDismiss.call(); + } + + /// Workaround: When using the move cursor methods, it seems the + /// focus goes back to the editor, this makes sure this handler + /// receives the next keypress. + /// + _focusNode.requestFocus(); + + return KeyEventResult.handled; + } + + return KeyEventResult.handled; + } + + void onSelect(int index) { + widget.onEmojiSelect.call( + context, + ( + startOffset - widget.startCharAmount, + _search.length + widget.startCharAmount + ), + emojiData.getEmojiById(searchedEmojis[index].id), + ); + widget.onDismiss.call(); + } + + void _insertCharacter(String character) { + widget.editorState.insertTextAtCurrentSelection(character); + + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta; + if (delta == null) { + return; + } + + search = widget.editorState + .getTextInSelection( + selection.copyWith( + start: selection.start.copyWith(offset: startOffset), + end: selection.start + .copyWith(offset: startOffset + _search.length + 1), + ), + ) + .join(); + } + + void _moveSelection(LogicalKeyboardKey key) { + bool didChange = false; + final index = _selectedIndexNotifier.value; + if (key == LogicalKeyboardKey.arrowUp || + (key == LogicalKeyboardKey.tab && + HardwareKeyboard.instance.isShiftPressed)) { + if (index == 0) { + changeSelectedIndex(max(0, searchedEmojis.length - 1)); + didChange = true; + } else if (index > 0) { + changeSelectedIndex(index - 1); + didChange = true; + } + } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab] + .contains(key)) { + if (index < searchedEmojis.length - 1) { + changeSelectedIndex(index + 1); + didChange = true; + } else if (index == searchedEmojis.length - 1) { + changeSelectedIndex(0); + didChange = true; + } + } + + if (mounted && didChange) { + _scrollToItem(); + } + } + + void _scrollToItem() { + final noEmojis = searchedEmojis.isEmpty; + if (noEmojis) return; + controller.scrollTo( + index: _selectedIndexNotifier.value, + duration: const Duration(milliseconds: 200), + alignment: 0.5, + ); + } + + void _deleteCharacterAtSelection() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = widget.editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + search = delta.toPlainText().substring( + startOffset, + startOffset - 1 + _search.length, + ); + } + + bool _canDeleteLastCharacter() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; + if (delta == null) { + return false; + } + + return delta.isNotEmpty; + } +} + +typedef SelectEmojiItemHandler = void Function( + BuildContext context, + (int start, int end) replacement, + String emoji, +); diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart new file mode 100644 index 0000000000..58269a32d5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart @@ -0,0 +1,204 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'emoji_actions_command.dart'; +import 'emoji_handler.dart'; + +abstract class EmojiMenuService { + void show(); + + void dismiss(); +} + +class EmojiMenu extends EmojiMenuService { + EmojiMenu({ + required this.context, + required this.editorState, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final BuildContext context; + final EditorState editorState; + final bool Function()? cancelBySpaceHandler; + + final int startCharAmount; + + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); + } + + _menuEntry?.remove(); + _menuEntry = null; + + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + selectionService.currentSelection.removeListener(_onSelectionChange); + } + emojiMenuService = null; + } + + void _onSelectionUpdate() => selectionChangedByMenu = true; + + @override + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + + const menuHeight = 400.0, menuWidth = 300.0; + const Offset menuOffset = Offset(0, 10); + final Offset editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final Size editorSize = editorState.renderBox!.size; + final editorHeight = editorSize.height, editorWidth = editorSize.width; + // Default to opening the overlay below + Alignment alignment = Alignment.topLeft; + + final firstRect = selectionRects.first; + Offset offset = firstRect.bottomRight + menuOffset; + + // Show above + if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { + offset = firstRect.topRight - menuOffset; + alignment = Alignment.bottomLeft; + offset = Offset( + offset.dx, + editorHeight - offset.dy, + ); + } + + // Show on the left + if (offset.dx > (editorWidth - menuWidth)) { + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorWidth - offset.dx, + offset.dy, + ); + } + + final (left, top, right, bottom) = _getPosition(alignment, offset); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + + // GestureDetector handles clicks outside of the context menu, + // to dismiss the context menu. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: EmojiHandler( + editorState: editorState, + menuService: this, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, + onEmojiSelect: ( + BuildContext context, + (int, int) replacement, + String emoji, + ) async { + final selection = editorState.selection; + + if (selection == null) return; + final node = + editorState.document.nodeAtPath(selection.end.path); + if (node == null) return; + final transaction = editorState.transaction + ..replaceText( + node, + replacement.$1, + replacement.$2, + emoji, + ); + await editorState.apply(transaction); + }, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + selectionService.currentSelection.addListener(_onSelectionChange); + } + + void _onSelectionChange() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + if (selectionService.currentSelection.value == null) { + return; + } + } + + if (!selectionChangedByMenu) { + return dismiss(); + } + + selectionChangedByMenu = false; + } + + (double? left, double? top, double? right, double? bottom) _getPosition( + Alignment alignment, + Offset offset, + ) { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } +} From 9115e208ac699c052c211d513891f2b23c096a6e Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 26 Mar 2025 13:31:32 +0800 Subject: [PATCH 039/207] fix: numbered list generated by ai should keep the same index as the input (#7622) Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com> --- .../ai/operations/ai_writer_cubit.dart | 2 +- .../base/markdown_text_robot.dart | 52 +++++++++++++++++-- .../numbered_list/numbered_list_icon.dart | 35 ++++++++++--- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 20247bf3a3..3275f0d75c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -519,7 +519,7 @@ class AiWriterCubit extends Cubit { editorState, aiWriterNode!, ); - _textRobot.start(position: position); + _textRobot.start(position: position, previousSelection: selection); records.add( AiWriterRecord.user( content: prompt, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 378c4d88cc..d6cfee929c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; import 'package:synchronized/synchronized.dart'; const _enableDebug = false; @@ -28,6 +30,9 @@ class MarkdownTextRobot { /// Only for debug via [_enableDebug]. final List _debugMarkdownTexts = []; + /// Selection before the refresh. + Selection? _previousSelection; + bool get hasAnyResult => _markdownText.isNotEmpty; String get markdownText => _markdownText; @@ -56,9 +61,11 @@ class MarkdownTextRobot { } void start({ + Selection? previousSelection, Position? position, }) { _insertPosition = position ?? editorState.selection?.start; + _previousSelection = previousSelection ?? editorState.selection; if (_enableDebug) { Log.info( @@ -175,11 +182,40 @@ class MarkdownTextRobot { tableWidth: 250.0, ).root.children; + // check if the first selected node before the refresh is a numbered list node + final previousSelection = _previousSelection; + final previousSelectedNode = previousSelection == null + ? null + : editorState.getNodeAtPath(previousSelection.start.path); + final firstNodeIsNumberedList = previousSelectedNode != null && + previousSelectedNode.type == NumberedListBlockKeys.type; + final newNodes = attributes == null ? documentNodes - : documentNodes - .map((node) => _styleDelta(node: node, attributes: attributes)) - .toList(); + : documentNodes.mapIndexed((index, node) { + final n = _styleDelta(node: node, attributes: attributes); + n.externalValues = AINodeExternalValues( + isAINode: true, + ); + if (index == 0 && n.type == NumberedListBlockKeys.type) { + if (firstNodeIsNumberedList) { + final builder = NumberedListIndexBuilder( + editorState: editorState, + node: previousSelectedNode, + ); + final firstIndex = builder.indexInSameLevel; + n.updateAttributes({ + NumberedListBlockKeys.number: firstIndex, + }); + } + + n.externalValues = AINodeExternalValues( + isAINode: true, + isFirstNumberedListNode: true, + ); + } + return n; + }).toList(); if (newNodes.isEmpty) { return; @@ -247,3 +283,13 @@ class MarkdownTextRobot { ); } } + +class AINodeExternalValues extends NodeExternalValues { + const AINodeExternalValues({ + this.isAINode = false, + this.isFirstNumberedListNode = false, + }); + + final bool isAINode; + final bool isFirstNumberedListNode; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart index 3fad80bf32..f77083d21d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -31,7 +32,7 @@ class NumberedListIcon extends StatelessWidget { return Padding( padding: const EdgeInsets.only(left: 6.0, right: 10.0), child: Text( - node.levelString, + node.buildLevelString(context), style: adjustedTextStyle, strutStyle: StrutStyle.fromTextStyle(combinedTextStyle), textHeightBehavior: TextHeightBehavior( @@ -47,9 +48,12 @@ class NumberedListIcon extends StatelessWidget { } } -extension on Node { - String get levelString { - final builder = _NumberedListIconBuilder(node: this); +extension NumberedListNodeIndex on Node { + String buildLevelString(BuildContext context) { + final builder = NumberedListIndexBuilder( + editorState: context.read(), + node: this, + ); final indexInRootLevel = builder.indexInRootLevel; final indexInSameLevel = builder.indexInSameLevel; final level = indexInRootLevel % 3; @@ -62,11 +66,13 @@ extension on Node { } } -class _NumberedListIconBuilder { - _NumberedListIconBuilder({ +class NumberedListIndexBuilder { + NumberedListIndexBuilder({ + required this.editorState, required this.node, }); + final EditorState editorState; final Node node; // the level of the current node @@ -88,7 +94,13 @@ class _NumberedListIconBuilder { Node? previous = node.previous; // if the previous one is not a numbered list, then it is the first one - if (previous == null || previous.type != NumberedListBlockKeys.type) { + final aiNodeExternalValues = + node.externalValues?.unwrapOrNull(); + + if (previous == null || + previous.type != NumberedListBlockKeys.type || + (aiNodeExternalValues != null && + aiNodeExternalValues.isFirstNumberedListNode)) { return node.attributes[NumberedListBlockKeys.number] ?? level; } @@ -97,10 +109,17 @@ class _NumberedListIconBuilder { startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; level++; previous = previous.previous; + + // break the loop if the start number is found when the current node is an AI node + if (aiNodeExternalValues != null && startNumber != null) { + return startNumber + level - 1; + } } + if (startNumber != null) { - return startNumber + level - 1; + level = startNumber + level - 1; } + return level; } } From 7372f5583cfc07ba537811b66dd7796b6366762f Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 26 Mar 2025 13:56:24 +0800 Subject: [PATCH 040/207] chore: bump version 0.8.8 (#7627) --- frontend/Makefile.toml | 2 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 44337645fe..0b23c5df26 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.8.7" +APPFLOWY_VERSION = "0.8.8" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index e005f7153d..4710d52b4d 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an your data. The best open source alternative to Notion. publish_to: "none" -version: 0.8.7 +version: 0.8.8 environment: flutter: ">=3.27.4" From 815bb11cde82e28bd2795e88ae8ace075a06561b Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 26 Mar 2025 14:19:57 +0800 Subject: [PATCH 041/207] chore: implement local ai config --- .../lib/ai/service/ai_input_control.dart | 2 +- .../home/mobile_home_setting_page.dart | 1 - .../setting/ai/ai_settings_group.dart | 24 ++-- .../application/ai_model_switch_listener.dart | 6 +- .../settings/ai/settings_ai_bloc.dart | 109 ++++----------- .../setting_ai_view/model_selection.dart | 15 ++- .../setting_ai_view/settings_ai_view.dart | 5 +- frontend/rust-lib/Cargo.lock | 4 +- frontend/rust-lib/Cargo.toml | 4 +- .../flowy-codegen/src/ts_event/mod.rs | 1 - frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 18 ++- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 125 ++++++++++-------- frontend/rust-lib/flowy-ai/src/entities.rs | 20 ++- .../rust-lib/flowy-ai/src/event_handler.rs | 22 +-- frontend/rust-lib/flowy-ai/src/event_map.rs | 2 +- .../flowy-ai/src/local_ai/controller.rs | 1 - frontend/rust-lib/flowy-ai/src/util.rs | 2 +- 17 files changed, 175 insertions(+), 186 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart index 8e4b42d1ce..c468fdd6e9 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart @@ -14,7 +14,7 @@ class AIModelStateNotifier { : _isDesktop = UniversalPlatform.isDesktop, _localAIListener = UniversalPlatform.isDesktop ? LocalAIStateListener() : null, - _aiModelSwitchListener = AIModelSwitchListener(chatId: objectId); + _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId); final String objectId; final bool _isDesktop; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index b5845f763e..09e22fc746 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -100,7 +100,6 @@ class _MobileHomeSettingPageState extends State { key: ValueKey(currentWorkspaceId), userProfile: userProfile, workspaceId: currentWorkspaceId, - currentWorkspaceMemberRole: state.currentWorkspace?.role, ), const SupportSettingGroup(), const AboutSettingGroup(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart index 3b75b08543..b43ada6e42 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart @@ -5,8 +5,6 @@ import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; -import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; @@ -18,12 +16,10 @@ class AiSettingsGroup extends StatelessWidget { super.key, required this.userProfile, required this.workspaceId, - this.currentWorkspaceMemberRole, }); final UserProfilePB userProfile; final String workspaceId; - final AFRolePB? currentWorkspaceMemberRole; @override Widget build(BuildContext context) { @@ -32,7 +28,6 @@ class AiSettingsGroup extends StatelessWidget { create: (context) => SettingsAIBloc( userProfile, workspaceId, - currentWorkspaceMemberRole, )..add(const SettingsAIEvent.started()), child: BlocBuilder( builder: (context, state) { @@ -48,7 +43,7 @@ class AiSettingsGroup extends StatelessWidget { children: [ Flexible( child: FlowyText( - state.selectedAIModel, + state.availableModels?.selectedModel.name ?? "", color: theme.colorScheme.onSurface, overflow: TextOverflow.ellipsis, ), @@ -84,16 +79,19 @@ class AiSettingsGroup extends StatelessWidget { title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), builder: (_) { return Column( - children: availableModels - .mapIndexed( - (index, model) => FlowyOptionTile.checkbox( - text: model.name, - showTopBorder: index == 0, - isSelected: state.selectedAIModel == model.name, + children: (availableModels?.models ?? []) + .asMap() + .entries + .map( + (entry) => FlowyOptionTile.checkbox( + text: entry.value.name, + showTopBorder: entry.key == 0, + isSelected: + availableModels?.selectedModel.name == entry.value.name, onTap: () { context .read() - .add(SettingsAIEvent.selectModel(model.name)); + .add(SettingsAIEvent.selectModel(entry.value)); context.pop(); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart index f25ea4deca..2cfc349bf8 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart @@ -12,14 +12,14 @@ import 'package:appflowy_result/appflowy_result.dart'; typedef OnUpdateSelectedModel = void Function(AIModelPB model); class AIModelSwitchListener { - AIModelSwitchListener({required this.chatId}) { - _parser = ChatNotificationParser(id: chatId, callback: _callback); + AIModelSwitchListener({required this.objectId}) { + _parser = ChatNotificationParser(id: objectId, callback: _callback); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } - final String chatId; + final String objectId; StreamSubscription? _subscription; ChatNotificationParser? _parser; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 6dcd52b294..1d50cde480 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -1,5 +1,5 @@ +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; import 'package:appflowy/user/application/user_listener.dart'; -import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; @@ -10,48 +10,38 @@ import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_ai_bloc.freezed.dart'; -part 'settings_ai_bloc.g.dart'; class SettingsAIBloc extends Bloc { SettingsAIBloc( this.userProfile, this.workspaceId, - AFRolePB? currentWorkspaceMemberRole, ) : _userListener = UserListener(userProfile: userProfile), - _userService = UserBackendService(userId: userProfile.id), + _aiModelSwitchListener = + AIModelSwitchListener(objectId: "ai_models_global_active_model"), super( SettingsAIState( - selectedAIModel: userProfile.aiModel, userProfile: userProfile, - currentWorkspaceMemberRole: currentWorkspaceMemberRole, ), ) { + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) { + if (!isClosed) { + _loadModelList(); + } + }, + ); _dispatch(); - - if (currentWorkspaceMemberRole == null) { - _userService.getWorkspaceMember().then((result) { - result.fold( - (member) { - if (!isClosed) { - add(SettingsAIEvent.refreshMember(member)); - } - }, - (err) { - Log.error(err); - }, - ); - }); - } } final UserListener _userListener; final UserProfilePB userProfile; - final UserBackendService _userService; final String workspaceId; + final AIModelSwitchListener _aiModelSwitchListener; @override Future close() async { await _userListener.stop(); + await _aiModelSwitchListener.stop(); return super.close(); } @@ -67,7 +57,6 @@ class SettingsAIBloc extends Bloc { } }, ); - _loadUserWorkspaceSetting(); _loadModelList(); }, didReceiveUserProfile: (userProfile) { @@ -82,38 +71,25 @@ class SettingsAIBloc extends Bloc { !(state.aiSettings?.disableSearchIndexing ?? false), ); }, - selectModel: (String model) async { - await _updateUserWorkspaceSetting(model: model); + selectModel: (AIModelPB model) async { + if (!model.isLocal) { + await _updateUserWorkspaceSetting(model: model.name); + } }, didLoadAISetting: (UseAISettingPB settings) { emit( state.copyWith( aiSettings: settings, - selectedAIModel: settings.aiModel, enableSearchIndexing: !settings.disableSearchIndexing, ), ); }, - didLoadAvailableModels: (List models) { - if (state.selectedAIModel.isEmpty) { - final m = models.firstWhere((model) => model.isDefault); - _updateUserWorkspaceSetting(model: m.name); - emit( - state.copyWith( - availableModels: models, - selectedAIModel: m.name, - ), - ); - } else { - emit( - state.copyWith( - availableModels: models, - ), - ); - } - }, - refreshMember: (member) { - emit(state.copyWith(currentWorkspaceMemberRole: member.role)); + didLoadAvailableModels: (AvailableModelsPB models) { + emit( + state.copyWith( + availableModels: models, + ), + ); }, ); }); @@ -148,24 +124,11 @@ class SettingsAIBloc extends Bloc { (err) => Log.error(err), ); - void _loadUserWorkspaceSetting() { - final payload = UserWorkspaceIdPB(workspaceId: workspaceId); - UserEventGetWorkspaceSetting(payload).send().then((result) { - result.fold((settings) { - if (!isClosed) { - add(SettingsAIEvent.didLoadAISetting(settings)); - } - }, (err) { - Log.error(err); - }); - }); - } - void _loadModelList() { AIEventGetServerAvailableModels().send().then((result) { - result.fold((config) { + result.fold((models) { if (!isClosed) { - add(SettingsAIEvent.didLoadAvailableModels(config.models)); + add(SettingsAIEvent.didLoadAvailableModels(models)); } }, (err) { Log.error(err); @@ -182,17 +145,15 @@ class SettingsAIEvent with _$SettingsAIEvent { ) = _DidLoadWorkspaceSetting; const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; - const factory SettingsAIEvent.refreshMember(WorkspaceMemberPB member) = - _RefreshMember; - const factory SettingsAIEvent.selectModel(String model) = _SelectAIModel; + const factory SettingsAIEvent.selectModel(AIModelPB model) = _SelectAIModel; const factory SettingsAIEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; const factory SettingsAIEvent.didLoadAvailableModels( - List models, + AvailableModelsPB models, ) = _DidLoadAvailableModels; } @@ -201,23 +162,7 @@ class SettingsAIState with _$SettingsAIState { const factory SettingsAIState({ required UserProfilePB userProfile, UseAISettingPB? aiSettings, - @Default("Default") String selectedAIModel, - AFRolePB? currentWorkspaceMemberRole, - @Default([]) List availableModels, + AvailableModelsPB? availableModels, @Default(true) bool enableSearchIndexing, }) = _SettingsAIState; } - -@JsonSerializable() -class ModelList { - ModelList({ - required this.models, - }); - - factory ModelList.fromJson(Map json) => - _$ModelListFromJson(json); - - final List models; - - Map toJson() => _$ModelListToJson(this); -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 8cd2f31114..bec4013a36 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -15,6 +16,10 @@ class AIModelSelection extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + if (state.availableModels == null) { + return const SizedBox.shrink(); + } + return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( @@ -28,17 +33,17 @@ class AIModelSelection extends StatelessWidget { ), const Spacer(), Flexible( - child: SettingsDropdown( + child: SettingsDropdown( key: const Key('_AIModelSelection'), onChanged: (model) => context .read() .add(SettingsAIEvent.selectModel(model)), - selectedOption: state.selectedAIModel, - options: state.availableModels + selectedOption: state.availableModels!.selectedModel, + options: state.availableModels!.models .map( - (model) => buildDropdownMenuEntry( + (model) => buildDropdownMenuEntry( context, - value: model.name, + value: model, label: model.name, ), ) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart index 7102520ac1..0fc1ef293c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart @@ -42,9 +42,8 @@ class SettingsAIView extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - SettingsAIBloc(userProfile, workspaceId, currentWorkspaceMemberRole) - ..add(const SettingsAIEvent.started()), + create: (_) => SettingsAIBloc(userProfile, workspaceId) + ..add(const SettingsAIEvent.started()), child: BlocBuilder( builder: (context, state) { final children = [ diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 6b6af73c8f..ba07ff9c3b 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ [[package]] name = "appflowy-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=cd991507870d225e76df41164deee356cd9921a7#cd991507870d225e76df41164deee356cd9921a7" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=52ad76f21f8f3a7b510dae029836b5fe86479e5a#52ad76f21f8f3a7b510dae029836b5fe86479e5a" dependencies = [ "anyhow", "appflowy-plugin", @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "appflowy-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=cd991507870d225e76df41164deee356cd9921a7#cd991507870d225e76df41164deee356cd9921a7" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=52ad76f21f8f3a7b510dae029836b5fe86479e5a#52ad76f21f8f3a7b510dae029836b5fe86479e5a" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index a660ebcb5e..843a939ae3 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,5 +152,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "cd991507870d225e76df41164deee356cd9921a7" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "cd991507870d225e76df41164deee356cd9921a7" } +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "52ad76f21f8f3a7b510dae029836b5fe86479e5a" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "52ad76f21f8f3a7b510dae029836b5fe86479e5a" } diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs index da8c05f360..97a7f5f529 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs @@ -153,7 +153,6 @@ pub fn parse_event_crate(event_crate: &TsEventCrate) -> Vec { attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) - .into_iter() .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 4639a93501..29cef4afde 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -29,10 +29,26 @@ pub struct AIModel { pub is_local: bool, } +impl AIModel { + pub fn server(name: String) -> Self { + Self { + name, + is_local: false, + } + } + + pub fn local(name: String) -> Self { + Self { + name, + is_local: true, + } + } +} + impl Default for AIModel { fn default() -> Self { Self { - name: "default".to_string(), + name: "Auto".to_string(), is_local: false, } } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 2486637ef8..03a18dcdec 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -27,7 +27,7 @@ use lib_infra::util::timestamp; use std::path::PathBuf; use std::sync::{Arc, Weak}; use tokio::sync::RwLock; -use tracing::{error, info, trace}; +use tracing::{error, info, instrument, trace}; pub trait AIUserService: Send + Sync + 'static { fn user_id(&self) -> Result; @@ -62,6 +62,8 @@ struct ServerModelsCache { timestamp: Option, } +pub const GLOBAL_ACTIVE_MODEL_KEY: &str = "global_active_model"; + pub struct AIManager { pub cloud_service_wm: Arc, pub user_service: Arc, @@ -266,7 +268,7 @@ impl AIManager { Ok(()) } - pub async fn get_workspace_select_model(&self) -> FlowyResult { + async fn get_workspace_select_model(&self) -> FlowyResult { let workspace_id = self.user_service.workspace_id()?; let model = self .cloud_service_wm @@ -275,7 +277,7 @@ impl AIManager { Ok(model) } - pub async fn get_server_available_models(&self) -> FlowyResult> { + async fn get_server_available_models(&self) -> FlowyResult> { let workspace_id = self.user_service.workspace_id()?; let now = timestamp(); @@ -339,97 +341,108 @@ impl AIManager { } } - pub async fn update_selected_model(&self, source: String, model: AIModelPB) -> FlowyResult<()> { + pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> { let source_key = ai_available_models_key(&source); self .store_preferences - .set_object::(&source_key, &model.clone().into())?; + .set_object::(&source_key, &model)?; chat_notification_builder(&source, ChatNotification::DidUpdateSelectedModel) - .payload(model) + .payload(AIModelPB::from(model)) .send(); Ok(()) } + #[instrument(skip_all, level = "debug")] + pub async fn toggle_local_ai(&self) -> FlowyResult<()> { + let enabled = self.local_ai.toggle_local_ai().await?; + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + if enabled { + if let Some(name) = self.local_ai.get_plugin_chat_model() { + info!("Set global active model to local ai: {}", name); + let model = AIModel::local(name); + self.update_selected_model(source_key, model).await?; + } + } else { + info!("Set global active model to default"); + let global_active_model = self + .get_workspace_select_model() + .await + .map(AIModel::server) + .unwrap_or_else(|_| AIModel::default()); + + self + .update_selected_model(source_key, global_active_model) + .await?; + } + + Ok(()) + } + pub async fn get_available_models(&self, source: String) -> FlowyResult { // Build the models list from server models and mark them as non-local. let mut models: Vec = self .get_server_available_models() .await? .into_iter() - .map(|m| AIModelPB { - name: m.name, - is_local: false, - }) + .map(|m| AIModelPB::server(m.name)) .collect(); - // Optionally add the local plugin model. + // If user enable local ai, then add local ai model to the list. if let Some(local_model) = self.local_ai.get_plugin_chat_model() { - models.push(AIModelPB { - name: local_model, - is_local: true, - }); + models.push(AIModelPB::local(local_model)); } if models.is_empty() { return Ok(AvailableModelsPB { models, - selected_model: None, + selected_model: AIModelPB::default(), }); } + // Global active model is the model selected by the user in the workspace settings. + let global_active_model = self + .get_workspace_select_model() + .await + .map(AIModel::server) + .unwrap_or_else(|_| AIModel::default()); + + let mut user_selected_model = global_active_model.clone(); let source_key = ai_available_models_key(&source); - // Retrieve stored selected model, if any. - let stored_selected = self.store_preferences.get_object::(&source_key); - - // Get workspace default model once. - let workspace_default = self.get_workspace_select_model().await.ok(); - - // Determine the effective selected model. - let effective_selected = stored_selected.unwrap_or_else(|| { - if let Some(ws_name) = workspace_default.clone() { - let model = AIModel { - name: ws_name, - is_local: false, - }; - // Store the default if not present. - let _ = self.store_preferences.set_object(&source_key, &model); - model - } else { - AIModel::default() - } - }); - - // Find a matching model in the available list. - let used_model = models - .iter() - .find(|m| m.name == effective_selected.name) - .cloned() - .or_else(|| { - // If no match, try to use the workspace default if available. - if let Some(ws_name) = workspace_default { - Some(AIModelPB { - name: ws_name, - is_local: false, - }) - } else { - models.first().cloned() + // If source is provided, try to get the user-selected model from the store. User selected + // model will be used as the active model if it exists. + match self.store_preferences.get_object::(&source_key) { + None => { + // when there is selected model and current local ai is active, then use local ai + if let Some(local_ai_model) = models.iter().find(|m| m.is_local) { + user_selected_model = AIModel::from(local_ai_model.clone()); } - }); + }, + Some(model) => { + user_selected_model = model; + }, + } + + // If user selected model is not available in the list, use the global active model. + let active_model = models + .iter() + .find(|m| m.name == user_selected_model.name) + .cloned() + .or_else(|| Some(AIModelPB::from(global_active_model))); // Update the stored preference if a different model is used. - if let Some(ref used) = used_model { - if used.name != effective_selected.name { + if let Some(ref active_model) = active_model { + if active_model.name != user_selected_model.name { self .store_preferences - .set_object::(&source_key, &AIModel::from(used.clone()))?; + .set_object::(&source_key, &AIModel::from(active_model.clone()))?; } } Ok(AvailableModelsPB { models, - selected_model: used_model, + selected_model: active_model.unwrap_or_default(), }) } diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index ace7e3d807..0e996228c6 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -234,8 +234,8 @@ pub struct AvailableModelsPB { #[pb(index = 1)] pub models: Vec, - #[pb(index = 2, one_of)] - pub selected_model: Option, + #[pb(index = 2)] + pub selected_model: AIModelPB, } #[derive(Default, ProtoBuf, Clone, Debug)] @@ -247,6 +247,22 @@ pub struct AIModelPB { pub is_local: bool, } +impl AIModelPB { + pub fn server(name: String) -> Self { + Self { + name, + is_local: false, + } + } + + pub fn local(name: String) -> Self { + Self { + name, + is_local: true, + } + } +} + impl From for AIModelPB { fn from(model: AIModel) -> Self { Self { diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 2bc182b9b1..6bedebd261 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -1,10 +1,13 @@ use std::fs; use std::path::PathBuf; -use crate::ai_manager::AIManager; +use crate::ai_manager::{AIManager, GLOBAL_ACTIVE_MODEL_KEY}; use crate::completion::AICompletion; use crate::entities::*; -use flowy_ai_pub::cloud::{ChatMessageMetadata, ChatMessageType, ChatRAGData, ContextLoader}; +use crate::util::ai_available_models_key; +use flowy_ai_pub::cloud::{ + AIModel, ChatMessageMetadata, ChatMessageType, ChatRAGData, ContextLoader, +}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; use std::sync::{Arc, Weak}; @@ -104,14 +107,11 @@ pub(crate) async fn regenerate_response_handler( #[tracing::instrument(level = "debug", skip_all, err)] pub(crate) async fn get_server_model_list_handler( ai_manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; - let models = ai_manager.get_server_available_models().await?; - let models = models - .into_iter() - .map(AvailableModelPB::from) - .collect::>(); - data_result_ok(ServerAvailableModelsPB { models }) + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + let models = ai_manager.get_available_models(source_key).await?; + data_result_ok(models) } #[tracing::instrument(level = "debug", skip_all, err)] @@ -132,7 +132,7 @@ pub(crate) async fn update_selected_model_handler( let data = data.try_into_inner()?; let ai_manager = upgrade_ai_manager(ai_manager)?; ai_manager - .update_selected_model(data.source, data.selected_model) + .update_selected_model(data.source, AIModel::from(data.selected_model)) .await?; Ok(()) } @@ -286,7 +286,7 @@ pub(crate) async fn toggle_local_ai_handler( ai_manager: AFPluginState>, ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; - let _ = ai_manager.local_ai.toggle_local_ai().await?; + ai_manager.toggle_local_ai().await?; let state = ai_manager.local_ai.get_local_ai_state().await; data_result_ok(state) } diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs index 84c2266f0b..0e24ca6a21 100644 --- a/frontend/rust-lib/flowy-ai/src/event_map.rs +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -115,7 +115,7 @@ pub enum AIEvent { #[event(input = "RegenerateResponsePB")] RegenerateResponse = 27, - #[event(output = "ServerAvailableModelsPB")] + #[event(output = "AvailableModelsPB")] GetServerAvailableModels = 28, #[event(output = "LocalAISettingPB")] diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 04d73831b9..503345e206 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -322,7 +322,6 @@ impl LocalAIController { let enabled = !self.store_preferences.get_bool(&key).unwrap_or(true); self.store_preferences.set_bool(&key, enabled)?; self.toggle_plugin(enabled).await?; - Ok(enabled) } diff --git a/frontend/rust-lib/flowy-ai/src/util.rs b/frontend/rust-lib/flowy-ai/src/util.rs index d1667ae98b..a181d1b1d3 100644 --- a/frontend/rust-lib/flowy-ai/src/util.rs +++ b/frontend/rust-lib/flowy-ai/src/util.rs @@ -1,3 +1,3 @@ pub fn ai_available_models_key(object_id: &str) -> String { - format!("ai_available_models_{}", object_id) + format!("ai_models_{}", object_id) } From b3b13e550da75aa8521a078a7b8ecccc0f2ea962 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:29:11 +0800 Subject: [PATCH 042/207] chore: adjust popover shadows (#7626) --- .../ai/ai_writer_block_component.dart | 16 +++----- .../ai_writer_prompt_input_more_button.dart | 11 ++--- .../src/flowy_overlay/appflowy_popover.dart | 40 +++++++++---------- 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 3e1316a58f..a4bc4f50e3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -226,7 +226,7 @@ class _OverlayContentState extends State { final showSuggestedActionsWithin = showSuggestedActions && markdownText.isNotEmpty; - final darkBorderColor = Theme.of(context).isLightMode + final borderColor = Theme.of(context).isLightMode ? Color(0x1F1F2329) : Color(0xFF505469); @@ -241,7 +241,7 @@ class _OverlayContentState extends State { context, color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.all(Radius.circular(8.0)), - borderColor: darkBorderColor, + borderColor: borderColor, ), child: SuggestionActionBar( currentCommand: command, @@ -257,7 +257,7 @@ class _OverlayContentState extends State { decoration: _getModalDecoration( context, color: null, - borderColor: darkBorderColor, + borderColor: borderColor, borderRadius: BorderRadius.all(Radius.circular(12.0)), ), constraints: BoxConstraints(maxHeight: 400), @@ -341,13 +341,9 @@ class _OverlayContentState extends State { strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: borderRadius, - boxShadow: const [ - BoxShadow( - offset: Offset(0, 4), - blurRadius: 20, - color: Color(0x1A1F2329), - ), - ], + boxShadow: Theme.of(context).isLightMode + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart index eefb122a20..1bd80d081d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -1,6 +1,7 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; @@ -102,13 +103,9 @@ class MoreAiWriterCommands extends StatelessWidget { strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: BorderRadius.all(Radius.circular(8.0)), - boxShadow: const [ - BoxShadow( - offset: Offset(0, 4), - blurRadius: 20, - color: Color(0x1A1F2329), - ), - ], + boxShadow: Theme.of(context).isLightMode + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall, ), child: IntrinsicWidth( child: Column( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 0ef500d967..2da547afda 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -4,6 +4,23 @@ import 'package:flutter/material.dart'; export 'package:appflowy_popover/appflowy_popover.dart'; +class ShadowConstants { + ShadowConstants._(); + + static const List lightSmall = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 20, color: Color(0x1A1F2329)), + ]; + static const List lightMedium = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x121F2225)), + ]; + static const List darkSmall = [ + BoxShadow(offset: Offset(0, 2), blurRadius: 16, color: Color(0x5C000000)), + ]; + static const List darkMedium = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x5C000000)), + ]; +} + class AppFlowyPopover extends StatelessWidget { const AppFlowyPopover({ super.key, @@ -162,26 +179,9 @@ extension PopoverDecoration on BuildContext { final borderColor = Theme.of(this).brightness == Brightness.light ? ColorSchemeConstants.lightBorderColor : ColorSchemeConstants.darkBorderColor; - final shadows = [ - const BoxShadow( - color: Color(0x0A1F2329), - blurRadius: 24, - offset: Offset(0, 8), - spreadRadius: 8, - ), - const BoxShadow( - color: Color(0x0A1F2329), - blurRadius: 12, - offset: Offset(0, 6), - spreadRadius: 0, - ), - const BoxShadow( - color: Color(0x0F1F2329), - blurRadius: 8, - offset: Offset(0, 4), - spreadRadius: -8, - ) - ]; + final shadows = Theme.of(this).brightness == Brightness.light + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall; return ShapeDecoration( color: color ?? Theme.of(this).cardColor, shape: RoundedRectangleBorder( From eb0cff36c92a9fce87af2faef5555d4d381d6162 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:15:20 +0800 Subject: [PATCH 043/207] chore: improve ai model selection ui --- frontend/appflowy_flutter/lib/ai/ai.dart | 3 + .../lib/ai/service/ai_entities.dart | 8 + .../lib/ai/service/ai_input_control.dart | 138 ---------- .../ai/service/ai_model_state_notifier.dart | 174 +++++++++++++ .../lib/ai/service/ai_prompt_input_bloc.dart | 42 ++- .../lib/ai/service/select_model_bloc.dart | 98 ++++--- .../desktop_prompt_text_field.dart | 240 +----------------- .../predefined_format_buttons.dart | 2 + .../prompt_input/select_model_menu.dart | 221 ++++++++++++++++ .../ai_chat/application/chat_bloc.dart | 9 +- .../lib/plugins/ai_chat/chat_page.dart | 7 +- .../message/ai_change_model_bottom_sheet.dart | 145 +++++++++++ .../message/ai_message_action_bar.dart | 87 +++++++ .../message/ai_message_bubble.dart | 35 +++ .../presentation/message/ai_text_message.dart | 3 + frontend/resources/translations/en.json | 10 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 19 +- frontend/rust-lib/flowy-ai/src/entities.rs | 3 + .../rust-lib/flowy-ai/src/event_handler.rs | 1 + 19 files changed, 797 insertions(+), 448 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart create mode 100644 frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart create mode 100644 frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart diff --git a/frontend/appflowy_flutter/lib/ai/ai.dart b/frontend/appflowy_flutter/lib/ai/ai.dart index e3f52a8168..9bfeeb4e00 100644 --- a/frontend/appflowy_flutter/lib/ai/ai.dart +++ b/frontend/appflowy_flutter/lib/ai/ai.dart @@ -2,6 +2,8 @@ export 'service/ai_entities.dart'; export 'service/ai_prompt_input_bloc.dart'; export 'service/appflowy_ai_service.dart'; export 'service/error.dart'; +export 'service/ai_model_state_notifier.dart'; +export 'service/select_model_bloc.dart'; export 'widgets/loading_indicator.dart'; export 'widgets/prompt_input/action_buttons.dart'; export 'widgets/prompt_input/desktop_prompt_text_field.dart'; @@ -13,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart'; export 'widgets/prompt_input/predefined_format_buttons.dart'; export 'widgets/prompt_input/select_sources_bottom_sheet.dart'; export 'widgets/prompt_input/select_sources_menu.dart'; +export 'widgets/prompt_input/select_model_menu.dart'; export 'widgets/prompt_input/send_button.dart'; diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart index b8592bc32b..249a92019a 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart @@ -4,6 +4,14 @@ import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; +enum AiType { + cloud, + local; + + bool get isCloud => this == cloud; + bool get isLocal => this == local; +} + class PredefinedFormat extends Equatable { const PredefinedFormat({ required this.imageFormat, diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart b/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart deleted file mode 100644 index c468fdd6e9..0000000000 --- a/frontend/appflowy_flutter/lib/ai/service/ai_input_control.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:protobuf/protobuf.dart'; -import 'package:universal_platform/universal_platform.dart'; - -class AIModelStateNotifier { - AIModelStateNotifier({required this.objectId}) - : _isDesktop = UniversalPlatform.isDesktop, - _localAIListener = - UniversalPlatform.isDesktop ? LocalAIStateListener() : null, - _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId); - - final String objectId; - final bool _isDesktop; - final LocalAIStateListener? _localAIListener; - final AIModelSwitchListener _aiModelSwitchListener; - - LocalAIPB? _localAIState; - AvailableModelsPB? _availableModels; - - // Callbacks - void Function(AiType, bool, String)? onChanged; - void Function(AvailableModelsPB)? onAvailableModelsChanged; - - String hintText() { - final aiType = getCurrentAiType(); - if (aiType.isLocal) { - return isEditable() - ? LocaleKeys.chat_inputLocalAIMessageHint.tr() - : LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(); - } - return LocaleKeys.chat_inputMessageHint.tr(); - } - - AiType getCurrentAiType() { - // On non-desktop platforms, always return cloud type. - if (!_isDesktop) return AiType.cloud; - return (_availableModels?.selectedModel.isLocal ?? false) - ? AiType.local - : AiType.cloud; - } - - bool isEditable() { - // On non-desktop platforms, always editable. - if (!_isDesktop) return true; - return getCurrentAiType().isLocal - ? _localAIState?.state == RunningStatePB.Running - : true; - } - - void _notifyStateChanged() { - onChanged?.call(getCurrentAiType(), isEditable(), hintText()); - } - - Future init() async { - // Load both available models and local state concurrently. - await Future.wait([ - _loadAvailableModels(), - _loadLocalAIState(), - ]); - } - - Future _loadAvailableModels() async { - final payload = AvailableModelsQueryPB(source: objectId); - final result = await AIEventGetAvailableModels(payload).send(); - result.fold( - (models) { - _availableModels = models; - onAvailableModelsChanged?.call(models); - _notifyStateChanged(); - }, - (err) => Log.error("Failed to get available models: $err"), - ); - } - - Future _loadLocalAIState() async { - final result = await AIEventGetLocalAIState().send(); - result.fold( - (state) { - _localAIState = state; - _notifyStateChanged(); - }, - (error) { - Log.error("Failed to get local AI state: $error"); - _notifyStateChanged(); - }, - ); - } - - void startListening({ - void Function(AiType, bool, String)? onChanged, - void Function(AvailableModelsPB)? onAvailableModelsChanged, - }) { - this.onChanged = onChanged; - this.onAvailableModelsChanged = onAvailableModelsChanged; - - // Only start local AI listener on desktop platforms. - if (_isDesktop) { - _localAIListener?.start( - stateCallback: (state) { - _localAIState = state; - if (state.state == RunningStatePB.Running || - state.state == RunningStatePB.Stopped) { - _loadAvailableModels(); - } - }, - ); - } - - _aiModelSwitchListener.start( - onUpdateSelectedModel: (model) { - if (_availableModels != null) { - final updatedModels = _availableModels!.deepCopy() - ..selectedModel = model; - _availableModels = updatedModels; - onAvailableModelsChanged?.call(updatedModels); - } - if (model.isLocal && _isDesktop) { - _loadLocalAIState(); - } else { - _notifyStateChanged(); - } - }, - ); - } - - Future stop() async { - onChanged = null; - await _localAIListener?.stop(); - await _aiModelSwitchListener.stop(); - } -} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart new file mode 100644 index 0000000000..066280e3db --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -0,0 +1,174 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:universal_platform/universal_platform.dart'; + +typedef OnModelStateChangedCallback = void Function(AiType, bool, String); +typedef OnAvailableModelsChangedCallback = void Function( + List, + AiModel?, +); + +class AIModelStateNotifier { + AIModelStateNotifier({required this.objectId}) + : _localAIListener = + UniversalPlatform.isDesktop ? LocalAIStateListener() : null, + _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) { + _startListening(); + _init(); + } + + final String objectId; + final LocalAIStateListener? _localAIListener; + final AIModelSwitchListener _aiModelSwitchListener; + LocalAIPB? _localAIState; + AvailableModelsPB? _availableModels; + + // callbacks + final List _stateChangedCallbacks = []; + final List + _availableModelsChangedCallbacks = []; + + void _startListening() { + if (UniversalPlatform.isDesktop) { + _localAIListener?.start( + stateCallback: (state) async { + _localAIState = state; + _notifyStateChanged(); + + if (state.state == RunningStatePB.Running || + state.state == RunningStatePB.Stopped) { + await _loadAvailableModels(); + _notifyAvailableModelsChanged(); + } + }, + ); + } + + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) async { + final updatedModels = _availableModels?.deepCopy() + ?..selectedModel = model; + _availableModels = updatedModels; + _notifyAvailableModelsChanged(); + + if (model.isLocal && UniversalPlatform.isDesktop) { + await _loadLocalAiState(); + } + _notifyStateChanged(); + }, + ); + } + + void _init() async { + await Future.wait([_loadLocalAiState(), _loadAvailableModels()]); + _notifyStateChanged(); + _notifyAvailableModelsChanged(); + } + + void addListener({ + OnModelStateChangedCallback? onStateChanged, + OnAvailableModelsChangedCallback? onAvailableModelsChanged, + }) { + if (onStateChanged != null) { + _stateChangedCallbacks.add(onStateChanged); + } + if (onAvailableModelsChanged != null) { + _availableModelsChangedCallbacks.add(onAvailableModelsChanged); + } + } + + void removeListener({ + OnModelStateChangedCallback? onStateChanged, + OnAvailableModelsChangedCallback? onAvailableModelsChanged, + }) { + if (onStateChanged != null) { + _stateChangedCallbacks.remove(onStateChanged); + } + if (onAvailableModelsChanged != null) { + _availableModelsChangedCallbacks.remove(onAvailableModelsChanged); + } + } + + Future dispose() async { + _stateChangedCallbacks.clear(); + _availableModelsChangedCallbacks.clear(); + await _localAIListener?.stop(); + await _aiModelSwitchListener.stop(); + } + + (AiType, String, bool) getState() { + if (UniversalPlatform.isMobile) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + final availableModels = _availableModels; + final localAiState = _localAIState; + + if (availableModels == null) { + Log.warn("No available models"); + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + if (localAiState == null) { + Log.warn("Cannot get local AI state"); + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + if (availableModels.selectedModel.isLocal == false) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + final editable = localAiState.state == RunningStatePB.Running; + final hintText = editable + ? LocaleKeys.chat_inputLocalAIMessageHint.tr() + : LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(); + + return (AiType.local, hintText, editable); + } + + (List, AiModel?) getAvailableModels() { + final availableModels = _availableModels; + if (availableModels == null) { + return ([], null); + } + final models = availableModels.models.map(AiModel.fromPB).toList(); + final selectedModel = AiModel.fromPB(availableModels.selectedModel); + return (models, selectedModel); + } + + void _notifyAvailableModelsChanged() { + final (models, selectedModel) = getAvailableModels(); + for (final callback in _availableModelsChangedCallbacks) { + callback(models, selectedModel); + } + } + + void _notifyStateChanged() { + final (type, hintText, isEditable) = getState(); + for (final callback in _stateChangedCallbacks) { + callback(type, isEditable, hintText); + } + } + + Future _loadAvailableModels() { + final payload = AvailableModelsQueryPB(source: objectId); + return AIEventGetAvailableModels(payload).send().fold( + (models) => _availableModels = models, + (err) => Log.error("Failed to get available models: $err"), + ); + } + + Future _loadLocalAiState() { + return AIEventGetLocalAIState().send().fold( + (localAIState) => _localAIState = localAIState, + (error) => Log.error("Failed to get local AI state: $error"), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart index e2acda9a1e..95854ab047 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:appflowy/ai/service/ai_input_control.dart'; +import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -14,17 +14,18 @@ class AIPromptInputBloc extends Bloc { AIPromptInputBloc({ required String objectId, required PredefinedFormat? predefinedFormat, - }) : _aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), - super(AIPromptInputState.initial(objectId, predefinedFormat)) { + }) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), + super(AIPromptInputState.initial(predefinedFormat)) { _dispatch(); + _startListening(); _init(); } - final AIModelStateNotifier _aiModelStateNotifier; + final AIModelStateNotifier aiModelStateNotifier; @override Future close() async { - await _aiModelStateNotifier.stop(); + await aiModelStateNotifier.dispose(); return super.close(); } @@ -36,7 +37,6 @@ class AIPromptInputBloc extends Bloc { emit( state.copyWith( aiType: aiType, - supportChatWithFile: false, editable: editable, hintText: hintText, ), @@ -103,16 +103,17 @@ class AIPromptInputBloc extends Bloc { ); } - void _init() { - _aiModelStateNotifier.startListening( - onChanged: (aiType, editable, hintText) { - if (!isClosed) { - add(AIPromptInputEvent.updateAIState(aiType, editable, hintText)); - } + void _startListening() { + aiModelStateNotifier.addListener( + onStateChanged: (aiType, editable, hintText) { + add(AIPromptInputEvent.updateAIState(aiType, editable, hintText)); }, ); + } - _aiModelStateNotifier.init(); + void _init() { + final (aiType, hintText, isEditable) = aiModelStateNotifier.getState(); + add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText)); } Map consumeMetadata() { @@ -155,7 +156,6 @@ class AIPromptInputEvent with _$AIPromptInputEvent { @freezed class AIPromptInputState with _$AIPromptInputState { const factory AIPromptInputState({ - required String objectId, required AiType aiType, required bool supportChatWithFile, required bool showPredefinedFormats, @@ -166,12 +166,8 @@ class AIPromptInputState with _$AIPromptInputState { required String hintText, }) = _AIPromptInputState; - factory AIPromptInputState.initial( - String objectId, - PredefinedFormat? format, - ) => + factory AIPromptInputState.initial(PredefinedFormat? format) => AIPromptInputState( - objectId: objectId, aiType: AiType.cloud, supportChatWithFile: false, showPredefinedFormats: format != null, @@ -182,11 +178,3 @@ class AIPromptInputState with _$AIPromptInputState { hintText: '', ); } - -enum AiType { - cloud, - local; - - bool get isCloud => this == cloud; - bool get isLocal => this == local; -} diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart index 665533bd40..46e9b5ef92 100644 --- a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart +++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart @@ -1,82 +1,110 @@ import 'dart:async'; -import 'package:appflowy/ai/service/ai_input_control.dart'; +import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:protobuf/protobuf.dart'; part 'select_model_bloc.freezed.dart'; +class AiModel extends Equatable { + const AiModel({ + required this.name, + required this.isLocal, + }); + + factory AiModel.fromPB(AIModelPB pb) { + return AiModel(name: pb.name, isLocal: pb.isLocal); + } + + AIModelPB toPB() { + return AIModelPB() + ..name = name + ..isLocal = isLocal; + } + + final String name; + final bool isLocal; + + @override + List get props => [name, isLocal]; +} + class SelectModelBloc extends Bloc { SelectModelBloc({ - required this.objectId, - }) : _aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), - super(const SelectModelState()) { - _aiModelStateNotifier.init(); - _aiModelStateNotifier.startListening( - onAvailableModelsChanged: (models) { - if (!isClosed) { - add(SelectModelEvent.didLoadModels(models)); - } - }, - ); - + required AIModelStateNotifier aiModelStateNotifier, + }) : _aiModelStateNotifier = aiModelStateNotifier, + super(SelectModelState.initial()) { on( - (event, emit) async { - await event.when( - selectModel: (AIModelPB model) async { - await AIEventUpdateSelectedModel( + (event, emit) { + event.when( + selectModel: (model) { + AIEventUpdateSelectedModel( UpdateSelectedModelPB( - source: objectId, - selectedModel: model, + source: _aiModelStateNotifier.objectId, + selectedModel: model.toPB(), ), ).send(); - state.availableModels?.freeze(); - final newAvailableModels = state.availableModels?.rebuild((m) { - m.selectedModel = model; - }); - + emit(state.copyWith(selectedModel: model)); + }, + didLoadModels: (models, selectedModel) { emit( - state.copyWith( - availableModels: newAvailableModels, + SelectModelState( + models: models, + selectedModel: selectedModel, ), ); }, - didLoadModels: (AvailableModelsPB models) { - emit(state.copyWith(availableModels: models)); - }, ); }, ); + + _aiModelStateNotifier.addListener( + onAvailableModelsChanged: _onAvailableModelsChanged, + ); } - final String objectId; final AIModelStateNotifier _aiModelStateNotifier; @override Future close() async { - await _aiModelStateNotifier.stop(); + _aiModelStateNotifier.removeListener( + onAvailableModelsChanged: _onAvailableModelsChanged, + ); await super.close(); } + + void _onAvailableModelsChanged(List models, AiModel? selectedModel) { + if (!isClosed) { + add(SelectModelEvent.didLoadModels(models, selectedModel)); + } + } } @freezed class SelectModelEvent with _$SelectModelEvent { const factory SelectModelEvent.selectModel( - AIModelPB model, + AiModel model, ) = _SelectModel; const factory SelectModelEvent.didLoadModels( - AvailableModelsPB models, + List models, + AiModel? selectedModel, ) = _DidLoadModels; } @freezed class SelectModelState with _$SelectModelState { const factory SelectModelState({ - AvailableModelsPB? availableModels, + required List models, + required AiModel? selectedModel, }) = _SelectModelState; + + factory SelectModelState.initial() => SelectModelState( + models: [], + selectedModel: null, + ); } diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index bf21a59ad0..fcf487da94 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -1,17 +1,14 @@ import 'package:appflowy/ai/ai.dart'; -import 'package:appflowy/ai/service/select_model_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:extended_text_field/extended_text_field.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -169,7 +166,6 @@ class _DesktopPromptInputState extends State { top: null, child: TextFieldTapRegion( child: _PromptBottomActions( - objectId: state.objectId, showPredefinedFormats: state.showPredefinedFormats, onTogglePredefinedFormatSection: () => @@ -567,7 +563,6 @@ class PromptInputTextField extends StatelessWidget { class _PromptBottomActions extends StatelessWidget { const _PromptBottomActions({ - required this.objectId, required this.sendButtonState, required this.showPredefinedFormats, required this.onTogglePredefinedFormatSection, @@ -579,7 +574,6 @@ class _PromptBottomActions extends StatelessWidget { this.extraBottomActionButton, }); - final String objectId; final bool showPredefinedFormats; final void Function() onTogglePredefinedFormatSection; final void Function() onStartMention; @@ -600,10 +594,16 @@ class _PromptBottomActions extends StatelessWidget { return Row( children: [ _predefinedFormatButton(), - SelectModelButton(objectId: objectId), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + SelectModelMenu( + aiModelStateNotifier: + context.read().aiModelStateNotifier, + ), const Spacer(), if (state.aiType.isCloud) ...[ - _selectSourcesButton(context), + _selectSourcesButton(), const HSpace( DesktopAIChatSizes.inputActionBarButtonSpacing, ), @@ -639,7 +639,7 @@ class _PromptBottomActions extends StatelessWidget { ); } - Widget _selectSourcesButton(BuildContext context) { + Widget _selectSourcesButton() { return PromptInputDesktopSelectSourcesButton( onUpdateSelectedSources: onUpdateSelectedSources, selectedSourcesNotifier: selectedSourcesNotifier, @@ -686,225 +686,3 @@ class _PromptBottomActions extends StatelessWidget { ); } } - -class SelectModelButton extends StatefulWidget { - const SelectModelButton({ - super.key, - required this.objectId, - }); - - final String objectId; - - @override - State createState() => _SelectModelButtonState(); -} - -class _SelectModelButtonState extends State { - final popoverController = PopoverController(); - late SelectModelBloc bloc; - - @override - void initState() { - super.initState(); - bloc = SelectModelBloc(objectId: widget.objectId); - } - - @override - void dispose() { - popoverController.close(); - bloc.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: BlocBuilder( - builder: (context, state) { - return AppFlowyPopover( - // constraints: BoxConstraints.loose(const Size(250, 200)), - offset: const Offset(0.0, -10.0), - direction: PopoverDirection.topWithLeftAligned, - margin: EdgeInsets.zero, - controller: popoverController, - onOpen: () {}, - onClose: () {}, - popupBuilder: (_) { - return BlocProvider.value( - value: bloc, - child: _PopoverSelectModel( - onClose: () => popoverController.close(), - ), - ); - }, - child: _CurrentModelButton( - key: ValueKey(state.availableModels?.selectedModel.name), - modelName: state.availableModels?.selectedModel.name ?? "", - onTap: () => popoverController.show(), - ), - ); - }, - ), - ); - } -} - -class _PopoverSelectModel extends StatelessWidget { - const _PopoverSelectModel({ - required this.onClose, - }); - - final VoidCallback onClose; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state.availableModels == null || - state.availableModels!.models.isEmpty) { - return const SizedBox.shrink(); - } - - // Separate models into local and cloud models - final localModels = state.availableModels!.models - .where((model) => model.isLocal) - .toList(); - - final cloudModels = state.availableModels!.models - .where((model) => !model.isLocal) - .toList(); - - return Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Local AI Models Section - if (localModels.isNotEmpty) ...[ - _ModelSectionHeader( - title: LocaleKeys.chat_changeFormat_localModel.tr(), - ), - const SizedBox(height: 4), - ...localModels.map( - (model) => _ModelItem( - model: model, - onTap: () { - context.read().add( - SelectModelEvent.selectModel(model), - ); - onClose(); - }, - ), - ), - const SizedBox(height: 8), - ], - - // Cloud AI Models Section - if (cloudModels.isNotEmpty) ...[ - if (localModels.isNotEmpty) - _ModelSectionHeader( - title: LocaleKeys.chat_changeFormat_cloudModel.tr(), - ), - const VSpace(4), - ...cloudModels.map( - (model) => _ModelItem( - model: model, - onTap: () { - context.read().add( - SelectModelEvent.selectModel(model), - ); - onClose(); - }, - ), - ), - ], - ], - ), - ); - }, - ); - } -} - -class _ModelSectionHeader extends StatelessWidget { - const _ModelSectionHeader({ - required this.title, - }); - - final String title; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 4, bottom: 2), - child: FlowyText( - title, - fontSize: 12, - color: Theme.of(context).hintColor, - fontWeight: FontWeight.w500, - ), - ); - } -} - -class _ModelItem extends StatelessWidget { - const _ModelItem({ - required this.model, - required this.onTap, - }); - - final AIModelPB model; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final modelName = model.name; - - return FlowyTextButton( - modelName, - fillColor: Colors.transparent, - onPressed: onTap, - ); - } -} - -class _CurrentModelButton extends StatelessWidget { - const _CurrentModelButton({ - required this.modelName, - required this.onTap, - super.key, - }); - - final String modelName; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return FlowyTooltip( - message: LocaleKeys.chat_changeFormat_switchModel.tr(), - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: SizedBox( - height: DesktopAIPromptSizes.actionBarButtonSize, - child: FlowyHover( - style: const HoverStyle( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), - child: FlowyText( - modelName, - fontSize: 12, - figmaLineHeight: 16, - color: Theme.of(context).hintColor, - ), - ), - ), - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart index 6d6fc8de31..403b978905 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart @@ -104,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget { }, child: FlowyTooltip( message: format.i18n, + preferBelow: false, child: SizedBox.square( dimension: _buttonSize, child: FlowyHover( @@ -150,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget { }, child: FlowyTooltip( message: format.i18n, + preferBelow: false, child: SizedBox.square( dimension: _buttonSize, child: FlowyHover( diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart new file mode 100644 index 0000000000..d6ade94cc5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -0,0 +1,221 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SelectModelMenu extends StatefulWidget { + const SelectModelMenu({ + super.key, + required this.aiModelStateNotifier, + }); + + final AIModelStateNotifier aiModelStateNotifier; + + @override + State createState() => _SelectModelMenuState(); +} + +class _SelectModelMenuState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SelectModelBloc( + aiModelStateNotifier: widget.aiModelStateNotifier, + ), + child: BlocBuilder( + builder: (context, state) { + if (state.selectedModel == null) { + return const SizedBox.shrink(); + } + return AppFlowyPopover( + offset: Offset(-12.0, 0.0), + constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), + direction: PopoverDirection.topWithLeftAligned, + margin: EdgeInsets.zero, + controller: popoverController, + popupBuilder: (popoverContext) { + return SelectModelPopoverContent( + models: state.models, + selectedModel: state.selectedModel, + onSelectModel: (model) { + if (model != state.selectedModel) { + context + .read() + .add(SelectModelEvent.selectModel(model)); + } + popoverController.close(); + }, + ); + }, + child: _CurrentModelButton( + modelName: state.selectedModel!.name, + onTap: () => popoverController.show(), + ), + ); + }, + ), + ); + } +} + +class SelectModelPopoverContent extends StatelessWidget { + const SelectModelPopoverContent({ + super.key, + required this.models, + required this.selectedModel, + this.onSelectModel, + }); + + final List models; + final AiModel? selectedModel; + final void Function(AiModel)? onSelectModel; + + @override + Widget build(BuildContext context) { + if (models.isEmpty) { + return const SizedBox.shrink(); + } + + // separate models into local and cloud models + final localModels = models.where((model) => model.isLocal).toList(); + final cloudModels = models.where((model) => !model.isLocal).toList(); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (localModels.isNotEmpty) ...[ + _ModelSectionHeader( + title: LocaleKeys.chat_switchModel_localModel.tr(), + ), + const VSpace(4.0), + ], + ...localModels.map( + (model) => _ModelItem( + model: model, + isSelected: model == selectedModel, + onTap: () => onSelectModel?.call(model), + ), + ), + if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[ + const VSpace(8.0), + _ModelSectionHeader( + title: LocaleKeys.chat_switchModel_cloudModel.tr(), + ), + const VSpace(4.0), + ], + ...cloudModels.map( + (model) => _ModelItem( + model: model, + isSelected: model == selectedModel, + onTap: () => onSelectModel?.call(model), + ), + ), + ], + ), + ); + } +} + +class _ModelSectionHeader extends StatelessWidget { + const _ModelSectionHeader({ + required this.title, + }); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: FlowyText( + title, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w500, + ), + ); + } +} + +class _ModelItem extends StatelessWidget { + const _ModelItem({ + required this.model, + required this.isSelected, + required this.onTap, + }); + + final AiModel model; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + onTap: onTap, + margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: FlowyText(model.name), + rightIcon: isSelected ? FlowySvg(FlowySvgs.check_s) : null, + ), + ); + } +} + +class _CurrentModelButton extends StatelessWidget { + const _CurrentModelButton({ + required this.modelName, + required this.onTap, + }); + + final String modelName; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_switchModel_label.tr(), + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.all(4.0), + child: Row( + children: [ + FlowyText( + modelName, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + ), + HSpace(2.0), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index e7aca346e0..f8a1b4d20c 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -239,9 +239,9 @@ class ChatBloc extends Bloc { ), ); }, - regenerateAnswer: (id, format) { + regenerateAnswer: (id, format, model) { _clearRelatedQuestions(); - _regenerateAnswer(id, format); + _regenerateAnswer(id, format, model); lastSentMessage = null; isFetchingRelatedQuestions = false; @@ -483,6 +483,7 @@ class ChatBloc extends Bloc { void _regenerateAnswer( String answerMessageIdString, PredefinedFormat? format, + AiModel? model, ) async { final id = temporaryMessageIDMap.entries .firstWhereOrNull((e) => e.value == answerMessageIdString) @@ -505,6 +506,9 @@ class ChatBloc extends Bloc { if (format != null) { payload.format = format.toPB(); } + if (model != null) { + payload.model = model.toPB(); + } await AIEventRegenerateResponse(payload).send().fold( (success) { @@ -637,6 +641,7 @@ class ChatEvent with _$ChatEvent { const factory ChatEvent.regenerateAnswer( String id, PredefinedFormat? format, + AiModel? model, ) = _RegenerateAnswer; // streaming answer 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 cbc4929f56..4f843d447b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -265,10 +265,13 @@ class _ChatContentPage extends StatelessWidget { _onSelectMetadata(context, metadata), onRegenerate: () => context .read() - .add(ChatEvent.regenerateAnswer(message.id, null)), + .add(ChatEvent.regenerateAnswer(message.id, null, null)), onChangeFormat: (format) => context .read() - .add(ChatEvent.regenerateAnswer(message.id, format)), + .add(ChatEvent.regenerateAnswer(message.id, format, null)), + onChangeModel: (model) => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, null, model)), onStopStream: () => context.read().add( const ChatEvent.stopStream(), ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart new file mode 100644 index 0000000000..2bf09d5e3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart @@ -0,0 +1,145 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +Future showChangeModelBottomSheet( + BuildContext context, + List models, +) { + return showMobileBottomSheet( + context, + showDragHandle: true, + builder: (context) => _ChangeModelBottomSheetContent(models: models), + ); +} + +class _ChangeModelBottomSheetContent extends StatefulWidget { + const _ChangeModelBottomSheetContent({ + required this.models, + }); + + final List models; + + @override + State<_ChangeModelBottomSheetContent> createState() => + _ChangeModelBottomSheetContentState(); +} + +class _ChangeModelBottomSheetContentState + extends State<_ChangeModelBottomSheetContent> { + AiModel? model; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Header( + onCancel: () => Navigator.of(context).pop(), + onDone: () => Navigator.of(context).pop(model), + ), + const VSpace(4.0), + _Body( + models: widget.models, + selectedModel: model, + onSelectModel: (format) { + setState(() => model = format); + }, + ), + const VSpace(16.0), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.onCancel, + required this.onDone, + }); + + final VoidCallback onCancel; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: onCancel, + ), + ), + Align( + child: Container( + constraints: const BoxConstraints(maxWidth: 250), + child: FlowyText( + LocaleKeys.chat_switchModel_label.tr(), + fontSize: 17.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: AppBarDoneButton( + onTap: onDone, + ), + ), + ], + ), + ); + } +} + +class _Body extends StatelessWidget { + const _Body({ + required this.models, + required this.selectedModel, + required this.onSelectModel, + }); + + final List models; + final AiModel? selectedModel; + final void Function(AiModel) onSelectModel; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: models + .mapIndexed( + (index, model) => _buildModelButton(model, index == 0), + ) + .toList(), + ); + } + + Widget _buildModelButton( + AiModel model, [ + bool isFirst = false, + ]) { + return FlowyOptionTile.checkbox( + text: model.name, + isSelected: model == selectedModel, + showTopBorder: isFirst, + onTap: () { + onSelectModel(model); + }, + ); + } +} 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 6b1d428d04..8649e08a5c 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 @@ -41,6 +41,7 @@ class AIMessageActionBar extends StatefulWidget { required this.showDecoration, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, this.onOverrideVisibility, }); @@ -48,6 +49,7 @@ class AIMessageActionBar extends StatefulWidget { final bool showDecoration; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AiModel)? onChangeModel; final void Function(bool)? onOverrideVisibility; @override @@ -126,6 +128,12 @@ class _AIMessageActionBarState extends State { popoverMutex: popoverMutex, onOverrideVisibility: widget.onOverrideVisibility, ), + ChangeModelButton( + isInHoverBar: widget.showDecoration, + onRegenerate: widget.onChangeModel, + popoverMutex: popoverMutex, + onOverrideVisibility: widget.onOverrideVisibility, + ), SaveToPageButton( textMessage: widget.message as TextMessage, isInHoverBar: widget.showDecoration, @@ -405,6 +413,85 @@ class _ChangeFormatPopoverContentState } } +class ChangeModelButton extends StatefulWidget { + const ChangeModelButton({ + super.key, + required this.isInHoverBar, + this.popoverMutex, + this.onRegenerate, + this.onOverrideVisibility, + }); + + final bool isInHoverBar; + final PopoverMutex? popoverMutex; + final void Function(AiModel)? onRegenerate; + final void Function(bool)? onOverrideVisibility; + + @override + State createState() => _ChangeModelButtonState(); +} + +class _ChangeModelButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + mutex: widget.popoverMutex, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: Offset(8, 0), + direction: PopoverDirection.rightWithBottomAligned, + constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), + onClose: () => widget.onOverrideVisibility?.call(false), + child: buildButton(context), + popupBuilder: (_) { + final bloc = context.read(); + final (models, _) = bloc.aiModelStateNotifier.getAvailableModels(); + return SelectModelPopoverContent( + models: models, + selectedModel: null, + onSelectModel: widget.onRegenerate, + ); + }, + ); + } + + Widget buildButton(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_switchModel_label.tr(), + child: FlowyIconButton( + width: 32.0, + height: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: widget.isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_sparks_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + onPressed: () { + widget.onOverrideVisibility?.call(true); + popoverController.show(); + }, + ), + ); + } +} + class SaveToPageButton extends StatefulWidget { const SaveToPageButton({ super.key, 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 770fb990b1..aa61a3f621 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 @@ -23,6 +23,7 @@ import 'package:universal_platform/universal_platform.dart'; import '../chat_avatar.dart'; import '../layout_define.dart'; +import 'ai_change_model_bottom_sheet.dart'; import 'ai_message_action_bar.dart'; import 'ai_change_format_bottom_sheet.dart'; import 'message_util.dart'; @@ -41,6 +42,7 @@ class ChatAIMessageBubble extends StatelessWidget { this.isSelectingMessages = false, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Message message; @@ -50,6 +52,7 @@ class ChatAIMessageBubble extends StatelessWidget { final bool isSelectingMessages; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AiModel)? onChangeModel; @override Widget build(BuildContext context) { @@ -73,6 +76,7 @@ class ChatAIMessageBubble extends StatelessWidget { message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: child, ); } @@ -82,6 +86,7 @@ class ChatAIMessageBubble extends StatelessWidget { message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: child, ); } @@ -91,6 +96,7 @@ class ChatAIMessageBubble extends StatelessWidget { message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: child, ); } @@ -103,12 +109,14 @@ class ChatAIBottomInlineActions extends StatelessWidget { required this.message, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AiModel)? onChangeModel; @override Widget build(BuildContext context) { @@ -127,6 +135,7 @@ class ChatAIBottomInlineActions extends StatelessWidget { showDecoration: false, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, ), ), const VSpace(32.0), @@ -142,12 +151,14 @@ class ChatAIMessageHover extends StatefulWidget { required this.message, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AiModel)? onChangeModel; @override State createState() => _ChatAIMessageHoverState(); @@ -229,6 +240,7 @@ class _ChatAIMessageHoverState extends State { showDecoration: true, onRegenerate: widget.onRegenerate, onChangeFormat: widget.onChangeFormat, + onChangeModel: widget.onChangeModel, onOverrideVisibility: (visibility) { overrideVisibility = visibility; }, @@ -302,12 +314,14 @@ class ChatAIMessagePopup extends StatelessWidget { required this.message, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AiModel)? onChangeModel; @override Widget build(BuildContext context) { @@ -328,6 +342,8 @@ class ChatAIMessagePopup extends StatelessWidget { _divider(), _changeFormatButton(context), _divider(), + _changeModelButton(context), + _divider(), _saveToPageButton(context), ], ); @@ -399,6 +415,25 @@ class ChatAIMessagePopup extends StatelessWidget { ); } + Widget _changeModelButton(BuildContext context) { + return MobileQuickActionButton( + onTap: () async { + final bloc = context.read(); + final (models, _) = bloc.aiModelStateNotifier.getAvailableModels(); + final result = await showChangeModelBottomSheet(context, models); + if (result != null) { + onChangeModel?.call(result); + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + icon: FlowySvgs.ai_sparks_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_switchModel_label.tr(), + ); + } + Widget _saveToPageButton(BuildContext context) { return MobileQuickActionButton( onTap: () async { 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 5a55072c17..e181de6570 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 @@ -36,6 +36,7 @@ class ChatAIMessageWidget extends StatelessWidget { this.onSelectedMetadata, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, this.isLastMessage = false, this.isStreaming = false, this.isSelectingMessages = false, @@ -53,6 +54,7 @@ class ChatAIMessageWidget extends StatelessWidget { final void Function()? onRegenerate; final void Function() onStopStream; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AiModel)? onChangeModel; final bool isStreaming; final bool isLastMessage; final bool isSelectingMessages; @@ -110,6 +112,7 @@ class ChatAIMessageWidget extends StatelessWidget { isSelectingMessages: isSelectingMessages, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 62de8be000..24f734af5d 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -247,14 +247,16 @@ "table": "Table", "blankDescription": "Format response", "defaultDescription": "Auto mode", - "localModel": "Local Model", - "cloudModel": "Cloud Model", - "switchModel": "Switch model", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", "tableWithImageDescription": "@:chat.changeFormat.table with image" }, + "switchModel": { + "label": "Switch model", + "localModel": "Local Model", + "cloudModel": "Cloud Model" + }, "selectBanner": { "saveButton": "Add to …", "selectMessages": "Select messages", @@ -3199,4 +3201,4 @@ "rewrite": "Rewrite", "insertBelow": "Insert below" } -} \ No newline at end of file +} diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 03a18dcdec..0a63d8ece0 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -248,22 +248,23 @@ impl AIManager { answer_message_id: i64, answer_stream_port: i64, format: Option, + model: Option, ) -> FlowyResult<()> { let chat = self.get_or_create_chat_instance(chat_id).await?; let question_message_id = chat .get_question_id_from_answer_id(answer_message_id) .await?; - let preferred_model = self - .store_preferences - .get_object::(&ai_available_models_key(chat_id)); + let model = model.map_or_else( + || { + self + .store_preferences + .get_object::(&ai_available_models_key(chat_id)) + }, + |model| Some(model.into()), + ); chat - .stream_regenerate_response( - question_message_id, - answer_stream_port, - format, - preferred_model, - ) + .stream_regenerate_response(question_message_id, answer_stream_port, format, model) .await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 0e996228c6..d10aa950e3 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -102,6 +102,9 @@ pub struct RegenerateResponsePB { #[pb(index = 4, one_of)] pub format: Option, + + #[pb(index = 5, one_of)] + pub model: Option, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 6bedebd261..ec8b7b4964 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -99,6 +99,7 @@ pub(crate) async fn regenerate_response_handler( data.answer_message_id, data.answer_stream_port, data.format, + data.model, ) .await?; Ok(()) From e11388491f7a7730fdcf66721770506e498ff309 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 25 Mar 2025 22:59:20 +0800 Subject: [PATCH 044/207] chore: emit ready state faster --- .../lib/ai/service/select_model_bloc.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart index 46e9b5ef92..c6c6c03ccf 100644 --- a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart +++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart @@ -36,7 +36,7 @@ class SelectModelBloc extends Bloc { SelectModelBloc({ required AIModelStateNotifier aiModelStateNotifier, }) : _aiModelStateNotifier = aiModelStateNotifier, - super(SelectModelState.initial()) { + super(SelectModelState.initial(aiModelStateNotifier)) { on( (event, emit) { event.when( @@ -103,8 +103,11 @@ class SelectModelState with _$SelectModelState { required AiModel? selectedModel, }) = _SelectModelState; - factory SelectModelState.initial() => SelectModelState( - models: [], - selectedModel: null, - ); + factory SelectModelState.initial(AIModelStateNotifier notifier) { + final (models, selectedModel) = notifier.getAvailableModels(); + return SelectModelState( + models: models, + selectedModel: selectedModel, + ); + } } From f9e1dcca6ca41a8bd64d1c30b9dda4a0ad48dabe Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 26 Mar 2025 01:57:45 +0800 Subject: [PATCH 045/207] chore: code nit --- .../lib/ai/service/ai_model_state_notifier.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart index 066280e3db..a94c863acd 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -121,7 +121,7 @@ class AIModelStateNotifier { return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); } - if (availableModels.selectedModel.isLocal == false) { + if (!availableModels.selectedModel.isLocal) { return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); } From f7f2e71ee15f649f23926fd0b79ab8848d12bb21 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 27 Mar 2025 10:05:26 +0800 Subject: [PATCH 046/207] chore: adjust popover shadows (#7630) --- .../lib/src/flowy_overlay/appflowy_popover.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 2da547afda..6a154d4d48 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -14,10 +14,10 @@ class ShadowConstants { BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x121F2225)), ]; static const List darkSmall = [ - BoxShadow(offset: Offset(0, 2), blurRadius: 16, color: Color(0x5C000000)), + BoxShadow(offset: Offset(0, 2), blurRadius: 16, color: Color(0x7A000000)), ]; static const List darkMedium = [ - BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x5C000000)), + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x7A000000)), ]; } From 9147f64b652fdddf32fba946f615a585878684e9 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:10:23 +0800 Subject: [PATCH 047/207] chore: adjust suggestion action button position (#7632) --- .../editor_plugins/ai/ai_writer_block_component.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index a4bc4f50e3..0f1e077d6d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -222,9 +222,11 @@ class _OverlayContentState extends State { final isInitialReadyState = state is ReadyAiWriterState && state.isFirstRun; final showSuggestedActionsPopup = - showSuggestedActions && markdownText.isEmpty; - final showSuggestedActionsWithin = - showSuggestedActions && markdownText.isNotEmpty; + showSuggestedActions && markdownText.isEmpty || + (markdownText.isNotEmpty && command != AiWriterCommand.explain); + final showSuggestedActionsWithin = showSuggestedActions && + markdownText.isNotEmpty && + command == AiWriterCommand.explain; final borderColor = Theme.of(context).isLightMode ? Color(0x1F1F2329) From 85288119924ba61eb5743b588c4b351bdc7dbe2f Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 27 Mar 2025 12:02:58 +0800 Subject: [PATCH 048/207] chore: remove ai model class --- .../ai/service/ai_model_state_notifier.dart | 10 ++--- .../lib/ai/service/select_model_bloc.dart | 41 +++++-------------- .../prompt_input/select_model_menu.dart | 9 ++-- .../ai_chat/application/chat_bloc.dart | 6 +-- .../message/ai_change_model_bottom_sheet.dart | 20 ++++----- .../message/ai_message_action_bar.dart | 5 ++- .../message/ai_message_bubble.dart | 9 ++-- .../presentation/message/ai_text_message.dart | 3 +- 8 files changed, 42 insertions(+), 61 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart index a94c863acd..c43bd01c6f 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -12,8 +12,8 @@ import 'package:universal_platform/universal_platform.dart'; typedef OnModelStateChangedCallback = void Function(AiType, bool, String); typedef OnAvailableModelsChangedCallback = void Function( - List, - AiModel?, + List, + AIModelPB?, ); class AIModelStateNotifier { @@ -133,14 +133,12 @@ class AIModelStateNotifier { return (AiType.local, hintText, editable); } - (List, AiModel?) getAvailableModels() { + (List, AIModelPB?) getAvailableModels() { final availableModels = _availableModels; if (availableModels == null) { return ([], null); } - final models = availableModels.models.map(AiModel.fromPB).toList(); - final selectedModel = AiModel.fromPB(availableModels.selectedModel); - return (models, selectedModel); + return (availableModels.models, availableModels.selectedModel); } void _notifyAvailableModelsChanged() { diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart index c6c6c03ccf..7ad52b9ec4 100644 --- a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart +++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart @@ -3,35 +3,11 @@ import 'dart:async'; import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'select_model_bloc.freezed.dart'; -class AiModel extends Equatable { - const AiModel({ - required this.name, - required this.isLocal, - }); - - factory AiModel.fromPB(AIModelPB pb) { - return AiModel(name: pb.name, isLocal: pb.isLocal); - } - - AIModelPB toPB() { - return AIModelPB() - ..name = name - ..isLocal = isLocal; - } - - final String name; - final bool isLocal; - - @override - List get props => [name, isLocal]; -} - class SelectModelBloc extends Bloc { SelectModelBloc({ required AIModelStateNotifier aiModelStateNotifier, @@ -44,7 +20,7 @@ class SelectModelBloc extends Bloc { AIEventUpdateSelectedModel( UpdateSelectedModelPB( source: _aiModelStateNotifier.objectId, - selectedModel: model.toPB(), + selectedModel: model, ), ).send(); @@ -77,7 +53,10 @@ class SelectModelBloc extends Bloc { await super.close(); } - void _onAvailableModelsChanged(List models, AiModel? selectedModel) { + void _onAvailableModelsChanged( + List models, + AIModelPB? selectedModel, + ) { if (!isClosed) { add(SelectModelEvent.didLoadModels(models, selectedModel)); } @@ -87,20 +66,20 @@ class SelectModelBloc extends Bloc { @freezed class SelectModelEvent with _$SelectModelEvent { const factory SelectModelEvent.selectModel( - AiModel model, + AIModelPB model, ) = _SelectModel; const factory SelectModelEvent.didLoadModels( - List models, - AiModel? selectedModel, + List models, + AIModelPB? selectedModel, ) = _DidLoadModels; } @freezed class SelectModelState with _$SelectModelState { const factory SelectModelState({ - required List models, - required AiModel? selectedModel, + required List models, + required AIModelPB? selectedModel, }) = _SelectModelState; factory SelectModelState.initial(AIModelStateNotifier notifier) { diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart index d6ade94cc5..b9e3daada9 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -1,6 +1,7 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -72,9 +73,9 @@ class SelectModelPopoverContent extends StatelessWidget { this.onSelectModel, }); - final List models; - final AiModel? selectedModel; - final void Function(AiModel)? onSelectModel; + final List models; + final AIModelPB? selectedModel; + final void Function(AIModelPB)? onSelectModel; @override Widget build(BuildContext context) { @@ -154,7 +155,7 @@ class _ModelItem extends StatelessWidget { required this.onTap, }); - final AiModel model; + final AIModelPB model; final bool isSelected; final VoidCallback onTap; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index f8a1b4d20c..4924a42c0d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -483,7 +483,7 @@ class ChatBloc extends Bloc { void _regenerateAnswer( String answerMessageIdString, PredefinedFormat? format, - AiModel? model, + AIModelPB? model, ) async { final id = temporaryMessageIDMap.entries .firstWhereOrNull((e) => e.value == answerMessageIdString) @@ -507,7 +507,7 @@ class ChatBloc extends Bloc { payload.format = format.toPB(); } if (model != null) { - payload.model = model.toPB(); + payload.model = model; } await AIEventRegenerateResponse(payload).send().fold( @@ -641,7 +641,7 @@ class ChatEvent with _$ChatEvent { const factory ChatEvent.regenerateAnswer( String id, PredefinedFormat? format, - AiModel? model, + AIModelPB? model, ) = _RegenerateAnswer; // streaming answer diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart index 2bf09d5e3c..aa0d840574 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart @@ -1,18 +1,18 @@ -import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -Future showChangeModelBottomSheet( +Future showChangeModelBottomSheet( BuildContext context, - List models, + List models, ) { - return showMobileBottomSheet( + return showMobileBottomSheet( context, showDragHandle: true, builder: (context) => _ChangeModelBottomSheetContent(models: models), @@ -24,7 +24,7 @@ class _ChangeModelBottomSheetContent extends StatefulWidget { required this.models, }); - final List models; + final List models; @override State<_ChangeModelBottomSheetContent> createState() => @@ -33,7 +33,7 @@ class _ChangeModelBottomSheetContent extends StatefulWidget { class _ChangeModelBottomSheetContentState extends State<_ChangeModelBottomSheetContent> { - AiModel? model; + AIModelPB? model; @override Widget build(BuildContext context) { @@ -113,9 +113,9 @@ class _Body extends StatelessWidget { required this.onSelectModel, }); - final List models; - final AiModel? selectedModel; - final void Function(AiModel) onSelectModel; + final List models; + final AIModelPB? selectedModel; + final void Function(AIModelPB) onSelectModel; @override Widget build(BuildContext context) { @@ -130,7 +130,7 @@ class _Body extends StatelessWidget { } Widget _buildModelButton( - AiModel model, [ + AIModelPB model, [ bool isFirst = false, ]) { return FlowyOptionTile.checkbox( 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 8649e08a5c..150ce20192 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 @@ -21,6 +21,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -49,7 +50,7 @@ class AIMessageActionBar extends StatefulWidget { final bool showDecoration; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; - final void Function(AiModel)? onChangeModel; + final void Function(AIModelPB)? onChangeModel; final void Function(bool)? onOverrideVisibility; @override @@ -424,7 +425,7 @@ class ChangeModelButton extends StatefulWidget { final bool isInHoverBar; final PopoverMutex? popoverMutex; - final void Function(AiModel)? onRegenerate; + final void Function(AIModelPB)? onRegenerate; final void Function(bool)? onOverrideVisibility; @override 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 aa61a3f621..eed5f0a520 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 @@ -12,6 +12,7 @@ import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -52,7 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget { final bool isSelectingMessages; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; - final void Function(AiModel)? onChangeModel; + final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { @@ -116,7 +117,7 @@ class ChatAIBottomInlineActions extends StatelessWidget { final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; - final void Function(AiModel)? onChangeModel; + final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { @@ -158,7 +159,7 @@ class ChatAIMessageHover extends StatefulWidget { final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; - final void Function(AiModel)? onChangeModel; + final void Function(AIModelPB)? onChangeModel; @override State createState() => _ChatAIMessageHoverState(); @@ -321,7 +322,7 @@ class ChatAIMessagePopup extends StatelessWidget { final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; - final void Function(AiModel)? onChangeModel; + final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { 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 e181de6570..380767105f 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 @@ -4,6 +4,7 @@ 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/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -54,7 +55,7 @@ class ChatAIMessageWidget extends StatelessWidget { final void Function()? onRegenerate; final void Function() onStopStream; final void Function(PredefinedFormat)? onChangeFormat; - final void Function(AiModel)? onChangeModel; + final void Function(AIModelPB)? onChangeModel; final bool isStreaming; final bool isLastMessage; final bool isSelectingMessages; From 4686e13390d66ab497f7cdf51a1dad2a9c0c011c Mon Sep 17 00:00:00 2001 From: Morn Date: Thu, 27 Mar 2025 13:28:35 +0800 Subject: [PATCH 049/207] feat: add animation for floating toolbar (#7623) --- .../lib/plugins/document/presentation/editor_page.dart | 4 +++- .../desktop_toolbar/desktop_floating_toolbar.dart | 7 ++++++- frontend/appflowy_flutter/pubspec.lock | 4 ++-- frontend/appflowy_flutter/pubspec.yaml | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index b14d930d65..d0d26c43e5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -443,9 +443,11 @@ class _AppFlowyEditorPageState extends State color: Theme.of(context).cardColor, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), - toolbarBuilder: (context, child, onDismiss) => DesktopFloatingToolbar( + toolbarBuilder: (context, child, onDismiss, isMetricsChanged) => + DesktopFloatingToolbar( editorState: editorState, onDismiss: onDismiss, + enableAnimation: !isMetricsChanged, child: child, ), placeHolderBuilder: (_) => customPlaceholderItem, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart index 2cc9d700e2..076358034c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'toolbar_animation.dart'; import 'toolbar_cubit.dart'; class DesktopFloatingToolbar extends StatefulWidget { @@ -10,11 +11,13 @@ class DesktopFloatingToolbar extends StatefulWidget { required this.editorState, required this.child, required this.onDismiss, + this.enableAnimation = true, }); final EditorState editorState; final Widget child; final VoidCallback onDismiss; + final bool enableAnimation; @override State createState() => _DesktopFloatingToolbarState(); @@ -46,7 +49,9 @@ class _DesktopFloatingToolbarState extends State { left: position!.left, top: position!.top, right: position!.right, - child: widget.child, + child: widget.enableAnimation + ? ToolbarAnimationWidget(child: widget.child) + : widget.child, ), ); } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index d61c5338f5..ea4cb90fe1 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -90,8 +90,8 @@ packages: dependency: "direct main" description: path: "." - ref: f46e991 - resolved-ref: f46e991d0a9c5a95bd14be4cc96e68171c9ed9bc + ref: "8f314fd" + resolved-ref: "8f314fda5981e650a52ba522ba7915e13940d837" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 4710d52b4d..77663760a2 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -180,7 +180,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "f46e991" + ref: "8f314fd" appflowy_editor_plugins: git: From 76cb23e23358796e4d34cf8bc1be715b85db8a73 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 27 Mar 2025 14:17:47 +0800 Subject: [PATCH 050/207] feat: add bloc observer (#7633) * chore: bump version 0.8.8 * feat: add bloc observer * chore: update comment * chore: update comment --- .../callout/callout_block_component.dart | 8 +- .../simple_table_node_extension.dart | 24 +++++- .../appflowy_flutter/lib/startup/startup.dart | 2 +- .../lib/startup/tasks/app_widget.dart | 10 --- .../lib/startup/tasks/debug_task.dart | 35 +++++++- .../application/sidebar/space/space_bloc.dart | 12 --- frontend/appflowy_flutter/macos/Podfile.lock | 46 +++++------ .../example/macos/Runner/AppDelegate.swift | 6 +- .../lib/appflowy_backend.dart | 17 +--- .../packages/appflowy_backend/lib/log.dart | 79 ++++++++----------- .../packages/appflowy_backend/pubspec.yaml | 2 +- .../packages/appflowy_result/pubspec.yaml | 39 +-------- .../packages/flowy_infra/pubspec.yaml | 41 +--------- .../pubspec.yaml | 4 +- .../flowy_infra_ui_web/pubspec.yaml | 4 +- frontend/appflowy_flutter/pubspec.lock | 52 ++++++++---- frontend/appflowy_flutter/pubspec.yaml | 10 ++- 17 files changed, 170 insertions(+), 221 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart index 522b694edb..a7fcccd186 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart @@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' show StringTranslateExtension; @@ -176,12 +175,7 @@ class _CalloutBlockComponentWidgetState EmojiIconData result = EmojiIconData.emoji('📌'); try { result = EmojiIconData(FlowyIconType.values.byName(type), icon); - } catch (e) { - Log.info( - 'get emoji error with icon:[$icon], type:[$type] within calloutBlockComponentWidget', - e, - ); - } + } catch (_) {} return result; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart index 0987bc29d3..1a2e21c305 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart @@ -238,8 +238,13 @@ extension TableNodeExtension on Node { try { final columnWidths = parentTableNode.attributes[SimpleTableBlockKeys.columnWidths]; - final width = columnWidths?[columnIndex.toString()]; - return width ?? SimpleTableConstants.defaultColumnWidth; + final width = columnWidths?[columnIndex.toString()] as Object?; + if (width == null) { + return SimpleTableConstants.defaultColumnWidth; + } + return width.toDouble( + defaultValue: SimpleTableConstants.defaultColumnWidth, + ); } catch (e) { Log.warn('get column width: $e'); return SimpleTableConstants.defaultColumnWidth; @@ -856,3 +861,18 @@ extension TableNodeExtension on Node { return TableAlign.left; } } + +extension on Object { + double toDouble({double defaultValue = 0}) { + if (this is double) { + return this as double; + } + if (this is String) { + return double.tryParse(this as String) ?? defaultValue; + } + if (this is int) { + return (this as int).toDouble(); + } + return defaultValue; + } +} diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 7fa18fc54d..2e9b2c9afd 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -119,7 +119,7 @@ class FlowyRunner { // this task should be second task, for handling memory leak. // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored. MemoryLeakDetectorTask(), - const DebugTask(), + DebugTask(), const FeatureFlagTask(), // localization diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index a398db3061..2be124e2fe 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -17,7 +17,6 @@ import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_b import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; -import 'package:appflowy_backend/log.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'; @@ -64,7 +63,6 @@ class InitAppWidgetTask extends LaunchTask { child: widget, ); - Bloc.observer = ApplicationBlocObserver(); runApp( EasyLocalization( supportedLocales: const [ @@ -283,14 +281,6 @@ class AppGlobals { static BuildContext get context => rootNavKey.currentContext!; } -class ApplicationBlocObserver extends BlocObserver { - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - Log.debug(error); - super.onError(bloc, error, stackTrace); - } -} - Future appTheme(String themeName) async { if (themeName.isEmpty) { return AppTheme.fallback; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart index 082e25e250..9a34e84f70 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart @@ -1,18 +1,45 @@ +import 'package:appflowy/startup/startup.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_bloc_logger/talker_bloc_logger.dart'; import 'package:universal_platform/universal_platform.dart'; -import '../startup.dart'; - class DebugTask extends LaunchTask { - const DebugTask(); + DebugTask(); + + final Talker talker = Talker(); @override Future initialize(LaunchContext context) async { - // the hotkey manager is not supported on mobile + // hide the keyboard on mobile if (UniversalPlatform.isMobile && kDebugMode) { await SystemChannels.textInput.invokeMethod('TextInput.hide'); } + + // log the bloc events + if (kDebugMode) { + Bloc.observer = TalkerBlocObserver( + talker: talker, + settings: TalkerBlocLoggerSettings( + // Disabled by default to prevent mixing with AppFlowy logs + // Enable to observe all bloc events + enabled: false, + printEventFullData: false, + printStateFullData: false, + printChanges: true, + printClosings: true, + printCreations: true, + transitionFilter: (_, transition) { + // By default, observe all transitions + // You can add your own filter here if needed + // when you want to observer a specific bloc + return true; + }, + ), + ); + } } @override diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index 0f1dc4e987..d4c1b4fe16 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -331,16 +331,6 @@ class SpaceBloc extends Bloc { final (spaces, _, _) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); - Log.info( - 'receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', - ); - - for (var i = 0; i < spaces.length; i++) { - Log.info( - 'receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', - ); - } - emit( state.copyWith( spaces: spaces, @@ -496,7 +486,6 @@ class SpaceBloc extends Bloc { } void _initial(UserProfilePB userProfile, String workspaceId) { - Log.info('initial(or reset) space bloc: $workspaceId, ${userProfile.id}'); _workspaceService = WorkspaceService(workspaceId: workspaceId); this.userProfile = userProfile; @@ -507,7 +496,6 @@ class SpaceBloc extends Bloc { workspaceId: workspaceId, )..start( sectionChanged: (result) async { - Log.info('did receive section views changed'); if (isClosed) { return; } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index b4a1a3d20d..30ee626f09 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 - auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c - irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 - local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift index d53ef64377..b3c1761412 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart index 69e287f117..f69fd16927 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart @@ -4,11 +4,11 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:logger/logger.dart'; import 'ffi.dart' as ffi; @@ -62,28 +62,15 @@ class RustLogStreamReceiver { late StreamController _streamController; late StreamSubscription _subscription; int get port => _ffiPort.sendPort.nativePort; - late Logger _logger; RustLogStreamReceiver._internal() { _ffiPort = RawReceivePort(); _streamController = StreamController(); _ffiPort.handler = _streamController.add; - _logger = Logger( - printer: PrettyPrinter( - methodCount: 0, // number of method calls to be displayed - errorMethodCount: 8, // number of method calls if stacktrace is provided - lineLength: 120, // width of the output - colors: false, // Colorful log messages - printEmojis: false, // Print an emoji for each log message - dateTimeFormat: - DateTimeFormat.none, // Should each log print contain a timestamp - ), - level: kDebugMode ? Level.trace : Level.info, - ); _subscription = _streamController.stream.listen((data) { String decodedString = utf8.decode(data); - _logger.i(decodedString); + Log.info(decodedString); }); } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart index 355a196621..ce0a4e2248 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -3,64 +3,43 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart' as ffi; import 'package:flutter/foundation.dart'; -import 'package:logger/logger.dart'; +import 'package:talker/talker.dart'; import 'ffi.dart'; class Log { static final shared = Log(); - // ignore: unused_field - late Logger _logger; - bool _enabled = false; + late Talker _logger; + + bool enableFlutterLog = true; // used to disable log in tests @visibleForTesting bool disableLog = false; Log() { - _logger = Logger( - printer: PrettyPrinter( - methodCount: 2, // Number of method calls to be displayed - errorMethodCount: 8, // Number of method calls if stacktrace is provided - lineLength: 120, // Width of the output - colors: true, // Colorful log messages - printEmojis: true, // Print an emoji for each log message - ), - level: kDebugMode ? Level.trace : Level.info, + _logger = Talker( + filter: LogLevelTalkerFilter(), ); } - static void enableFlutterLog() { - shared._enabled = true; - } - // Generic internal logging function to reduce code duplication - static void _log(Level level, int rustLevel, dynamic msg, - [dynamic error, StackTrace? stackTrace]) { - if (shared._enabled) { - switch (level) { - case Level.info: - shared._logger.i(msg, stackTrace: stackTrace); - break; - case Level.debug: - shared._logger.d(msg, stackTrace: stackTrace); - break; - case Level.warning: - shared._logger.w(msg, stackTrace: stackTrace); - break; - case Level.error: - shared._logger.e(msg, stackTrace: stackTrace); - break; - case Level.trace: - shared._logger.t(msg, stackTrace: stackTrace); - break; - default: - shared._logger.log(level, msg, stackTrace: stackTrace); - } + static void _log( + LogLevel level, + int rustLevel, + dynamic msg, [ + dynamic error, + StackTrace? stackTrace, + ]) { + // only forward logs to flutter in debug mode, otherwise log to rust to + // persist logs in the file system + if (shared.enableFlutterLog && kDebugMode) { + shared._logger.log(msg, logLevel: level, stackTrace: stackTrace); + } else { + String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); + rust_log(rustLevel, toNativeUtf8(formattedMessage)); } - String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); - rust_log(rustLevel, toNativeUtf8(formattedMessage)); } static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -68,7 +47,7 @@ class Log { return; } - _log(Level.info, 0, msg, error, stackTrace); + _log(LogLevel.info, 0, msg, error, stackTrace); } static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -76,7 +55,7 @@ class Log { return; } - _log(Level.debug, 1, msg, error, stackTrace); + _log(LogLevel.debug, 1, msg, error, stackTrace); } static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -84,7 +63,7 @@ class Log { return; } - _log(Level.warning, 3, msg, error, stackTrace); + _log(LogLevel.warning, 3, msg, error, stackTrace); } static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -92,7 +71,7 @@ class Log { return; } - _log(Level.trace, 2, msg, error, stackTrace); + _log(LogLevel.verbose, 2, msg, error, stackTrace); } static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -100,7 +79,7 @@ class Log { return; } - _log(Level.error, 4, msg, error, stackTrace); + _log(LogLevel.error, 4, msg, error, stackTrace); } } @@ -119,3 +98,11 @@ String _formatMessageWithStackTrace(dynamic msg, StackTrace? stackTrace) { } return msg.toString(); } + +class LogLevelTalkerFilter implements TalkerFilter { + @override + bool filter(TalkerData data) { + // filter out the debug logs in release mode + return kDebugMode ? true : data.logLevel != LogLevel.debug; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml index 9ff267929a..18aea4838b 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ffi: ^2.0.2 isolates: ^3.0.3+8 protobuf: ^3.1.0 - logger: ^2.4.0 + talker: ^4.7.1 plugin_platform_interface: ^2.1.3 appflowy_result: path: ../appflowy_result diff --git a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml index fa2e35f329..5d8f0d88c2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml @@ -1,7 +1,7 @@ name: appflowy_result description: "A new Flutter package project." version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=3.3.0 <4.0.0" @@ -9,40 +9,3 @@ environment: dev_dependencies: flutter_lints: ^3.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml index cb5cbb9cee..478c649664 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -1,5 +1,5 @@ name: flowy_infra -description: A new Flutter package project. +description: AppFlowy Infra. version: 0.0.1 homepage: https://appflowy.io @@ -15,7 +15,7 @@ dependencies: path: ^1.8.2 time: ">=2.0.0" uuid: ">=2.2.2" - bloc: ^8.1.2 + bloc: ^9.0.0 freezed_annotation: ^2.1.0 file_picker: ^8.0.2 file: ^7.0.0 @@ -25,40 +25,3 @@ dev_dependencies: flutter_lints: ^3.0.1 freezed: ^2.4.7 json_serializable: ^6.5.4 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml index f2e3eb8749..4a8ad910cb 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml @@ -1,7 +1,7 @@ name: flowy_infra_ui_platform_interface description: A new Flutter package project. version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=2.12.0 <3.0.0" @@ -17,5 +17,3 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 - -flutter: \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml index bbdac0d2e4..d4364a6400 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml @@ -1,7 +1,7 @@ name: flowy_infra_ui_web description: A new Flutter package project. version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy publish_to: none environment: @@ -25,4 +25,4 @@ flutter: platforms: web: pluginClass: FlowyInfraUIPlugin - fileName: flowy_infra_ui_web.dart \ No newline at end of file + fileName: flowy_infra_ui_web.dart diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index ea4cb90fe1..e468ba89b4 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -30,6 +30,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.11" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" any_date: dependency: "direct main" description: @@ -253,18 +261,18 @@ packages: dependency: "direct main" description: name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "9.0.0" bloc_test: dependency: "direct dev" description: name: bloc_test - sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" url: "https://pub.dev" source: hosted - version: "9.1.7" + version: "10.0.0" boolean_selector: dependency: transitive description: @@ -783,10 +791,10 @@ packages: dependency: "direct main" description: name: flutter_bloc - sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + sha256: "1046d719fbdf230330d3443187cc33cc11963d15c9089f6cc56faa42a4c5f0cc" url: "https://pub.dev" source: hosted - version: "8.1.6" + version: "9.1.0" flutter_cache_manager: dependency: "direct main" description: @@ -1331,14 +1339,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.6" - logger: - dependency: transitive - description: - name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 - url: "https://pub.dev" - source: hosted - version: "2.5.0" logging: dependency: transitive description: @@ -2186,6 +2186,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + talker: + dependency: "direct main" + description: + name: talker + sha256: "45abef5b92f9b9bd42c3f20133ad4b20ab12e1da2aa206fc0a40ea874bed7c5d" + url: "https://pub.dev" + source: hosted + version: "4.7.1" + talker_bloc_logger: + dependency: "direct main" + description: + name: talker_bloc_logger + sha256: "2214a5f6ef9ff33494dc6149321c270356962725cc8fc1a485d44b1d9b812ddd" + url: "https://pub.dev" + source: hosted + version: "4.7.1" + talker_logger: + dependency: transitive + description: + name: talker_logger + sha256: ed9b20b8c09efff9f6b7c63fc6630ee2f84aa92661ae09e5ba04e77272bf2ad2 + url: "https://pub.dev" + source: hosted + version: "4.7.1" term_glyph: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 77663760a2..48d380a622 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 - bloc: ^8.1.2 + bloc: ^9.0.0 cached_network_image: ^3.3.0 calendar_view: git: @@ -67,7 +67,7 @@ dependencies: flutter: sdk: flutter flutter_animate: ^4.5.0 - flutter_bloc: ^8.1.3 + flutter_bloc: ^9.1.0 flutter_cache_manager: ^3.3.1 flutter_chat_core: 0.0.2 flutter_chat_ui: ^2.0.0-dev.1 @@ -148,9 +148,13 @@ dependencies: xml: ^6.5.0 window_manager: ^0.4.3 saver_gallery: ^4.0.1 + talker_bloc_logger: ^4.7.1 + talker: ^4.7.1 dev_dependencies: - bloc_test: ^9.1.2 + # Introduce talker to log the bloc events, and only log the events in the development mode + + bloc_test: ^10.0.0 build_runner: ^2.4.9 envied_generator: ^1.0.1 flutter_lints: ^5.0.0 From ccb020e8851759d823cd77129c60aedf32372a12 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 14:16:58 +0800 Subject: [PATCH 051/207] chore: bump lai --- frontend/rust-lib/Cargo.lock | 125 ++++++++++-------- frontend/rust-lib/Cargo.toml | 5 +- frontend/rust-lib/flowy-ai/Cargo.toml | 7 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 2 +- frontend/rust-lib/flowy-ai/src/entities.rs | 2 +- frontend/rust-lib/flowy-ai/src/lib.rs | 5 +- .../flowy-ai/src/local_ai/controller.rs | 6 +- .../flowy-ai/src/local_ai/resource.rs | 2 +- .../flowy-ai/src/local_ai/stream_util.rs | 2 +- frontend/rust-lib/flowy-ai/src/mcp/client.rs | 45 ------- frontend/rust-lib/flowy-ai/src/mcp/manager.rs | 50 ++----- frontend/rust-lib/flowy-ai/src/mcp/mod.rs | 2 - frontend/rust-lib/flowy-ai/src/mcp/util.rs | 6 - .../src/middleware/chat_service_mw.rs | 2 +- frontend/rust-lib/flowy-core/Cargo.toml | 2 +- .../src/deps_resolve/database_deps.rs | 2 +- .../rust-lib/flowy-core/src/log_filter.rs | 4 +- frontend/scripts/tool/update_local_ai_rev.sh | 2 +- 18 files changed, 103 insertions(+), 168 deletions(-) delete mode 100644 frontend/rust-lib/flowy-ai/src/mcp/client.rs delete mode 100644 frontend/rust-lib/flowy-ai/src/mcp/util.rs diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 7b4967728f..07b39ecaa0 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -342,6 +342,61 @@ dependencies = [ "subtle", ] +[[package]] +name = "af-local-ai" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=bcd9782fa3d6f6d36f2fa6d065e834a1400f156e#bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" +dependencies = [ + "af-plugin", + "anyhow", + "bytes", + "futures", + "reqwest 0.11.27", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "zip 2.2.0", + "zip-extensions", +] + +[[package]] +name = "af-mcp" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=bcd9782fa3d6f6d36f2fa6d065e834a1400f156e#bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" +dependencies = [ + "anyhow", + "futures-util", + "mcp_daemon", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "af-plugin" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=bcd9782fa3d6f6d36f2fa6d065e834a1400f156e#bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" +dependencies = [ + "anyhow", + "cfg-if", + "crossbeam-utils", + "log", + "once_cell", + "parking_lot 0.12.1", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tracing", + "winreg 0.55.0", + "xattr", +] + [[package]] name = "again" version = "0.1.2" @@ -434,9 +489,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" @@ -473,47 +528,6 @@ dependencies = [ "thiserror 1.0.64", ] -[[package]] -name = "appflowy-local-ai" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=52ad76f21f8f3a7b510dae029836b5fe86479e5a#52ad76f21f8f3a7b510dae029836b5fe86479e5a" -dependencies = [ - "anyhow", - "appflowy-plugin", - "bytes", - "futures", - "reqwest 0.11.27", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "zip 2.2.0", - "zip-extensions", -] - -[[package]] -name = "appflowy-plugin" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=52ad76f21f8f3a7b510dae029836b5fe86479e5a#52ad76f21f8f3a7b510dae029836b5fe86479e5a" -dependencies = [ - "anyhow", - "cfg-if", - "crossbeam-utils", - "log", - "once_cell", - "parking_lot 0.12.1", - "serde", - "serde_json", - "thiserror 1.0.64", - "tokio", - "tokio-stream", - "tracing", - "winreg 0.55.0", - "xattr", -] - [[package]] name = "arbitrary" version = "1.3.2" @@ -2450,10 +2464,11 @@ dependencies = [ name = "flowy-ai" version = "0.1.0" dependencies = [ + "af-local-ai", + "af-mcp", + "af-plugin", "allo-isolate", "anyhow", - "appflowy-local-ai", - "appflowy-plugin", "arc-swap", "base64 0.21.5", "bytes", @@ -2472,7 +2487,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "log", - "mcp_daemon", "md5", "notify", "pin-project", @@ -2545,8 +2559,8 @@ dependencies = [ name = "flowy-core" version = "0.1.0" dependencies = [ + "af-local-ai", "anyhow", - "appflowy-local-ai", "arc-swap", "base64 0.21.5", "bytes", @@ -6664,9 +6678,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -7498,22 +7512,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" dependencies = [ "backtrace", "bytes", "libc", - "mio 0.8.9", - "num_cpus", + "mio 1.0.3", "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2 0.5.5", "tokio-macros", "tracing", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -7528,9 +7541,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 843a939ae3..02df770eb1 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,5 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "52ad76f21f8f3a7b510dae029836b5fe86479e5a" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "52ad76f21f8f3a7b510dae029836b5fe86479e5a" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" } diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index 2fe8b724c9..22cecd3713 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -12,6 +12,7 @@ flowy-error = { path = "../flowy-error", features = [ "impl_from_dispatch_error", "impl_from_collab_folder", "impl_from_sqlite", + "impl_from_appflowy_cloud", ] } lib-dispatch = { workspace = true } tracing.workspace = true @@ -34,8 +35,8 @@ serde_json = { workspace = true } anyhow = "1.0.86" tokio-stream = "0.1.15" tokio-util = { workspace = true, features = ["full"] } -appflowy-local-ai = { version = "0.1.0", features = ["verbose"] } -appflowy-plugin = { version = "0.1.0" } +af-local-ai = { version = "0.1.0", features = ["verbose"] } +af-plugin = { version = "0.1.0" } reqwest = { version = "0.11.27", features = ["json"] } sha2 = "0.10.7" base64 = "0.21.5" @@ -50,7 +51,7 @@ collab-integrate.workspace = true [target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] notify = "6.1.1" -mcp_daemon = "0.2.1" +af-mcp = { version = "0.1.0" } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.55" diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 03a18dcdec..cb9030ea18 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -8,7 +8,7 @@ use crate::middleware::chat_service_mw::AICloudServiceMiddleware; use crate::persistence::{insert_chat, read_chat_metadata, ChatTable}; use std::collections::HashMap; -use appflowy_plugin::manager::PluginManager; +use af_plugin::manager::PluginManager; use dashmap::DashMap; use flowy_ai_pub::cloud::{AIModel, ChatCloudService, ChatSettings, UpdateChatParams}; use flowy_error::{FlowyError, FlowyResult}; diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 0e996228c6..fe65a1cc4c 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -1,4 +1,4 @@ -use appflowy_plugin::core::plugin::RunningState; +use af_plugin::core::plugin::RunningState; use std::collections::HashMap; use crate::local_ai::controller::LocalAISetting; diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index 046a1ea6b9..3a15eec576 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -6,7 +6,10 @@ mod chat; mod completion; pub mod entities; mod local_ai; -mod mcp; + +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +pub mod mcp; + mod middleware; pub mod notification; mod persistence; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 503345e206..59113a59ae 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -4,8 +4,8 @@ use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; +use af_plugin::manager::PluginManager; use anyhow::Error; -use appflowy_plugin::manager::PluginManager; use flowy_ai_pub::cloud::{ChatCloudService, ChatMessageMetadata, ContextLoader, LocalAIConfig}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; @@ -15,8 +15,8 @@ use std::collections::HashMap; use crate::local_ai::watch::is_plugin_ready; use crate::stream_message::StreamMessage; -use appflowy_local_ai::ollama_plugin::OllamaAIPlugin; -use appflowy_plugin::core::plugin::RunningState; +use af_local_ai::ollama_plugin::OllamaAIPlugin; +use af_plugin::core::plugin::RunningState; use arc_swap::ArcSwapOption; use futures_util::SinkExt; use lib_infra::util::get_operating_system; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index a162096f7d..3eddcf2039 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -11,7 +11,7 @@ use crate::local_ai::watch::{watch_offline_app, WatchContext}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; -use appflowy_local_ai::ollama_plugin::OllamaPluginConfig; +use af_local_ai::ollama_plugin::OllamaPluginConfig; use lib_infra::util::{get_operating_system, OperatingSystem}; use reqwest::Client; use serde::Deserialize; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs index 76eb01ea6f..fbe4157c8c 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs @@ -1,4 +1,4 @@ -use appflowy_plugin::error::PluginError; +use af_plugin::error::PluginError; use flowy_ai_pub::cloud::QuestionStreamValue; use flowy_error::FlowyError; diff --git a/frontend/rust-lib/flowy-ai/src/mcp/client.rs b/frontend/rust-lib/flowy-ai/src/mcp/client.rs deleted file mode 100644 index 8f03e75c31..0000000000 --- a/frontend/rust-lib/flowy-ai/src/mcp/client.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::mcp::util::map_mcp_error; -use flowy_error::{FlowyError, FlowyResult}; -use mcp_daemon::transport::{ClientStdioTransport, Transport}; -use mcp_daemon::types::Implementation; -use mcp_daemon::Client; - -pub struct MCPServerConfig { - server_cmd: String, - args: Vec, -} - -impl MCPServerConfig { - pub fn is_sse_server(&self) -> bool { - self.server_cmd.starts_with("http") - } -} - -// https://modelcontextprotocol.io/docs/concepts/tools -#[derive(Clone)] -pub struct MCPClient { - pub client: Client, - pub transport: ClientStdioTransport, -} - -impl MCPClient { - pub async fn initialize(&self) -> Result<(), FlowyError> { - self.transport.open().await.map_err(map_mcp_error)?; - self.client.start().await.map_err(map_mcp_error)?; - - let implementation = Implementation { - name: "test".to_string(), - version: "0.0.1".to_string(), - }; - self - .client - .initialize(implementation) - .map(|err| map_mcp_error)?; - Ok(()) - } - - pub async fn stop(&mut self) -> FlowyResult<()> { - self.transport.close().await.map_err(map_mcp_error)?; - Ok(()) - } -} diff --git a/frontend/rust-lib/flowy-ai/src/mcp/manager.rs b/frontend/rust-lib/flowy-ai/src/mcp/manager.rs index 18450f266d..9e40a51f68 100644 --- a/frontend/rust-lib/flowy-ai/src/mcp/manager.rs +++ b/frontend/rust-lib/flowy-ai/src/mcp/manager.rs @@ -1,29 +1,8 @@ -use crate::mcp::client::MCPClient; -use anyhow::Context; +use af_mcp::client::{MCPClient, MCPServerConfig}; +use af_mcp::entities::ToolsList; use dashmap::DashMap; -use flowy_error::{ErrorCode, FlowyError}; -use mcp_daemon::transport::{ - ClientHttpTransport, ClientStdioTransport, ServerStdioTransport, Transport, TransportError, -}; -use mcp_daemon::types::Implementation; -use mcp_daemon::Client; -use serde_json::Value; -use std::io::{BufRead, BufReader}; -use std::process::{Child, Command, Stdio}; +use flowy_error::FlowyError; use std::sync::Arc; -use std::thread; -use tracing::{debug, info}; - -pub struct MCPServerConfig { - server_cmd: String, - args: Vec, -} - -impl MCPServerConfig { - pub fn is_sse_server(&self) -> bool { - self.server_cmd.starts_with("http") - } -} pub struct MCPClientManager { stdio_clients: Arc>, @@ -37,7 +16,7 @@ impl MCPClientManager { } pub async fn connect_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { - let client = connect_to_stdio_server(&config.server_cmd, config.args.as_ref()).await?; + let client = MCPClient::new_stdio(config.clone()).await?; self.stdio_clients.insert(config.server_cmd, client.clone()); client.initialize().await?; Ok(()) @@ -50,20 +29,11 @@ impl MCPClientManager { } Ok(()) } -} -async fn connect_to_stdio_server(command: &str, args: &[&str]) -> Result { - info!( - "Connecting to running server with command: {} {}", - command, - args.join(" ") - ); - - let transport = ClientStdioTransport::new(command, args).map_err(map_mcp_error)?; - let client = Client::builder(transport.clone()).build(); - Ok(MCPClient { client, transport }) -} - -fn map_mcp_error(err: TransportError) -> FlowyError { - FlowyError::new(ErrorCode::MCPError, err.to_string()) + pub async fn tool_list(&self, server_cmd: &str) -> Option { + let client = self.stdio_clients.get(server_cmd)?; + let tools = client.list_tools().await.ok(); + tracing::trace!("{}: tool list: {:?}", server_cmd, tools); + tools + } } diff --git a/frontend/rust-lib/flowy-ai/src/mcp/mod.rs b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs index 1286a7cf46..8f73c8326c 100644 --- a/frontend/rust-lib/flowy-ai/src/mcp/mod.rs +++ b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs @@ -1,3 +1 @@ -mod client; mod manager; -mod util; diff --git a/frontend/rust-lib/flowy-ai/src/mcp/util.rs b/frontend/rust-lib/flowy-ai/src/mcp/util.rs deleted file mode 100644 index 1288e9baa6..0000000000 --- a/frontend/rust-lib/flowy-ai/src/mcp/util.rs +++ /dev/null @@ -1,6 +0,0 @@ -use flowy_error::{ErrorCode, FlowyError}; -use mcp_daemon::transport::TransportError; - -pub(crate) fn map_mcp_error(err: TransportError) -> FlowyError { - FlowyError::new(ErrorCode::MCPError, err.to_string()) -} diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 37bf5b5daf..477249a944 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -5,7 +5,7 @@ use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; use crate::persistence::{select_single_message, ChatMessageTable}; -use appflowy_plugin::error::PluginError; +use af_plugin::error::PluginError; use std::collections::HashMap; use flowy_ai_pub::cloud::{ diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 8c33996046..f535dd757c 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -38,7 +38,7 @@ flowy-storage-pub = { workspace = true } client-api.workspace = true flowy-ai = { workspace = true } flowy-ai-pub = { workspace = true } -appflowy-local-ai = { version = "0.1.0" } +af-local-ai = { version = "0.1.0" } tracing.workspace = true diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs index 328b90aa15..715b8207b8 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs @@ -1,4 +1,4 @@ -use appflowy_local_ai::ai_ops::{LocalAITranslateItem, LocalAITranslateRowData}; +use af_local_ai::ai_ops::{LocalAITranslateItem, LocalAITranslateRowData}; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; use flowy_ai::ai_manager::AIManager; diff --git a/frontend/rust-lib/flowy-core/src/log_filter.rs b/frontend/rust-lib/flowy-core/src/log_filter.rs index 63877862f0..6704ad0507 100644 --- a/frontend/rust-lib/flowy-core/src/log_filter.rs +++ b/frontend/rust-lib/flowy-core/src/log_filter.rs @@ -57,8 +57,8 @@ pub fn create_log_filter( filters.push(format!("lib_infra={}", level)); filters.push(format!("flowy_search={}", level)); filters.push(format!("flowy_chat={}", level)); - filters.push(format!("appflowy_local_ai={}", level)); - filters.push(format!("appflowy_plugin={}", level)); + filters.push(format!("af_local_ai={}", level)); + filters.push(format!("af_plugin={}", level)); filters.push(format!("flowy_ai={}", level)); filters.push(format!("flowy_storage={}", level)); // Enable the frontend logs. DO NOT DISABLE. diff --git a/frontend/scripts/tool/update_local_ai_rev.sh b/frontend/scripts/tool/update_local_ai_rev.sh index 83c5e67d80..af24e0ba9f 100755 --- a/frontend/scripts/tool/update_local_ai_rev.sh +++ b/frontend/scripts/tool/update_local_ai_rev.sh @@ -15,7 +15,7 @@ for dir in "${directories[@]}"; do pushd "$dir" > /dev/null # Define the crates to update - crates=("appflowy-local-ai" "appflowy-plugin") + crates=("af-local-ai" "af-plugin" "af-mcp") for crate in "${crates[@]}"; do sed -i.bak "/^${crate}[[:alnum:]-]*[[:space:]]*=/s/rev = \"[a-fA-F0-9]\{6,40\}\"/rev = \"$NEW_REV\"/g" Cargo.toml From a26ebbccc1a3ad3391fa45ac730606d782712b70 Mon Sep 17 00:00:00 2001 From: Morn Date: Thu, 27 Mar 2025 14:19:51 +0800 Subject: [PATCH 052/207] fix: toolbar launch review issues (#7631) * fix: keep the turn into menu within six-dot same as toolbar * fix: change some icon color within toolbar * fix: improve toolbar * chore: update editor dependency * fix: update editor dependency --- .../document/presentation/editor_page.dart | 16 +- .../option/turn_into_option_action.dart | 321 +++++++----------- .../ai/ai_writer_toolbar_item.dart | 9 +- .../base/toolbar_extension.dart | 14 +- .../desktop_floating_toolbar.dart | 4 +- .../link/link_create_menu.dart | 109 ++++++ .../desktop_toolbar/link/link_hover_menu.dart | 75 ++++ .../custom_format_toolbar_items.dart | 3 +- .../custom_link_toolbar_item.dart | 147 +------- .../custom_placeholder_toolbar_item.dart | 4 +- .../custom_text_align_toolbar_item.dart | 31 +- .../more_option_toolbar_item.dart | 131 ++++++- .../text_heading_toolbar_item.dart | 7 +- .../text_suggestions_toolbar_item.dart | 142 ++++++-- .../appflowy_flutter/lib/startup/startup.dart | 2 + .../appearance/desktop_appearance.dart | 11 +- .../flowy_infra/lib/theme_extension_v2.dart | 91 +++++ frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 19 files changed, 714 insertions(+), 409 deletions(-) create mode 100644 frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index d0d26c43e5..a6cff2ae07 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -22,6 +22,7 @@ import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -350,6 +351,8 @@ class _AppFlowyEditorPageState extends State final isViewDeleted = context.read().state.isDeleted; final isLocked = context.read()?.state.isLocked ?? false; + + final themeV2 = AFThemeExtensionV2.of(context); final editor = Directionality( textDirection: textDirection, child: AppFlowyEditor( @@ -428,7 +431,6 @@ class _AppFlowyEditorPageState extends State ), ); } - return Center( child: FloatingToolbar( floatingToolbarHeight: 40, @@ -436,12 +438,18 @@ class _AppFlowyEditorPageState extends State style: FloatingToolbarStyle( backgroundColor: Theme.of(context).cardColor, toolbarActiveColor: Color(0xffe0f8fd), - toolbarElevation: 10, ), items: toolbarItems, - decoration: ShapeDecoration( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), color: Theme.of(context).cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + boxShadow: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 24, + color: themeV2.shadow_medium, + ), + ], ), toolbarBuilder: (context, child, onDismiss, isMetricsChanged) => DesktopFloatingToolbar( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart index ffb238303e..c927fcf85f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -2,8 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys, quoteNode; @@ -149,214 +148,134 @@ class TurnIntoOptionMenu extends StatelessWidget { @override Widget build(BuildContext context) { + if (hasNonSupportedTypes) { + return buildItem( + pateItem, + textSuggestionItem, + context.read().editorState, + ); + } + + return _buildTurnIntoOptions(context, node); + } + + Widget _buildTurnIntoOptions(BuildContext context, Node node) { + final editorState = context.read().editorState; + SuggestionItem currentSuggestionItem = textSuggestionItem; + final List suggestionItems = suggestions.sublist(0, 4); + final List turnIntoItems = + suggestions.sublist(4, suggestions.length); + final textColor = Color(0xff99A1A8); + + void refreshSuggestions() { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) return; + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) return; + final nodeType = node.type; + SuggestionType? suggestionType; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) { + suggestionType = SuggestionType.h1; + } else if (level == 2) { + suggestionType = SuggestionType.h2; + } else if (level == 3) { + suggestionType = SuggestionType.h3; + } + } else if (nodeType == ToggleListBlockKeys.type) { + final level = node.attributes[ToggleListBlockKeys.level]; + if (level == null) { + suggestionType = SuggestionType.toggle; + } else if (level == 1) { + suggestionType = SuggestionType.toggleH1; + } else if (level == 2) { + suggestionType = SuggestionType.toggleH2; + } else if (level == 3) { + suggestionType = SuggestionType.toggleH3; + } + } else { + suggestionType = nodeType2SuggestionType[nodeType]; + } + if (suggestionType == null) return; + suggestionItems.clear(); + turnIntoItems.clear(); + for (final item in suggestions) { + if (item.type.group == suggestionType.group && + item.type != suggestionType) { + suggestionItems.add(item); + } else { + turnIntoItems.add(item); + } + } + currentSuggestionItem = + suggestions.where((item) => item.type == suggestionType).first; + } + + refreshSuggestions(); + return Column( mainAxisSize: MainAxisSize.min, - children: _buildTurnIntoOptions(context, node), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildSubTitle( + LocaleKeys.document_toolbar_suggestions.tr(), + textColor, + ), + ...List.generate(suggestionItems.length, (index) { + return buildItem( + suggestionItems[index], + currentSuggestionItem, + editorState, + ); + }), + buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), + ...List.generate(turnIntoItems.length, (index) { + return buildItem( + turnIntoItems[index], + currentSuggestionItem, + editorState, + ); + }), + ], ); } - List _buildTurnIntoOptions(BuildContext context, Node node) { - final children = []; - - if (hasNonSupportedTypes) { - return children - ..add( - _TurnInfoButton( - type: SubPageBlockKeys.type, - node: node, - ), - ); - } - - for (final type in EditorOptionActionType.turnInto.supportTypes) { - if (type == ToggleListBlockKeys.type) { - // toggle list block and toggle heading block are the same type, - // but they have different attributes. - - // toggle list block - children.add( - _TurnInfoButton( - type: type, - node: node, - ), - ); - - // toggle heading block - for (final i in [1, 2, 3]) { - children.add( - _TurnInfoButton( - type: type, - node: node, - level: i, - ), - ); - } - } else if (type != HeadingBlockKeys.type) { - children.add( - _TurnInfoButton( - type: type, - node: node, - ), - ); - } else { - for (final i in [1, 2, 3]) { - children.add( - _TurnInfoButton( - type: type, - node: node, - level: i, - ), - ); - } - } - } - - return children; - } -} - -class _TurnInfoButton extends StatelessWidget { - const _TurnInfoButton({ - required this.type, - required this.node, - this.level, - }); - - final String type; - final Node node; - final int? level; - - @override - Widget build(BuildContext context) { - final name = _buildLocalization(type, level: level); - final leftIcon = _buildLeftIcon(type, level: level); - final rightIcon = _buildRightIcon(type, node, level: level); - - return HoverButton( - name: name, - leftIcon: FlowySvg(leftIcon), - rightIcon: rightIcon, - itemHeight: ActionListSizes.itemHeight, - onTap: () => BlockActionOptionCubit.turnIntoBlock( - type, - node, - context.read().editorState, - level: level, - currentViewId: getIt().latestOpenView?.id, + Widget buildSubTitle(String text, Color color) { + return Container( + height: 32, + margin: EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.semibold( + text, + color: color, + figmaLineHeight: 16, + ), ), ); } - Widget? _buildRightIcon(String type, Node node, {int? level}) { - if (type != node.type) { - return null; - } - - if (node.type == HeadingBlockKeys.type) { - final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1; - if (level != nodeLevel) { - return null; - } - } - - if (node.type == ToggleListBlockKeys.type) { - final nodeLevel = node.attributes[ToggleListBlockKeys.level]; - if (level != nodeLevel) { - return null; - } - } - - return const FlowySvg( - FlowySvgs.workspace_selected_s, - blendMode: null, + Widget buildItem( + SuggestionItem item, + SuggestionItem currentSuggestionItem, + EditorState state, + ) { + final isSelected = item.type == currentSuggestionItem.type; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(item.svg), + iconPadding: 12, + text: FlowyText( + item.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () => item.onTap.call(state, false), + ), ); } - - FlowySvgData _buildLeftIcon(String type, {int? level}) { - if (type == ParagraphBlockKeys.type) { - return FlowySvgs.type_text_m; - } else if (type == HeadingBlockKeys.type) { - switch (level) { - case 1: - return FlowySvgs.type_h1_m; - case 2: - return FlowySvgs.type_h2_m; - case 3: - return FlowySvgs.type_h3_m; - default: - return FlowySvgs.type_text_m; - } - } else if (type == QuoteBlockKeys.type) { - return FlowySvgs.type_quote_m; - } else if (type == BulletedListBlockKeys.type) { - return FlowySvgs.type_bulleted_list_m; - } else if (type == NumberedListBlockKeys.type) { - return FlowySvgs.type_numbered_list_m; - } else if (type == TodoListBlockKeys.type) { - return FlowySvgs.type_todo_m; - } else if (type == CalloutBlockKeys.type) { - return FlowySvgs.type_callout_m; - } else if (type == SubPageBlockKeys.type) { - return FlowySvgs.icon_document_s; - } else if (type == ToggleListBlockKeys.type) { - switch (level) { - case 1: - return FlowySvgs.type_toggle_h1_m; - case 2: - return FlowySvgs.type_toggle_h2_m; - case 3: - return FlowySvgs.type_toggle_h3_m; - default: - return FlowySvgs.type_toggle_list_m; - } - } - - throw UnimplementedError('Unsupported block type: $type'); - } - - String _buildLocalization( - String type, { - int? level, - }) { - switch (type) { - case ParagraphBlockKeys.type: - return LocaleKeys.document_slashMenu_name_text.tr(); - case HeadingBlockKeys.type: - switch (level) { - case 1: - return LocaleKeys.document_slashMenu_name_heading1.tr(); - case 2: - return LocaleKeys.document_slashMenu_name_heading2.tr(); - case 3: - return LocaleKeys.document_slashMenu_name_heading3.tr(); - default: - return LocaleKeys.document_slashMenu_name_text.tr(); - } - case QuoteBlockKeys.type: - return LocaleKeys.document_slashMenu_name_quote.tr(); - case BulletedListBlockKeys.type: - return LocaleKeys.editor_bulletedListShortForm.tr(); - case NumberedListBlockKeys.type: - return LocaleKeys.editor_numberedListShortForm.tr(); - case TodoListBlockKeys.type: - return LocaleKeys.editor_checkbox.tr(); - case CalloutBlockKeys.type: - return LocaleKeys.document_slashMenu_name_callout.tr(); - case SubPageBlockKeys.type: - return LocaleKeys.editor_page.tr(); - case ToggleListBlockKeys.type: - switch (level) { - case 1: - return LocaleKeys.editor_toggleHeading1ShortForm.tr(); - case 2: - return LocaleKeys.editor_toggleHeading2ShortForm.tr(); - case 3: - return LocaleKeys.editor_toggleHeading3ShortForm.tr(); - default: - return LocaleKeys.editor_toggleListShortForm.tr(); - } - } - - throw UnimplementedError('Unsupported block type: $type'); - } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index dc20f2363f..25502fd975 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -118,7 +119,7 @@ class _AiWriterToolbarActionListState extends State { } Widget buildChild(BuildContext context) { - final iconColor = Theme.of(context).iconTheme.color; + final themeV2 = AFThemeExtensionV2.of(context); final child = FlowyIconButton( width: 48, height: 32, @@ -130,13 +131,13 @@ class _AiWriterToolbarActionListState extends State { FlowySvg( FlowySvgs.toolbar_ai_writer_m, size: Size.square(20), - color: iconColor, + color: themeV2.icon_primary, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: iconColor, + color: themeV2.icon_tertiary, ), ], ), @@ -187,7 +188,7 @@ class ImproveWritingButton extends StatelessWidget { icon: FlowySvg( FlowySvgs.toolbar_ai_improve_writing_m, size: Size.square(20.0), - color: Theme.of(context).iconTheme.color, + color: AFThemeExtensionV2.of(context).icon_primary, ), onPressed: () { if (_isAIEnabled(editorState)) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart index 73241382dd..77245a9f95 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; @@ -35,9 +37,7 @@ bool onlyShowInSingleTextTypeSelectionAndExcludeTable( notShowInTable(editorState); } -bool enableSuggestions( - EditorState editorState, -) { +bool enableSuggestions(EditorState editorState) { final selection = editorState.selection; if (selection == null || !selection.isSingle) { return false; @@ -46,10 +46,18 @@ bool enableSuggestions( if (node == null) { return false; } + if (isNarrowWindow(editorState)) return false; + return (node.delta != null && suggestionsItemTypes.contains(node.type)) && notShowInTable(editorState); } +bool isNarrowWindow(EditorState editorState) { + final editorSize = editorState.renderBox?.size ?? Size.zero; + if (editorSize.width < 650) return true; + return false; +} + final Set suggestionsItemTypes = { ...toolbarItemWhiteList, ToggleListBlockKeys.type, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart index 076358034c..daa066d50c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -61,10 +62,11 @@ class _DesktopFloatingToolbarState extends State { ) { const toolbarHeight = 40, topLimit = toolbarHeight + 8; final bool isLongMenu = onlyShowInSingleSelectionAndTextType(editorState); - final menuWidth = isLongMenu ? 650.0 : 420.0; final editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final editorSize = editorState.renderBox?.size ?? Size.zero; + final menuWidth = + isLongMenu ? (isNarrowWindow(editorState) ? 490.0 : 660.0) : 420.0; final editorRect = editorOffset & editorSize; final left = rect.left, leftStart = 50; final top = diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart index 4d3ef71cce..2f5edc195d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -148,6 +148,115 @@ class _LinkCreateMenuState extends State { } } +void showLinkCreateMenu( + BuildContext context, + EditorState editorState, + Selection selection, +) { + final (left, top, right, bottom, alignment) = _getPosition(editorState); + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: top, + bottom: bottom, + left: left, + right: right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkCreateMenu( + alignment: alignment, + editorState: editorState, + onSubmitted: (link, isPage) async { + await editorState.formatDelta(selection, { + BuiltInAttributeKey.href: link, + kIsPageLink: isPage, + }); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +// get a proper position for link menu +( + double? left, + double? top, + double? right, + double? bottom, + LinkMenuAlignment alignment, +) _getPosition( + EditorState editorState, +) { + final rect = editorState.selectionRects().first; + const menuHeight = 222.0, menuWidth = 320.0; + + double? left, right, top, bottom; + LinkMenuAlignment alignment = LinkMenuAlignment.topLeft; + final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero), + editorSize = editorState.renderBox!.size; + final editorBottom = editorSize.height + editorOffset.dy, + editorRight = editorSize.width + editorOffset.dx; + final overflowBottom = rect.bottom + menuHeight > editorBottom, + overflowTop = rect.top - menuHeight < 0, + overflowLeft = rect.left - menuWidth < 0, + overflowRight = rect.right + menuWidth > editorRight; + + if (overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else if (overflowBottom && !overflowTop) { + /// show at top + bottom = editorBottom - rect.top; + } else if (!overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else { + top = 0; + } + + if (overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else if (overflowRight && !overflowLeft) { + /// show at left + right = editorRight - rect.right; + } else if (!overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else { + left = 0; + } + + if (left != null && top != null) { + alignment = LinkMenuAlignment.bottomRight; + } else if (left != null && bottom != null) { + alignment = LinkMenuAlignment.topRight; + } else if (right != null && top != null) { + alignment = LinkMenuAlignment.bottomLeft; + } else if (right != null && bottom != null) { + alignment = LinkMenuAlignment.topLeft; + } + + return (left, top, right, bottom, alignment); +} + ShapeDecoration buildToolbarLinkDecoration(BuildContext context) => ShapeDecoration( color: Theme.of(context).cardColor, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart index e5950b5e79..e47386699b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -57,10 +57,19 @@ class _LinkHoverTriggerState extends State { Attributes get attribute => widget.attribute; + HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection); + + @override + void initState() { + super.initState(); + getIt()._add(triggerKey, showLinkHoverMenu); + } + @override void dispose() { hoverMenuController.close(); editMenuController.close(); + getIt()._remove(triggerKey, showLinkHoverMenu); super.dispose(); } @@ -217,6 +226,31 @@ class _LinkHoverTriggerState extends State { .setData(ClipboardServiceData(plainText: href)); hoverMenuController.close(); } + + void removeLink( + EditorState editorState, + Selection selection, + bool isHref, + ) { + if (!isHref) return; + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..formatText( + node, + index, + length, + { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); + } } class LinkHoverMenu extends StatelessWidget { @@ -320,3 +354,44 @@ class LinkHoverMenu extends StatelessWidget { ); } } + +class HoverTriggerKey { + HoverTriggerKey(this.nodeId, this.selection); + + final String nodeId; + final Selection selection; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HoverTriggerKey && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + selection == other.selection; + + @override + int get hashCode => nodeId.hashCode ^ selection.hashCode; +} + +class LinkHoverTriggers { + final Map> _map = {}; + + void _add(HoverTriggerKey key, VoidCallback callback) { + final callbacks = _map[key] ?? {}; + callbacks.add(callback); + _map[key] = callbacks; + } + + void _remove(HoverTriggerKey key, VoidCallback callback) { + final callbacks = _map[key] ?? {}; + callbacks.remove(callback); + _map[key] = callbacks; + } + + void call(HoverTriggerKey key) { + final callbacks = _map[key] ?? {}; + for (final callback in callbacks) { + callback.call(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart index a171c8ca4d..d665230a59 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart @@ -6,6 +6,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; @@ -79,7 +80,7 @@ class _FormatToolbarItem extends ToolbarItem { size: Size.square(20.0), color: (isDark && isHighlight) ? Color(0xFF282E3A) - : Theme.of(context).iconTheme.color, + : AFThemeExtensionV2.of(context).icon_primary, ), onPressed: () => editorState.toggleAttribute( name, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index 355e7b3b7a..fb241d5309 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -1,7 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; @@ -14,7 +17,8 @@ const kIsPageLink = 'is_page_link'; final customLinkItem = ToolbarItem( id: ToolbarId.link.id, group: 4, - isActive: onlyShowInSingleSelectionAndTextType, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); @@ -42,9 +46,11 @@ final customLinkItem = ToolbarItem( onPressed: () { toolbarCubit?.dismiss(); if (isHref) { - removeLink(editorState, selection, isHref); + getIt().call( + HoverTriggerKey(nodes.first.id, selection), + ); } else { - _showLinkMenu(context, editorState, selection, isHref); + showLinkCreateMenu(context, editorState, selection); } }, ); @@ -62,78 +68,6 @@ final customLinkItem = ToolbarItem( }, ); -void removeLink( - EditorState editorState, - Selection selection, - bool isHref, -) { - if (!isHref) return; - final node = editorState.getNodeAtPath(selection.end.path); - if (node == null) { - return; - } - final index = selection.normalized.startIndex; - final length = selection.length; - final transaction = editorState.transaction - ..formatText( - node, - index, - length, - { - BuiltInAttributeKey.href: null, - kIsPageLink: null, - }, - ); - editorState.apply(transaction); -} - -void _showLinkMenu( - BuildContext context, - EditorState editorState, - Selection selection, - bool isHref, -) { - final (left, top, right, bottom, alignment) = _getPosition(editorState); - - final node = editorState.getNodeAtPath(selection.end.path); - if (node == null) { - return; - } - - OverlayEntry? overlay; - - void dismissOverlay() { - keepEditorFocusNotifier.decrease(); - overlay?.remove(); - overlay = null; - } - - keepEditorFocusNotifier.increase(); - overlay = FullScreenOverlayEntry( - top: top, - bottom: bottom, - left: left, - right: right, - dismissCallback: () => keepEditorFocusNotifier.decrease(), - builder: (context) { - return LinkCreateMenu( - alignment: alignment, - editorState: editorState, - onSubmitted: (link, isPage) async { - await editorState.formatDelta(selection, { - BuiltInAttributeKey.href: link, - kIsPageLink: isPage, - }); - dismissOverlay(); - }, - onDismiss: dismissOverlay, - ); - }, - ).build(); - - Overlay.of(context, rootOverlay: true).insert(overlay!); -} - extension AttributeExtension on Attributes { bool get isPage { if (this[kIsPageLink] is bool) { @@ -143,69 +77,6 @@ extension AttributeExtension on Attributes { } } -// get a proper position for link menu -( - double? left, - double? top, - double? right, - double? bottom, - LinkMenuAlignment alignment, -) _getPosition( - EditorState editorState, -) { - final rect = editorState.selectionRects().first; - const menuHeight = 222.0, menuWidth = 320.0; - - double? left, right, top, bottom; - LinkMenuAlignment alignment = LinkMenuAlignment.topLeft; - final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero), - editorSize = editorState.renderBox!.size; - final editorBottom = editorSize.height + editorOffset.dy, - editorRight = editorSize.width + editorOffset.dx; - final overflowBottom = rect.bottom + menuHeight > editorBottom, - overflowTop = rect.top - menuHeight < 0, - overflowLeft = rect.left - menuWidth < 0, - overflowRight = rect.right + menuWidth > editorRight; - - if (overflowTop && !overflowBottom) { - /// show at bottom - top = rect.bottom; - } else if (overflowBottom && !overflowTop) { - /// show at top - bottom = editorBottom - rect.top; - } else if (!overflowTop && !overflowBottom) { - /// show at bottom - top = rect.bottom; - } else { - top = 0; - } - - if (overflowLeft && !overflowRight) { - /// show at right - left = rect.left; - } else if (overflowRight && !overflowLeft) { - /// show at left - right = editorRight - rect.right; - } else if (!overflowLeft && !overflowRight) { - /// show at right - left = rect.left; - } else { - left = 0; - } - - if (left != null && top != null) { - alignment = LinkMenuAlignment.bottomRight; - } else if (left != null && bottom != null) { - alignment = LinkMenuAlignment.topRight; - } else if (right != null && top != null) { - alignment = LinkMenuAlignment.bottomLeft; - } else if (right != null && bottom != null) { - alignment = LinkMenuAlignment.topLeft; - } - - return (left, top, right, bottom, alignment); -} - enum LinkMenuAlignment { topLeft, topRight, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart index 898f442891..e087731c82 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart @@ -1,5 +1,6 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -43,5 +44,6 @@ ToolbarItem group1PaddingItem = ToolbarItem group4PaddingItem = buildPaddingPlaceholderItem( 4, - isActive: onlyShowInSingleSelectionAndTextType, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart index 74198cb46b..d751728526 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart @@ -1,8 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,7 +13,8 @@ import 'toolbar_id_enum.dart'; final ToolbarItem customTextAlignItem = ToolbarItem( id: ToolbarId.textAlign.id, group: 4, - isActive: onlyShowInSingleSelectionAndTextType, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), builder: ( context, editorState, @@ -33,18 +36,29 @@ class TextAlignActionList extends StatefulWidget { required this.editorState, required this.highlightColor, this.tooltipBuilder, + this.child, + this.onSelect, + this.popoverController, + this.popoverDirection = PopoverDirection.bottomWithLeftAligned, + this.showOffset = const Offset(0, 2), }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; final Color highlightColor; + final Widget? child; + final VoidCallback? onSelect; + final PopoverController? popoverController; + final PopoverDirection popoverDirection; + final Offset showOffset; @override State createState() => _TextAlignActionListState(); } class _TextAlignActionListState extends State { - final popoverController = PopoverController(); + late PopoverController popoverController = + widget.popoverController ?? PopoverController(); bool isSelected = false; @@ -62,8 +76,8 @@ class _TextAlignActionListState extends State { Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, - direction: PopoverDirection.bottomWithLeftAligned, - offset: const Offset(0, 2.0), + direction: widget.popoverDirection, + offset: widget.showOffset, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { @@ -72,7 +86,7 @@ class _TextAlignActionListState extends State { keepEditorFocusNotifier.decrease(); }, popupBuilder: (context) => buildPopoverContent(), - child: buildChild(context), + child: widget.child ?? buildChild(context), ); } @@ -82,7 +96,7 @@ class _TextAlignActionListState extends State { } Widget buildChild(BuildContext context) { - final iconColor = Theme.of(context).iconTheme.color; + final themeV2 = AFThemeExtensionV2.of(context); final child = FlowyIconButton( width: 48, height: 32, @@ -94,13 +108,13 @@ class _TextAlignActionListState extends State { FlowySvg( FlowySvgs.toolbar_alignment_m, size: Size.square(20), - color: iconColor, + color: themeV2.icon_primary, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: iconColor, + color: themeV2.icon_tertiary, ), ], ), @@ -149,6 +163,7 @@ class _TextAlignActionListState extends State { isHighlight ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { command.onAlignChanged(editorState); + widget.onSelect?.call(); popoverController.close(); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart index f844080a98..fde59eee51 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart @@ -1,8 +1,12 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports @@ -10,6 +14,10 @@ import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_u 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 'custom_text_align_toolbar_item.dart'; +import 'text_suggestions_toolbar_item.dart'; const _kMoreOptionItemId = 'editor.more_option'; const kFontToolbarItemId = 'editor.font'; @@ -54,7 +62,9 @@ class MoreOptionActionList extends StatefulWidget { class _MoreOptionActionListState extends State { final popoverController = PopoverController(); - final fontPopoverController = PopoverController(); + PopoverController fontPopoverController = PopoverController(); + PopoverController suggestionsPopoverController = PopoverController(); + PopoverController textAlignPopoverController = PopoverController(); bool isSelected = false; @@ -62,11 +72,15 @@ class _MoreOptionActionListState extends State { Color get highlightColor => widget.highlightColor; + MoreOptionCommand? tappedCommand; + @override void dispose() { super.dispose(); popoverController.close(); fontPopoverController.close(); + suggestionsPopoverController.close(); + textAlignPopoverController.close(); } @override @@ -154,11 +168,17 @@ class _MoreOptionActionListState extends State { Widget buildPopoverContent() { final showFormula = onlyShowInSingleSelectionAndTextType(editorState); const fontColor = Color(0xff99A1A8); + final isNarrow = isNarrowWindow(editorState); return MouseRegion( child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(4.0), children: [ + if (isNarrow) ...[ + buildTurnIntoSelector(), + buildCommandItem(MoreOptionCommand.link), + buildTextAlignSelector(), + ], buildFontSelector(), buildCommandItem( MoreOptionCommand.strikethrough, @@ -197,6 +217,7 @@ class _MoreOptionActionListState extends State { Widget buildCommandItem( MoreOptionCommand command, { Widget? rightIcon, + VoidCallback? onTap, }) { final isFontCommand = command == MoreOptionCommand.font; return SizedBox( @@ -212,12 +233,14 @@ class _MoreOptionActionListState extends State { figmaLineHeight: 20, fontWeight: FontWeight.w400, ), - onTap: () { - command.onExecute(editorState); - if (command != MoreOptionCommand.font) { - popoverController.close(); - } - }, + onTap: onTap ?? + () { + command.onExecute(editorState, context); + hideOtherPopovers(command); + if (command != MoreOptionCommand.font) { + popoverController.close(); + } + }, ), ); } @@ -255,9 +278,73 @@ class _MoreOptionActionListState extends State { ), ); } + + Widget buildTurnIntoSelector() { + final selectionRects = editorState.selectionRects(); + double height = -6; + if (selectionRects.isNotEmpty) height = selectionRects.first.height; + return SuggestionsActionList( + editorState: editorState, + popoverController: suggestionsPopoverController, + popoverDirection: PopoverDirection.leftWithTopAligned, + showOffset: Offset(-8, height), + onSelect: () => context.read()?.dismiss(), + child: buildCommandItem( + MoreOptionCommand.suggestions, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + onTap: () { + if (tappedCommand == MoreOptionCommand.suggestions) return; + hideOtherPopovers(MoreOptionCommand.suggestions); + keepEditorFocusNotifier.increase(); + suggestionsPopoverController.show(); + }, + ), + ); + } + + Widget buildTextAlignSelector() { + return TextAlignActionList( + editorState: editorState, + popoverController: textAlignPopoverController, + popoverDirection: PopoverDirection.leftWithTopAligned, + showOffset: Offset(-8, 0), + onSelect: () => context.read()?.dismiss(), + highlightColor: highlightColor, + child: buildCommandItem( + MoreOptionCommand.textAlign, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + onTap: () { + if (tappedCommand == MoreOptionCommand.textAlign) return; + hideOtherPopovers(MoreOptionCommand.textAlign); + keepEditorFocusNotifier.increase(); + textAlignPopoverController.show(); + }, + ), + ); + } + + void hideOtherPopovers(MoreOptionCommand currentCommand) { + if (tappedCommand == currentCommand) return; + if (tappedCommand == MoreOptionCommand.font) { + fontPopoverController.close(); + fontPopoverController = PopoverController(); + } else if (tappedCommand == MoreOptionCommand.suggestions) { + suggestionsPopoverController.close(); + suggestionsPopoverController = PopoverController(); + } else if (tappedCommand == MoreOptionCommand.textAlign) { + textAlignPopoverController.close(); + textAlignPopoverController = PopoverController(); + } + tappedCommand = currentCommand; + } } enum MoreOptionCommand { + suggestions(FlowySvgs.turninto_s), + link(FlowySvgs.toolbar_link_m), + textAlign( + FlowySvgs.toolbar_alignment_m, + ), font(FlowySvgs.type_font_m), strikethrough(FlowySvgs.type_strikethrough_m), formula(FlowySvgs.type_formula_m); @@ -268,6 +355,12 @@ enum MoreOptionCommand { String get title { switch (this) { + case suggestions: + return LocaleKeys.document_toolbar_turnInto.tr(); + case link: + return LocaleKeys.document_toolbar_link.tr(); + case textAlign: + return LocaleKeys.button_align.tr(); case font: return LocaleKeys.document_toolbar_font.tr(); case strikethrough: @@ -277,14 +370,26 @@ enum MoreOptionCommand { } } - Future onExecute(EditorState editorState) async { - if (this == strikethrough) { + Future onExecute(EditorState editorState, BuildContext context) async { + final selection = editorState.selection!; + if (this == link) { + final nodes = editorState.getNodesInSelection(selection); + final isHref = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[AppFlowyRichTextKeys.href] != null, + ); + }); + context.read()?.dismiss(); + if (isHref) { + getIt().call( + HoverTriggerKey(nodes.first.id, selection), + ); + } else { + showLinkCreateMenu(context, editorState, selection); + } + } else if (this == strikethrough) { await editorState.toggleAttribute(name); } else if (this == formula) { - final selection = editorState.selection; - if (selection == null || selection.isCollapsed) { - return; - } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart index 4367ddb489..625fedff79 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -77,7 +78,7 @@ class _TextHeadingActionListState extends State { } Widget buildChild(BuildContext context) { - final iconColor = Theme.of(context).iconTheme.color; + final themeV2 = AFThemeExtensionV2.of(context); final child = FlowyIconButton( width: 48, height: 32, @@ -89,13 +90,13 @@ class _TextHeadingActionListState extends State { FlowySvg( FlowySvgs.toolbar_text_format_m, size: Size.square(20), - color: iconColor, + color: themeV2.icon_primary, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: iconColor, + color: themeV2.icon_tertiary, ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart index 97245297b5..c7f3b513f3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -1,14 +1,16 @@ import 'dart:collection'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; @@ -45,17 +47,28 @@ class SuggestionsActionList extends StatefulWidget { super.key, required this.editorState, this.tooltipBuilder, + this.child, + this.onSelect, + this.popoverController, + this.popoverDirection = PopoverDirection.bottomWithLeftAligned, + this.showOffset = const Offset(0, 2), }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; + final Widget? child; + final VoidCallback? onSelect; + final PopoverController? popoverController; + final PopoverDirection popoverDirection; + final Offset showOffset; @override State createState() => _SuggestionsActionListState(); } class _SuggestionsActionListState extends State { - final popoverController = PopoverController(); + late PopoverController popoverController = + widget.popoverController ?? PopoverController(); bool isSelected = false; @@ -83,8 +96,8 @@ class _SuggestionsActionListState extends State { Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, - direction: PopoverDirection.bottomWithLeftAligned, - offset: const Offset(0, 2.0), + direction: widget.popoverDirection, + offset: widget.showOffset, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { @@ -94,7 +107,7 @@ class _SuggestionsActionListState extends State { }, constraints: const BoxConstraints(maxWidth: 240, maxHeight: 400), popupBuilder: (context) => buildPopoverContent(context), - child: buildChild(context), + child: widget.child ?? buildChild(context), ); } @@ -104,7 +117,8 @@ class _SuggestionsActionListState extends State { } Widget buildChild(BuildContext context) { - final iconColor = Theme.of(context).iconTheme.color; + final themeV2 = AFThemeExtensionV2.of(context); + final child = FlowyHover( isSelected: () => isSelected, style: HoverStyle( @@ -147,7 +161,7 @@ class _SuggestionsActionListState extends State { FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: iconColor, + color: themeV2.icon_tertiary, ), ], ), @@ -206,7 +220,8 @@ class _SuggestionsActionListState extends State { ), rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { - item.onTap(widget.editorState); + item.onTap(widget.editorState, true); + widget.onSelect?.call(); popoverController.close(); }, ), @@ -289,10 +304,10 @@ class SuggestionItem { final SuggestionType type; final String title; final FlowySvgData svg; - final ValueChanged onTap; + final Function(EditorState state, bool keepSelection) onTap; } -enum SuggestionGroup { textHeading, list, toggle, quote } +enum SuggestionGroup { textHeading, list, toggle, quote, page } enum SuggestionType { text(SuggestionGroup.textHeading), @@ -307,7 +322,8 @@ enum SuggestionType { toggleH2(SuggestionGroup.toggle), toggleH3(SuggestionGroup.toggle), callOut(SuggestionGroup.quote), - quote(SuggestionGroup.quote); + quote(SuggestionGroup.quote), + page(SuggestionGroup.page); const SuggestionType(this.group); @@ -318,94 +334,166 @@ final textSuggestionItem = SuggestionItem( type: SuggestionType.text, title: AppFlowyEditorL10n.current.text, svg: FlowySvgs.type_text_m, - onTap: (state) => formatNodeToText(state), + onTap: (state, _) => formatNodeToText(state), ); final h1SuggestionItem = SuggestionItem( type: SuggestionType.h1, title: LocaleKeys.document_toolbar_h1.tr(), svg: FlowySvgs.type_h1_m, - onTap: (state) => _turnInto(state, HeadingBlockKeys.type, level: 1), + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 1, + keepSelection: keepSelection, + ), ); final h2SuggestionItem = SuggestionItem( type: SuggestionType.h2, title: LocaleKeys.document_toolbar_h2.tr(), svg: FlowySvgs.type_h2_m, - onTap: (state) => _turnInto(state, HeadingBlockKeys.type, level: 2), + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 2, + keepSelection: keepSelection, + ), ); final h3SuggestionItem = SuggestionItem( type: SuggestionType.h3, title: LocaleKeys.document_toolbar_h3.tr(), svg: FlowySvgs.type_h3_m, - onTap: (state) => _turnInto(state, HeadingBlockKeys.type, level: 3), + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 3, + keepSelection: keepSelection, + ), ); final checkboxSuggestionItem = SuggestionItem( type: SuggestionType.checkbox, title: LocaleKeys.editor_checkbox.tr(), svg: FlowySvgs.type_todo_m, - onTap: (state) => _turnInto(state, TodoListBlockKeys.type), + onTap: (state, keepSelection) => _turnInto( + state, + TodoListBlockKeys.type, + keepSelection: keepSelection, + ), ); final bulletedSuggestionItem = SuggestionItem( type: SuggestionType.bulleted, title: LocaleKeys.editor_bulletedListShortForm.tr(), svg: FlowySvgs.type_bulleted_list_m, - onTap: (state) => _turnInto(state, BulletedListBlockKeys.type), + onTap: (state, keepSelection) => _turnInto( + state, + BulletedListBlockKeys.type, + keepSelection: keepSelection, + ), ); final numberedSuggestionItem = SuggestionItem( type: SuggestionType.numbered, title: LocaleKeys.editor_numberedListShortForm.tr(), svg: FlowySvgs.type_numbered_list_m, - onTap: (state) => _turnInto(state, NumberedListBlockKeys.type), + onTap: (state, keepSelection) => _turnInto( + state, + NumberedListBlockKeys.type, + keepSelection: keepSelection, + ), ); final toggleSuggestionItem = SuggestionItem( type: SuggestionType.toggle, title: LocaleKeys.editor_toggleListShortForm.tr(), svg: FlowySvgs.type_toggle_list_m, - onTap: (state) => _turnInto(state, ToggleListBlockKeys.type), + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + keepSelection: keepSelection, + ), ); final toggleH1SuggestionItem = SuggestionItem( type: SuggestionType.toggleH1, title: LocaleKeys.editor_toggleHeading1ShortForm.tr(), svg: FlowySvgs.type_toggle_h1_m, - onTap: (state) => _turnInto(state, ToggleListBlockKeys.type, level: 1), + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 1, + keepSelection: keepSelection, + ), ); final toggleH2SuggestionItem = SuggestionItem( type: SuggestionType.toggleH2, title: LocaleKeys.editor_toggleHeading2ShortForm.tr(), svg: FlowySvgs.type_toggle_h2_m, - onTap: (state) => _turnInto(state, ToggleListBlockKeys.type, level: 2), + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 2, + keepSelection: keepSelection, + ), ); final toggleH3SuggestionItem = SuggestionItem( type: SuggestionType.toggleH3, title: LocaleKeys.editor_toggleHeading3ShortForm.tr(), svg: FlowySvgs.type_toggle_h3_m, - onTap: (state) => _turnInto(state, ToggleListBlockKeys.type, level: 3), + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 3, + keepSelection: keepSelection, + ), ); final callOutSuggestionItem = SuggestionItem( type: SuggestionType.callOut, title: LocaleKeys.document_plugins_callout.tr(), svg: FlowySvgs.type_callout_m, - onTap: (state) => _turnInto(state, CalloutBlockKeys.type), + onTap: (state, keepSelection) => _turnInto( + state, + CalloutBlockKeys.type, + keepSelection: keepSelection, + ), ); final quoteSuggestionItem = SuggestionItem( type: SuggestionType.quote, title: LocaleKeys.editor_quote.tr(), svg: FlowySvgs.type_quote_m, - onTap: (state) => _turnInto(state, QuoteBlockKeys.type), + onTap: (state, keepSelection) => _turnInto( + state, + QuoteBlockKeys.type, + keepSelection: keepSelection, + ), ); -Future _turnInto(EditorState state, String type, {int? level}) async { +final pateItem = SuggestionItem( + type: SuggestionType.page, + title: LocaleKeys.editor_page.tr(), + svg: FlowySvgs.icon_document_s, + onTap: (state, keepSelection) => _turnInto( + state, + SubPageBlockKeys.type, + viewId: getIt().latestOpenView?.id, + keepSelection: keepSelection, + ), +); + +Future _turnInto( + EditorState state, + String type, { + int? level, + String? viewId, + bool keepSelection = true, +}) async { final selection = state.selection!; final node = state.getNodeAtPath(selection.start.path)!; await BlockActionOptionCubit.turnIntoBlock( @@ -413,7 +501,8 @@ Future _turnInto(EditorState state, String type, {int? level}) async { node, state, level: level, - keepSelection: true, + currentViewId: viewId, + keepSelection: keepSelection, ); } @@ -431,6 +520,7 @@ final suggestions = UnmodifiableListView([ toggleH3SuggestionItem, callOutSuggestionItem, quoteSuggestionItem, + pateItem, ]); final nodeType2SuggestionType = UnmodifiableMapView({ diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 2e9b2c9afd..bf5dcfdafc 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; @@ -185,6 +186,7 @@ Future initGetIt( ); getIt.registerSingleton(PluginSandbox()); getIt.registerSingleton(ViewExpanderRegistry()); + getIt.registerSingleton(LinkHoverTriggers()); await DependencyResolver.resolve(getIt, mode); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart index 0b5c0c5f98..568fd76db0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -2,6 +2,7 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; class DesktopAppearance extends BaseAppearance { @@ -14,9 +15,8 @@ class DesktopAppearance extends BaseAppearance { ) { assert(codeFontFamily.isNotEmpty); - final theme = brightness == Brightness.light - ? appTheme.lightTheme - : appTheme.darkTheme; + final isLight = brightness == Brightness.light; + final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; final colorScheme = ColorScheme( brightness: brightness, @@ -152,6 +152,11 @@ class DesktopAppearance extends BaseAppearance { lightIconColor: theme.lightIconColor, toolbarHoverColor: theme.toolbarHoverColor, ), + isLight + ? lightAFThemeV2 + : darkAFThemeV2.copyWith( + icon_primary: theme.icon, + ), ], ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart new file mode 100644 index 0000000000..7fac9e07d0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart @@ -0,0 +1,91 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:flutter/material.dart'; + +@immutable +class AFThemeExtensionV2 extends ThemeExtension { + static AFThemeExtensionV2 of(BuildContext context) => + Theme.of(context).extension()!; + + static AFThemeExtensionV2? maybeOf(BuildContext context) => + Theme.of(context).extension(); + + const AFThemeExtensionV2({ + required this.icon_primary, + required this.icon_tertiary, + required this.border_grey_quaternary, + required this.fill_theme_select, + required this.fill_grey_thick_alpha_1, + required this.shadow_medium, + }); + + final Color icon_primary; + final Color icon_tertiary; + final Color border_grey_quaternary; + final Color fill_theme_select; + final Color fill_grey_thick_alpha_1; + final Color shadow_medium; + + @override + AFThemeExtensionV2 copyWith({ + Color? icon_primary, + Color? icon_tertiary, + Color? border_grey_quaternary, + Color? fill_theme_select, + Color? fill_grey_thick_alpha_1, + Color? shadow_medium, + }) => + AFThemeExtensionV2( + icon_primary: icon_primary ?? this.icon_primary, + icon_tertiary: icon_tertiary ?? this.icon_tertiary, + border_grey_quaternary: + border_grey_quaternary ?? this.border_grey_quaternary, + fill_theme_select: fill_theme_select ?? this.fill_theme_select, + fill_grey_thick_alpha_1: + fill_grey_thick_alpha_1 ?? this.fill_grey_thick_alpha_1, + shadow_medium: shadow_medium ?? this.shadow_medium, + ); + + @override + ThemeExtension lerp( + ThemeExtension? other, double t) { + if (other is! AFThemeExtensionV2) { + return this; + } + return AFThemeExtensionV2( + icon_primary: + Color.lerp(icon_primary, other.icon_primary, t) ?? icon_primary, + icon_tertiary: + Color.lerp(icon_tertiary, other.icon_tertiary, t) ?? icon_tertiary, + border_grey_quaternary: + Color.lerp(border_grey_quaternary, other.border_grey_quaternary, t) ?? + border_grey_quaternary, + fill_theme_select: + Color.lerp(fill_theme_select, other.fill_theme_select, t) ?? + fill_theme_select, + fill_grey_thick_alpha_1: Color.lerp( + fill_grey_thick_alpha_1, other.fill_grey_thick_alpha_1, t) ?? + fill_grey_thick_alpha_1, + shadow_medium: + Color.lerp(shadow_medium, other.shadow_medium, t) ?? shadow_medium, + ); + } +} + +const AFThemeExtensionV2 darkAFThemeV2 = AFThemeExtensionV2( + icon_primary: Color(0xFF1F2329), + icon_tertiary: Color(0xFF99A1A8), + border_grey_quaternary: Color(0xFFE8ECF3), + fill_theme_select: Color(0x00BCF01F), + fill_grey_thick_alpha_1: Color(0x1F23290F), + shadow_medium: Color(0x1F22251F), +); + +const AFThemeExtensionV2 lightAFThemeV2 = AFThemeExtensionV2( + icon_primary: Color(0xFF1F2329), + icon_tertiary: Color(0xFF99A1A8), + border_grey_quaternary: Color(0xFFE8ECF3), + fill_theme_select: Color(0x00BCF01F), + fill_grey_thick_alpha_1: Color(0x1F23290F), + shadow_medium: Color(0x1F22251F), +); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index e468ba89b4..1e156a4530 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: "8f314fd" - resolved-ref: "8f314fda5981e650a52ba522ba7915e13940d837" + ref: "5ad9d77" + resolved-ref: "5ad9d771a8496dea95a9c5b1ec77f76df3983037" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 48d380a622..f623932a16 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -184,7 +184,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "8f314fd" + ref: "5ad9d77" appflowy_editor_plugins: git: From 07a78b4ad75e931a979ddb1c2f3c5e34c803088f Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 27 Mar 2025 14:26:45 +0800 Subject: [PATCH 053/207] chore: adjust default model name (#7634) --- .../ai/service/ai_model_state_notifier.dart | 10 ++++++ .../prompt_input/select_model_menu.dart | 36 +++++++++++++------ .../ai_writer_prompt_input_more_button.dart | 7 ---- .../setting_ai_view/model_selection.dart | 3 +- frontend/resources/translations/en.json | 3 +- 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart index c43bd01c6f..c65913cf19 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -170,3 +170,13 @@ class AIModelStateNotifier { ); } } + +extension AiModelExtension on AIModelPB { + bool get isDefault { + return name == "Default"; + } + + String get i18n { + return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name; + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart index b9e3daada9..1860bd7d63 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -55,7 +55,7 @@ class _SelectModelMenuState extends State { ); }, child: _CurrentModelButton( - modelName: state.selectedModel!.name, + model: state.selectedModel!, onTap: () => popoverController.show(), ), ); @@ -166,7 +166,10 @@ class _ModelItem extends StatelessWidget { child: FlowyButton( onTap: onTap, margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), - text: FlowyText(model.name), + text: FlowyText( + model.i18n, + overflow: TextOverflow.ellipsis, + ), rightIcon: isSelected ? FlowySvg(FlowySvgs.check_s) : null, ), ); @@ -175,11 +178,11 @@ class _ModelItem extends StatelessWidget { class _CurrentModelButton extends StatelessWidget { const _CurrentModelButton({ - required this.modelName, + required this.model, required this.onTap, }); - final String modelName; + final AIModelPB model; final VoidCallback onTap; @override @@ -199,13 +202,26 @@ class _CurrentModelButton extends StatelessWidget { padding: const EdgeInsetsDirectional.all(4.0), child: Row( children: [ - FlowyText( - modelName, - fontSize: 12, - figmaLineHeight: 16, - color: Theme.of(context).hintColor, + Padding( + // TODO: remove this after change icon to 20px + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.ai_sparks_s, + color: Theme.of(context).hintColor, + size: Size.square(16), + ), ), - HSpace(2.0), + if (!model.isDefault) + Padding( + padding: EdgeInsetsDirectional.only(end: 2.0), + child: FlowyText( + model.i18n, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart index 1bd80d081d..75c6589326 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -42,13 +42,6 @@ class AiWriterPromptMoreButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - FlowySvg( - FlowySvgs.ai_sparks_s, - color: isEnabled - ? Theme.of(context).hintColor - : Theme.of(context).disabledColor, - ), - const HSpace(2.0), FlowyText( LocaleKeys.ai_more.tr(), fontSize: 12, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index bec4013a36..3df5ad3987 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:flutter/material.dart'; @@ -44,7 +45,7 @@ class AIModelSelection extends StatelessWidget { (model) => buildDropdownMenuEntry( context, value: model, - label: model.name, + label: model.i18n, ), ) .toList(), diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 24f734af5d..429f1ea8c3 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -255,7 +255,8 @@ "switchModel": { "label": "Switch model", "localModel": "Local Model", - "cloudModel": "Cloud Model" + "cloudModel": "Cloud Model", + "autoModel": "Auto" }, "selectBanner": { "saveButton": "Add to …", From d3483618890aacf2e77d397844841311de19294b Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 16:31:18 +0800 Subject: [PATCH 054/207] chore: return desc of ai model --- frontend/appflowy_flutter/macos/Podfile.lock | 46 ++++++++++---------- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 28 ++++++++++-- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 35 ++++++++------- frontend/rust-lib/flowy-ai/src/entities.rs | 33 +++----------- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 30ee626f09..b4a1a3d20d 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 - appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 - auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 - connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 - file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 - irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c - window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 29cef4afde..f0ff1df6cb 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -1,3 +1,4 @@ +use crate::cloud::ai_dto::AvailableModel; pub use client_api::entity::ai_dto::{ AppFlowyOfflineAI, CompleteTextParams, CompletionMessage, CompletionMetadata, CompletionType, CreateChatContext, CustomPrompt, LLMModel, LocalAIConfig, ModelInfo, ModelList, OutputContent, @@ -27,29 +28,50 @@ pub type StreamComplete = BoxStream<'static, Result for AIModel { + fn from(value: AvailableModel) -> Self { + let desc = value + .metadata + .as_ref() + .and_then(|v| v.get("desc").map(|v| v.as_str().unwrap_or(""))) + .unwrap_or(""); + Self { + name: value.name, + is_local: false, + desc: desc.to_string(), + } + } } impl AIModel { - pub fn server(name: String) -> Self { + pub fn server(name: String, desc: String) -> Self { Self { name, is_local: false, + desc, } } - pub fn local(name: String) -> Self { + pub fn local(name: String, desc: String) -> Self { Self { name, is_local: true, + desc, } } } +const DEFAULT_MODEL_NAME: &str = "Auto"; impl Default for AIModel { fn default() -> Self { Self { - name: "Auto".to_string(), + name: DEFAULT_MODEL_NAME.to_string(), is_local: false, + desc: "".to_string(), } } } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 0a63d8ece0..6cea59516e 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -361,20 +361,18 @@ impl AIManager { if enabled { if let Some(name) = self.local_ai.get_plugin_chat_model() { info!("Set global active model to local ai: {}", name); - let model = AIModel::local(name); + let model = AIModel::local(name, "".to_string()); self.update_selected_model(source_key, model).await?; } } else { info!("Set global active model to default"); - let global_active_model = self - .get_workspace_select_model() - .await - .map(AIModel::server) - .unwrap_or_else(|_| AIModel::default()); - - self - .update_selected_model(source_key, global_active_model) - .await?; + let global_active_model = self.get_workspace_select_model().await?; + let models = self.get_server_available_models().await?; + if let Some(model) = models.into_iter().find(|m| m.name == global_active_model) { + self + .update_selected_model(source_key, AIModel::from(model)) + .await?; + } } Ok(()) @@ -382,21 +380,21 @@ impl AIManager { pub async fn get_available_models(&self, source: String) -> FlowyResult { // Build the models list from server models and mark them as non-local. - let mut models: Vec = self + let mut models: Vec = self .get_server_available_models() .await? .into_iter() - .map(|m| AIModelPB::server(m.name)) + .map(|m| AIModel::from(m)) .collect(); // If user enable local ai, then add local ai model to the list. if let Some(local_model) = self.local_ai.get_plugin_chat_model() { - models.push(AIModelPB::local(local_model)); + models.push(AIModel::local(local_model, "".to_string())); } if models.is_empty() { return Ok(AvailableModelsPB { - models, + models: models.into_iter().map(|m| m.into()).collect(), selected_model: AIModelPB::default(), }); } @@ -405,7 +403,7 @@ impl AIManager { let global_active_model = self .get_workspace_select_model() .await - .map(AIModel::server) + .map(|m| AIModel::server(m, "".to_string())) .unwrap_or_else(|_| AIModel::default()); let mut user_selected_model = global_active_model.clone(); @@ -430,7 +428,7 @@ impl AIManager { .iter() .find(|m| m.name == user_selected_model.name) .cloned() - .or_else(|| Some(AIModelPB::from(global_active_model))); + .or_else(|| Some(AIModel::from(global_active_model))); // Update the stored preference if a different model is used. if let Some(ref active_model) = active_model { @@ -441,9 +439,10 @@ impl AIManager { } } + let selected_model = AIModelPB::from(active_model.unwrap_or_default()); Ok(AvailableModelsPB { - models, - selected_model: active_model.unwrap_or_default(), + models: models.into_iter().map(|m| m.into()).collect(), + selected_model, }) } diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index d10aa950e3..a0116a3388 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -200,19 +200,9 @@ pub struct AvailableModelPB { #[pb(index = 2)] pub is_default: bool, -} -impl From for AvailableModelPB { - fn from(value: AvailableModel) -> Self { - let is_default = value - .metadata - .and_then(|v| v.get("is_default").map(|v| v.as_bool().unwrap_or(false))) - .unwrap_or(false); - Self { - name: value.name, - is_default, - } - } + #[pb(index = 3)] + pub desc: String, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] @@ -248,22 +238,9 @@ pub struct AIModelPB { #[pb(index = 2)] pub is_local: bool, -} -impl AIModelPB { - pub fn server(name: String) -> Self { - Self { - name, - is_local: false, - } - } - - pub fn local(name: String) -> Self { - Self { - name, - is_local: true, - } - } + #[pb(index = 3)] + pub desc: String, } impl From for AIModelPB { @@ -271,6 +248,7 @@ impl From for AIModelPB { Self { name: model.name, is_local: model.is_local, + desc: model.desc, } } } @@ -280,6 +258,7 @@ impl From for AIModel { AIModel { name: value.name, is_local: value.is_local, + desc: value.desc, } } } From 7ee29dcbc524cc973421ae0caab7cf96c2387a23 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 17:09:01 +0800 Subject: [PATCH 055/207] chore: return model desc --- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 4 ++-- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 23 ++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index f0ff1df6cb..7eb8386de1 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -65,11 +65,11 @@ impl AIModel { } } -const DEFAULT_MODEL_NAME: &str = "Auto"; +pub const DEFAULT_AI_MODEL_NAME: &str = "Auto"; impl Default for AIModel { fn default() -> Self { Self { - name: DEFAULT_MODEL_NAME.to_string(), + name: DEFAULT_AI_MODEL_NAME.to_string(), is_local: false, desc: "".to_string(), } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 6cea59516e..6092839747 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -10,7 +10,9 @@ use std::collections::HashMap; use appflowy_plugin::manager::PluginManager; use dashmap::DashMap; -use flowy_ai_pub::cloud::{AIModel, ChatCloudService, ChatSettings, UpdateChatParams}; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatSettings, UpdateChatParams, DEFAULT_AI_MODEL_NAME, +}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; @@ -275,6 +277,10 @@ impl AIManager { .cloud_service_wm .get_workspace_default_model(&workspace_id) .await?; + + if model.is_empty() { + return Ok(DEFAULT_AI_MODEL_NAME.to_string()); + } Ok(model) } @@ -387,6 +393,8 @@ impl AIManager { .map(|m| AIModel::from(m)) .collect(); + trace!("[Model Selection]: Available models: {:?}", models); + // If user enable local ai, then add local ai model to the list. if let Some(local_model) = self.local_ai.get_plugin_chat_model() { models.push(AIModel::local(local_model, "".to_string())); @@ -400,13 +408,18 @@ impl AIManager { } // Global active model is the model selected by the user in the workspace settings. - let global_active_model = self + let server_active_model = self .get_workspace_select_model() .await .map(|m| AIModel::server(m, "".to_string())) .unwrap_or_else(|_| AIModel::default()); - let mut user_selected_model = global_active_model.clone(); + trace!( + "[Model Selection] server active model: {:?}", + server_active_model + ); + + let mut user_selected_model = server_active_model.clone(); let source_key = ai_available_models_key(&source); // If source is provided, try to get the user-selected model from the store. User selected @@ -419,6 +432,7 @@ impl AIManager { } }, Some(model) => { + trace!("[Model Selection] user select model: {:?}", model); user_selected_model = model; }, } @@ -428,7 +442,7 @@ impl AIManager { .iter() .find(|m| m.name == user_selected_model.name) .cloned() - .or_else(|| Some(AIModel::from(global_active_model))); + .or_else(|| Some(AIModel::from(server_active_model))); // Update the stored preference if a different model is used. if let Some(ref active_model) = active_model { @@ -439,6 +453,7 @@ impl AIManager { } } + trace!("[Model Selection] final active model: {:?}", active_model); let selected_model = AIModelPB::from(active_model.unwrap_or_default()); Ok(AvailableModelsPB { models: models.into_iter().map(|m| m.into()).collect(), From f574b6b9c297f0494edae52045c340963b6333da Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 17:10:35 +0800 Subject: [PATCH 056/207] chore: update default name --- .../lib/ai/service/ai_model_state_notifier.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart index c65913cf19..80ff8d630e 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -173,7 +173,7 @@ class AIModelStateNotifier { extension AiModelExtension on AIModelPB { bool get isDefault { - return name == "Default"; + return name == "Auto"; } String get i18n { From b83b9646780dca302d6a36992d6af5980b6befc9 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 27 Mar 2025 18:01:45 +0800 Subject: [PATCH 057/207] chore: add model description UI --- .../prompt_input/select_model_menu.dart | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart index 1860bd7d63..64a62b902c 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -161,16 +161,37 @@ class _ModelItem extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - height: 32, + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 32), child: FlowyButton( onTap: onTap, margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), - text: FlowyText( - model.i18n, - overflow: TextOverflow.ellipsis, + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + model.i18n, + figmaLineHeight: 20, + overflow: TextOverflow.ellipsis, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + if (model.desc.isNotEmpty) + FlowyText( + model.desc, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ], ), - rightIcon: isSelected ? FlowySvg(FlowySvgs.check_s) : null, + rightIcon: isSelected + ? FlowySvg( + FlowySvgs.check_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.primary, + ) + : null, ), ); } From f6f19a0a078cf02b510a1c777ea75c30692f4dd6 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 19:17:07 +0800 Subject: [PATCH 058/207] chore: display ai model desc on setting page --- .../setting_ai_view/model_selection.dart | 2 ++ .../shared/af_dropdown_menu_entry.dart | 35 +++++++++++++++---- frontend/rust-lib/flowy-ai/src/entities.rs | 1 - 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 3df5ad3987..2c4c4fa920 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -46,6 +46,8 @@ class AIModelSelection extends StatelessWidget { context, value: model, label: model.i18n, + subLabel: model.desc, + maximumHeight: 49, ), ) .toList(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart index d005901cff..1d1e58626c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart @@ -9,14 +9,40 @@ DropdownMenuEntry buildDropdownMenuEntry( BuildContext context, { required T value, required String label, + String? subLabel, T? selectedValue, Widget? leadingWidget, Widget? trailingWidget, String? fontFamily, + double maximumHeight = 29, }) { final fontFamilyUsed = fontFamily != null ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily : defaultFontFamily; + Widget? labelWidget; + if (subLabel != null) { + labelWidget = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + label, + fontSize: 14, + ), + const VSpace(4), + FlowyText.regular( + subLabel, + fontSize: 10, + ), + ], + ); + } else { + labelWidget = FlowyText.regular( + label, + fontSize: 14, + textAlign: TextAlign.start, + fontFamily: fontFamilyUsed, + ); + } return DropdownMenuEntry( style: ButtonStyle( @@ -26,17 +52,12 @@ DropdownMenuEntry buildDropdownMenuEntry( const EdgeInsets.symmetric(horizontal: 6, vertical: 4), ), minimumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), - maximumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), + maximumSize: WidgetStatePropertyAll(Size(double.infinity, maximumHeight)), ), value: value, label: label, leadingIcon: leadingWidget, - labelWidget: FlowyText.regular( - label, - fontSize: 14, - textAlign: TextAlign.start, - fontFamily: fontFamilyUsed, - ), + labelWidget: labelWidget, trailingIcon: Row( children: [ if (trailingWidget != null) ...[ diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index a0116a3388..353ff9d0a6 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use crate::local_ai::controller::LocalAISetting; use crate::local_ai::resource::PendingResource; -use flowy_ai_pub::cloud::ai_dto::AvailableModel; use flowy_ai_pub::cloud::{ AIModel, ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, From ff70595a15f7ef7c17f0c4323d6503070f0bfe37 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 20:03:07 +0800 Subject: [PATCH 059/207] chore: display ai model desc and fix flashing when select model --- .../settings/ai/settings_ai_bloc.dart | 24 +++- .../setting_ai_view/model_selection.dart | 10 +- .../setting_ai_view/settings_ai_view.dart | 24 ++-- .../settings/settings_dialog.dart | 2 +- .../shared/af_dropdown_menu_entry.dart | 4 +- .../flowy-codegen/src/protobuf_file/mod.rs | 116 +++++++++--------- 6 files changed, 100 insertions(+), 80 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 1d50cde480..4383e0dbef 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -11,13 +11,15 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_ai_bloc.freezed.dart'; +const String aiModelsGlobalActiveModel = "ai_models_global_active_model"; + class SettingsAIBloc extends Bloc { SettingsAIBloc( this.userProfile, this.workspaceId, ) : _userListener = UserListener(userProfile: userProfile), _aiModelSwitchListener = - AIModelSwitchListener(objectId: "ai_models_global_active_model"), + AIModelSwitchListener(objectId: aiModelsGlobalActiveModel), super( SettingsAIState( userProfile: userProfile, @@ -58,6 +60,7 @@ class SettingsAIBloc extends Bloc { }, ); _loadModelList(); + _loadUserWorkspaceSetting(); }, didReceiveUserProfile: (userProfile) { emit(state.copyWith(userProfile: userProfile)); @@ -75,6 +78,12 @@ class SettingsAIBloc extends Bloc { if (!model.isLocal) { await _updateUserWorkspaceSetting(model: model.name); } + await AIEventUpdateSelectedModel( + UpdateSelectedModelPB( + source: aiModelsGlobalActiveModel, + selectedModel: model, + ), + ).send(); }, didLoadAISetting: (UseAISettingPB settings) { emit( @@ -135,6 +144,19 @@ class SettingsAIBloc extends Bloc { }); }); } + + void _loadUserWorkspaceSetting() { + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); + UserEventGetWorkspaceSetting(payload).send().then((result) { + result.fold((settings) { + if (!isClosed) { + add(SettingsAIEvent.didLoadAISetting(settings)); + } + }, (err) { + Log.error(err); + }); + }); + } } @freezed diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 2c4c4fa920..05db831e04 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -12,13 +12,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class AIModelSelection extends StatelessWidget { const AIModelSelection({super.key}); + static const double height = 49; @override Widget build(BuildContext context) { return BlocBuilder( + buildWhen: (previous, current) => + previous.availableModels != current.availableModels, builder: (context, state) { if (state.availableModels == null) { - return const SizedBox.shrink(); + return const SizedBox( + // Using same height as SettingsDropdown to avoid layout shift + height: height, + ); } return Padding( @@ -47,7 +53,7 @@ class AIModelSelection extends StatelessWidget { value: model, label: model.i18n, subLabel: model.desc, - maximumHeight: 49, + maximumHeight: height, ), ) .toList(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart index 0fc1ef293c..efb969700e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart @@ -44,22 +44,14 @@ class SettingsAIView extends StatelessWidget { return BlocProvider( create: (_) => SettingsAIBloc(userProfile, workspaceId) ..add(const SettingsAIEvent.started()), - child: BlocBuilder( - builder: (context, state) { - final children = [ - const AIModelSelection(), - ]; - - children.add(const _AISearchToggle(value: false)); - children.add(const LocalAISetting()); - - return SettingsBody( - title: LocaleKeys.settings_aiPage_title.tr(), - description: - LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), - children: children, - ); - }, + child: SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), + children: [ + const AIModelSelection(), + const _AISearchToggle(value: false), + const LocalAISetting(), + ], ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 5165744627..2cf83276b4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -141,7 +141,7 @@ class SettingsDialog extends StatelessWidget { case SettingsPage.ai: if (user.authenticator == AuthenticatorPB.AppFlowyCloud) { return SettingsAIView( - key: ValueKey(user.hashCode), + key: ValueKey(workspaceId), userProfile: user, currentWorkspaceMemberRole: currentWorkspaceMemberRole, workspaceId: workspaceId, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart index 1d1e58626c..720f7793f2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart @@ -9,7 +9,7 @@ DropdownMenuEntry buildDropdownMenuEntry( BuildContext context, { required T value, required String label, - String? subLabel, + String subLabel = '', T? selectedValue, Widget? leadingWidget, Widget? trailingWidget, @@ -20,7 +20,7 @@ DropdownMenuEntry buildDropdownMenuEntry( ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily : defaultFontFamily; Widget? labelWidget; - if (subLabel != null) { + if (subLabel.isNotEmpty) { labelWidget = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs index 8baa78c096..677e7bcddf 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs @@ -76,64 +76,64 @@ pub fn dart_gen(crate_name: &str) { } } -#[allow(unused_variables)] -fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { - // 1. generate the proto files to proto_file_dir - #[cfg(feature = "proto_gen")] - let proto_crates = gen_proto_files(crate_name); - - for proto_crate in proto_crates { - let mut proto_file_paths = vec![]; - let mut file_names = vec![]; - let proto_file_output_path = proto_crate - .proto_output_path() - .to_str() - .unwrap() - .to_string(); - let protobuf_output_path = proto_crate - .protobuf_crate_path() - .to_str() - .unwrap() - .to_string(); - - for (path, file_name) in WalkDir::new(&proto_file_output_path) - .into_iter() - .filter_map(|e| e.ok()) - .map(|e| { - let path = e.path().to_str().unwrap().to_string(); - let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); - (path, file_name) - }) - { - if path.ends_with(".proto") { - // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project - println!("cargo:rerun-if-changed={}", path); - proto_file_paths.push(path); - file_names.push(file_name); - } - } - let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); - - // 2. generate the protobuf files(Dart) - #[cfg(feature = "ts")] - generate_ts_protobuf_files( - dest_folder_name, - &proto_file_output_path, - &proto_file_paths, - &file_names, - &protoc_bin_path, - &project, - ); - - // 3. generate the protobuf files(Rust) - generate_rust_protobuf_files( - &protoc_bin_path, - &proto_file_paths, - &proto_file_output_path, - &protobuf_output_path, - ); - } -} +// #[allow(unused_variables)] +// fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { +// // 1. generate the proto files to proto_file_dir +// #[cfg(feature = "proto_gen")] +// let proto_crates = gen_proto_files(crate_name); +// +// for proto_crate in proto_crates { +// let mut proto_file_paths = vec![]; +// let mut file_names = vec![]; +// let proto_file_output_path = proto_crate +// .proto_output_path() +// .to_str() +// .unwrap() +// .to_string(); +// let protobuf_output_path = proto_crate +// .protobuf_crate_path() +// .to_str() +// .unwrap() +// .to_string(); +// +// for (path, file_name) in WalkDir::new(&proto_file_output_path) +// .into_iter() +// .filter_map(|e| e.ok()) +// .map(|e| { +// let path = e.path().to_str().unwrap().to_string(); +// let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); +// (path, file_name) +// }) +// { +// if path.ends_with(".proto") { +// // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project +// println!("cargo:rerun-if-changed={}", path); +// proto_file_paths.push(path); +// file_names.push(file_name); +// } +// } +// let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); +// +// // 2. generate the protobuf files(Dart) +// #[cfg(feature = "ts")] +// generate_ts_protobuf_files( +// dest_folder_name, +// &proto_file_output_path, +// &proto_file_paths, +// &file_names, +// &protoc_bin_path, +// &project, +// ); +// +// // 3. generate the protobuf files(Rust) +// generate_rust_protobuf_files( +// &protoc_bin_path, +// &proto_file_paths, +// &proto_file_output_path, +// &protobuf_output_path, +// ); +// } +// } fn generate_rust_protobuf_files( protoc_bin_path: &Path, From 8cf31b8afc5557606a74310e70966ef4e45b4ed5 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 20:51:29 +0800 Subject: [PATCH 060/207] chore: bump client api --- .../ai/service/ai_model_state_notifier.dart | 1 - frontend/rust-lib/Cargo.lock | 40 +++++++++---------- frontend/rust-lib/Cargo.toml | 20 +++++----- .../src/middleware/chat_service_mw.rs | 2 +- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart index 80ff8d630e..0bcc41da9b 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -113,7 +113,6 @@ class AIModelStateNotifier { final localAiState = _localAIState; if (availableModels == null) { - Log.warn("No available models"); return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); } if (localAiState == null) { diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index ba07ff9c3b..0e800af7ed 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "bytes", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "again", "anyhow", @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "collab-entity", "collab-rt-entity", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "futures-channel", "futures-util", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "arc-swap", @@ -924,7 +924,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "async-trait", @@ -964,7 +964,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "arc-swap", @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "bytes", @@ -1005,7 +1005,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "arc-swap", @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "collab-importer" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "async-recursion", @@ -1091,7 +1091,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "async-stream", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "bincode", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "async-trait", @@ -1168,7 +1168,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=45239d2#45239d2ae871cc355ea2cc1d5d578e21c8263242" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" dependencies = [ "anyhow", "collab", @@ -1546,7 +1546,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "bincode", "bytes", @@ -2980,7 +2980,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -2995,7 +2995,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "app-error", "jsonwebtoken", @@ -3610,7 +3610,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "bytes", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=dfd44f8fb7152ace03eb03e46391cf76266948b9#dfd44f8fb7152ace03eb03e46391cf76266948b9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 843a939ae3..269cabb938 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "dfd44f8fb7152ace03eb03e46391cf76266948b9" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "dfd44f8fb7152ace03eb03e46391cf76266948b9" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f7288f46c27dc8e3c7829cda1b70b61118e88336" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f7288f46c27dc8e3c7829cda1b70b61118e88336" } [profile.dev] opt-level = 0 @@ -139,14 +139,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } -collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "45239d2" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 37bf5b5daf..9067156125 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -287,7 +287,7 @@ impl ChatCloudService for AICloudServiceMiddleware { Some(model) => model.is_local, }; - info!("stream_complete use model: {:?}", ai_model); + info!("stream_complete use custom model: {:?}", ai_model); if use_local_ai { if self.local_ai.is_running() { match self From 7456c65799d31bb5b7595b9d6bd212075a09419f Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 27 Mar 2025 20:54:48 +0800 Subject: [PATCH 061/207] chore: clippy --- frontend/rust-lib/collab-integrate/src/collab_builder.rs | 2 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 8 ++++---- .../rust-lib/flowy-core/src/deps_resolve/collab_deps.rs | 2 +- .../flowy-core/src/deps_resolve/folder_deps/mod.rs | 2 +- frontend/rust-lib/flowy-database2/src/manager.rs | 6 +++--- .../rust-lib/flowy-server/src/af_cloud/impls/database.rs | 4 ++-- .../src/services/data_import/appflowy_data_import.rs | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index b6e89a5a2d..c94660dbfd 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -284,7 +284,7 @@ impl AppFlowyCollabBuilder { object.uid, object.workspace_id.clone(), object.object_id.to_string(), - object.collab_type.clone(), + object.collab_type, collab_db, persistence_config, ); diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 6092839747..000955cb33 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -390,7 +390,7 @@ impl AIManager { .get_server_available_models() .await? .into_iter() - .map(|m| AIModel::from(m)) + .map(AIModel::from) .collect(); trace!("[Model Selection]: Available models: {:?}", models); @@ -428,7 +428,7 @@ impl AIManager { None => { // when there is selected model and current local ai is active, then use local ai if let Some(local_ai_model) = models.iter().find(|m| m.is_local) { - user_selected_model = AIModel::from(local_ai_model.clone()); + user_selected_model = local_ai_model.clone(); } }, Some(model) => { @@ -442,14 +442,14 @@ impl AIManager { .iter() .find(|m| m.name == user_selected_model.name) .cloned() - .or_else(|| Some(AIModel::from(server_active_model))); + .or(Some(server_active_model)); // Update the stored preference if a different model is used. if let Some(ref active_model) = active_model { if active_model.name != user_selected_model.name { self .store_preferences - .set_object::(&source_key, &AIModel::from(active_model.clone()))?; + .set_object::(&source_key, &active_model.clone())?; } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs index a8827e06b0..c3d0358fcb 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs @@ -24,7 +24,7 @@ impl SnapshotPersistence for SnapshotDBImpl { collab_type: &CollabType, encoded_v1: Vec, ) -> Result<(), PersistenceError> { - let collab_type = collab_type.clone(); + let collab_type = *collab_type; let object_id = object_id.to_string(); let weak_user = self.0.clone(); tokio::task::spawn_blocking(move || { diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs index 9bed61d918..ef69fe7d82 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs @@ -197,7 +197,7 @@ impl FolderQueryService for FolderServiceImpl { } async fn get_collab(&self, object_id: &str, collab_type: CollabType) -> Option { - let encode_collab = get_encoded_collab_v1_from_disk(&self.user, object_id, collab_type.clone()) + let encode_collab = get_encoded_collab_v1_from_disk(&self.user, object_id, collab_type) .await .ok(); diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index fca8db4f97..63f95011bd 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -776,7 +776,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { collab_type: CollabType, encoded_collab: Option<(EncodedCollab, bool)>, ) -> Result { - let object = self.build_collab_object(object_id, collab_type.clone())?; + let object = self.build_collab_object(object_id, collab_type)?; let data_source = if self.persistence.is_collab_exist(object_id) { trace!( "build collab: {}:{} from local encode collab", @@ -796,7 +796,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { object_id, encoded_collab.is_none(), ); - match self.get_encode_collab(object_id, collab_type.clone()).await { + match self.get_encode_collab(object_id, collab_type).await { Ok(Some(encode_collab)) => { info!( "build collab: {}:{} with remote encode collab, {} bytes", @@ -885,7 +885,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { .filter_map(|object_id| { self .persistence - .get_encoded_collab(object_id.as_str(), collab_type.clone()) + .get_encoded_collab(object_id.as_str(), collab_type) .map(|encoded_collab| (object_id.clone(), encoded_collab)) }) .collect(); diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index 4d264365ec..51e01bf4c1 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -45,7 +45,7 @@ where let cloned_user = self.user.clone(); let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id.clone(), collab_type.clone()), + inner: QueryCollab::new(object_id.clone(), collab_type), }; let result = try_get_client?.get_collab(params).await; match result { @@ -102,7 +102,7 @@ where let client = try_get_client?; let params = object_ids .into_iter() - .map(|object_id| QueryCollab::new(object_id, object_ty.clone())) + .map(|object_id| QueryCollab::new(object_id, object_ty)) .collect(); let results = client.batch_get_collab(&workspace_id, params).await?; check_request_workspace_id_is_match(&workspace_id, &cloned_user, "batch get database object")?; diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index 129b605281..c2325815d6 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -1248,7 +1248,7 @@ pub async fn upload_collab_objects_data( objects.push(UserCollabParams { object_id: oid, encoded_collab, - collab_type: collab_type.clone(), + collab_type, }); size_counter += obj_size; } From 07c767c4fadbc8834649cf559a65561921e88095 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 28 Mar 2025 11:23:29 +0800 Subject: [PATCH 062/207] fix: ai writer ux issues (#7640) * chore: fix local ai font weight * chore: adjust auto response format copy * fix: escape shortcut * chore: more action button icon size --- .../desktop_prompt_text_field.dart | 37 +++--- .../ai/ai_writer_block_component.dart | 13 ++- .../ai_writer_prompt_input_more_button.dart | 2 +- .../ai/widgets/ai_writer_scroll_wrapper.dart | 110 ++++++++++-------- .../setting_ai_view/local_ai_setting.dart | 2 +- frontend/resources/translations/en.json | 2 +- 6 files changed, 94 insertions(+), 72 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index fcf487da94..97850f6a1c 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -55,16 +55,18 @@ class _DesktopPromptInputState extends State { super.initState(); widget.textController.addListener(handleTextControllerChanged); - focusNode.addListener( - () { - if (!widget.hideDecoration) { - setState(() {}); // refresh border color - } - if (!focusNode.hasFocus) { - cancelMentionPage(); // hide menu when lost focus - } - }, - ); + focusNode + ..addListener( + () { + if (!widget.hideDecoration) { + setState(() {}); // refresh border color + } + if (!focusNode.hasFocus) { + cancelMentionPage(); // hide menu when lost focus + } + }, + ) + ..onKeyEvent = handleKeyEvent; updateSendButtonState(); @@ -344,11 +346,16 @@ class _DesktopPromptInputState extends State { } KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { - if (event.character == '@') { - WidgetsBinding.instance.addPostFrameCallback((_) { - inputControlCubit.startSearching(widget.textController.value); - overlayController.show(); - }); + // if (event.character == '@') { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // inputControlCubit.startSearching(widget.textController.value); + // overlayController.show(); + // }); + // } + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + node.unfocus(); + return KeyEventResult.handled; } return KeyEventResult.ignored; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 0f1e077d6d..769fabcd1f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -98,6 +98,7 @@ class _AIWriterBlockComponentState extends State { final textController = TextEditingController(); final overlayController = OverlayPortalController(); final layerLink = LayerLink(); + final focusNode = FocusNode(); late final editorState = context.read(); @@ -114,6 +115,7 @@ class _AIWriterBlockComponentState extends State { @override void dispose() { textController.dispose(); + focusNode.dispose(); super.dispose(); } @@ -145,10 +147,13 @@ class _AIWriterBlockComponentState extends State { bottom: 16.0, ), width: constraints.maxWidth, - child: OverlayContent( - editorState: editorState, - node: widget.node, - textController: textController, + child: Focus( + focusNode: focusNode, + child: OverlayContent( + editorState: editorState, + node: widget.node, + textController: textController, + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart index 75c6589326..72b8d9560b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -135,9 +135,9 @@ class MoreAiWriterCommands extends StatelessWidget { return FlowyButton( leftIcon: FlowySvg( command.icon, - size: const Size.square(16), color: Theme.of(context).iconTheme.color, ), + leftIconSize: const Size.square(20), margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), text: FlowyText( command.i18n, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart index 326d1a44ab..90bce596a0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -36,11 +36,9 @@ class _AiWriterScrollWrapperState extends State { onCreateNode: () { aiWriterRegistered = true; widget.editorState.service.keyboardService?.disableShortcuts(); - HardwareKeyboard.instance.addHandler(cancelShortcutHandler); }, onRemoveNode: () { aiWriterRegistered = false; - HardwareKeyboard.instance.removeHandler(cancelShortcutHandler); widget.editorState.service.keyboardService?.enableShortcuts(); }, onAppendToDocument: onAppendToDocument, @@ -48,6 +46,7 @@ class _AiWriterScrollWrapperState extends State { bool userHasScrolled = false; bool aiWriterRegistered = false; + bool dialogShown = false; @override void initState() { @@ -67,46 +66,51 @@ class _AiWriterScrollWrapperState extends State { value: aiWriterCubit, child: NotificationListener( onNotification: handleScrollNotification, - child: MultiBlocListener( - listeners: [ - BlocListener( - listener: (context, state) { - if (state is DocumentContentEmptyAiWriterState) { - showConfirmDialog( - context: context, - title: LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), - description: LocaleKeys - .ai_continueWritingEmptyDocumentDescription - .tr(), - onConfirm: state.onConfirm, - ); - } - }, - ), - BlocListener( - listenWhen: (previous, current) => - previous is GeneratingAiWriterState && - current is ReadyAiWriterState, - listener: (context, state) { - widget.editorState.updateSelectionWithReason(null); - }, - ), - ], - child: OverlayPortal( - controller: overlayController, - overlayChildBuilder: (context) { - return BlocBuilder( - builder: (context, state) { - return AiWriterGestureDetector( - behavior: state is RegisteredAiWriter - ? HitTestBehavior.translucent - : HitTestBehavior.deferToChild, - onPointerEvent: () => onTapOutside(context), - ); + child: Focus( + autofocus: true, + onKeyEvent: handleKeyEvent, + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state is DocumentContentEmptyAiWriterState) { + showConfirmDialog( + context: context, + title: + LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), + description: LocaleKeys + .ai_continueWritingEmptyDocumentDescription + .tr(), + onConfirm: state.onConfirm, + ); + } }, - ); - }, - child: widget.child, + ), + BlocListener( + listenWhen: (previous, current) => + previous is GeneratingAiWriterState && + current is ReadyAiWriterState, + listener: (context, state) { + widget.editorState.updateSelectionWithReason(null); + }, + ), + ], + child: OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return BlocBuilder( + builder: (context, state) { + return AiWriterGestureDetector( + behavior: state is RegisteredAiWriter + ? HitTestBehavior.translucent + : HitTestBehavior.deferToChild, + onPointerEvent: () => onTapOutside(context), + ); + }, + ); + }, + child: widget.child, + ), ), ), ), @@ -153,9 +157,15 @@ class _AiWriterScrollWrapperState extends State { } } - bool cancelShortcutHandler(KeyEvent event) { - if (event is! KeyUpEvent) { - return false; + KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { + if (!aiWriterRegistered) { + return KeyEventResult.ignored; + } + if (dialogShown) { + return KeyEventResult.handled; + } + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; } switch (event.logicalKey) { @@ -163,6 +173,7 @@ class _AiWriterScrollWrapperState extends State { if (aiWriterCubit.state case GeneratingAiWriterState _) { aiWriterCubit.stopStream(); } else if (aiWriterCubit.hasUnusedResponse()) { + dialogShown = true; showConfirmDialog( context: context, title: LocaleKeys.button_discard.tr(), @@ -171,23 +182,22 @@ class _AiWriterScrollWrapperState extends State { style: ConfirmPopupStyle.cancelAndOk, onConfirm: stopAndExit, onCancel: () {}, - ); + ).then((_) => dialogShown = false); } else { stopAndExit(); } - return true; + return KeyEventResult.handled; case LogicalKeyboardKey.keyC - when HardwareKeyboard.instance.logicalKeysPressed - .contains(LogicalKeyboardKey.controlLeft): + when HardwareKeyboard.instance.isControlPressed: if (aiWriterCubit.state case GeneratingAiWriterState _) { aiWriterCubit.stopStream(); } - return true; + return KeyEventResult.handled; default: break; } - return false; + return KeyEventResult.ignored; } void onAppendToDocument() { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index feec20afdb..57a72b6ca1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -84,7 +84,7 @@ class LocalAISettingHeader extends StatelessWidget { children: [ Row( children: [ - FlowyText( + FlowyText.medium( LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), ), const Spacer(), diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 429f1ea8c3..2b22da596c 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -246,7 +246,7 @@ "number": "Numbered list", "table": "Table", "blankDescription": "Format response", - "defaultDescription": "Auto mode", + "defaultDescription": "Auto response format", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", From 8aa32ca3fac69867c6da11124542a26157a4f86c Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 28 Mar 2025 16:27:16 +0800 Subject: [PATCH 063/207] chore: disable mcp --- frontend/rust-lib/Cargo.lock | 6 +++--- frontend/rust-lib/Cargo.toml | 6 +++--- frontend/rust-lib/flowy-ai/src/lib.rs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 07b39ecaa0..3119cec96c 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=bcd9782fa3d6f6d36f2fa6d065e834a1400f156e#bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=94913c8fb922047f52c6d01d0e16413ce25c970f#94913c8fb922047f52c6d01d0e16413ce25c970f" dependencies = [ "af-plugin", "anyhow", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=bcd9782fa3d6f6d36f2fa6d065e834a1400f156e#bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=94913c8fb922047f52c6d01d0e16413ce25c970f#94913c8fb922047f52c6d01d0e16413ce25c970f" dependencies = [ "anyhow", "futures-util", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=bcd9782fa3d6f6d36f2fa6d065e834a1400f156e#bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=94913c8fb922047f52c6d01d0e16413ce25c970f#94913c8fb922047f52c6d01d0e16413ce25c970f" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 02df770eb1..7aa9023d5e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "bcd9782fa3d6f6d36f2fa6d065e834a1400f156e" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "94913c8fb922047f52c6d01d0e16413ce25c970f" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "94913c8fb922047f52c6d01d0e16413ce25c970f" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "94913c8fb922047f52c6d01d0e16413ce25c970f" } diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index 3a15eec576..6ab100fd6e 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -7,8 +7,8 @@ mod completion; pub mod entities; mod local_ai; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub mod mcp; +// #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +// pub mod mcp; mod middleware; pub mod notification; From fbf031b06d9461b87c2fa770f97e1f3fffb4560c Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 28 Mar 2025 23:00:27 +0800 Subject: [PATCH 064/207] chore: bump local ai --- frontend/rust-lib/Cargo.lock | 6 +++--- frontend/rust-lib/Cargo.toml | 6 +++--- .../rust-lib/flowy-ai/src/local_ai/controller.rs | 13 +++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 0e996ae1ff..d59db1a875 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=94913c8fb922047f52c6d01d0e16413ce25c970f#94913c8fb922047f52c6d01d0e16413ce25c970f" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e#dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" dependencies = [ "af-plugin", "anyhow", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=94913c8fb922047f52c6d01d0e16413ce25c970f#94913c8fb922047f52c6d01d0e16413ce25c970f" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e#dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" dependencies = [ "anyhow", "futures-util", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=94913c8fb922047f52c6d01d0e16413ce25c970f#94913c8fb922047f52c6d01d0e16413ce25c970f" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e#dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index bbae7bc0f3..66a82bc2ca 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "94913c8fb922047f52c6d01d0e16413ce25c970f" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "94913c8fb922047f52c6d01d0e16413ce25c970f" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "94913c8fb922047f52c6d01d0e16413ce25c970f" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 59113a59ae..79a599b904 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -98,16 +98,17 @@ impl LocalAIController { if let Ok(workspace_id) = cloned_user_service.workspace_id() { let key = local_ai_enabled_key(&workspace_id); info!("[AI Plugin] state: {:?}", state); - - let new_state = RunningStatePB::from(state); - let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); let mut ready = false; let mut lack_of_resource = None; - if enabled { - ready = is_plugin_ready(); - lack_of_resource = cloned_llm_res.get_lack_of_resource().await; + let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); + if !matches!(state, RunningState::UnexpectedStop { .. }) { + if enabled { + ready = is_plugin_ready(); + lack_of_resource = cloned_llm_res.get_lack_of_resource().await; + } } + let new_state = RunningStatePB::from(state); chat_notification_builder( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::UpdateLocalAIState, From 7b89d76ceac05c811636e49291bd5b3d1b009142 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 28 Mar 2025 23:31:00 +0800 Subject: [PATCH 065/207] chore: clippy --- frontend/rust-lib/flowy-ai/src/local_ai/controller.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 79a599b904..c9b9ab52be 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -101,11 +101,9 @@ impl LocalAIController { let mut ready = false; let mut lack_of_resource = None; let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); - if !matches!(state, RunningState::UnexpectedStop { .. }) { - if enabled { - ready = is_plugin_ready(); - lack_of_resource = cloned_llm_res.get_lack_of_resource().await; - } + if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { + ready = is_plugin_ready(); + lack_of_resource = cloned_llm_res.get_lack_of_resource().await; } let new_state = RunningStatePB::from(state); From 917aa60c9881e2d987990cf9ad641a7969e2bfd8 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 29 Mar 2025 15:32:45 +0800 Subject: [PATCH 066/207] chore: bump lai --- frontend/rust-lib/Cargo.lock | 6 +++--- frontend/rust-lib/Cargo.toml | 6 +++--- frontend/rust-lib/flowy-ai/Cargo.toml | 2 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 12 +++++++++++- .../flowy-ai/src/local_ai/controller.rs | 17 ++++++++--------- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index d59db1a875..01b9251e23 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e#dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" dependencies = [ "af-plugin", "anyhow", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e#dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" dependencies = [ "anyhow", "futures-util", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e#dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 66a82bc2ca..b4a0a34b14 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "dd7002f75a981a3ce0d5456eb7dac9ca0bb88a5e" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index 22cecd3713..ee056021c4 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -35,7 +35,7 @@ serde_json = { workspace = true } anyhow = "1.0.86" tokio-stream = "0.1.15" tokio-util = { workspace = true, features = ["full"] } -af-local-ai = { version = "0.1.0", features = ["verbose"] } +af-local-ai = { version = "0.1.0" } af-plugin = { version = "0.1.0" } reqwest = { version = "0.11.27", features = ["json"] } sha2 = "0.10.7" diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 10f7bc2d09..eabf41a1e3 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -117,8 +117,18 @@ impl AIManager { } } + #[instrument(skip_all, err)] pub async fn initialize(&self, _workspace_id: &str) -> Result<(), FlowyError> { - self.local_ai.reload().await?; + let local_ai = self.local_ai.clone(); + tokio::spawn(async move { + if let Err(err) = local_ai.destroy_plugin().await { + error!("Failed to destroy plugin: {}", err); + } + + if let Err(err) = local_ai.reload().await { + error!("[AI Manager] failed to reload local AI: {:?}", err); + } + }); Ok(()) } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index c9b9ab52be..c3f57e6fac 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -175,7 +175,6 @@ impl LocalAIController { pub async fn reload(&self) -> FlowyResult<()> { let is_enabled = self.is_enabled(); - self.toggle_plugin(is_enabled).await?; Ok(()) } @@ -459,7 +458,6 @@ async fn initialize_ai_plugin( llm_resource: &Arc, ret: Option>, ) -> FlowyResult<()> { - let plugin = plugin.clone(); let lack_of_resource = llm_resource.get_lack_of_resource().await; chat_notification_builder( @@ -489,16 +487,17 @@ async fn initialize_ai_plugin( }) .send(); - if let Err(err) = plugin.destroy_plugin().await { - error!( - "[AI Plugin] failed to destroy plugin when lack of resource: {:?}", - err - ); - } - return Ok(()); } + if let Err(err) = plugin.destroy_plugin().await { + error!( + "[AI Plugin] failed to destroy plugin when lack of resource: {:?}", + err + ); + } + + let plugin = plugin.clone(); let cloned_llm_res = llm_resource.clone(); tokio::task::spawn_blocking(move || { futures::executor::block_on(async move { From 3a879b01865c0823acbf83d0bee0ec5c4c457baa Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Sat, 29 Mar 2025 21:56:05 +0800 Subject: [PATCH 067/207] feat: improve local ai settings page UI (#7643) * chore: improve local ai settings page * chore: improve local ai settings page * chore: simplify state and improve ui --- .../ai/download_offline_ai_app_bloc.dart | 41 --- .../settings/ai/local_ai_bloc.dart | 147 +++++---- .../ai/local_ai_on_boarding_bloc.dart | 2 +- .../ai/local_ai_setting_panel_bloc.dart | 92 ------ .../settings/ai/ollama_setting_bloc.dart | 8 +- .../settings/ai/plugin_state_bloc.dart | 146 --------- .../pages/setting_ai_view/init_local_ai.dart | 82 ----- .../setting_ai_view/local_ai_setting.dart | 244 ++++++++------ .../local_ai_setting_panel.dart | 32 -- .../pages/setting_ai_view/ollama_setting.dart | 114 +++++++ .../pages/setting_ai_view/ollma_setting.dart | 168 ---------- .../pages/setting_ai_view/plugin_state.dart | 248 -------------- .../plugin_status_indicator.dart | 309 ++++++++++++++++++ frontend/resources/translations/en.json | 14 +- frontend/rust-lib/flowy-ai/src/entities.rs | 34 +- .../flowy-ai/src/local_ai/controller.rs | 23 +- .../flowy-ai/src/local_ai/resource.rs | 62 ++-- 17 files changed, 731 insertions(+), 1035 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart deleted file mode 100644 index 185a8c049f..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; -part 'download_offline_ai_app_bloc.freezed.dart'; - -class DownloadOfflineAIBloc - extends Bloc { - DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { - on(_handleEvent); - } - - Future _handleEvent( - DownloadOfflineAIEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIDownloadLink().send(); - await result.fold( - (app) async { - await launchUrl(Uri.parse(app.link)); - }, - (err) {}, - ); - }, - ); - } -} - -@freezed -class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { - const factory DownloadOfflineAIEvent.started() = _Started; -} - -@freezed -class DownloadOfflineAIState with _$DownloadOfflineAIState { - const factory DownloadOfflineAIState() = _DownloadOfflineAIState; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart index 66ba7fe572..24befd9480 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -1,94 +1,121 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'local_llm_listener.dart'; + part 'local_ai_bloc.freezed.dart'; -class LocalAIToggleBloc extends Bloc { - LocalAIToggleBloc() : super(const LocalAIToggleState()) { - on(_handleEvent); +class LocalAiPluginBloc extends Bloc { + LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) { + on(_handleEvent); + _startListening(); + _getLocalAiState(); + } + + final listener = LocalAIStateListener(); + + @override + Future close() async { + await listener.stop(); + return super.close(); } Future _handleEvent( - LocalAIToggleEvent event, - Emitter emit, + LocalAiPluginEvent event, + Emitter emit, ) async { await event.when( - started: () async { - final result = await AIEventGetLocalAIState().send(); - _handleResult(emit, result); + didReceiveAiState: (aiState) { + emit( + ReadyLocalAiPluginState( + isEnabled: aiState.enabled, + runningState: aiState.state, + lackOfResource: + aiState.hasLackOfResource() ? aiState.lackOfResource : null, + ), + ); + }, + didReceiveLackOfResources: (resources) { + if (state case final ReadyLocalAiPluginState readyState) { + emit( + ReadyLocalAiPluginState( + isEnabled: readyState.isEnabled, + runningState: readyState.runningState, + lackOfResource: resources, + ), + ); + } }, toggle: () async { - emit( - state.copyWith( - pageIndicator: const LocalAIToggleStateIndicator.loading(), - ), - ); - unawaited( - AIEventToggleLocalAI().send().then( - (result) { - if (!isClosed) { - add(LocalAIToggleEvent.handleResult(result)); - } - }, - ), + emit(LoadingLocalAiPluginState()); + await AIEventToggleLocalAI().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, ); }, - handleResult: (result) { - _handleResult(emit, result); + restart: () async { + emit(LoadingLocalAiPluginState()); + await AIEventRestartLocalAI().send(); }, ); } - void _handleResult( - Emitter emit, - FlowyResult result, - ) { - result.fold( - (localAI) { - emit( - state.copyWith( - pageIndicator: - LocalAIToggleStateIndicator.isEnabled(localAI.enabled), - ), - ); + void _startListening() { + listener.start( + stateCallback: (pluginState) { + add(LocalAiPluginEvent.didReceiveAiState(pluginState)); }, - (err) { - emit( - state.copyWith( - pageIndicator: LocalAIToggleStateIndicator.error(err), - ), - ); + resourceCallback: (data) { + add(LocalAiPluginEvent.didReceiveLackOfResources(data)); }, ); } + + void _getLocalAiState() { + AIEventGetLocalAIState().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, + ); + } } @freezed -class LocalAIToggleEvent with _$LocalAIToggleEvent { - const factory LocalAIToggleEvent.started() = _Started; - const factory LocalAIToggleEvent.toggle() = _Toggle; - const factory LocalAIToggleEvent.handleResult( - FlowyResult result, - ) = _HandleResult; +class LocalAiPluginEvent with _$LocalAiPluginEvent { + const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) = + _DidReceiveAiState; + const factory LocalAiPluginEvent.didReceiveLackOfResources( + LackOfAIResourcePB resources, + ) = _DidReceiveLackOfResources; + const factory LocalAiPluginEvent.toggle() = _Toggle; + const factory LocalAiPluginEvent.restart() = _Restart; } -@freezed -class LocalAIToggleState with _$LocalAIToggleState { - const factory LocalAIToggleState({ - @Default(LocalAIToggleStateIndicator.loading()) - LocalAIToggleStateIndicator pageIndicator, - }) = _LocalAIToggleState; +sealed class LocalAiPluginState { + const LocalAiPluginState(); } -@freezed -class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator { - // when start downloading the model - const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError; - const factory LocalAIToggleStateIndicator.isEnabled(bool isEnabled) = _Ready; - const factory LocalAIToggleStateIndicator.loading() = _Loading; +class ReadyLocalAiPluginState extends LocalAiPluginState { + const ReadyLocalAiPluginState({ + required this.isEnabled, + required this.runningState, + required this.lackOfResource, + }); + + final bool isEnabled; + final RunningStatePB runningState; + final LackOfAIResourcePB? lackOfResource; +} + +class LoadingLocalAiPluginState extends LocalAiPluginState { + const LoadingLocalAiPluginState(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart index 2c1bf34a87..3bb26a182b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart @@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc _dispatch(); } - Future _onPaymentSuccessful() async { + void _onPaymentSuccessful() { if (isClosed) { return; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart deleted file mode 100644 index f6d5ef949d..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'local_ai_setting_panel_bloc.freezed.dart'; - -class LocalAISettingPanelBloc - extends Bloc { - LocalAISettingPanelBloc() - : listener = LocalAIStateListener(), - super(const LocalAISettingPanelState()) { - on(_handleEvent); - - listener.start( - stateCallback: (newState) { - if (!isClosed) { - add(LocalAISettingPanelEvent.updateAIState(newState)); - } - }, - ); - - AIEventGetLocalAIState().send().fold( - (localAIState) { - if (!isClosed) { - add(LocalAISettingPanelEvent.updateAIState(localAIState)); - } - }, - Log.error, - ); - } - - final LocalAIStateListener listener; - - /// Handles incoming events and dispatches them to the appropriate handler. - Future _handleEvent( - LocalAISettingPanelEvent event, - Emitter emit, - ) async { - event.when( - updateAIState: (LocalAIPB pluginState) { - if (pluginState.isPluginExecutableReady) { - emit( - state.copyWith( - runningState: pluginState.state, - progressIndicator: const LocalAIProgress.checkPluginState(), - ), - ); - } else { - emit( - state.copyWith( - progressIndicator: const LocalAIProgress.downloadLocalAIApp(), - ), - ); - } - }, - ); - } - - @override - Future close() async { - await listener.stop(); - return super.close(); - } -} - -@freezed -class LocalAISettingPanelEvent with _$LocalAISettingPanelEvent { - const factory LocalAISettingPanelEvent.updateAIState( - LocalAIPB aiState, - ) = _UpdateAIState; -} - -@freezed -class LocalAISettingPanelState with _$LocalAISettingPanelState { - const factory LocalAISettingPanelState({ - LocalAIProgress? progressIndicator, - @Default(RunningStatePB.Connecting) RunningStatePB runningState, - }) = _LocalAIChatSettingState; -} - -@freezed -class LocalAIProgress with _$LocalAIProgress { - const factory LocalAIProgress.checkPluginState() = _CheckPluginStateProgress; - const factory LocalAIProgress.downloadLocalAIApp() = - _DownloadLocalAIAppProgress; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart index 8555d8cdc8..f5c4209028 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart @@ -80,11 +80,9 @@ class OllamaSettingBloc extends Bloc { } add(OllamaSettingEvent.updateSetting(setting)); AIEventUpdateLocalAISetting(setting).send().fold( - (_) { - Log.info('AI setting updated successfully'); - }, - (err) => Log.error("update ai setting failed: $err"), - ); + (_) => Log.info('AI setting updated successfully'), + (err) => Log.error("update ai setting failed: $err"), + ); }, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart deleted file mode 100644 index 4c3130ea00..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'plugin_state_bloc.freezed.dart'; - -class PluginStateBloc extends Bloc { - PluginStateBloc() - : listener = LocalAIStateListener(), - super( - const PluginStateState( - action: PluginStateAction.unknown(), - ), - ) { - listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(PluginStateEvent.updateLocalAIState(pluginState)); - } - }, - resourceCallback: (data) { - if (!isClosed) { - add(PluginStateEvent.resourceStateChange(data)); - } - }, - ); - - on(_handleEvent); - } - - final LocalAIStateListener listener; - - @override - Future close() async { - await listener.stop(); - return super.close(); - } - - Future _handleEvent( - PluginStateEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIState().send(); - result.fold( - (pluginState) { - if (!isClosed) { - add(PluginStateEvent.updateLocalAIState(pluginState)); - } - }, - (err) => Log.error(err.toString()), - ); - }, - updateLocalAIState: (LocalAIPB aiState) { - // if the offline ai is not started, ask user to start it - if (aiState.hasLackOfResource()) { - emit( - PluginStateState( - action: PluginStateAction.lackOfResource(aiState.lackOfResource), - ), - ); - return; - } - - // Chech state of the plugin - switch (aiState.state) { - case RunningStatePB.ReadyToRun: - emit( - const PluginStateState( - action: PluginStateAction.readToRun(), - ), - ); - - case RunningStatePB.Connecting: - emit( - const PluginStateState( - action: PluginStateAction.initializingPlugin(), - ), - ); - case RunningStatePB.Connected: - emit( - const PluginStateState( - action: PluginStateAction.initializingPlugin(), - ), - ); - break; - case RunningStatePB.Running: - emit(const PluginStateState(action: PluginStateAction.running())); - break; - case RunningStatePB.Stopped: - emit( - state.copyWith(action: const PluginStateAction.restartPlugin()), - ); - default: - break; - } - }, - restartLocalAI: () async { - emit( - const PluginStateState(action: PluginStateAction.restartPlugin()), - ); - unawaited(AIEventRestartLocalAI().send()); - }, - resourceStateChange: (data) { - emit( - PluginStateState( - action: PluginStateAction.lackOfResource(data.resourceDesc), - ), - ); - }, - ); - } -} - -@freezed -class PluginStateEvent with _$PluginStateEvent { - const factory PluginStateEvent.started() = _Started; - const factory PluginStateEvent.updateLocalAIState(LocalAIPB aiState) = - _UpdateLocalAIState; - const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI; - const factory PluginStateEvent.resourceStateChange(LackOfAIResourcePB data) = - _ResourceStateChange; -} - -@freezed -class PluginStateState with _$PluginStateState { - const factory PluginStateState({ - required PluginStateAction action, - }) = _PluginStateState; -} - -@freezed -class PluginStateAction with _$PluginStateAction { - const factory PluginStateAction.unknown() = _Unknown; - const factory PluginStateAction.readToRun() = _ReadyToRun; - const factory PluginStateAction.initializingPlugin() = _InitializingPlugin; - const factory PluginStateAction.running() = _PluginRunning; - const factory PluginStateAction.restartPlugin() = _RestartPlugin; - const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource; -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart deleted file mode 100644 index 2de410a5e5..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/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 InitLocalAIIndicator extends StatelessWidget { - const InitLocalAIIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all( - Radius.circular(4), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: BlocBuilder( - builder: (context, state) { - switch (state.runningState) { - case RunningStatePB.Connecting: - case RunningStatePB.Connected: - return Row( - children: [ - const HSpace(8), - Expanded( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIInitializing - .tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ), - ], - ); - case RunningStatePB.Running: - return SizedBox( - height: 30, - child: Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - ), - ], - ), - ); - case RunningStatePB.Stopped: - return Row( - children: [ - const HSpace(8), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStopped.tr(), - fontSize: 11, - color: const Color(0xFFC62828), - ), - ], - ); - default: - return const SizedBox.shrink(); - } - }, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 57a72b6ca1..080dc426d1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -1,130 +1,174 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.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 LocalAISetting extends StatelessWidget { +import 'ollama_setting.dart'; +import 'plugin_status_indicator.dart'; + +class LocalAISetting extends StatefulWidget { const LocalAISetting({super.key}); + @override + State createState() => _LocalAISettingState(); +} + +class _LocalAISettingState extends State { + final expandableController = ExpandableController(initialExpanded: false); + + @override + void dispose() { + expandableController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => - LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: ExpandableNotifier( - child: BlocListener( - listener: (context, state) { - final controller = - ExpandableController.of(context, required: true)!; - - state.pageIndicator.when( - error: (_) => controller.expanded = true, - isEnabled: (enabled) => controller.expanded = enabled, - loading: () => controller.expanded = true, - ); - }, - child: ExpandablePanel( - theme: const ExpandableThemeData( - headerAlignment: ExpandablePanelHeaderAlignment.center, - tapBodyToCollapse: false, - hasIcon: false, - tapBodyToExpand: false, - tapHeaderToExpand: false, - ), - header: const LocalAISettingHeader(), - collapsed: const SizedBox.shrink(), - expanded: Column( - children: [ - const VSpace(12), - DecoratedBox( - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), - child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: LocalAISettingPanel(), - ), - ), - ], - ), + create: (context) => LocalAiPluginBloc(), + child: BlocConsumer( + listener: (context, state) { + final newIsExpanded = switch (state) { + final ReadyLocalAiPluginState readyLocalAiToggleState => + readyLocalAiToggleState.isEnabled, + _ => false, + }; + expandableController.value = newIsExpanded; + }, + builder: (context, state) { + return ExpandablePanel( + controller: expandableController, + theme: ExpandableThemeData( + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, ), - ), - ), + header: LocalAiSettingHeader( + isEnabled: state is ReadyLocalAiPluginState && state.isEnabled, + isToggleable: state is ReadyLocalAiPluginState, + ), + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: EdgeInsets.only(top: 12), + child: LocalAISettingPanel(), + ), + ); + }, ), ); } } -class LocalAISettingHeader extends StatelessWidget { - const LocalAISettingHeader({super.key}); +class LocalAiSettingHeader extends StatelessWidget { + const LocalAiSettingHeader({ + super.key, + required this.isEnabled, + required this.isToggleable, + }); + + final bool isEnabled; + final bool isToggleable; @override Widget build(BuildContext context) { - return BlocBuilder( + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), + ), + const VSpace(4), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), + maxLines: 3, + fontSize: 12, + ), + ], + ), + ), + IgnorePointer( + ignoring: !isToggleable, + child: Opacity( + opacity: isToggleable ? 1 : 0.5, + child: Toggle( + value: isEnabled, + onChanged: (_) => _onToggleChanged(context), + ), + ), + ), + ], + ); + } + + void _onToggleChanged(BuildContext context) { + if (isEnabled) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(), + description: + LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () { + context + .read() + .add(const LocalAiPluginEvent.toggle()); + }, + ); + } else { + context.read().add(const LocalAiPluginEvent.toggle()); + } + } +} + +class LocalAISettingPanel extends StatelessWidget { + const LocalAISettingPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( builder: (context, state) { - return state.pageIndicator.when( - error: (error) => SizedBox.shrink(), - loading: () => const SizedBox.shrink(), - isEnabled: (isEnabled) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - FlowyText.medium( - LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), - ), - const Spacer(), - Toggle( - value: isEnabled, - onChanged: (_) { - if (isEnabled) { - showConfirmDialog( - context: context, - title: LocaleKeys - .settings_aiPage_keys_disableLocalAITitle - .tr(), - description: LocaleKeys - .settings_aiPage_keys_disableLocalAIDescription - .tr(), - confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () => context - .read() - .add(const LocalAIToggleEvent.toggle()), - ); - } else { - context - .read() - .add(const LocalAIToggleEvent.toggle()); - } - }, - ), - ], - ), - const VSpace(4), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), - maxLines: 3, - fontSize: 12, - ), - ], - ); - }, + if (state is! ReadyLocalAiPluginState) { + return const SizedBox.shrink(); + } + + final showIndicator = _showIndicator(state); + final showSettings = _showSettings(state); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showIndicator) const LocalAIStatusIndicator(), + if (showSettings && showIndicator) const VSpace(10), + if (showSettings) OllamaSettingPage(), + ], ); }, ); } + + bool _showIndicator(ReadyLocalAiPluginState state) { + return state.runningState != RunningStatePB.Running || + state.lackOfResource != null; + } + + bool _showSettings(ReadyLocalAiPluginState state) { + return ![ + RunningStatePB.Connecting, + RunningStatePB.Connected, + ].contains(state.runningState) && + (state.lackOfResource == null || + state.lackOfResource!.resourceType == + LackOfAIResourceTypePB.MissingModel); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart deleted file mode 100644 index 5357db5c91..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'plugin_state.dart'; - -class LocalAISettingPanel extends StatelessWidget { - const LocalAISettingPanel({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => LocalAISettingPanelBloc(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - OllamaSettingPage(), - VSpace(6), - PluginStateIndicator(), - ], - ); - }, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart new file mode 100644 index 0000000000..2abcb552d1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OllamaSettingPage extends StatelessWidget { + const OllamaSettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + OllamaSettingBloc()..add(const OllamaSettingEvent.started()), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.inputItems != current.inputItems || + previous.isEdited != current.isEdited, + builder: (context, state) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + padding: EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + for (final item in state.inputItems) + _SettingItemWidget(item: item), + _SaveButton(isEdited: state.isEdited), + ], + ), + ); + }, + ), + ); + } +} + +class _SettingItemWidget extends StatelessWidget { + const _SettingItemWidget({required this.item}); + + final SettingItem item; + + @override + Widget build(BuildContext context) { + return Column( + key: ValueKey(item.content + item.settingType.title), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + item.settingType.title, + fontSize: 12, + figmaLineHeight: 16, + ), + const VSpace(4), + SizedBox( + height: 32, + child: FlowyTextField( + hintText: item.hintText, + text: item.content, + onChanged: (content) { + context.read().add( + OllamaSettingEvent.onEdit(content, item.settingType), + ); + }, + ), + ), + ], + ); + } +} + +class _SaveButton extends StatelessWidget { + const _SaveButton({required this.isEdited}); + + final bool isEdited; + + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.centerEnd, + child: FlowyTooltip( + message: isEdited ? null : 'No changes', + child: SizedBox( + child: FlowyButton( + text: FlowyText( + 'Apply', + figmaLineHeight: 20, + color: Theme.of(context).colorScheme.onPrimary, + ), + disable: !isEdited, + expandText: false, + margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200), + onTap: () { + if (isEdited) { + context + .read() + .add(const OllamaSettingEvent.submit()); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart deleted file mode 100644 index 8af4e35914..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class OllamaSettingPage extends StatelessWidget { - const OllamaSettingPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - OllamaSettingBloc()..add(const OllamaSettingEvent.started()), - child: BlocBuilder( - buildWhen: (previous, current) => - previous.inputItems != current.inputItems || - previous.isEdited != current.isEdited, - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListView.separated( - physics: NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.inputItems.length, - separatorBuilder: (_, __) => const VSpace(10), - itemBuilder: (context, index) { - final item = state.inputItems[index]; - return _SettingItemWidget(item: item); - }, - ), - const VSpace(6), - Opacity( - opacity: 0.6, - child: _InstallOllamaInstruction(), - ), - _SaveButton(isEdited: state.isEdited), - ], - ); - }, - ), - ); - } -} - -class _SettingItemWidget extends StatelessWidget { - const _SettingItemWidget({required this.item}); - final SettingItem item; - - @override - Widget build(BuildContext context) { - return Column( - key: ValueKey(item.content + item.settingType.title), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText(item.settingType.title), - const VSpace(8), - FlowyTextField( - hintText: item.hintText, - text: item.content, - onChanged: (content) { - context.read().add( - OllamaSettingEvent.onEdit(content, item.settingType), - ); - }, - ), - ], - ); - } -} - -class _SaveButton extends StatelessWidget { - const _SaveButton({required this.isEdited}); - - final bool isEdited; - - @override - Widget build(BuildContext context) { - final tooltipMessage = isEdited ? 'Click to apply changes' : 'No changes'; - return SizedBox( - height: 50, - child: Row( - children: [ - const Spacer(), - SizedBox( - width: 120, - child: FlowyTooltip( - message: tooltipMessage, - child: Opacity( - opacity: isEdited ? 1 : 0.5, - child: FlowyTextButton( - 'Apply', - mainAxisAlignment: MainAxisAlignment.center, - onPressed: isEdited - ? () { - context - .read() - .add(const OllamaSettingEvent.submit()); - } - : null, - ), - ), - ), - ), - ], - ), - ); - } -} - -class _InstallOllamaInstruction extends StatelessWidget { - const _InstallOllamaInstruction(); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - maxLines: 3, - textAlign: TextAlign.left, - text: TextSpan( - children: [ - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString( - "https://appflowy.com/guide/appflowy-local-ai-ollama", - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - ], - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart deleted file mode 100644 index ab9303b429..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; -import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class PluginStateIndicator extends StatelessWidget { - const PluginStateIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - PluginStateBloc()..add(const PluginStateEvent.started()), - child: BlocBuilder( - builder: (context, state) { - return state.action.when( - unknown: () => const SizedBox.shrink(), - readToRun: () => const _PrepareRunning(), - initializingPlugin: () => const InitLocalAIIndicator(), - running: () => const _LocalAIRunning(), - restartPlugin: () => const _RestartPluginButton(), - lackOfResource: (desc) => _LackOfResource(desc: desc), - ); - }, - ), - ); - } -} - -class _PrepareRunning extends StatelessWidget { - const _PrepareRunning(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStart.tr(), - maxLines: 3, - ), - ), - ], - ); - } -} - -class _RestartPluginButton extends StatelessWidget { - const _RestartPluginButton(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const FlowySvg( - FlowySvgs.download_warn_s, - color: Color(0xFFC62828), - ), - const HSpace(6), - FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()), - const Spacer(), - SizedBox( - height: 30, - child: FlowyButton( - useIntrinsicWidth: true, - text: - FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()), - onTap: () { - context.read().add( - const PluginStateEvent.restartLocalAI(), - ); - }, - ), - ), - ], - ); - } -} - -class _LocalAIRunning extends StatelessWidget { - const _LocalAIRunning(); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all( - Radius.circular(4), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - Flexible( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class OpenOrDownloadOfflineAIApp extends StatelessWidget { - const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); - - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DownloadOfflineAIBloc(), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - maxLines: 3, - textAlign: TextAlign.left, - text: TextSpan( - children: [ - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = - () => context.read().add( - const DownloadOfflineAIEvent.started(), - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } -} - -class _LackOfResource extends StatelessWidget { - const _LackOfResource({required this.desc}); - - final String desc; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - FlowySvgs.toast_warning_filled_s, - size: const Size.square(20.0), - blendMode: null, - ), - const HSpace(6), - Expanded( - child: FlowyText( - desc, - maxLines: 3, - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart new file mode 100644 index 0000000000..b79e228a0c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart @@ -0,0 +1,309 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalAIStatusIndicator extends StatelessWidget { + const LocalAIStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final readyState = switch (state) { + final ReadyLocalAiPluginState readyLocalAiToggleState => + readyLocalAiToggleState, + _ => null, + }; + if (readyState == null) { + return const SizedBox.shrink(); + } + + final lackOfResource = readyState.lackOfResource; + if (lackOfResource != null) { + return _LackOfResource(resource: lackOfResource); + } + + return switch (readyState.runningState) { + RunningStatePB.ReadyToRun => const _ReadyToRun(), + RunningStatePB.Connecting || + RunningStatePB.Connected => + _Initializing(), + RunningStatePB.Running => const _LocalAIRunning(), + RunningStatePB.Stopped => const _RestartPluginButton(), + _ => const SizedBox.shrink(), + }; + }, + ); + } +} + +class _ReadyToRun extends StatelessWidget { + const _ReadyToRun(); + + @override + Widget build(BuildContext context) { + return FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStart.tr(), + maxLines: 3, + ); + } +} + +class _Initializing extends StatelessWidget { + const _Initializing(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + padding: const EdgeInsets.all(8.0), + width: double.infinity, + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ); + } +} + +class _RestartPluginButton extends StatelessWidget { + const _RestartPluginButton(); + + @override + Widget build(BuildContext context) { + final textStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.toast_warning_filled_s, + color: Color(0xFFC62828), + ), + const HSpace(8), + Expanded( + child: RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: + LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _LocalAIRunning extends StatelessWidget { + const _LocalAIRunning(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), + ); + } +} + +class _LackOfResource extends StatelessWidget { + const _LackOfResource({required this.resource}); + + final LackOfAIResourcePB resource; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + FlowySvg( + FlowySvgs.toast_error_filled_s, + size: const Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: switch (resource.resourceType) { + LackOfAIResourceTypePB.PluginExecutableNotReady => + _buildNoLAI(context), + LackOfAIResourceTypePB.OllamaServerNotReady => + _buildNoOllama(context), + LackOfAIResourceTypePB.MissingModel => + _buildNoModel(context, resource.missingModelNames), + _ => const SizedBox.shrink(), + }, + ), + ], + ), + ); + } + + TextStyle? _textStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + } + + Widget _buildNoLAI(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: _textStyle(context)), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoOllama(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: textStyle), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoModel(BuildContext context, List modelNames) { + final textStyle = _textStyle(context); + + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + ], + ), + ); + } + + List _downloadInstructions(TextStyle? textStyle) { + return [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan(text: ' ', style: textStyle), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr(), + style: textStyle, + ), + ]; + } +} + +void onDownloadButtonPressed() { + AIEventGetLocalAIDownloadLink().send().fold( + (app) => afLaunchUri(Uri.parse(app.link)), + (err) {}, + ); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2b22da596c..01c3a51b19 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -854,14 +854,14 @@ "downloadAIModelButton": "Download", "downloadingModel": "Downloading", "localAILoaded": "Local AI Model successfully added and ready to use", - "localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on", + "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", "localAIRunning": "Local AI is running", - "localAIInitializing": "Local AI is loading and may take a few minutes, depending on your device", + "localAIInitializing": "Local AI is loading. This may take a few minutes depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI", - "restartLocalAI": "Restart Local AI", + "restartLocalAI": "Restart", "disableLocalAITitle": "Disable local AI", "disableLocalAIDescription": "Do you want to disable local AI?", "localAIToggleTitle": "AppFlowy Local AI (LAI)", @@ -875,10 +875,12 @@ "activeOfflineAI": "Active", "downloadOfflineAI": "Download", "openModelDirectory": "Open folder", - "localAISetupInstruction1": "Follow these", + "localAISetupInstruction1": "Please follow these", "localAISetupInstruction2": "instructions", - "localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI. Skip if you've already set it up", - "startLocalAI": "It may take a few seconds to start the local AI" + "localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI.", + "laiNotReady": "The Local AI app was not installed correctly.", + "ollamaNotReady": "The Ollama server is not ready.", + "modelsMissing": "Cannot find the model. You can use the ollama pull command to install them." } }, "planPage": { diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index b24d0cb13e..a7915aae23 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -589,7 +589,7 @@ pub struct LocalAIPB { pub is_plugin_executable_ready: bool, #[pb(index = 3, one_of)] - pub lack_of_resource: Option, + pub lack_of_resource: Option, #[pb(index = 4)] pub state: RunningStatePB, @@ -724,5 +724,35 @@ impl From for LocalAISetting { #[derive(Default, ProtoBuf, Clone, Debug)] pub struct LackOfAIResourcePB { #[pb(index = 1)] - pub resource_desc: String, + pub resource_type: LackOfAIResourceTypePB, + + #[pb(index = 2)] + pub missing_model_names: Vec, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum)] +pub enum LackOfAIResourceTypePB { + #[default] + PluginExecutableNotReady = 0, + OllamaServerNotReady = 1, + MissingModel = 2, +} + +impl From for LackOfAIResourcePB { + fn from(value: PendingResource) -> Self { + match value { + PendingResource::PluginExecutableNotReady => Self { + resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady, + missing_model_names: vec![], + }, + PendingResource::OllamaServerNotReady => Self { + resource_type: LackOfAIResourceTypePB::OllamaServerNotReady, + missing_model_names: vec![], + }, + PendingResource::MissingModel(model_name) => Self { + resource_type: LackOfAIResourceTypePB::MissingModel, + missing_model_names: vec![model_name], + }, + } + } } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index c3f57e6fac..bca710a907 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -1,5 +1,5 @@ use crate::ai_manager::AIUserService; -use crate::entities::{LackOfAIResourcePB, LocalAIPB, RunningStatePB}; +use crate::entities::{LocalAIPB, RunningStatePB}; use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, @@ -274,14 +274,15 @@ impl LocalAIController { pub async fn get_local_ai_state(&self) -> LocalAIPB { let start = std::time::Instant::now(); let enabled = self.is_enabled(); - let mut is_plugin_executable_ready = false; - let mut state = RunningState::ReadyToConnect; - let mut lack_of_resource = None; - if enabled { - is_plugin_executable_ready = is_plugin_ready(); - state = self.ai_plugin.get_plugin_running_state(); - lack_of_resource = self.resource.get_lack_of_resource().await; - } + let (is_plugin_executable_ready, state, lack_of_resource) = if enabled { + ( + is_plugin_ready(), + self.ai_plugin.get_plugin_running_state(), + self.resource.get_lack_of_resource().await, + ) + } else { + (false, RunningState::ReadyToConnect, None) + }; let elapsed = start.elapsed(); debug!( "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", @@ -482,9 +483,7 @@ async fn initialize_ai_plugin( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(LackOfAIResourcePB { - resource_desc: lack_of_resource, - }) + .payload(lack_of_resource) .send(); return Ok(()); diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index 3eddcf2039..c40a9a9b88 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -47,22 +47,13 @@ pub enum WatchDiskEvent { Remove, } +#[derive(Debug, Clone)] pub enum PendingResource { PluginExecutableNotReady, OllamaServerNotReady, MissingModel(String), } -impl PendingResource { - pub fn desc(self) -> String { - match self { - PendingResource::PluginExecutableNotReady => "The Local AI app was not installed correctly. Please follow the instructions to install the Local AI application".to_string(), - PendingResource::OllamaServerNotReady => "Ollama is not ready. Please follow the instructions to install Ollama".to_string(), - PendingResource::MissingModel(model) => format!("Cannot find the model: {}. Please use the ollama pull command to install the model", model), - } - } -} - pub struct LocalAIResourceController { user_service: Arc, resource_service: Arc, @@ -128,10 +119,10 @@ impl LocalAIResourceController { return false; } - match self.calculate_pending_resources().await { - Ok(res) => res.is_empty(), - Err(_) => false, - } + self + .calculate_pending_resources() + .await + .is_ok_and(|r| r.is_none()) } pub async fn get_plugin_download_link(&self) -> FlowyResult { @@ -147,36 +138,35 @@ impl LocalAIResourceController { #[instrument(level = "info", skip_all, err)] pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { self.resource_service.store_setting(setting)?; - let mut resources = self.calculate_pending_resources().await?; - if let Some(resource) = resources.pop() { + if let Some(resource) = self.calculate_pending_resources().await? { chat_notification_builder( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(LackOfAIResourcePB { - resource_desc: resource.desc(), - }) + .payload(LackOfAIResourcePB::from(resource)) .send(); } Ok(()) } - pub async fn get_lack_of_resource(&self) -> Option { - let mut resources = self.calculate_pending_resources().await.ok()?; - resources.pop().map(|r| r.desc()) + pub async fn get_lack_of_resource(&self) -> Option { + self + .calculate_pending_resources() + .await + .ok()? + .map(Into::into) } - pub async fn calculate_pending_resources(&self) -> FlowyResult> { - let mut resources = vec![]; + pub async fn calculate_pending_resources(&self) -> FlowyResult> { let app_path = ollama_plugin_path(); if !is_plugin_ready() { trace!("[LLM Resource] offline app not found: {:?}", app_path); - resources.push(PendingResource::PluginExecutableNotReady); - return Ok(resources); + return Ok(Some(PendingResource::PluginExecutableNotReady)); } let setting = self.get_llm_setting(); let client = Client::builder().timeout(Duration::from_secs(5)).build()?; + match client.get(&setting.ollama_server_url).send().await { Ok(resp) if resp.status().is_success() => { info!( @@ -189,8 +179,7 @@ impl LocalAIResourceController { "[LLM Resource] Ollama server is not responding at {}", setting.ollama_server_url ); - resources.push(PendingResource::OllamaServerNotReady); - return Ok(resources); + return Ok(Some(PendingResource::OllamaServerNotReady)); }, } @@ -201,12 +190,8 @@ impl LocalAIResourceController { match client.get(&tags_url).send().await { Ok(resp) if resp.status().is_success() => { - let tags: TagsResponse = resp.json().await.map_err(|e| { - log::error!( - "[LLM Resource] Failed to parse /api/tags JSON response: {:?}", - e - ); - e + let tags: TagsResponse = resp.json().await.inspect_err(|e| { + log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") })?; // Check each required model is present in the response. for required in &required_models { @@ -215,9 +200,7 @@ impl LocalAIResourceController { "[LLM Resource] required model '{}' not found in API response", required ); - resources.push(PendingResource::MissingModel(required.clone())); - // Optionally, you could continue checking all models rather than returning early. - return Ok(resources); + return Ok(Some(PendingResource::MissingModel(required.clone()))); } } }, @@ -226,12 +209,11 @@ impl LocalAIResourceController { "[LLM Resource] Failed to fetch models from {} (GET /api/tags)", setting.ollama_server_url ); - resources.push(PendingResource::OllamaServerNotReady); - return Ok(resources); + return Ok(Some(PendingResource::OllamaServerNotReady)); }, } - Ok(resources) + Ok(None) } #[instrument(level = "info", skip_all)] From 34a858e94836466447f717cc2f0cd142ce34bbb7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 09:02:06 +0800 Subject: [PATCH 068/207] chore: show plugin version --- .../ai/download_offline_ai_app_bloc.dart | 41 ------- .../ai/local_ai_setting_panel_bloc.dart | 2 +- .../settings/ai/plugin_state_bloc.dart | 8 +- .../pages/setting_ai_view/plugin_state.dart | 105 ++---------------- frontend/resources/translations/ar-SA.json | 4 +- frontend/resources/translations/en.json | 4 +- frontend/resources/translations/ko-KR.json | 4 +- frontend/rust-lib/Cargo.lock | 6 +- frontend/rust-lib/Cargo.toml | 6 +- frontend/rust-lib/flowy-ai/src/entities.rs | 17 ++- .../rust-lib/flowy-ai/src/event_handler.rs | 9 -- frontend/rust-lib/flowy-ai/src/event_map.rs | 4 - .../flowy-ai/src/local_ai/controller.rs | 22 ++-- 13 files changed, 49 insertions(+), 183 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart deleted file mode 100644 index 185a8c049f..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; -part 'download_offline_ai_app_bloc.freezed.dart'; - -class DownloadOfflineAIBloc - extends Bloc { - DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { - on(_handleEvent); - } - - Future _handleEvent( - DownloadOfflineAIEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIDownloadLink().send(); - await result.fold( - (app) async { - await launchUrl(Uri.parse(app.link)); - }, - (err) {}, - ); - }, - ); - } -} - -@freezed -class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { - const factory DownloadOfflineAIEvent.started() = _Started; -} - -@freezed -class DownloadOfflineAIState with _$DownloadOfflineAIState { - const factory DownloadOfflineAIState() = _DownloadOfflineAIState; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart index f6d5ef949d..60c68b70c6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart @@ -44,7 +44,7 @@ class LocalAISettingPanelBloc ) async { event.when( updateAIState: (LocalAIPB pluginState) { - if (pluginState.isPluginExecutableReady) { + if (pluginState.pluginDownloaded) { emit( state.copyWith( runningState: pluginState.state, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart index 4c3130ea00..d91d7151ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -91,7 +91,11 @@ class PluginStateBloc extends Bloc { ); break; case RunningStatePB.Running: - emit(const PluginStateState(action: PluginStateAction.running())); + emit( + PluginStateState( + action: PluginStateAction.running(aiState.pluginVersion), + ), + ); break; case RunningStatePB.Stopped: emit( @@ -140,7 +144,7 @@ class PluginStateAction with _$PluginStateAction { const factory PluginStateAction.unknown() = _Unknown; const factory PluginStateAction.readToRun() = _ReadyToRun; const factory PluginStateAction.initializingPlugin() = _InitializingPlugin; - const factory PluginStateAction.running() = _PluginRunning; + const factory PluginStateAction.running(String version) = _PluginRunning; const factory PluginStateAction.restartPlugin() = _RestartPlugin; const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart index ab9303b429..b8461eb141 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart @@ -1,15 +1,11 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -27,7 +23,7 @@ class PluginStateIndicator extends StatelessWidget { unknown: () => const SizedBox.shrink(), readToRun: () => const _PrepareRunning(), initializingPlugin: () => const InitLocalAIIndicator(), - running: () => const _LocalAIRunning(), + running: (version) => _LocalAIRunning(version: version), restartPlugin: () => const _RestartPluginButton(), lackOfResource: (desc) => _LackOfResource(desc: desc), ); @@ -88,7 +84,9 @@ class _RestartPluginButton extends StatelessWidget { } class _LocalAIRunning extends StatelessWidget { - const _LocalAIRunning(); + const _LocalAIRunning({required this.version}); + + final String version; @override Widget build(BuildContext context) { @@ -115,7 +113,11 @@ class _LocalAIRunning extends StatelessWidget { const HSpace(6), Flexible( child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), + LocaleKeys.settings_aiPage_keys_localAIRunning.tr( + args: [ + version, + ], + ), fontSize: 11, color: const Color(0xFF1E4620), maxLines: 3, @@ -131,95 +133,6 @@ class _LocalAIRunning extends StatelessWidget { } } -class OpenOrDownloadOfflineAIApp extends StatelessWidget { - const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); - - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DownloadOfflineAIBloc(), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - maxLines: 3, - textAlign: TextAlign.left, - text: TextSpan( - children: [ - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = - () => context.read().add( - const DownloadOfflineAIEvent.started(), - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } -} - class _LackOfResource extends StatelessWidget { const _LackOfResource({required this.desc}); diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 62708e664f..bb4534d855 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -857,7 +857,7 @@ "localAIStart": "بدأت الدردشة المحلية بالذكاء الاصطناعي...", "localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...", "localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي", - "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل", + "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل. الإصدار: {}", "localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك", "localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي", "failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي", @@ -3217,4 +3217,4 @@ "rewrite": "إعادة كتابة", "insertBelow": "أدخل أدناه" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2b22da596c..8129157941 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -857,7 +857,7 @@ "localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", - "localAIRunning": "Local AI is running", + "localAIRunning": "Local AI is running. Version: {}", "localAIInitializing": "Local AI is loading and may take a few minutes, depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI", @@ -3202,4 +3202,4 @@ "rewrite": "Rewrite", "insertBelow": "Insert below" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index f956327073..7ed8373c72 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -851,7 +851,7 @@ "localAIStart": "로컬 AI가 시작 중입니다. 느리다면 껐다가 다시 켜보세요", "localAILoading": "로컬 AI 채팅 모델이 로드 중입니다...", "localAIStopped": "로컬 AI가 중지되었습니다", - "localAIRunning": "로컬 AI가 실행 중입니다", + "localAIRunning": "로컬 AI가 실행 중입니다. 버전: {}", "localAIInitializing": "로컬 AI가 로드 중이며 장치에 따라 몇 분이 소요될 수 있습니다", "localAINotReadyTextFieldPrompt": "로컬 AI가 로드되는 동안 편집할 수 없습니다", "failToLoadLocalAI": "로컬 AI를 시작하지 못했습니다", @@ -3185,4 +3185,4 @@ "rewrite": "다시 작성", "insertBelow": "아래에 삽입" } -} +} \ No newline at end of file diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 01b9251e23..6bc02bda8a 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=593bc3fbedf2a2c7e00ec28b10f2268c1983be65#593bc3fbedf2a2c7e00ec28b10f2268c1983be65" dependencies = [ "af-plugin", "anyhow", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=593bc3fbedf2a2c7e00ec28b10f2268c1983be65#593bc3fbedf2a2c7e00ec28b10f2268c1983be65" dependencies = [ "anyhow", "futures-util", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=593bc3fbedf2a2c7e00ec28b10f2268c1983be65#593bc3fbedf2a2c7e00ec28b10f2268c1983be65" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index b4a0a34b14..6baa7dea3e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "593bc3fbedf2a2c7e00ec28b10f2268c1983be65" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "593bc3fbedf2a2c7e00ec28b10f2268c1983be65" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "593bc3fbedf2a2c7e00ec28b10f2268c1983be65" } diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index b24d0cb13e..30a3e5dd28 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -585,20 +585,17 @@ pub struct LocalAIPB { #[pb(index = 1)] pub enabled: bool, - #[pb(index = 2)] - pub is_plugin_executable_ready: bool, - - #[pb(index = 3, one_of)] + #[pb(index = 2, one_of)] pub lack_of_resource: Option, - #[pb(index = 4)] + #[pb(index = 3)] pub state: RunningStatePB, -} -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct LocalAIAppLinkPB { - #[pb(index = 1)] - pub link: String, + #[pb(index = 4, one_of)] + pub plugin_version: Option, + + #[pb(index = 5)] + pub plugin_downloaded: bool, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index ec8b7b4964..3150d3e378 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -301,15 +301,6 @@ pub(crate) async fn get_local_ai_state_handler( data_result_ok(state) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_offline_app_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let link = ai_manager.local_ai.get_plugin_download_link().await?; - data_result_ok(LocalAIAppLinkPB { link }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub(crate) async fn create_chat_context_handler( data: AFPluginData, diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs index 0e24ca6a21..51c49eaabb 100644 --- a/frontend/rust-lib/flowy-ai/src/event_map.rs +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -34,7 +34,6 @@ pub fn init(ai_manager: Weak) -> AFPlugin { .event(AIEvent::RestartLocalAI, restart_local_ai_handler) .event(AIEvent::ToggleLocalAI, toggle_local_ai_handler) .event(AIEvent::GetLocalAIState, get_local_ai_state_handler) - .event(AIEvent::GetLocalAIDownloadLink, get_offline_app_handler) .event(AIEvent::GetLocalAISetting, get_local_ai_setting_handler) .event( AIEvent::UpdateLocalAISetting, @@ -97,9 +96,6 @@ pub enum AIEvent { #[event(output = "LocalAIPB")] GetLocalAIState = 19, - #[event(output = "LocalAIAppLinkPB")] - GetLocalAIDownloadLink = 22, - #[event(input = "CreateChatContextPB")] CreateChatContext = 23, diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index c3f57e6fac..1bbe356e2b 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -98,11 +98,11 @@ impl LocalAIController { if let Ok(workspace_id) = cloned_user_service.workspace_id() { let key = local_ai_enabled_key(&workspace_id); info!("[AI Plugin] state: {:?}", state); - let mut ready = false; + let mut plugin_downloaded = false; let mut lack_of_resource = None; let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { - ready = is_plugin_ready(); + plugin_downloaded = is_plugin_ready(); lack_of_resource = cloned_llm_res.get_lack_of_resource().await; } @@ -113,9 +113,10 @@ impl LocalAIController { ) .payload(LocalAIPB { enabled, - is_plugin_executable_ready: ready, + plugin_downloaded, lack_of_resource, state: new_state, + plugin_version: None, }) .send(); } @@ -274,12 +275,14 @@ impl LocalAIController { pub async fn get_local_ai_state(&self) -> LocalAIPB { let start = std::time::Instant::now(); let enabled = self.is_enabled(); - let mut is_plugin_executable_ready = false; + let mut plugin_downloaded = false; let mut state = RunningState::ReadyToConnect; let mut lack_of_resource = None; + let mut plugin_version = None; if enabled { - is_plugin_executable_ready = is_plugin_ready(); + plugin_downloaded = is_plugin_ready(); state = self.ai_plugin.get_plugin_running_state(); + plugin_version = self.ai_plugin.plugin_info().await.ok().map(|v| v.version); lack_of_resource = self.resource.get_lack_of_resource().await; } let elapsed = start.elapsed(); @@ -290,9 +293,10 @@ impl LocalAIController { ); LocalAIPB { enabled, - is_plugin_executable_ready, + plugin_downloaded, state: RunningStatePB::from(state), lack_of_resource, + plugin_version, } } @@ -442,9 +446,10 @@ impl LocalAIController { ) .payload(LocalAIPB { enabled, - is_plugin_executable_ready: true, + plugin_downloaded: true, state: RunningStatePB::Stopped, lack_of_resource: None, + plugin_version: None, }) .send(); } @@ -466,9 +471,10 @@ async fn initialize_ai_plugin( ) .payload(LocalAIPB { enabled: true, - is_plugin_executable_ready: true, + plugin_downloaded: true, state: RunningStatePB::ReadyToRun, lack_of_resource: lack_of_resource.clone(), + plugin_version: None, }) .send(); From 12a4bf67f64249620f6d25b132080a82bf901dc6 Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Sun, 30 Mar 2025 09:54:08 +0800 Subject: [PATCH 069/207] Revert "feat: improve local ai settings page UI (#7643)" This reverts commit 3a879b01865c0823acbf83d0bee0ec5c4c457baa. --- .../ai/download_offline_ai_app_bloc.dart | 41 +++ .../settings/ai/local_ai_bloc.dart | 151 ++++----- .../ai/local_ai_on_boarding_bloc.dart | 2 +- .../ai/local_ai_setting_panel_bloc.dart | 92 ++++++ .../settings/ai/ollama_setting_bloc.dart | 8 +- .../settings/ai/plugin_state_bloc.dart | 146 +++++++++ .../pages/setting_ai_view/init_local_ai.dart | 82 +++++ .../setting_ai_view/local_ai_setting.dart | 244 ++++++-------- .../local_ai_setting_panel.dart | 32 ++ .../pages/setting_ai_view/ollama_setting.dart | 114 ------- .../pages/setting_ai_view/ollma_setting.dart | 168 ++++++++++ .../pages/setting_ai_view/plugin_state.dart | 248 ++++++++++++++ .../plugin_status_indicator.dart | 309 ------------------ frontend/resources/translations/en.json | 14 +- frontend/rust-lib/flowy-ai/src/entities.rs | 34 +- .../flowy-ai/src/local_ai/controller.rs | 23 +- .../flowy-ai/src/local_ai/resource.rs | 62 ++-- 17 files changed, 1037 insertions(+), 733 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart new file mode 100644 index 0000000000..185a8c049f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; +part 'download_offline_ai_app_bloc.freezed.dart'; + +class DownloadOfflineAIBloc + extends Bloc { + DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { + on(_handleEvent); + } + + Future _handleEvent( + DownloadOfflineAIEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetLocalAIDownloadLink().send(); + await result.fold( + (app) async { + await launchUrl(Uri.parse(app.link)); + }, + (err) {}, + ); + }, + ); + } +} + +@freezed +class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { + const factory DownloadOfflineAIEvent.started() = _Started; +} + +@freezed +class DownloadOfflineAIState with _$DownloadOfflineAIState { + const factory DownloadOfflineAIState() = _DownloadOfflineAIState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart index 24befd9480..66ba7fe572 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -1,121 +1,94 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'local_llm_listener.dart'; - part 'local_ai_bloc.freezed.dart'; -class LocalAiPluginBloc extends Bloc { - LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) { - on(_handleEvent); - _startListening(); - _getLocalAiState(); - } - - final listener = LocalAIStateListener(); - - @override - Future close() async { - await listener.stop(); - return super.close(); +class LocalAIToggleBloc extends Bloc { + LocalAIToggleBloc() : super(const LocalAIToggleState()) { + on(_handleEvent); } Future _handleEvent( - LocalAiPluginEvent event, - Emitter emit, + LocalAIToggleEvent event, + Emitter emit, ) async { await event.when( - didReceiveAiState: (aiState) { + started: () async { + final result = await AIEventGetLocalAIState().send(); + _handleResult(emit, result); + }, + toggle: () async { emit( - ReadyLocalAiPluginState( - isEnabled: aiState.enabled, - runningState: aiState.state, - lackOfResource: - aiState.hasLackOfResource() ? aiState.lackOfResource : null, + state.copyWith( + pageIndicator: const LocalAIToggleStateIndicator.loading(), + ), + ); + unawaited( + AIEventToggleLocalAI().send().then( + (result) { + if (!isClosed) { + add(LocalAIToggleEvent.handleResult(result)); + } + }, ), ); }, - didReceiveLackOfResources: (resources) { - if (state case final ReadyLocalAiPluginState readyState) { - emit( - ReadyLocalAiPluginState( - isEnabled: readyState.isEnabled, - runningState: readyState.runningState, - lackOfResource: resources, - ), - ); - } + handleResult: (result) { + _handleResult(emit, result); }, - toggle: () async { - emit(LoadingLocalAiPluginState()); - await AIEventToggleLocalAI().send().fold( - (aiState) { - add(LocalAiPluginEvent.didReceiveAiState(aiState)); - }, - Log.error, + ); + } + + void _handleResult( + Emitter emit, + FlowyResult result, + ) { + result.fold( + (localAI) { + emit( + state.copyWith( + pageIndicator: + LocalAIToggleStateIndicator.isEnabled(localAI.enabled), + ), ); }, - restart: () async { - emit(LoadingLocalAiPluginState()); - await AIEventRestartLocalAI().send(); + (err) { + emit( + state.copyWith( + pageIndicator: LocalAIToggleStateIndicator.error(err), + ), + ); }, ); } - - void _startListening() { - listener.start( - stateCallback: (pluginState) { - add(LocalAiPluginEvent.didReceiveAiState(pluginState)); - }, - resourceCallback: (data) { - add(LocalAiPluginEvent.didReceiveLackOfResources(data)); - }, - ); - } - - void _getLocalAiState() { - AIEventGetLocalAIState().send().fold( - (aiState) { - add(LocalAiPluginEvent.didReceiveAiState(aiState)); - }, - Log.error, - ); - } } @freezed -class LocalAiPluginEvent with _$LocalAiPluginEvent { - const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) = - _DidReceiveAiState; - const factory LocalAiPluginEvent.didReceiveLackOfResources( - LackOfAIResourcePB resources, - ) = _DidReceiveLackOfResources; - const factory LocalAiPluginEvent.toggle() = _Toggle; - const factory LocalAiPluginEvent.restart() = _Restart; +class LocalAIToggleEvent with _$LocalAIToggleEvent { + const factory LocalAIToggleEvent.started() = _Started; + const factory LocalAIToggleEvent.toggle() = _Toggle; + const factory LocalAIToggleEvent.handleResult( + FlowyResult result, + ) = _HandleResult; } -sealed class LocalAiPluginState { - const LocalAiPluginState(); +@freezed +class LocalAIToggleState with _$LocalAIToggleState { + const factory LocalAIToggleState({ + @Default(LocalAIToggleStateIndicator.loading()) + LocalAIToggleStateIndicator pageIndicator, + }) = _LocalAIToggleState; } -class ReadyLocalAiPluginState extends LocalAiPluginState { - const ReadyLocalAiPluginState({ - required this.isEnabled, - required this.runningState, - required this.lackOfResource, - }); - - final bool isEnabled; - final RunningStatePB runningState; - final LackOfAIResourcePB? lackOfResource; -} - -class LoadingLocalAiPluginState extends LocalAiPluginState { - const LoadingLocalAiPluginState(); +@freezed +class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator { + // when start downloading the model + const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError; + const factory LocalAIToggleStateIndicator.isEnabled(bool isEnabled) = _Ready; + const factory LocalAIToggleStateIndicator.loading() = _Loading; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart index 3bb26a182b..2c1bf34a87 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart @@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc _dispatch(); } - void _onPaymentSuccessful() { + Future _onPaymentSuccessful() async { if (isClosed) { return; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart new file mode 100644 index 0000000000..f6d5ef949d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'local_ai_setting_panel_bloc.freezed.dart'; + +class LocalAISettingPanelBloc + extends Bloc { + LocalAISettingPanelBloc() + : listener = LocalAIStateListener(), + super(const LocalAISettingPanelState()) { + on(_handleEvent); + + listener.start( + stateCallback: (newState) { + if (!isClosed) { + add(LocalAISettingPanelEvent.updateAIState(newState)); + } + }, + ); + + AIEventGetLocalAIState().send().fold( + (localAIState) { + if (!isClosed) { + add(LocalAISettingPanelEvent.updateAIState(localAIState)); + } + }, + Log.error, + ); + } + + final LocalAIStateListener listener; + + /// Handles incoming events and dispatches them to the appropriate handler. + Future _handleEvent( + LocalAISettingPanelEvent event, + Emitter emit, + ) async { + event.when( + updateAIState: (LocalAIPB pluginState) { + if (pluginState.isPluginExecutableReady) { + emit( + state.copyWith( + runningState: pluginState.state, + progressIndicator: const LocalAIProgress.checkPluginState(), + ), + ); + } else { + emit( + state.copyWith( + progressIndicator: const LocalAIProgress.downloadLocalAIApp(), + ), + ); + } + }, + ); + } + + @override + Future close() async { + await listener.stop(); + return super.close(); + } +} + +@freezed +class LocalAISettingPanelEvent with _$LocalAISettingPanelEvent { + const factory LocalAISettingPanelEvent.updateAIState( + LocalAIPB aiState, + ) = _UpdateAIState; +} + +@freezed +class LocalAISettingPanelState with _$LocalAISettingPanelState { + const factory LocalAISettingPanelState({ + LocalAIProgress? progressIndicator, + @Default(RunningStatePB.Connecting) RunningStatePB runningState, + }) = _LocalAIChatSettingState; +} + +@freezed +class LocalAIProgress with _$LocalAIProgress { + const factory LocalAIProgress.checkPluginState() = _CheckPluginStateProgress; + const factory LocalAIProgress.downloadLocalAIApp() = + _DownloadLocalAIAppProgress; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart index f5c4209028..8555d8cdc8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart @@ -80,9 +80,11 @@ class OllamaSettingBloc extends Bloc { } add(OllamaSettingEvent.updateSetting(setting)); AIEventUpdateLocalAISetting(setting).send().fold( - (_) => Log.info('AI setting updated successfully'), - (err) => Log.error("update ai setting failed: $err"), - ); + (_) { + Log.info('AI setting updated successfully'); + }, + (err) => Log.error("update ai setting failed: $err"), + ); }, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart new file mode 100644 index 0000000000..4c3130ea00 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'plugin_state_bloc.freezed.dart'; + +class PluginStateBloc extends Bloc { + PluginStateBloc() + : listener = LocalAIStateListener(), + super( + const PluginStateState( + action: PluginStateAction.unknown(), + ), + ) { + listener.start( + stateCallback: (pluginState) { + if (!isClosed) { + add(PluginStateEvent.updateLocalAIState(pluginState)); + } + }, + resourceCallback: (data) { + if (!isClosed) { + add(PluginStateEvent.resourceStateChange(data)); + } + }, + ); + + on(_handleEvent); + } + + final LocalAIStateListener listener; + + @override + Future close() async { + await listener.stop(); + return super.close(); + } + + Future _handleEvent( + PluginStateEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetLocalAIState().send(); + result.fold( + (pluginState) { + if (!isClosed) { + add(PluginStateEvent.updateLocalAIState(pluginState)); + } + }, + (err) => Log.error(err.toString()), + ); + }, + updateLocalAIState: (LocalAIPB aiState) { + // if the offline ai is not started, ask user to start it + if (aiState.hasLackOfResource()) { + emit( + PluginStateState( + action: PluginStateAction.lackOfResource(aiState.lackOfResource), + ), + ); + return; + } + + // Chech state of the plugin + switch (aiState.state) { + case RunningStatePB.ReadyToRun: + emit( + const PluginStateState( + action: PluginStateAction.readToRun(), + ), + ); + + case RunningStatePB.Connecting: + emit( + const PluginStateState( + action: PluginStateAction.initializingPlugin(), + ), + ); + case RunningStatePB.Connected: + emit( + const PluginStateState( + action: PluginStateAction.initializingPlugin(), + ), + ); + break; + case RunningStatePB.Running: + emit(const PluginStateState(action: PluginStateAction.running())); + break; + case RunningStatePB.Stopped: + emit( + state.copyWith(action: const PluginStateAction.restartPlugin()), + ); + default: + break; + } + }, + restartLocalAI: () async { + emit( + const PluginStateState(action: PluginStateAction.restartPlugin()), + ); + unawaited(AIEventRestartLocalAI().send()); + }, + resourceStateChange: (data) { + emit( + PluginStateState( + action: PluginStateAction.lackOfResource(data.resourceDesc), + ), + ); + }, + ); + } +} + +@freezed +class PluginStateEvent with _$PluginStateEvent { + const factory PluginStateEvent.started() = _Started; + const factory PluginStateEvent.updateLocalAIState(LocalAIPB aiState) = + _UpdateLocalAIState; + const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI; + const factory PluginStateEvent.resourceStateChange(LackOfAIResourcePB data) = + _ResourceStateChange; +} + +@freezed +class PluginStateState with _$PluginStateState { + const factory PluginStateState({ + required PluginStateAction action, + }) = _PluginStateState; +} + +@freezed +class PluginStateAction with _$PluginStateAction { + const factory PluginStateAction.unknown() = _Unknown; + const factory PluginStateAction.readToRun() = _ReadyToRun; + const factory PluginStateAction.initializingPlugin() = _InitializingPlugin; + const factory PluginStateAction.running() = _PluginRunning; + const factory PluginStateAction.restartPlugin() = _RestartPlugin; + const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart new file mode 100644 index 0000000000..2de410a5e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart @@ -0,0 +1,82 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/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 InitLocalAIIndicator extends StatelessWidget { + const InitLocalAIIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: BlocBuilder( + builder: (context, state) { + switch (state.runningState) { + case RunningStatePB.Connecting: + case RunningStatePB.Connected: + return Row( + children: [ + const HSpace(8), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIInitializing + .tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ); + case RunningStatePB.Running: + return SizedBox( + height: 30, + child: Row( + children: [ + const HSpace(8), + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + ), + ], + ), + ); + case RunningStatePB.Stopped: + return Row( + children: [ + const HSpace(8), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStopped.tr(), + fontSize: 11, + color: const Color(0xFFC62828), + ), + ], + ); + default: + return const SizedBox.shrink(); + } + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 080dc426d1..57a72b6ca1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -1,174 +1,130 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.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'; -import 'ollama_setting.dart'; -import 'plugin_status_indicator.dart'; - -class LocalAISetting extends StatefulWidget { +class LocalAISetting extends StatelessWidget { const LocalAISetting({super.key}); - @override - State createState() => _LocalAISettingState(); -} - -class _LocalAISettingState extends State { - final expandableController = ExpandableController(initialExpanded: false); - - @override - void dispose() { - expandableController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => LocalAiPluginBloc(), - child: BlocConsumer( - listener: (context, state) { - final newIsExpanded = switch (state) { - final ReadyLocalAiPluginState readyLocalAiToggleState => - readyLocalAiToggleState.isEnabled, - _ => false, - }; - expandableController.value = newIsExpanded; - }, - builder: (context, state) { - return ExpandablePanel( - controller: expandableController, - theme: ExpandableThemeData( - tapBodyToCollapse: false, - hasIcon: false, - tapBodyToExpand: false, - tapHeaderToExpand: false, + create: (context) => + LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: ExpandableNotifier( + child: BlocListener( + listener: (context, state) { + final controller = + ExpandableController.of(context, required: true)!; + + state.pageIndicator.when( + error: (_) => controller.expanded = true, + isEnabled: (enabled) => controller.expanded = enabled, + loading: () => controller.expanded = true, + ); + }, + child: ExpandablePanel( + theme: const ExpandableThemeData( + headerAlignment: ExpandablePanelHeaderAlignment.center, + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, + ), + header: const LocalAISettingHeader(), + collapsed: const SizedBox.shrink(), + expanded: Column( + children: [ + const VSpace(12), + DecoratedBox( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: LocalAISettingPanel(), + ), + ), + ], + ), ), - header: LocalAiSettingHeader( - isEnabled: state is ReadyLocalAiPluginState && state.isEnabled, - isToggleable: state is ReadyLocalAiPluginState, - ), - collapsed: const SizedBox.shrink(), - expanded: Padding( - padding: EdgeInsets.only(top: 12), - child: LocalAISettingPanel(), - ), - ); - }, + ), + ), ), ); } } -class LocalAiSettingHeader extends StatelessWidget { - const LocalAiSettingHeader({ - super.key, - required this.isEnabled, - required this.isToggleable, - }); - - final bool isEnabled; - final bool isToggleable; +class LocalAISettingHeader extends StatelessWidget { + const LocalAISettingHeader({super.key}); @override Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.medium( - LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), - ), - const VSpace(4), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), - maxLines: 3, - fontSize: 12, - ), - ], - ), - ), - IgnorePointer( - ignoring: !isToggleable, - child: Opacity( - opacity: isToggleable ? 1 : 0.5, - child: Toggle( - value: isEnabled, - onChanged: (_) => _onToggleChanged(context), - ), - ), - ), - ], - ); - } - - void _onToggleChanged(BuildContext context) { - if (isEnabled) { - showConfirmDialog( - context: context, - title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(), - description: - LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(), - confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () { - context - .read() - .add(const LocalAiPluginEvent.toggle()); - }, - ); - } else { - context.read().add(const LocalAiPluginEvent.toggle()); - } - } -} - -class LocalAISettingPanel extends StatelessWidget { - const LocalAISettingPanel({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { - if (state is! ReadyLocalAiPluginState) { - return const SizedBox.shrink(); - } - - final showIndicator = _showIndicator(state); - final showSettings = _showSettings(state); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showIndicator) const LocalAIStatusIndicator(), - if (showSettings && showIndicator) const VSpace(10), - if (showSettings) OllamaSettingPage(), - ], + return state.pageIndicator.when( + error: (error) => SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + isEnabled: (isEnabled) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FlowyText.medium( + LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), + ), + const Spacer(), + Toggle( + value: isEnabled, + onChanged: (_) { + if (isEnabled) { + showConfirmDialog( + context: context, + title: LocaleKeys + .settings_aiPage_keys_disableLocalAITitle + .tr(), + description: LocaleKeys + .settings_aiPage_keys_disableLocalAIDescription + .tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context + .read() + .add(const LocalAIToggleEvent.toggle()), + ); + } else { + context + .read() + .add(const LocalAIToggleEvent.toggle()); + } + }, + ), + ], + ), + const VSpace(4), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), + maxLines: 3, + fontSize: 12, + ), + ], + ); + }, ); }, ); } - - bool _showIndicator(ReadyLocalAiPluginState state) { - return state.runningState != RunningStatePB.Running || - state.lackOfResource != null; - } - - bool _showSettings(ReadyLocalAiPluginState state) { - return ![ - RunningStatePB.Connecting, - RunningStatePB.Connected, - ].contains(state.runningState) && - (state.lackOfResource == null || - state.lackOfResource!.resourceType == - LackOfAIResourceTypePB.MissingModel); - } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart new file mode 100644 index 0000000000..5357db5c91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'plugin_state.dart'; + +class LocalAISettingPanel extends StatelessWidget { + const LocalAISettingPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => LocalAISettingPanelBloc(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OllamaSettingPage(), + VSpace(6), + PluginStateIndicator(), + ], + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart deleted file mode 100644 index 2abcb552d1..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class OllamaSettingPage extends StatelessWidget { - const OllamaSettingPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - OllamaSettingBloc()..add(const OllamaSettingEvent.started()), - child: BlocBuilder( - buildWhen: (previous, current) => - previous.inputItems != current.inputItems || - previous.isEdited != current.isEdited, - builder: (context, state) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - ), - padding: EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 10, - children: [ - for (final item in state.inputItems) - _SettingItemWidget(item: item), - _SaveButton(isEdited: state.isEdited), - ], - ), - ); - }, - ), - ); - } -} - -class _SettingItemWidget extends StatelessWidget { - const _SettingItemWidget({required this.item}); - - final SettingItem item; - - @override - Widget build(BuildContext context) { - return Column( - key: ValueKey(item.content + item.settingType.title), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText( - item.settingType.title, - fontSize: 12, - figmaLineHeight: 16, - ), - const VSpace(4), - SizedBox( - height: 32, - child: FlowyTextField( - hintText: item.hintText, - text: item.content, - onChanged: (content) { - context.read().add( - OllamaSettingEvent.onEdit(content, item.settingType), - ); - }, - ), - ), - ], - ); - } -} - -class _SaveButton extends StatelessWidget { - const _SaveButton({required this.isEdited}); - - final bool isEdited; - - @override - Widget build(BuildContext context) { - return Align( - alignment: AlignmentDirectional.centerEnd, - child: FlowyTooltip( - message: isEdited ? null : 'No changes', - child: SizedBox( - child: FlowyButton( - text: FlowyText( - 'Apply', - figmaLineHeight: 20, - color: Theme.of(context).colorScheme.onPrimary, - ), - disable: !isEdited, - expandText: false, - margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), - backgroundColor: Theme.of(context).colorScheme.primary, - hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200), - onTap: () { - if (isEdited) { - context - .read() - .add(const OllamaSettingEvent.submit()); - } - }, - ), - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart new file mode 100644 index 0000000000..8af4e35914 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart @@ -0,0 +1,168 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OllamaSettingPage extends StatelessWidget { + const OllamaSettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + OllamaSettingBloc()..add(const OllamaSettingEvent.started()), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.inputItems != current.inputItems || + previous.isEdited != current.isEdited, + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListView.separated( + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: state.inputItems.length, + separatorBuilder: (_, __) => const VSpace(10), + itemBuilder: (context, index) { + final item = state.inputItems[index]; + return _SettingItemWidget(item: item); + }, + ), + const VSpace(6), + Opacity( + opacity: 0.6, + child: _InstallOllamaInstruction(), + ), + _SaveButton(isEdited: state.isEdited), + ], + ); + }, + ), + ); + } +} + +class _SettingItemWidget extends StatelessWidget { + const _SettingItemWidget({required this.item}); + final SettingItem item; + + @override + Widget build(BuildContext context) { + return Column( + key: ValueKey(item.content + item.settingType.title), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText(item.settingType.title), + const VSpace(8), + FlowyTextField( + hintText: item.hintText, + text: item.content, + onChanged: (content) { + context.read().add( + OllamaSettingEvent.onEdit(content, item.settingType), + ); + }, + ), + ], + ); + } +} + +class _SaveButton extends StatelessWidget { + const _SaveButton({required this.isEdited}); + + final bool isEdited; + + @override + Widget build(BuildContext context) { + final tooltipMessage = isEdited ? 'Click to apply changes' : 'No changes'; + return SizedBox( + height: 50, + child: Row( + children: [ + const Spacer(), + SizedBox( + width: 120, + child: FlowyTooltip( + message: tooltipMessage, + child: Opacity( + opacity: isEdited ? 1 : 0.5, + child: FlowyTextButton( + 'Apply', + mainAxisAlignment: MainAxisAlignment.center, + onPressed: isEdited + ? () { + context + .read() + .add(const OllamaSettingEvent.submit()); + } + : null, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _InstallOllamaInstruction extends StatelessWidget { + const _InstallOllamaInstruction(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 3, + textAlign: TextAlign.left, + text: TextSpan( + children: [ + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart new file mode 100644 index 0000000000..ab9303b429 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart @@ -0,0 +1,248 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PluginStateIndicator extends StatelessWidget { + const PluginStateIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + PluginStateBloc()..add(const PluginStateEvent.started()), + child: BlocBuilder( + builder: (context, state) { + return state.action.when( + unknown: () => const SizedBox.shrink(), + readToRun: () => const _PrepareRunning(), + initializingPlugin: () => const InitLocalAIIndicator(), + running: () => const _LocalAIRunning(), + restartPlugin: () => const _RestartPluginButton(), + lackOfResource: (desc) => _LackOfResource(desc: desc), + ); + }, + ), + ); + } +} + +class _PrepareRunning extends StatelessWidget { + const _PrepareRunning(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStart.tr(), + maxLines: 3, + ), + ), + ], + ); + } +} + +class _RestartPluginButton extends StatelessWidget { + const _RestartPluginButton(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const FlowySvg( + FlowySvgs.download_warn_s, + color: Color(0xFFC62828), + ), + const HSpace(6), + FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()), + const Spacer(), + SizedBox( + height: 30, + child: FlowyButton( + useIntrinsicWidth: true, + text: + FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()), + onTap: () { + context.read().add( + const PluginStateEvent.restartLocalAI(), + ); + }, + ), + ), + ], + ); + } +} + +class _LocalAIRunning extends StatelessWidget { + const _LocalAIRunning(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Row( + children: [ + const HSpace(8), + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Flexible( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class OpenOrDownloadOfflineAIApp extends StatelessWidget { + const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DownloadOfflineAIBloc(), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 3, + textAlign: TextAlign.left, + text: TextSpan( + children: [ + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = + () => context.read().add( + const DownloadOfflineAIEvent.started(), + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +class _LackOfResource extends StatelessWidget { + const _LackOfResource({required this.desc}); + + final String desc; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + FlowySvgs.toast_warning_filled_s, + size: const Size.square(20.0), + blendMode: null, + ), + const HSpace(6), + Expanded( + child: FlowyText( + desc, + maxLines: 3, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart deleted file mode 100644 index b79e228a0c..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/util/theme_extension.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; -import 'package:appflowy_result/appflowy_result.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/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class LocalAIStatusIndicator extends StatelessWidget { - const LocalAIStatusIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final readyState = switch (state) { - final ReadyLocalAiPluginState readyLocalAiToggleState => - readyLocalAiToggleState, - _ => null, - }; - if (readyState == null) { - return const SizedBox.shrink(); - } - - final lackOfResource = readyState.lackOfResource; - if (lackOfResource != null) { - return _LackOfResource(resource: lackOfResource); - } - - return switch (readyState.runningState) { - RunningStatePB.ReadyToRun => const _ReadyToRun(), - RunningStatePB.Connecting || - RunningStatePB.Connected => - _Initializing(), - RunningStatePB.Running => const _LocalAIRunning(), - RunningStatePB.Stopped => const _RestartPluginButton(), - _ => const SizedBox.shrink(), - }; - }, - ); - } -} - -class _ReadyToRun extends StatelessWidget { - const _ReadyToRun(); - - @override - Widget build(BuildContext context) { - return FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStart.tr(), - maxLines: 3, - ); - } -} - -class _Initializing extends StatelessWidget { - const _Initializing(); - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - padding: const EdgeInsets.all(8.0), - width: double.infinity, - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ); - } -} - -class _RestartPluginButton extends StatelessWidget { - const _RestartPluginButton(); - - @override - Widget build(BuildContext context) { - final textStyle = - Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); - - return Container( - decoration: BoxDecoration( - color: Theme.of(context).isLightMode - ? const Color(0x80FFE7EE) - : const Color(0x80591734), - borderRadius: BorderRadius.all(Radius.circular(8.0)), - ), - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const FlowySvg( - FlowySvgs.toast_warning_filled_s, - color: Color(0xFFC62828), - ), - const HSpace(8), - Expanded( - child: RichText( - maxLines: 3, - text: TextSpan( - children: [ - TextSpan( - text: - LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(), - style: textStyle, - ), - TextSpan( - text: ' ', - style: textStyle, - ), - TextSpan( - text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(), - style: textStyle?.copyWith( - fontWeight: FontWeight.w600, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - afLaunchUrlString( - "https://appflowy.com/guide/appflowy-local-ai-ollama", - ); - }, - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _LocalAIRunning extends StatelessWidget { - const _LocalAIRunning(); - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all(Radius.circular(8.0)), - ), - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - Expanded( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ), - ], - ), - ); - } -} - -class _LackOfResource extends StatelessWidget { - const _LackOfResource({required this.resource}); - - final LackOfAIResourcePB resource; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).isLightMode - ? const Color(0x80FFE7EE) - : const Color(0x80591734), - borderRadius: BorderRadius.all(Radius.circular(8.0)), - ), - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - FlowySvg( - FlowySvgs.toast_error_filled_s, - size: const Size.square(20.0), - blendMode: null, - ), - const HSpace(8), - Expanded( - child: switch (resource.resourceType) { - LackOfAIResourceTypePB.PluginExecutableNotReady => - _buildNoLAI(context), - LackOfAIResourceTypePB.OllamaServerNotReady => - _buildNoOllama(context), - LackOfAIResourceTypePB.MissingModel => - _buildNoModel(context, resource.missingModelNames), - _ => const SizedBox.shrink(), - }, - ), - ], - ), - ); - } - - TextStyle? _textStyle(BuildContext context) { - return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); - } - - Widget _buildNoLAI(BuildContext context) { - final textStyle = _textStyle(context); - return RichText( - maxLines: 3, - text: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(), - style: textStyle, - ), - TextSpan(text: ' ', style: _textStyle(context)), - ..._downloadInstructions(textStyle), - ], - ), - ); - } - - Widget _buildNoOllama(BuildContext context) { - final textStyle = _textStyle(context); - return RichText( - maxLines: 3, - text: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(), - style: textStyle, - ), - TextSpan(text: ' ', style: textStyle), - ..._downloadInstructions(textStyle), - ], - ), - ); - } - - Widget _buildNoModel(BuildContext context, List modelNames) { - final textStyle = _textStyle(context); - - return RichText( - maxLines: 3, - text: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), - style: textStyle, - ), - TextSpan( - text: ' ', - style: textStyle, - ), - ], - ), - ); - } - - List _downloadInstructions(TextStyle? textStyle) { - return [ - TextSpan( - text: LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr(), - style: textStyle, - ), - TextSpan( - text: ' ', - style: textStyle, - ), - TextSpan( - text: LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr(), - style: textStyle?.copyWith( - fontWeight: FontWeight.w600, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - afLaunchUrlString( - "https://appflowy.com/guide/appflowy-local-ai-ollama", - ); - }, - ), - TextSpan(text: ' ', style: textStyle), - TextSpan( - text: LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr(), - style: textStyle, - ), - ]; - } -} - -void onDownloadButtonPressed() { - AIEventGetLocalAIDownloadLink().send().fold( - (app) => afLaunchUri(Uri.parse(app.link)), - (err) {}, - ); -} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 01c3a51b19..2b22da596c 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -854,14 +854,14 @@ "downloadAIModelButton": "Download", "downloadingModel": "Downloading", "localAILoaded": "Local AI Model successfully added and ready to use", - "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", + "localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", "localAIRunning": "Local AI is running", - "localAIInitializing": "Local AI is loading. This may take a few minutes depending on your device", + "localAIInitializing": "Local AI is loading and may take a few minutes, depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI", - "restartLocalAI": "Restart", + "restartLocalAI": "Restart Local AI", "disableLocalAITitle": "Disable local AI", "disableLocalAIDescription": "Do you want to disable local AI?", "localAIToggleTitle": "AppFlowy Local AI (LAI)", @@ -875,12 +875,10 @@ "activeOfflineAI": "Active", "downloadOfflineAI": "Download", "openModelDirectory": "Open folder", - "localAISetupInstruction1": "Please follow these", + "localAISetupInstruction1": "Follow these", "localAISetupInstruction2": "instructions", - "localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI.", - "laiNotReady": "The Local AI app was not installed correctly.", - "ollamaNotReady": "The Ollama server is not ready.", - "modelsMissing": "Cannot find the model. You can use the ollama pull command to install them." + "localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI. Skip if you've already set it up", + "startLocalAI": "It may take a few seconds to start the local AI" } }, "planPage": { diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index a7915aae23..b24d0cb13e 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -589,7 +589,7 @@ pub struct LocalAIPB { pub is_plugin_executable_ready: bool, #[pb(index = 3, one_of)] - pub lack_of_resource: Option, + pub lack_of_resource: Option, #[pb(index = 4)] pub state: RunningStatePB, @@ -724,35 +724,5 @@ impl From for LocalAISetting { #[derive(Default, ProtoBuf, Clone, Debug)] pub struct LackOfAIResourcePB { #[pb(index = 1)] - pub resource_type: LackOfAIResourceTypePB, - - #[pb(index = 2)] - pub missing_model_names: Vec, -} - -#[derive(Debug, Default, Clone, ProtoBuf_Enum)] -pub enum LackOfAIResourceTypePB { - #[default] - PluginExecutableNotReady = 0, - OllamaServerNotReady = 1, - MissingModel = 2, -} - -impl From for LackOfAIResourcePB { - fn from(value: PendingResource) -> Self { - match value { - PendingResource::PluginExecutableNotReady => Self { - resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady, - missing_model_names: vec![], - }, - PendingResource::OllamaServerNotReady => Self { - resource_type: LackOfAIResourceTypePB::OllamaServerNotReady, - missing_model_names: vec![], - }, - PendingResource::MissingModel(model_name) => Self { - resource_type: LackOfAIResourceTypePB::MissingModel, - missing_model_names: vec![model_name], - }, - } - } + pub resource_desc: String, } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index bca710a907..c3f57e6fac 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -1,5 +1,5 @@ use crate::ai_manager::AIUserService; -use crate::entities::{LocalAIPB, RunningStatePB}; +use crate::entities::{LackOfAIResourcePB, LocalAIPB, RunningStatePB}; use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, @@ -274,15 +274,14 @@ impl LocalAIController { pub async fn get_local_ai_state(&self) -> LocalAIPB { let start = std::time::Instant::now(); let enabled = self.is_enabled(); - let (is_plugin_executable_ready, state, lack_of_resource) = if enabled { - ( - is_plugin_ready(), - self.ai_plugin.get_plugin_running_state(), - self.resource.get_lack_of_resource().await, - ) - } else { - (false, RunningState::ReadyToConnect, None) - }; + let mut is_plugin_executable_ready = false; + let mut state = RunningState::ReadyToConnect; + let mut lack_of_resource = None; + if enabled { + is_plugin_executable_ready = is_plugin_ready(); + state = self.ai_plugin.get_plugin_running_state(); + lack_of_resource = self.resource.get_lack_of_resource().await; + } let elapsed = start.elapsed(); debug!( "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", @@ -483,7 +482,9 @@ async fn initialize_ai_plugin( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(lack_of_resource) + .payload(LackOfAIResourcePB { + resource_desc: lack_of_resource, + }) .send(); return Ok(()); diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index c40a9a9b88..3eddcf2039 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -47,13 +47,22 @@ pub enum WatchDiskEvent { Remove, } -#[derive(Debug, Clone)] pub enum PendingResource { PluginExecutableNotReady, OllamaServerNotReady, MissingModel(String), } +impl PendingResource { + pub fn desc(self) -> String { + match self { + PendingResource::PluginExecutableNotReady => "The Local AI app was not installed correctly. Please follow the instructions to install the Local AI application".to_string(), + PendingResource::OllamaServerNotReady => "Ollama is not ready. Please follow the instructions to install Ollama".to_string(), + PendingResource::MissingModel(model) => format!("Cannot find the model: {}. Please use the ollama pull command to install the model", model), + } + } +} + pub struct LocalAIResourceController { user_service: Arc, resource_service: Arc, @@ -119,10 +128,10 @@ impl LocalAIResourceController { return false; } - self - .calculate_pending_resources() - .await - .is_ok_and(|r| r.is_none()) + match self.calculate_pending_resources().await { + Ok(res) => res.is_empty(), + Err(_) => false, + } } pub async fn get_plugin_download_link(&self) -> FlowyResult { @@ -138,35 +147,36 @@ impl LocalAIResourceController { #[instrument(level = "info", skip_all, err)] pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { self.resource_service.store_setting(setting)?; - if let Some(resource) = self.calculate_pending_resources().await? { + let mut resources = self.calculate_pending_resources().await?; + if let Some(resource) = resources.pop() { chat_notification_builder( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(LackOfAIResourcePB::from(resource)) + .payload(LackOfAIResourcePB { + resource_desc: resource.desc(), + }) .send(); } Ok(()) } - pub async fn get_lack_of_resource(&self) -> Option { - self - .calculate_pending_resources() - .await - .ok()? - .map(Into::into) + pub async fn get_lack_of_resource(&self) -> Option { + let mut resources = self.calculate_pending_resources().await.ok()?; + resources.pop().map(|r| r.desc()) } - pub async fn calculate_pending_resources(&self) -> FlowyResult> { + pub async fn calculate_pending_resources(&self) -> FlowyResult> { + let mut resources = vec![]; let app_path = ollama_plugin_path(); if !is_plugin_ready() { trace!("[LLM Resource] offline app not found: {:?}", app_path); - return Ok(Some(PendingResource::PluginExecutableNotReady)); + resources.push(PendingResource::PluginExecutableNotReady); + return Ok(resources); } let setting = self.get_llm_setting(); let client = Client::builder().timeout(Duration::from_secs(5)).build()?; - match client.get(&setting.ollama_server_url).send().await { Ok(resp) if resp.status().is_success() => { info!( @@ -179,7 +189,8 @@ impl LocalAIResourceController { "[LLM Resource] Ollama server is not responding at {}", setting.ollama_server_url ); - return Ok(Some(PendingResource::OllamaServerNotReady)); + resources.push(PendingResource::OllamaServerNotReady); + return Ok(resources); }, } @@ -190,8 +201,12 @@ impl LocalAIResourceController { match client.get(&tags_url).send().await { Ok(resp) if resp.status().is_success() => { - let tags: TagsResponse = resp.json().await.inspect_err(|e| { - log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") + let tags: TagsResponse = resp.json().await.map_err(|e| { + log::error!( + "[LLM Resource] Failed to parse /api/tags JSON response: {:?}", + e + ); + e })?; // Check each required model is present in the response. for required in &required_models { @@ -200,7 +215,9 @@ impl LocalAIResourceController { "[LLM Resource] required model '{}' not found in API response", required ); - return Ok(Some(PendingResource::MissingModel(required.clone()))); + resources.push(PendingResource::MissingModel(required.clone())); + // Optionally, you could continue checking all models rather than returning early. + return Ok(resources); } } }, @@ -209,11 +226,12 @@ impl LocalAIResourceController { "[LLM Resource] Failed to fetch models from {} (GET /api/tags)", setting.ollama_server_url ); - return Ok(Some(PendingResource::OllamaServerNotReady)); + resources.push(PendingResource::OllamaServerNotReady); + return Ok(resources); }, } - Ok(None) + Ok(resources) } #[instrument(level = "info", skip_all)] From af5c4bfe76c65882f130acac1577631c38db96c9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 09:09:40 +0800 Subject: [PATCH 070/207] chore: show plugin version --- .../pages/setting_ai_view/plugin_state.dart | 21 ++- frontend/resources/translations/ar-SA.json | 2 +- frontend/resources/translations/en.json | 2 +- frontend/resources/translations/ko-KR.json | 2 +- .../flowy-ai/src/local_ai/controller.rs | 133 ++++++++++++------ 5 files changed, 109 insertions(+), 51 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart index b8461eb141..12ce391a29 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart @@ -23,7 +23,10 @@ class PluginStateIndicator extends StatelessWidget { unknown: () => const SizedBox.shrink(), readToRun: () => const _PrepareRunning(), initializingPlugin: () => const InitLocalAIIndicator(), - running: (version) => _LocalAIRunning(version: version), + running: (version) => _LocalAIRunning( + key: ValueKey(version), + version: version, + ), restartPlugin: () => const _RestartPluginButton(), lackOfResource: (desc) => _LackOfResource(desc: desc), ); @@ -84,7 +87,7 @@ class _RestartPluginButton extends StatelessWidget { } class _LocalAIRunning extends StatelessWidget { - const _LocalAIRunning({required this.version}); + const _LocalAIRunning({required this.version, super.key}); final String version; @@ -111,13 +114,17 @@ class _LocalAIRunning extends StatelessWidget { color: Color(0xFF2E7D32), ), const HSpace(6), + if (version.isNotEmpty) + Flexible( + child: FlowyText( + "($version) ", + fontSize: 11, + color: const Color(0xFF1E4620), + ), + ), Flexible( child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIRunning.tr( - args: [ - version, - ], - ), + LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), fontSize: 11, color: const Color(0xFF1E4620), maxLines: 3, diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index bb4534d855..e6a8be240c 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -857,7 +857,7 @@ "localAIStart": "بدأت الدردشة المحلية بالذكاء الاصطناعي...", "localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...", "localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي", - "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل. الإصدار: {}", + "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل", "localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك", "localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي", "failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي", diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 8129157941..59877db7a6 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -857,7 +857,7 @@ "localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", - "localAIRunning": "Local AI is running. Version: {}", + "localAIRunning": "Local AI is running", "localAIInitializing": "Local AI is loading and may take a few minutes, depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI", diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 7ed8373c72..635efe92de 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -851,7 +851,7 @@ "localAIStart": "로컬 AI가 시작 중입니다. 느리다면 껐다가 다시 켜보세요", "localAILoading": "로컬 AI 채팅 모델이 로드 중입니다...", "localAIStopped": "로컬 AI가 중지되었습니다", - "localAIRunning": "로컬 AI가 실행 중입니다. 버전: {}", + "localAIRunning": "로컬 AI가 실행 중입니다", "localAIInitializing": "로컬 AI가 로드 중이며 장치에 따라 몇 분이 소요될 수 있습니다", "localAINotReadyTextFieldPrompt": "로컬 AI가 로드되는 동안 편집할 수 없습니다", "failToLoadLocalAI": "로컬 AI를 시작하지 못했습니다", diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 1bbe356e2b..03b014c89d 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -77,62 +77,88 @@ impl LocalAIController { "[AI Plugin] init local ai controller, thread: {:?}", std::thread::current().id() ); + + // Create the core plugin and resource controller let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); let res_impl = LLMResourceServiceImpl { user_service: user_service.clone(), cloud_service: cloud_service.clone(), store_preferences: store_preferences.clone(), }; - let local_ai_resource = Arc::new(LocalAIResourceController::new( user_service.clone(), res_impl, )); - let current_chat_id = ArcSwapOption::default(); + // Subscribe to state changes let mut running_state_rx = local_ai.subscribe_running_state(); - let cloned_llm_res = local_ai_resource.clone(); - let cloned_store_preferences = store_preferences.clone(); - let cloned_user_service = user_service.clone(); + + let cloned_llm_res = Arc::clone(&local_ai_resource); + let cloned_store_preferences = Arc::clone(&store_preferences); + let cloned_local_ai = Arc::clone(&local_ai); + let cloned_user_service = Arc::clone(&user_service); + + // Spawn a background task to listen for plugin state changes tokio::spawn(async move { while let Some(state) = running_state_rx.next().await { - if let Ok(workspace_id) = cloned_user_service.workspace_id() { - let key = local_ai_enabled_key(&workspace_id); - info!("[AI Plugin] state: {:?}", state); - let mut plugin_downloaded = false; - let mut lack_of_resource = None; - let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); - if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { - plugin_downloaded = is_plugin_ready(); - lack_of_resource = cloned_llm_res.get_lack_of_resource().await; - } + // Skip if we can’t get workspace_id + let Ok(workspace_id) = cloned_user_service.workspace_id() else { + continue; + }; - let new_state = RunningStatePB::from(state); - chat_notification_builder( - APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateLocalAIState, - ) - .payload(LocalAIPB { - enabled, - plugin_downloaded, - lack_of_resource, - state: new_state, - plugin_version: None, - }) - .send(); - } + let key = local_ai_enabled_key(&workspace_id); + info!("[AI Plugin] state: {:?}", state); + + // Read whether plugin is enabled from store; default to true + let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); + + // Only check resource status if the plugin isn’t in "UnexpectedStop" and is enabled + let (plugin_downloaded, lack_of_resource) = + if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { + // Possibly check plugin readiness and resource concurrency in parallel, + // but here we do it sequentially for clarity. + let downloaded = is_plugin_ready(); + let resource_lack = cloned_llm_res.get_lack_of_resource().await; + (downloaded, resource_lack) + } else { + (false, None) + }; + + // If plugin is running, retrieve version + let plugin_version = if matches!(state, RunningState::Running { .. }) { + match cloned_local_ai.plugin_info().await { + Ok(info) => Some(info.version), + Err(_) => None, + } + } else { + None + }; + + // Broadcast the new local AI state + let new_state = RunningStatePB::from(state); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded, + lack_of_resource, + state: new_state, + plugin_version, + }) + .send(); } }); Self { ai_plugin: local_ai, resource: local_ai_resource, - current_chat_id, + current_chat_id: ArcSwapOption::default(), store_preferences, user_service, cloud_service, } } - #[instrument(level = "debug", skip_all)] pub async fn observe_plugin_resource(&self) { debug!( @@ -275,22 +301,48 @@ impl LocalAIController { pub async fn get_local_ai_state(&self) -> LocalAIPB { let start = std::time::Instant::now(); let enabled = self.is_enabled(); - let mut plugin_downloaded = false; - let mut state = RunningState::ReadyToConnect; - let mut lack_of_resource = None; - let mut plugin_version = None; - if enabled { - plugin_downloaded = is_plugin_ready(); - state = self.ai_plugin.get_plugin_running_state(); - plugin_version = self.ai_plugin.plugin_info().await.ok().map(|v| v.version); - lack_of_resource = self.resource.get_lack_of_resource().await; + + // If not enabled, return immediately. + if !enabled { + debug!( + "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", + start.elapsed(), + std::thread::current().id() + ); + return LocalAIPB { + enabled: false, + plugin_downloaded: false, + state: RunningStatePB::from(RunningState::ReadyToConnect), + lack_of_resource: None, + plugin_version: None, + }; } + + let plugin_downloaded = is_plugin_ready(); + let state = self.ai_plugin.get_plugin_running_state(); + + // If the plugin is running, run both requests in parallel. + // Otherwise, only fetch the resource info. + let (plugin_version, lack_of_resource) = if matches!(state, RunningState::Running { .. }) { + // Launch both futures at once + let plugin_info_fut = self.ai_plugin.plugin_info(); + let resource_fut = self.resource.get_lack_of_resource(); + + let (plugin_info_res, resource_res) = tokio::join!(plugin_info_fut, resource_fut); + let plugin_version = plugin_info_res.ok().map(|info| info.version); + (plugin_version, resource_res) + } else { + let resource_res = self.resource.get_lack_of_resource().await; + (None, resource_res) + }; + let elapsed = start.elapsed(); debug!( "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", elapsed, std::thread::current().id() ); + LocalAIPB { enabled, plugin_downloaded, @@ -299,7 +351,6 @@ impl LocalAIController { plugin_version, } } - #[instrument(level = "debug", skip_all)] pub async fn restart_plugin(&self) { if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, None).await { From 5ae3f423136c31452051e967c06141795640b177 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 11:28:29 +0800 Subject: [PATCH 071/207] chore: fix windows build --- frontend/rust-lib/Cargo.lock | 6 +++--- frontend/rust-lib/Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 6bc02bda8a..69d6220409 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=593bc3fbedf2a2c7e00ec28b10f2268c1983be65#593bc3fbedf2a2c7e00ec28b10f2268c1983be65" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" dependencies = [ "af-plugin", "anyhow", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=593bc3fbedf2a2c7e00ec28b10f2268c1983be65#593bc3fbedf2a2c7e00ec28b10f2268c1983be65" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" dependencies = [ "anyhow", "futures-util", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=593bc3fbedf2a2c7e00ec28b10f2268c1983be65#593bc3fbedf2a2c7e00ec28b10f2268c1983be65" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 6baa7dea3e..9245232f29 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "593bc3fbedf2a2c7e00ec28b10f2268c1983be65" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "593bc3fbedf2a2c7e00ec28b10f2268c1983be65" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "593bc3fbedf2a2c7e00ec28b10f2268c1983be65" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } From f76ce2be1481a4195b462632da6cd096bf5fdef0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 12:06:08 +0800 Subject: [PATCH 072/207] chore: show not ready state when using ai writer with local ai --- .../lib/ai/service/appflowy_ai_service.dart | 10 ++++++++++ .../ai/ai_writer_block_component.dart | 18 +++++++++++++++++ .../ai/operations/ai_writer_cubit.dart | 20 +++++++++++++++++++ .../ai_writer_test/ai_writer_bloc_test.dart | 4 ++++ frontend/resources/translations/en.json | 3 ++- frontend/rust-lib/flowy-ai/src/completion.rs | 15 ++++++++++---- 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index 18b1c71029..f2d666a586 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -28,6 +28,7 @@ abstract class AIRepository { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function() onLocalAIInitializing, }); } @@ -45,12 +46,14 @@ class AppFlowyAIService implements AIRepository { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function() onLocalAIInitializing, }) async { final stream = AppFlowyCompletionStream( onStart: onStart, processMessage: processMessage, processAssistMessage: processAssistMessage, processError: onError, + onLocalAIInitializing: onLocalAIInitializing, onEnd: onEnd, ); @@ -85,6 +88,7 @@ abstract class CompletionStream { required this.processMessage, required this.processAssistMessage, required this.processError, + required this.onLocalAIInitializing, required this.onEnd, }); @@ -92,6 +96,7 @@ abstract class CompletionStream { final Future Function(String text) processMessage; final Future Function(String text) processAssistMessage; final void Function(AIError error) processError; + final void Function() onLocalAIInitializing; final Future Function() onEnd; } @@ -102,6 +107,7 @@ class AppFlowyCompletionStream extends CompletionStream { required super.processAssistMessage, required super.processError, required super.onEnd, + required super.onLocalAIInitializing, }) { _startListening(); } @@ -159,6 +165,10 @@ class AppFlowyCompletionStream extends CompletionStream { await onEnd(); } + if (event.startsWith("LOCAL_AI_NOT_READY:")) { + onLocalAIInitializing(); + } + if (event.startsWith("error:")) { processError( AIError(message: event.substring(6), code: AIErrorCode.other), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 769fabcd1f..1a94342f11 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -568,6 +568,24 @@ class MainContentArea extends StatelessWidget { ), ); } + if (state is LocalAIRunningAiWriterState) { + return Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + const HSpace(8.0), + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), + ), + ), + const HSpace(8.0), + const CircularProgressIndicator.adaptive(), + ], + ), + ); + } return const SizedBox.shrink(); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 3275f0d75c..fae66d316c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -390,6 +390,9 @@ class AiWriterCubit extends Cubit { AiWriterRecord.ai(content: _textRobot.markdownText), ); }, + onLocalAIInitializing: () { + emit(LocalAIRunningAiWriterState(command)); + }, ); if (stream != null) { @@ -481,6 +484,9 @@ class AiWriterCubit extends Cubit { AiWriterRecord.ai(content: _textRobot.markdownText), ); }, + onLocalAIInitializing: () { + emit(LocalAIRunningAiWriterState(command)); + }, ); if (stream != null) { emit( @@ -569,6 +575,9 @@ class AiWriterCubit extends Cubit { AiWriterRecord.ai(content: _textRobot.markdownText), ); }, + onLocalAIInitializing: () { + emit(LocalAIRunningAiWriterState(command)); + }, ); if (stream != null) { emit( @@ -639,6 +648,9 @@ class AiWriterCubit extends Cubit { } emit(ErrorAiWriterState(command, error: error)); }, + onLocalAIInitializing: () { + emit(LocalAIRunningAiWriterState(command)); + }, ); if (stream != null) { emit( @@ -714,3 +726,11 @@ class DocumentContentEmptyAiWriterState extends AiWriterState final void Function() onConfirm; } + +class LocalAIRunningAiWriterState extends AiWriterState + with RegisteredAiWriter { + const LocalAIRunningAiWriterState(this.command); + + @override + final AiWriterCommand command; +} diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart index bffe27e985..0cd494dd4d 100644 --- a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -30,6 +30,7 @@ class _MockAIRepository extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function() onLocalAIInitializing, }) async { final stream = _MockCompletionStream(); unawaited( @@ -62,6 +63,7 @@ class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function() onLocalAIInitializing, }) async { final stream = _MockCompletionStream(); unawaited( @@ -90,6 +92,7 @@ class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function() onLocalAIInitializing, }) async { final stream = _MockCompletionStream(); unawaited( @@ -120,6 +123,7 @@ class _MockErrorRepository extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function() onLocalAIInitializing, }) async { final stream = _MockCompletionStream(); unawaited( diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 59877db7a6..72b46000a2 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -858,7 +858,8 @@ "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", "localAIRunning": "Local AI is running", - "localAIInitializing": "Local AI is loading and may take a few minutes, depending on your device", + "localAIInitializing": "Local AI is loading and may take a few seconds, depending on your device", + "localAINotReadyRetryLater": "Local AI is initializing, please retry later", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI", "restartLocalAI": "Restart Local AI", diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 38dea5f5e4..4a59cb0a8c 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -12,6 +12,7 @@ use flowy_error::{FlowyError, FlowyResult}; use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; +use crate::stream_message::StreamMessage; use crate::util::ai_available_models_key; use flowy_sqlite::kv::KVStorePreferences; use std::sync::{Arc, Weak}; @@ -188,12 +189,18 @@ impl CompletionTask { } } -async fn handle_error(sink: &mut IsolateSink, error: FlowyError) { - if error.is_ai_response_limit_exceeded() { +async fn handle_error(sink: &mut IsolateSink, err: FlowyError) { + if err.is_ai_response_limit_exceeded() { let _ = sink.send("AI_RESPONSE_LIMIT".to_string()).await; - } else if error.is_ai_image_response_limit_exceeded() { + } else if err.is_ai_image_response_limit_exceeded() { let _ = sink.send("AI_IMAGE_RESPONSE_LIMIT".to_string()).await; + } else if err.is_ai_max_required() { + let _ = sink.send(format!("AI_MAX_REQUIRED:{}", err.msg)).await; + } else if err.is_local_ai_not_ready() { + let _ = sink.send(format!("LOCAL_AI_NOT_READY:{}", err.msg)).await; } else { - let _ = sink.send(format!("error:{}", error)).await; + let _ = sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; } } From 2277d7d23436c6cb2ecc62a2929e9578b2319ff0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 15:15:59 +0800 Subject: [PATCH 073/207] chore: show local ai disable --- .../lib/ai/service/ai_entities.dart | 14 +++ .../lib/ai/service/appflowy_ai_service.dart | 117 ++++++++++-------- .../application/chat_message_stream.dart | 34 ++--- .../ai/ai_writer_block_component.dart | 5 +- frontend/rust-lib/flowy-ai/src/chat.rs | 4 + frontend/rust-lib/flowy-ai/src/completion.rs | 2 + .../src/middleware/chat_service_mw.rs | 12 +- frontend/rust-lib/flowy-error/src/code.rs | 3 + frontend/rust-lib/flowy-error/src/errors.rs | 5 + 9 files changed, 116 insertions(+), 80 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart index 249a92019a..b08fadb7f8 100644 --- a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart @@ -4,6 +4,20 @@ import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; +class AIStreamEventPrefix { + static const data = 'data:'; + static const error = 'error:'; + static const metadata = 'metadata:'; + static const start = 'start:'; + static const finish = 'finish:'; + static const comment = 'comment:'; + static const aiResponseLimit = 'AI_RESPONSE_LIMIT'; + static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT'; + static const aiMaxRequired = 'AI_MAX_REQUIRED:'; + static const localAINotReady = 'LOCAL_AI_NOT_READY'; + static const localAIDisabled = 'LOCAL_AI_DISABLED'; +} + enum AiType { cloud, local; diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index f2d666a586..d96a3f1eed 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -121,59 +121,7 @@ class AppFlowyCompletionStream extends CompletionStream { _port.handler = _controller.add; _subscription = _controller.stream.listen( (event) async { - if (event == "AI_RESPONSE_LIMIT") { - processError( - AIError( - message: LocaleKeys.ai_textLimitReachedDescription.tr(), - code: AIErrorCode.aiResponseLimitExceeded, - ), - ); - } - - if (event == "AI_IMAGE_RESPONSE_LIMIT") { - processError( - AIError( - message: LocaleKeys.ai_imageLimitReachedDescription.tr(), - code: AIErrorCode.aiImageResponseLimitExceeded, - ), - ); - } - - if (event.startsWith("AI_MAX_REQUIRED:")) { - final msg = event.substring(16); - processError( - AIError( - message: msg, - code: AIErrorCode.other, - ), - ); - } - - if (event.startsWith("start:")) { - await onStart(); - } - - if (event.startsWith("data:")) { - await processMessage(event.substring(5)); - } - - if (event.startsWith("comment:")) { - await processAssistMessage(event.substring(8)); - } - - if (event.startsWith("finish:")) { - await onEnd(); - } - - if (event.startsWith("LOCAL_AI_NOT_READY:")) { - onLocalAIInitializing(); - } - - if (event.startsWith("error:")) { - processError( - AIError(message: event.substring(6), code: AIErrorCode.other), - ); - } + await _handleEvent(event); }, ); } @@ -183,4 +131,67 @@ class AppFlowyCompletionStream extends CompletionStream { await _subscription.cancel(); _port.close(); } + + Future _handleEvent(String event) async { + // Check simple matches first + if (event == AIStreamEventPrefix.aiResponseLimit) { + processError( + AIError( + message: LocaleKeys.ai_textLimitReachedDescription.tr(), + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + return; + } + + if (event == AIStreamEventPrefix.aiImageResponseLimit) { + processError( + AIError( + message: LocaleKeys.ai_imageLimitReachedDescription.tr(), + code: AIErrorCode.aiImageResponseLimitExceeded, + ), + ); + return; + } + + // Otherwise, parse out prefix:content + final colonIndex = event.indexOf(':'); + final hasColon = colonIndex != -1; + final prefix = hasColon ? event.substring(0, colonIndex) : event; + final content = hasColon ? event.substring(colonIndex + 1) : ''; + + switch (prefix) { + case AIStreamEventPrefix.aiMaxRequired: + processError(AIError(message: content, code: AIErrorCode.other)); + break; + + case AIStreamEventPrefix.start: + await onStart(); + break; + + case AIStreamEventPrefix.data: + await processMessage(content); + break; + + case AIStreamEventPrefix.comment: + await processAssistMessage(content); + break; + + case AIStreamEventPrefix.finish: + await onEnd(); + break; + + case AIStreamEventPrefix.localAINotReady: + onLocalAIInitializing(); + break; + + case AIStreamEventPrefix.error: + processError(AIError(message: content, code: AIErrorCode.other)); + break; + + default: + Log.debug('Unknown AI event: $event'); + break; + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart index 00d48e9347..c22559f21b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart @@ -2,19 +2,9 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:isolate'; +import 'package:appflowy/ai/service/ai_entities.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; -/// Constants for event prefixes. -class AnswerEventPrefix { - static const data = 'data:'; - static const error = 'error:'; - static const metadata = 'metadata:'; - static const aiResponseLimit = 'AI_RESPONSE_LIMIT'; - static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT'; - static const aiMaxRequired = 'AI_MAX_REQUIRED:'; - static const localAINotReady = 'LOCAL_AI_NOT_READY'; -} - /// A stream that receives answer events from an isolate or external process. /// It caches events that might occur before a listener is attached. class AnswerStream { @@ -68,31 +58,31 @@ class AnswerStream { /// Handles incoming events from the underlying stream. void _handleEvent(String event) { - if (event.startsWith(AnswerEventPrefix.data)) { + if (event.startsWith(AIStreamEventPrefix.data)) { _hasStarted = true; - final newText = event.substring(AnswerEventPrefix.data.length); + final newText = event.substring(AIStreamEventPrefix.data.length); _text += newText; _onData?.call(_text); - } else if (event.startsWith(AnswerEventPrefix.error)) { - _error = event.substring(AnswerEventPrefix.error.length); + } else if (event.startsWith(AIStreamEventPrefix.error)) { + _error = event.substring(AIStreamEventPrefix.error.length); _onError?.call(_error!); - } else if (event.startsWith(AnswerEventPrefix.metadata)) { - final s = event.substring(AnswerEventPrefix.metadata.length); + } else if (event.startsWith(AIStreamEventPrefix.metadata)) { + final s = event.substring(AIStreamEventPrefix.metadata.length); _onMetadata?.call(parseMetadata(s)); - } else if (event == AnswerEventPrefix.aiResponseLimit) { + } else if (event == AIStreamEventPrefix.aiResponseLimit) { _aiLimitReached = true; _onAIResponseLimit?.call(); - } else if (event == AnswerEventPrefix.aiImageResponseLimit) { + } else if (event == AIStreamEventPrefix.aiImageResponseLimit) { _aiImageLimitReached = true; _onAIImageResponseLimit?.call(); - } else if (event.startsWith(AnswerEventPrefix.aiMaxRequired)) { - final msg = event.substring(AnswerEventPrefix.aiMaxRequired.length); + } else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { + final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length); if (_onAIMaxRequired != null) { _onAIMaxRequired!(msg); } else { _pendingAIMaxRequiredEvents.add(msg); } - } else if (event.startsWith(AnswerEventPrefix.localAINotReady)) { + } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { if (_onLocalAIInitializing != null) { _onLocalAIInitializing!(); } else { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 1a94342f11..56b4934e3d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -577,11 +577,10 @@ class MainContentArea extends StatelessWidget { Opacity( opacity: 0.5, child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), + LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater + .tr(), ), ), - const HSpace(8.0), - const CircularProgressIndicator.adaptive(), ], ), ); diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index ea553758df..e656aefa1e 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -289,6 +289,10 @@ impl Chat { let _ = answer_sink .send(format!("LOCAL_AI_NOT_READY:{}", err.msg)) .await; + } else if err.is_local_ai_disabled() { + let _ = answer_sink + .send(format!("LOCAL_AI_DISABLED:{}", err.msg)) + .await; } else { let _ = answer_sink .send(StreamMessage::OnError(err.msg.clone()).to_string()) diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 4a59cb0a8c..8e505a49b9 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -198,6 +198,8 @@ async fn handle_error(sink: &mut IsolateSink, err: FlowyError) { let _ = sink.send(format!("AI_MAX_REQUIRED:{}", err.msg)).await; } else if err.is_local_ai_not_ready() { let _ = sink.send(format!("LOCAL_AI_NOT_READY:{}", err.msg)).await; + } else if err.is_local_ai_disabled() { + let _ = sink.send(format!("LOCAL_AI_DISABLED:{}", err.msg)).await; } else { let _ = sink .send(StreamMessage::OnError(err.msg.clone()).to_string()) diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 41e7bcebd9..55f85ec5f9 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -181,7 +181,11 @@ impl ChatCloudService for AICloudServiceMiddleware { }, } } else { - Err(FlowyError::local_ai_not_ready()) + if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } } } else { self @@ -313,7 +317,11 @@ impl ChatCloudService for AICloudServiceMiddleware { }, } } else { - Err(FlowyError::local_ai_not_ready()) + if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } } } else { self diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index b16099013b..3288252ad2 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -377,6 +377,9 @@ pub enum ErrorCode { #[error("MCP error")] MCPError = 129, + + #[error("Local AI disabled")] + LocalAIDisabled = 130, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index 0a6721a31a..4cef7a8990 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -99,6 +99,10 @@ impl FlowyError { self.code == ErrorCode::LocalAINotReady } + pub fn is_local_ai_disabled(&self) -> bool { + self.code == ErrorCode::LocalAIDisabled + } + pub fn is_ai_max_required(&self) -> bool { self.code == ErrorCode::AIMaxRequired } @@ -156,6 +160,7 @@ impl FlowyError { static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked); static_flowy_error!(local_ai_not_ready, ErrorCode::LocalAINotReady); + static_flowy_error!(local_ai_disabled, ErrorCode::LocalAIDisabled); } impl std::convert::From for FlowyError { From b63c4dfe212436be8cfcea8a478636e86f1d4322 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 20:12:20 +0800 Subject: [PATCH 074/207] chore: show local ai disable --- .../lib/ai/service/appflowy_ai_service.dart | 93 ++++++++++--------- .../ai/ai_writer_block_component.dart | 13 ++- .../ai/operations/ai_writer_cubit.dart | 25 +++-- .../ai_writer_test/ai_writer_bloc_test.dart | 12 ++- frontend/resources/translations/en.json | 1 + frontend/rust-lib/flowy-ai/src/ai_manager.rs | 17 +++- frontend/rust-lib/flowy-ai/src/chat.rs | 5 +- frontend/rust-lib/flowy-ai/src/completion.rs | 9 +- .../rust-lib/flowy-ai/src/event_handler.rs | 6 +- frontend/rust-lib/flowy-ai/src/event_map.rs | 6 +- 10 files changed, 106 insertions(+), 81 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index d96a3f1eed..39487652f8 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -15,6 +15,11 @@ import 'package:fixnum/fixnum.dart' as fixnum; import 'ai_entities.dart'; import 'error.dart'; +enum LocalAIStreamingState { + notReady, + disabled, +} + abstract class AIRepository { Future streamCompletion({ String? objectId, @@ -28,7 +33,8 @@ abstract class AIRepository { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, - required void Function() onLocalAIInitializing, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }); } @@ -46,14 +52,15 @@ class AppFlowyAIService implements AIRepository { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, - required void Function() onLocalAIInitializing, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }) async { final stream = AppFlowyCompletionStream( onStart: onStart, processMessage: processMessage, processAssistMessage: processAssistMessage, processError: onError, - onLocalAIInitializing: onLocalAIInitializing, + onLocalAIStreamingStateChange: onLocalAIStreamingStateChange, onEnd: onEnd, ); @@ -88,7 +95,7 @@ abstract class CompletionStream { required this.processMessage, required this.processAssistMessage, required this.processError, - required this.onLocalAIInitializing, + required this.onLocalAIStreamingStateChange, required this.onEnd, }); @@ -96,7 +103,8 @@ abstract class CompletionStream { final Future Function(String text) processMessage; final Future Function(String text) processAssistMessage; final void Function(AIError error) processError; - final void Function() onLocalAIInitializing; + final void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange; final Future Function() onEnd; } @@ -107,7 +115,7 @@ class AppFlowyCompletionStream extends CompletionStream { required super.processAssistMessage, required super.processError, required super.onEnd, - required super.onLocalAIInitializing, + required super.onLocalAIStreamingStateChange, }) { _startListening(); } @@ -155,43 +163,42 @@ class AppFlowyCompletionStream extends CompletionStream { } // Otherwise, parse out prefix:content - final colonIndex = event.indexOf(':'); - final hasColon = colonIndex != -1; - final prefix = hasColon ? event.substring(0, colonIndex) : event; - final content = hasColon ? event.substring(colonIndex + 1) : ''; - - switch (prefix) { - case AIStreamEventPrefix.aiMaxRequired: - processError(AIError(message: content, code: AIErrorCode.other)); - break; - - case AIStreamEventPrefix.start: - await onStart(); - break; - - case AIStreamEventPrefix.data: - await processMessage(content); - break; - - case AIStreamEventPrefix.comment: - await processAssistMessage(content); - break; - - case AIStreamEventPrefix.finish: - await onEnd(); - break; - - case AIStreamEventPrefix.localAINotReady: - onLocalAIInitializing(); - break; - - case AIStreamEventPrefix.error: - processError(AIError(message: content, code: AIErrorCode.other)); - break; - - default: - Log.debug('Unknown AI event: $event'); - break; + if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { + processError( + AIError( + message: event.substring(AIStreamEventPrefix.aiMaxRequired.length), + code: AIErrorCode.other, + ), + ); + } else if (event.startsWith(AIStreamEventPrefix.start)) { + await onStart(); + } else if (event.startsWith(AIStreamEventPrefix.data)) { + await processMessage( + event.substring(AIStreamEventPrefix.data.length), + ); + } else if (event.startsWith(AIStreamEventPrefix.comment)) { + await processAssistMessage( + event.substring(AIStreamEventPrefix.comment.length), + ); + } else if (event.startsWith(AIStreamEventPrefix.finish)) { + await onEnd(); + } else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) { + onLocalAIStreamingStateChange( + LocalAIStreamingState.disabled, + ); + } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { + onLocalAIStreamingStateChange( + LocalAIStreamingState.notReady, + ); + } else if (event.startsWith(AIStreamEventPrefix.error)) { + processError( + AIError( + message: event.substring(AIStreamEventPrefix.error.length), + code: AIErrorCode.other, + ), + ); + } else { + Log.debug('Unknown AI event: $event'); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 56b4934e3d..bfd7c24fea 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -568,7 +568,13 @@ class MainContentArea extends StatelessWidget { ), ); } - if (state is LocalAIRunningAiWriterState) { + if (state is LocalAIStreamingAiWriterState) { + final text = switch (state.state) { + LocalAIStreamingState.notReady => + LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(), + LocalAIStreamingState.disabled => + LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(), + }; return Padding( padding: EdgeInsets.all(8.0), child: Row( @@ -576,10 +582,7 @@ class MainContentArea extends StatelessWidget { const HSpace(8.0), Opacity( opacity: 0.5, - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater - .tr(), - ), + child: FlowyText(text), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index fae66d316c..076046624a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -390,8 +390,8 @@ class AiWriterCubit extends Cubit { AiWriterRecord.ai(content: _textRobot.markdownText), ); }, - onLocalAIInitializing: () { - emit(LocalAIRunningAiWriterState(command)); + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); @@ -484,8 +484,8 @@ class AiWriterCubit extends Cubit { AiWriterRecord.ai(content: _textRobot.markdownText), ); }, - onLocalAIInitializing: () { - emit(LocalAIRunningAiWriterState(command)); + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { @@ -575,8 +575,8 @@ class AiWriterCubit extends Cubit { AiWriterRecord.ai(content: _textRobot.markdownText), ); }, - onLocalAIInitializing: () { - emit(LocalAIRunningAiWriterState(command)); + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { @@ -648,8 +648,8 @@ class AiWriterCubit extends Cubit { } emit(ErrorAiWriterState(command, error: error)); }, - onLocalAIInitializing: () { - emit(LocalAIRunningAiWriterState(command)); + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { @@ -727,10 +727,15 @@ class DocumentContentEmptyAiWriterState extends AiWriterState final void Function() onConfirm; } -class LocalAIRunningAiWriterState extends AiWriterState +class LocalAIStreamingAiWriterState extends AiWriterState with RegisteredAiWriter { - const LocalAIRunningAiWriterState(this.command); + const LocalAIStreamingAiWriterState( + this.command, { + required this.state, + }); @override final AiWriterCommand command; + + final LocalAIStreamingState state; } diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart index 0cd494dd4d..bcd8b13d39 100644 --- a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -30,7 +30,8 @@ class _MockAIRepository extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, - required void Function() onLocalAIInitializing, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( @@ -63,7 +64,8 @@ class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, - required void Function() onLocalAIInitializing, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( @@ -92,7 +94,8 @@ class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, - required void Function() onLocalAIInitializing, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( @@ -123,7 +126,8 @@ class _MockErrorRepository extends Mock implements AppFlowyAIService { required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, - required void Function() onLocalAIInitializing, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 72b46000a2..e93f5273e5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -860,6 +860,7 @@ "localAIRunning": "Local AI is running", "localAIInitializing": "Local AI is loading and may take a few seconds, depending on your device", "localAINotReadyRetryLater": "Local AI is initializing, please retry later", + "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI", "restartLocalAI": "Restart Local AI", diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index eabf41a1e3..d8ef5977d8 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -246,7 +246,8 @@ impl AIManager { params: StreamMessageParams, ) -> Result { let chat = self.get_or_create_chat_instance(¶ms.chat_id).await?; - let question = chat.stream_chat_message(¶ms).await?; + let ai_model = self.get_active_model(¶ms.chat_id).await; + let question = chat.stream_chat_message(¶ms, ai_model).await?; let _ = self .external_service .notify_did_send_message(¶ms.chat_id, ¶ms.message) @@ -394,6 +395,20 @@ impl AIManager { Ok(()) } + pub async fn get_active_model(&self, source: &str) -> Option { + let mut model = self + .store_preferences + .get_object::(&ai_available_models_key(source)); + + if model.is_none() { + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + model = Some(AIModel::local(local_model, "".to_string())); + } + } + + model + } + pub async fn get_available_models(&self, source: String) -> FlowyResult { // Build the models list from server models and mark them as non-local. let mut models: Vec = self diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index e656aefa1e..50cec2c625 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -10,7 +10,6 @@ use crate::persistence::{ ChatMessageTable, }; use crate::stream_message::StreamMessage; -use crate::util::ai_available_models_key; use allo_isolate::Isolate; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, @@ -89,6 +88,7 @@ impl Chat { pub async fn stream_chat_message( &self, params: &StreamMessageParams, + preferred_ai_model: Option, ) -> Result { trace!( "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, metadata={:?}, format={:?}", @@ -143,9 +143,6 @@ impl Chat { // Save message to disk save_and_notify_message(uid, &self.chat_id, &self.user_service, question.clone())?; let format = params.format.clone().map(Into::into).unwrap_or_default(); - let preferred_ai_model = self - .store_preferences - .get_object::(&ai_available_models_key(&self.chat_id)); self.stream_response( params.answer_stream_port, answer_stream_buffer, diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 8e505a49b9..393685d642 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -13,7 +13,6 @@ use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; use crate::stream_message::StreamMessage; -use crate::util::ai_available_models_key; use flowy_sqlite::kv::KVStorePreferences; use std::sync::{Arc, Weak}; use tokio::select; @@ -23,26 +22,24 @@ pub struct AICompletion { tasks: Arc>>, cloud_service: Weak, user_service: Weak, - store_preferences: Arc, } impl AICompletion { pub fn new( cloud_service: Weak, user_service: Weak, - store_preferences: Arc, ) -> Self { Self { tasks: Arc::new(DashMap::new()), cloud_service, user_service, - store_preferences, } } pub async fn create_complete_task( &self, complete: CompleteTextPB, + preferred_model: Option, ) -> FlowyResult { if matches!(complete.completion_type, CompletionTypePB::CustomPrompt) && complete.custom_prompt.is_none() @@ -59,10 +56,6 @@ impl AICompletion { .ok_or_else(FlowyError::internal)? .workspace_id()?; let (tx, rx) = tokio::sync::mpsc::channel(1); - let preferred_model = self - .store_preferences - .get_object::(&ai_available_models_key(&complete.object_id)); - let task = CompletionTask::new( workspace_id, complete, diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 3150d3e378..4a36a5d37a 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -209,9 +209,13 @@ pub(crate) async fn stop_stream_handler( pub(crate) async fn start_complete_text_handler( data: AFPluginData, + ai_manager: AFPluginState>, tools: AFPluginState>, ) -> DataResult { - let task = tools.create_complete_task(data.into_inner()).await?; + let data = data.into_inner(); + let ai_manager = upgrade_ai_manager(ai_manager)?; + let ai_model = ai_manager.get_active_model(&data.object_id).await; + let task = tools.create_complete_task(data, ai_model).await?; data_result_ok(task) } diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs index 51c49eaabb..5020836a30 100644 --- a/frontend/rust-lib/flowy-ai/src/event_map.rs +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -13,11 +13,7 @@ pub fn init(ai_manager: Weak) -> AFPlugin { let strong_ai_manager = ai_manager.upgrade().unwrap(); let user_service = Arc::downgrade(&strong_ai_manager.user_service); let cloud_service = Arc::downgrade(&strong_ai_manager.cloud_service_wm); - let ai_tools = Arc::new(AICompletion::new( - cloud_service, - user_service, - strong_ai_manager.store_preferences.clone(), - )); + let ai_tools = Arc::new(AICompletion::new(cloud_service, user_service)); AFPlugin::new() .name("flowy-ai") .state(ai_manager) From e3a0806eee2e24cc47f015655fa09f5fd8a97b1e Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 30 Mar 2025 20:53:27 +0800 Subject: [PATCH 075/207] chore: clippy --- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 3 --- frontend/rust-lib/flowy-ai/src/chat.rs | 4 ---- frontend/rust-lib/flowy-ai/src/completion.rs | 1 - .../flowy-ai/src/middleware/chat_service_mw.rs | 16 ++++++---------- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index d8ef5977d8..1aac5b0109 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -139,7 +139,6 @@ impl AIManager { chat_id.to_string(), self.user_service.clone(), self.cloud_service_wm.clone(), - self.store_preferences.clone(), )) }); if self.local_ai.is_running() { @@ -235,7 +234,6 @@ impl AIManager { chat_id.to_string(), self.user_service.clone(), self.cloud_service_wm.clone(), - self.store_preferences.clone(), )); self.chats.insert(chat_id.to_string(), chat.clone()); Ok(chat) @@ -495,7 +493,6 @@ impl AIManager { chat_id.to_string(), self.user_service.clone(), self.cloud_service_wm.clone(), - self.store_preferences.clone(), )); self.chats.insert(chat_id.to_string(), chat.clone()); Ok(chat) diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index 50cec2c625..e00f21a863 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -15,7 +15,6 @@ use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, }; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; @@ -40,7 +39,6 @@ pub struct Chat { latest_message_id: Arc, stop_stream: Arc, stream_buffer: Arc>, - store_preferences: Arc, } impl Chat { @@ -49,7 +47,6 @@ impl Chat { chat_id: String, user_service: Arc, chat_service: Arc, - store_preferences: Arc, ) -> Chat { Chat { uid, @@ -60,7 +57,6 @@ impl Chat { latest_message_id: Default::default(), stop_stream: Arc::new(AtomicBool::new(false)), stream_buffer: Arc::new(Mutex::new(StringBuffer::default())), - store_preferences, } } diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 393685d642..27babfb28e 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -13,7 +13,6 @@ use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; use crate::stream_message::StreamMessage; -use flowy_sqlite::kv::KVStorePreferences; use std::sync::{Arc, Weak}; use tokio::select; use tracing::info; diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 55f85ec5f9..dd294e6f0e 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -180,12 +180,10 @@ impl ChatCloudService for AICloudServiceMiddleware { Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) }, } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) } else { - if self.local_ai.is_enabled() { - Err(FlowyError::local_ai_not_ready()) - } else { - Err(FlowyError::local_ai_disabled()) - } + Err(FlowyError::local_ai_disabled()) } } else { self @@ -316,12 +314,10 @@ impl ChatCloudService for AICloudServiceMiddleware { Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) }, } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) } else { - if self.local_ai.is_enabled() { - Err(FlowyError::local_ai_not_ready()) - } else { - Err(FlowyError::local_ai_disabled()) - } + Err(FlowyError::local_ai_disabled()) } } else { self From a2303d35e858616e394789bf0d3cb3c3847e902b Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 31 Mar 2025 10:24:46 +0800 Subject: [PATCH 076/207] chore: hide formats when using specific ai writer commands (#7648) * chore: hide formats for specific ai writer features * chore: use black color for selected model name --- .../desktop_prompt_text_field.dart | 27 ++++++++++++------- .../prompt_input/select_model_menu.dart | 1 - .../ai/ai_writer_block_component.dart | 6 +++++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index 97850f6a1c..a2676f2c15 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -23,6 +23,7 @@ class DesktopPromptInput extends StatefulWidget { required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, this.hideDecoration = false, + this.hideFormats = false, this.extraBottomActionButton, }); @@ -34,6 +35,7 @@ class DesktopPromptInput extends StatefulWidget { final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; final bool hideDecoration; + final bool hideFormats; final Widget? extraBottomActionButton; @override @@ -139,11 +141,11 @@ class _DesktopPromptInputState extends State { children: [ ConstrainedBox( constraints: getTextFieldConstraints( - state.showPredefinedFormats, + state.showPredefinedFormats && !widget.hideFormats, ), child: inputTextField(), ), - if (state.showPredefinedFormats) + if (state.showPredefinedFormats && !widget.hideFormats) Positioned.fill( bottom: null, child: TextFieldTapRegion( @@ -168,8 +170,9 @@ class _DesktopPromptInputState extends State { top: null, child: TextFieldTapRegion( child: _PromptBottomActions( - showPredefinedFormats: + showPredefinedFormatBar: state.showPredefinedFormats, + showPredefinedFormatButton: !widget.hideFormats, onTogglePredefinedFormatSection: () => context.read().add( AIPromptInputEvent @@ -571,7 +574,8 @@ class PromptInputTextField extends StatelessWidget { class _PromptBottomActions extends StatelessWidget { const _PromptBottomActions({ required this.sendButtonState, - required this.showPredefinedFormats, + required this.showPredefinedFormatBar, + required this.showPredefinedFormatButton, required this.onTogglePredefinedFormatSection, required this.onStartMention, required this.onSendPressed, @@ -581,7 +585,8 @@ class _PromptBottomActions extends StatelessWidget { this.extraBottomActionButton, }); - final bool showPredefinedFormats; + final bool showPredefinedFormatBar; + final bool showPredefinedFormatButton; final void Function() onTogglePredefinedFormatSection; final void Function() onStartMention; final SendButtonState sendButtonState; @@ -600,10 +605,12 @@ class _PromptBottomActions extends StatelessWidget { builder: (context, state) { return Row( children: [ - _predefinedFormatButton(), - const HSpace( - DesktopAIChatSizes.inputActionBarButtonSpacing, - ), + if (showPredefinedFormatButton) ...[ + _predefinedFormatButton(), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], SelectModelMenu( aiModelStateNotifier: context.read().aiModelStateNotifier, @@ -641,7 +648,7 @@ class _PromptBottomActions extends StatelessWidget { Widget _predefinedFormatButton() { return PromptInputDesktopToggleFormatButton( - showFormatBar: showPredefinedFormats, + showFormatBar: showPredefinedFormatBar, onTap: onTogglePredefinedFormatSection, ); } diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart index 64a62b902c..eef2663370 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -173,7 +173,6 @@ class _ModelItem extends StatelessWidget { model.i18n, figmaLineHeight: 20, overflow: TextOverflow.ellipsis, - color: isSelected ? Theme.of(context).colorScheme.primary : null, ), if (model.desc.isNotEmpty) FlowyText( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index bfd7c24fea..f78f7d35fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -490,6 +490,12 @@ class MainContentArea extends StatelessWidget { return DesktopPromptInput( isStreaming: false, hideDecoration: true, + hideFormats: [ + AiWriterCommand.fixSpellingAndGrammar, + AiWriterCommand.improveWriting, + AiWriterCommand.makeLonger, + AiWriterCommand.makeShorter, + ].contains(state.command), textController: textController, onSubmitted: (message, format, _) { cubit.runCommand(state.command, message, format); From 34d2b7f24e3ed3b4feb7e9599c4be462ecda068e Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 31 Mar 2025 12:05:11 +0800 Subject: [PATCH 077/207] chore: improve local ai settings page (#7651) * chore: improve local ai settings page * chore: move local to top in select list * chore: improve copy of missing model * chore: remove green background and add progress indicator * chore: change text color --- .../settings/ai/local_ai_bloc.dart | 171 ++++++--- .../ai/local_ai_on_boarding_bloc.dart | 2 +- .../ai/local_ai_setting_panel_bloc.dart | 92 ----- .../settings/ai/ollama_setting_bloc.dart | 8 +- .../settings/ai/plugin_state_bloc.dart | 150 -------- .../pages/setting_ai_view/init_local_ai.dart | 82 ---- .../setting_ai_view/local_ai_setting.dart | 222 ++++++----- .../local_ai_setting_panel.dart | 32 -- .../setting_ai_view/model_selection.dart | 19 +- .../pages/setting_ai_view/ollama_setting.dart | 114 ++++++ .../pages/setting_ai_view/ollma_setting.dart | 168 -------- .../pages/setting_ai_view/plugin_state.dart | 168 -------- .../plugin_status_indicator.dart | 359 ++++++++++++++++++ frontend/resources/translations/ar-SA.json | 8 +- frontend/resources/translations/en.json | 21 +- frontend/resources/translations/ko-KR.json | 8 +- frontend/rust-lib/flowy-ai/src/entities.rs | 34 +- .../flowy-ai/src/local_ai/controller.rs | 6 +- .../flowy-ai/src/local_ai/resource.rs | 62 ++- 19 files changed, 798 insertions(+), 928 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart index 66ba7fe572..c4a4ee3afa 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -1,94 +1,145 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'local_llm_listener.dart'; + part 'local_ai_bloc.freezed.dart'; -class LocalAIToggleBloc extends Bloc { - LocalAIToggleBloc() : super(const LocalAIToggleState()) { - on(_handleEvent); +class LocalAiPluginBloc extends Bloc { + LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) { + on(_handleEvent); + _startListening(); + _getLocalAiState(); + } + + final listener = LocalAIStateListener(); + + @override + Future close() async { + await listener.stop(); + return super.close(); } Future _handleEvent( - LocalAIToggleEvent event, - Emitter emit, + LocalAiPluginEvent event, + Emitter emit, ) async { await event.when( - started: () async { - final result = await AIEventGetLocalAIState().send(); - _handleResult(emit, result); + didReceiveAiState: (aiState) { + emit( + LocalAiPluginState.ready( + isEnabled: aiState.enabled, + version: aiState.pluginVersion, + runningState: aiState.state, + lackOfResource: + aiState.hasLackOfResource() ? aiState.lackOfResource : null, + ), + ); + }, + didReceiveLackOfResources: (resources) { + state.maybeMap( + ready: (readyState) { + emit(readyState.copyWith(lackOfResource: resources)); + }, + orElse: () {}, + ); }, toggle: () async { - emit( - state.copyWith( - pageIndicator: const LocalAIToggleStateIndicator.loading(), - ), - ); - unawaited( - AIEventToggleLocalAI().send().then( - (result) { - if (!isClosed) { - add(LocalAIToggleEvent.handleResult(result)); - } - }, - ), + emit(LocalAiPluginState.loading()); + await AIEventToggleLocalAI().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, ); }, - handleResult: (result) { - _handleResult(emit, result); + restart: () async { + emit(LocalAiPluginState.loading()); + await AIEventRestartLocalAI().send(); }, ); } - void _handleResult( - Emitter emit, - FlowyResult result, - ) { - result.fold( - (localAI) { - emit( - state.copyWith( - pageIndicator: - LocalAIToggleStateIndicator.isEnabled(localAI.enabled), - ), - ); + void _startListening() { + listener.start( + stateCallback: (pluginState) { + add(LocalAiPluginEvent.didReceiveAiState(pluginState)); }, - (err) { - emit( - state.copyWith( - pageIndicator: LocalAIToggleStateIndicator.error(err), - ), - ); + resourceCallback: (data) { + add(LocalAiPluginEvent.didReceiveLackOfResources(data)); }, ); } + + void _getLocalAiState() { + AIEventGetLocalAIState().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, + ); + } } @freezed -class LocalAIToggleEvent with _$LocalAIToggleEvent { - const factory LocalAIToggleEvent.started() = _Started; - const factory LocalAIToggleEvent.toggle() = _Toggle; - const factory LocalAIToggleEvent.handleResult( - FlowyResult result, - ) = _HandleResult; +class LocalAiPluginEvent with _$LocalAiPluginEvent { + const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) = + _DidReceiveAiState; + const factory LocalAiPluginEvent.didReceiveLackOfResources( + LackOfAIResourcePB resources, + ) = _DidReceiveLackOfResources; + const factory LocalAiPluginEvent.toggle() = _Toggle; + const factory LocalAiPluginEvent.restart() = _Restart; } @freezed -class LocalAIToggleState with _$LocalAIToggleState { - const factory LocalAIToggleState({ - @Default(LocalAIToggleStateIndicator.loading()) - LocalAIToggleStateIndicator pageIndicator, - }) = _LocalAIToggleState; -} +class LocalAiPluginState with _$LocalAiPluginState { + const LocalAiPluginState._(); -@freezed -class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator { - // when start downloading the model - const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError; - const factory LocalAIToggleStateIndicator.isEnabled(bool isEnabled) = _Ready; - const factory LocalAIToggleStateIndicator.loading() = _Loading; + const factory LocalAiPluginState.ready({ + required bool isEnabled, + required String version, + required RunningStatePB runningState, + required LackOfAIResourcePB? lackOfResource, + }) = ReadyLocalAiPluginState; + + const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState; + + bool get isEnabled { + return maybeWhen( + ready: (isEnabled, _, __, ___) => isEnabled, + orElse: () => false, + ); + } + + bool get showIndicator { + return maybeWhen( + ready: (isEnabled, _, runningState, lackOfResource) => + runningState != RunningStatePB.Running || lackOfResource != null, + orElse: () => false, + ); + } + + bool get showSettings { + return maybeWhen( + ready: (isEnabled, _, runningState, lackOfResource) { + final isConnecting = [ + RunningStatePB.Connecting, + RunningStatePB.Connected, + ].contains(runningState); + + final resourcesReadyOrMissingModel = lackOfResource == null || + lackOfResource.resourceType == LackOfAIResourceTypePB.MissingModel; + + return !isConnecting && resourcesReadyOrMissingModel; + }, + orElse: () => false, + ); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart index 2c1bf34a87..3bb26a182b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart @@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc _dispatch(); } - Future _onPaymentSuccessful() async { + void _onPaymentSuccessful() { if (isClosed) { return; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart deleted file mode 100644 index 60c68b70c6..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'local_ai_setting_panel_bloc.freezed.dart'; - -class LocalAISettingPanelBloc - extends Bloc { - LocalAISettingPanelBloc() - : listener = LocalAIStateListener(), - super(const LocalAISettingPanelState()) { - on(_handleEvent); - - listener.start( - stateCallback: (newState) { - if (!isClosed) { - add(LocalAISettingPanelEvent.updateAIState(newState)); - } - }, - ); - - AIEventGetLocalAIState().send().fold( - (localAIState) { - if (!isClosed) { - add(LocalAISettingPanelEvent.updateAIState(localAIState)); - } - }, - Log.error, - ); - } - - final LocalAIStateListener listener; - - /// Handles incoming events and dispatches them to the appropriate handler. - Future _handleEvent( - LocalAISettingPanelEvent event, - Emitter emit, - ) async { - event.when( - updateAIState: (LocalAIPB pluginState) { - if (pluginState.pluginDownloaded) { - emit( - state.copyWith( - runningState: pluginState.state, - progressIndicator: const LocalAIProgress.checkPluginState(), - ), - ); - } else { - emit( - state.copyWith( - progressIndicator: const LocalAIProgress.downloadLocalAIApp(), - ), - ); - } - }, - ); - } - - @override - Future close() async { - await listener.stop(); - return super.close(); - } -} - -@freezed -class LocalAISettingPanelEvent with _$LocalAISettingPanelEvent { - const factory LocalAISettingPanelEvent.updateAIState( - LocalAIPB aiState, - ) = _UpdateAIState; -} - -@freezed -class LocalAISettingPanelState with _$LocalAISettingPanelState { - const factory LocalAISettingPanelState({ - LocalAIProgress? progressIndicator, - @Default(RunningStatePB.Connecting) RunningStatePB runningState, - }) = _LocalAIChatSettingState; -} - -@freezed -class LocalAIProgress with _$LocalAIProgress { - const factory LocalAIProgress.checkPluginState() = _CheckPluginStateProgress; - const factory LocalAIProgress.downloadLocalAIApp() = - _DownloadLocalAIAppProgress; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart index 8555d8cdc8..f5c4209028 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart @@ -80,11 +80,9 @@ class OllamaSettingBloc extends Bloc { } add(OllamaSettingEvent.updateSetting(setting)); AIEventUpdateLocalAISetting(setting).send().fold( - (_) { - Log.info('AI setting updated successfully'); - }, - (err) => Log.error("update ai setting failed: $err"), - ); + (_) => Log.info('AI setting updated successfully'), + (err) => Log.error("update ai setting failed: $err"), + ); }, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart deleted file mode 100644 index d91d7151ab..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'plugin_state_bloc.freezed.dart'; - -class PluginStateBloc extends Bloc { - PluginStateBloc() - : listener = LocalAIStateListener(), - super( - const PluginStateState( - action: PluginStateAction.unknown(), - ), - ) { - listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(PluginStateEvent.updateLocalAIState(pluginState)); - } - }, - resourceCallback: (data) { - if (!isClosed) { - add(PluginStateEvent.resourceStateChange(data)); - } - }, - ); - - on(_handleEvent); - } - - final LocalAIStateListener listener; - - @override - Future close() async { - await listener.stop(); - return super.close(); - } - - Future _handleEvent( - PluginStateEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIState().send(); - result.fold( - (pluginState) { - if (!isClosed) { - add(PluginStateEvent.updateLocalAIState(pluginState)); - } - }, - (err) => Log.error(err.toString()), - ); - }, - updateLocalAIState: (LocalAIPB aiState) { - // if the offline ai is not started, ask user to start it - if (aiState.hasLackOfResource()) { - emit( - PluginStateState( - action: PluginStateAction.lackOfResource(aiState.lackOfResource), - ), - ); - return; - } - - // Chech state of the plugin - switch (aiState.state) { - case RunningStatePB.ReadyToRun: - emit( - const PluginStateState( - action: PluginStateAction.readToRun(), - ), - ); - - case RunningStatePB.Connecting: - emit( - const PluginStateState( - action: PluginStateAction.initializingPlugin(), - ), - ); - case RunningStatePB.Connected: - emit( - const PluginStateState( - action: PluginStateAction.initializingPlugin(), - ), - ); - break; - case RunningStatePB.Running: - emit( - PluginStateState( - action: PluginStateAction.running(aiState.pluginVersion), - ), - ); - break; - case RunningStatePB.Stopped: - emit( - state.copyWith(action: const PluginStateAction.restartPlugin()), - ); - default: - break; - } - }, - restartLocalAI: () async { - emit( - const PluginStateState(action: PluginStateAction.restartPlugin()), - ); - unawaited(AIEventRestartLocalAI().send()); - }, - resourceStateChange: (data) { - emit( - PluginStateState( - action: PluginStateAction.lackOfResource(data.resourceDesc), - ), - ); - }, - ); - } -} - -@freezed -class PluginStateEvent with _$PluginStateEvent { - const factory PluginStateEvent.started() = _Started; - const factory PluginStateEvent.updateLocalAIState(LocalAIPB aiState) = - _UpdateLocalAIState; - const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI; - const factory PluginStateEvent.resourceStateChange(LackOfAIResourcePB data) = - _ResourceStateChange; -} - -@freezed -class PluginStateState with _$PluginStateState { - const factory PluginStateState({ - required PluginStateAction action, - }) = _PluginStateState; -} - -@freezed -class PluginStateAction with _$PluginStateAction { - const factory PluginStateAction.unknown() = _Unknown; - const factory PluginStateAction.readToRun() = _ReadyToRun; - const factory PluginStateAction.initializingPlugin() = _InitializingPlugin; - const factory PluginStateAction.running(String version) = _PluginRunning; - const factory PluginStateAction.restartPlugin() = _RestartPlugin; - const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource; -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart deleted file mode 100644 index 2de410a5e5..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/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 InitLocalAIIndicator extends StatelessWidget { - const InitLocalAIIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all( - Radius.circular(4), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: BlocBuilder( - builder: (context, state) { - switch (state.runningState) { - case RunningStatePB.Connecting: - case RunningStatePB.Connected: - return Row( - children: [ - const HSpace(8), - Expanded( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIInitializing - .tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ), - ], - ); - case RunningStatePB.Running: - return SizedBox( - height: 30, - child: Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - ), - ], - ), - ); - case RunningStatePB.Stopped: - return Row( - children: [ - const HSpace(8), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStopped.tr(), - fontSize: 11, - color: const Color(0xFFC62828), - ), - ], - ); - default: - return const SizedBox.shrink(); - } - }, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 57a72b6ca1..751e7a6180 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -1,128 +1,150 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.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 LocalAISetting extends StatelessWidget { +import 'ollama_setting.dart'; +import 'plugin_status_indicator.dart'; + +class LocalAISetting extends StatefulWidget { const LocalAISetting({super.key}); + @override + State createState() => _LocalAISettingState(); +} + +class _LocalAISettingState extends State { + final expandableController = ExpandableController(initialExpanded: false); + + @override + void dispose() { + expandableController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => - LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: ExpandableNotifier( - child: BlocListener( - listener: (context, state) { - final controller = - ExpandableController.of(context, required: true)!; - - state.pageIndicator.when( - error: (_) => controller.expanded = true, - isEnabled: (enabled) => controller.expanded = enabled, - loading: () => controller.expanded = true, - ); - }, - child: ExpandablePanel( - theme: const ExpandableThemeData( - headerAlignment: ExpandablePanelHeaderAlignment.center, - tapBodyToCollapse: false, - hasIcon: false, - tapBodyToExpand: false, - tapHeaderToExpand: false, - ), - header: const LocalAISettingHeader(), - collapsed: const SizedBox.shrink(), - expanded: Column( - children: [ - const VSpace(12), - DecoratedBox( - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), - child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: LocalAISettingPanel(), - ), - ), - ], - ), + create: (context) => LocalAiPluginBloc(), + child: BlocConsumer( + listener: (context, state) { + expandableController.value = state.isEnabled; + }, + builder: (context, state) { + return ExpandablePanel( + controller: expandableController, + theme: ExpandableThemeData( + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, ), - ), - ), + header: LocalAiSettingHeader( + isEnabled: state.isEnabled, + isToggleable: state is ReadyLocalAiPluginState, + ), + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: EdgeInsets.only(top: 12), + child: LocalAISettingPanel(), + ), + ); + }, ), ); } } -class LocalAISettingHeader extends StatelessWidget { - const LocalAISettingHeader({super.key}); +class LocalAiSettingHeader extends StatelessWidget { + const LocalAiSettingHeader({ + super.key, + required this.isEnabled, + required this.isToggleable, + }); + + final bool isEnabled; + final bool isToggleable; @override Widget build(BuildContext context) { - return BlocBuilder( + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), + ), + const VSpace(4), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), + maxLines: 3, + fontSize: 12, + ), + ], + ), + ), + IgnorePointer( + ignoring: !isToggleable, + child: Opacity( + opacity: isToggleable ? 1 : 0.5, + child: Toggle( + value: isEnabled, + onChanged: (_) => _onToggleChanged(context), + ), + ), + ), + ], + ); + } + + void _onToggleChanged(BuildContext context) { + if (isEnabled) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(), + description: + LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () { + context + .read() + .add(const LocalAiPluginEvent.toggle()); + }, + ); + } else { + context.read().add(const LocalAiPluginEvent.toggle()); + } + } +} + +class LocalAISettingPanel extends StatelessWidget { + const LocalAISettingPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( builder: (context, state) { - return state.pageIndicator.when( - error: (error) => SizedBox.shrink(), - loading: () => const SizedBox.shrink(), - isEnabled: (isEnabled) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - FlowyText.medium( - LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), - ), - const Spacer(), - Toggle( - value: isEnabled, - onChanged: (_) { - if (isEnabled) { - showConfirmDialog( - context: context, - title: LocaleKeys - .settings_aiPage_keys_disableLocalAITitle - .tr(), - description: LocaleKeys - .settings_aiPage_keys_disableLocalAIDescription - .tr(), - confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () => context - .read() - .add(const LocalAIToggleEvent.toggle()), - ); - } else { - context - .read() - .add(const LocalAIToggleEvent.toggle()); - } - }, - ), - ], - ), - const VSpace(4), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), - maxLines: 3, - fontSize: 12, - ), - ], - ); - }, + if (state is! ReadyLocalAiPluginState) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LocalAIStatusIndicator(), + if (state.showSettings) ...[ + const VSpace(10), + OllamaSettingPage(), + ], + ], ); }, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart deleted file mode 100644 index 5357db5c91..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'plugin_state.dart'; - -class LocalAISettingPanel extends StatelessWidget { - const LocalAISettingPanel({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => LocalAISettingPanelBloc(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - OllamaSettingPage(), - VSpace(6), - PluginStateIndicator(), - ], - ); - }, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 05db831e04..861ef72432 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -20,25 +20,28 @@ class AIModelSelection extends StatelessWidget { buildWhen: (previous, current) => previous.availableModels != current.availableModels, builder: (context, state) { - if (state.availableModels == null) { + final models = state.availableModels?.models; + + if (models == null) { return const SizedBox( // Using same height as SettingsDropdown to avoid layout shift height: height, ); } + final localModels = models.where((model) => model.isLocal).toList(); + final cloudModels = models.where((model) => !model.isLocal).toList(); + return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( + Expanded( child: FlowyText.medium( LocaleKeys.settings_aiPage_keys_llmModelType.tr(), - fontSize: 14, + overflow: TextOverflow.ellipsis, ), ), - const Spacer(), Flexible( child: SettingsDropdown( key: const Key('_AIModelSelection'), @@ -46,12 +49,14 @@ class AIModelSelection extends StatelessWidget { .read() .add(SettingsAIEvent.selectModel(model)), selectedOption: state.availableModels!.selectedModel, - options: state.availableModels!.models + options: [...localModels, ...cloudModels] .map( (model) => buildDropdownMenuEntry( context, value: model, - label: model.i18n, + label: model.isLocal + ? "${model.i18n} (Local)" + : model.i18n, subLabel: model.desc, maximumHeight: height, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart new file mode 100644 index 0000000000..2abcb552d1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OllamaSettingPage extends StatelessWidget { + const OllamaSettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + OllamaSettingBloc()..add(const OllamaSettingEvent.started()), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.inputItems != current.inputItems || + previous.isEdited != current.isEdited, + builder: (context, state) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + padding: EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + for (final item in state.inputItems) + _SettingItemWidget(item: item), + _SaveButton(isEdited: state.isEdited), + ], + ), + ); + }, + ), + ); + } +} + +class _SettingItemWidget extends StatelessWidget { + const _SettingItemWidget({required this.item}); + + final SettingItem item; + + @override + Widget build(BuildContext context) { + return Column( + key: ValueKey(item.content + item.settingType.title), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + item.settingType.title, + fontSize: 12, + figmaLineHeight: 16, + ), + const VSpace(4), + SizedBox( + height: 32, + child: FlowyTextField( + hintText: item.hintText, + text: item.content, + onChanged: (content) { + context.read().add( + OllamaSettingEvent.onEdit(content, item.settingType), + ); + }, + ), + ), + ], + ); + } +} + +class _SaveButton extends StatelessWidget { + const _SaveButton({required this.isEdited}); + + final bool isEdited; + + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.centerEnd, + child: FlowyTooltip( + message: isEdited ? null : 'No changes', + child: SizedBox( + child: FlowyButton( + text: FlowyText( + 'Apply', + figmaLineHeight: 20, + color: Theme.of(context).colorScheme.onPrimary, + ), + disable: !isEdited, + expandText: false, + margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200), + onTap: () { + if (isEdited) { + context + .read() + .add(const OllamaSettingEvent.submit()); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart deleted file mode 100644 index 8af4e35914..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class OllamaSettingPage extends StatelessWidget { - const OllamaSettingPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - OllamaSettingBloc()..add(const OllamaSettingEvent.started()), - child: BlocBuilder( - buildWhen: (previous, current) => - previous.inputItems != current.inputItems || - previous.isEdited != current.isEdited, - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListView.separated( - physics: NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.inputItems.length, - separatorBuilder: (_, __) => const VSpace(10), - itemBuilder: (context, index) { - final item = state.inputItems[index]; - return _SettingItemWidget(item: item); - }, - ), - const VSpace(6), - Opacity( - opacity: 0.6, - child: _InstallOllamaInstruction(), - ), - _SaveButton(isEdited: state.isEdited), - ], - ); - }, - ), - ); - } -} - -class _SettingItemWidget extends StatelessWidget { - const _SettingItemWidget({required this.item}); - final SettingItem item; - - @override - Widget build(BuildContext context) { - return Column( - key: ValueKey(item.content + item.settingType.title), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText(item.settingType.title), - const VSpace(8), - FlowyTextField( - hintText: item.hintText, - text: item.content, - onChanged: (content) { - context.read().add( - OllamaSettingEvent.onEdit(content, item.settingType), - ); - }, - ), - ], - ); - } -} - -class _SaveButton extends StatelessWidget { - const _SaveButton({required this.isEdited}); - - final bool isEdited; - - @override - Widget build(BuildContext context) { - final tooltipMessage = isEdited ? 'Click to apply changes' : 'No changes'; - return SizedBox( - height: 50, - child: Row( - children: [ - const Spacer(), - SizedBox( - width: 120, - child: FlowyTooltip( - message: tooltipMessage, - child: Opacity( - opacity: isEdited ? 1 : 0.5, - child: FlowyTextButton( - 'Apply', - mainAxisAlignment: MainAxisAlignment.center, - onPressed: isEdited - ? () { - context - .read() - .add(const OllamaSettingEvent.submit()); - } - : null, - ), - ), - ), - ), - ], - ), - ); - } -} - -class _InstallOllamaInstruction extends StatelessWidget { - const _InstallOllamaInstruction(); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - maxLines: 3, - textAlign: TextAlign.left, - text: TextSpan( - children: [ - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString( - "https://appflowy.com/guide/appflowy-local-ai-ollama", - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - ], - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart deleted file mode 100644 index 12ce391a29..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.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:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class PluginStateIndicator extends StatelessWidget { - const PluginStateIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - PluginStateBloc()..add(const PluginStateEvent.started()), - child: BlocBuilder( - builder: (context, state) { - return state.action.when( - unknown: () => const SizedBox.shrink(), - readToRun: () => const _PrepareRunning(), - initializingPlugin: () => const InitLocalAIIndicator(), - running: (version) => _LocalAIRunning( - key: ValueKey(version), - version: version, - ), - restartPlugin: () => const _RestartPluginButton(), - lackOfResource: (desc) => _LackOfResource(desc: desc), - ); - }, - ), - ); - } -} - -class _PrepareRunning extends StatelessWidget { - const _PrepareRunning(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStart.tr(), - maxLines: 3, - ), - ), - ], - ); - } -} - -class _RestartPluginButton extends StatelessWidget { - const _RestartPluginButton(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const FlowySvg( - FlowySvgs.download_warn_s, - color: Color(0xFFC62828), - ), - const HSpace(6), - FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()), - const Spacer(), - SizedBox( - height: 30, - child: FlowyButton( - useIntrinsicWidth: true, - text: - FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()), - onTap: () { - context.read().add( - const PluginStateEvent.restartLocalAI(), - ); - }, - ), - ), - ], - ); - } -} - -class _LocalAIRunning extends StatelessWidget { - const _LocalAIRunning({required this.version, super.key}); - - final String version; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all( - Radius.circular(4), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - if (version.isNotEmpty) - Flexible( - child: FlowyText( - "($version) ", - fontSize: 11, - color: const Color(0xFF1E4620), - ), - ), - Flexible( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _LackOfResource extends StatelessWidget { - const _LackOfResource({required this.desc}); - - final String desc; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - FlowySvgs.toast_warning_filled_s, - size: const Size.square(20.0), - blendMode: null, - ), - const HSpace(6), - Expanded( - child: FlowyText( - desc, - maxLines: 3, - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart new file mode 100644 index 0000000000..fdf03bb9ca --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart @@ -0,0 +1,359 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalAIStatusIndicator extends StatelessWidget { + const LocalAIStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + ready: (_, version, runningState, lackOfResource) { + if (lackOfResource != null) { + return _LackOfResource(resource: lackOfResource); + } + + return switch (runningState) { + RunningStatePB.ReadyToRun => const _ReadyToRun(), + RunningStatePB.Connecting || + RunningStatePB.Connected => + _Initializing(), + RunningStatePB.Running => _LocalAIRunning(version: version), + RunningStatePB.Stopped => const _RestartPluginButton(), + _ => const SizedBox.shrink(), + }; + }, + orElse: () => const SizedBox.shrink(), + ); + }, + ); + } +} + +class _ReadyToRun extends StatelessWidget { + const _ReadyToRun(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + const HSpace(8.0), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStart.tr(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _Initializing extends StatelessWidget { + const _Initializing(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + HSpace(8), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _RestartPluginButton extends StatelessWidget { + const _RestartPluginButton(); + + @override + Widget build(BuildContext context) { + final textStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.toast_error_filled_s, + size: Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: + LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + context + .read() + .add(const LocalAiPluginEvent.restart()); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _LocalAIRunning extends StatelessWidget { + const _LocalAIRunning({ + required this.version, + }); + + final String version; + + @override + Widget build(BuildContext context) { + final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr(); + final text = version.isEmpty ? runningText : "$runningText ($version)"; + + return Container( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Expanded( + child: FlowyText( + text, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), + ); + } +} + +class _LackOfResource extends StatelessWidget { + const _LackOfResource({required this.resource}); + + final LackOfAIResourcePB resource; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + FlowySvg( + FlowySvgs.toast_error_filled_s, + size: const Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: switch (resource.resourceType) { + LackOfAIResourceTypePB.PluginExecutableNotReady => + _buildNoLAI(context), + LackOfAIResourceTypePB.OllamaServerNotReady => + _buildNoOllama(context), + LackOfAIResourceTypePB.MissingModel => + _buildNoModel(context, resource.missingModelNames), + _ => const SizedBox.shrink(), + }, + ), + ], + ), + ); + } + + TextStyle? _textStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + } + + Widget _buildNoLAI(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: _textStyle(context)), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoOllama(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: textStyle), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoModel(BuildContext context, List modelNames) { + final textStyle = _textStyle(context); + + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_instructions.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(), + style: textStyle, + ), + ], + ), + ); + } + + List _downloadInstructions(TextStyle? textStyle) { + return [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_instructions.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan(text: ' ', style: textStyle), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(), + style: textStyle, + ), + ]; + } +} diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index e6a8be240c..6642644580 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -875,9 +875,9 @@ "activeOfflineAI": "نشط", "downloadOfflineAI": "التنزيل", "openModelDirectory": "افتح المجلد", - "localAISetupInstruction1": "اتبع هؤلاء", - "localAISetupInstruction2": "التعليمات", - "localAISetupInstruction3": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل", + "pleaseFollowThese": "اتبع هؤلاء", + "instructions": "التعليمات", + "installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل", "startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي" } }, @@ -3217,4 +3217,4 @@ "rewrite": "إعادة كتابة", "insertBelow": "أدخل أدناه" } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index e93f5273e5..dcb39527c8 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -854,16 +854,16 @@ "downloadAIModelButton": "Download", "downloadingModel": "Downloading", "localAILoaded": "Local AI Model successfully added and ready to use", - "localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on", + "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", "localAIRunning": "Local AI is running", - "localAIInitializing": "Local AI is loading and may take a few seconds, depending on your device", "localAINotReadyRetryLater": "Local AI is initializing, please retry later", "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", + "localAIInitializing": "Local AI is loading. This may take a few minutes depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", - "failToLoadLocalAI": "Failed to start local AI", - "restartLocalAI": "Restart Local AI", + "failToLoadLocalAI": "Failed to start local AI.", + "restartLocalAI": "Restart", "disableLocalAITitle": "Disable local AI", "disableLocalAIDescription": "Do you want to disable local AI?", "localAIToggleTitle": "AppFlowy Local AI (LAI)", @@ -877,10 +877,13 @@ "activeOfflineAI": "Active", "downloadOfflineAI": "Download", "openModelDirectory": "Open folder", - "localAISetupInstruction1": "Follow these", - "localAISetupInstruction2": "instructions", - "localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI. Skip if you've already set it up", - "startLocalAI": "It may take a few seconds to start the local AI" + "laiNotReady": "The Local AI app was not installed correctly.", + "ollamaNotReady": "The Ollama server is not ready.", + "pleaseFollowThese": "Please follow these", + "instructions": "instructions", + "installOllamaLai": "to set up Ollama and AppFlowy Local AI.", + "modelsMissing": "Cannot find the required models.", + "downloadModel": "to download them." } }, "planPage": { @@ -3204,4 +3207,4 @@ "rewrite": "Rewrite", "insertBelow": "Insert below" } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 635efe92de..8e7192cf0b 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -868,9 +868,9 @@ "activeOfflineAI": "활성화됨", "downloadOfflineAI": "다운로드", "openModelDirectory": "폴더 열기", - "localAISetupInstruction1": "이 지침을 따르세요", - "localAISetupInstruction2": "지침", - "localAISetupInstruction3": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요", + "pleaseFollowThese": "지침", + "instructions": "이 지침을 따르세요", + "installOllamaLai": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요", "startLocalAI": "로컬 AI를 시작하는 데 몇 초가 소요될 수 있습니다" } }, @@ -3185,4 +3185,4 @@ "rewrite": "다시 작성", "insertBelow": "아래에 삽입" } -} \ No newline at end of file +} diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 30a3e5dd28..0075e35c04 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -586,7 +586,7 @@ pub struct LocalAIPB { pub enabled: bool, #[pb(index = 2, one_of)] - pub lack_of_resource: Option, + pub lack_of_resource: Option, #[pb(index = 3)] pub state: RunningStatePB, @@ -721,5 +721,35 @@ impl From for LocalAISetting { #[derive(Default, ProtoBuf, Clone, Debug)] pub struct LackOfAIResourcePB { #[pb(index = 1)] - pub resource_desc: String, + pub resource_type: LackOfAIResourceTypePB, + + #[pb(index = 2)] + pub missing_model_names: Vec, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum)] +pub enum LackOfAIResourceTypePB { + #[default] + PluginExecutableNotReady = 0, + OllamaServerNotReady = 1, + MissingModel = 2, +} + +impl From for LackOfAIResourcePB { + fn from(value: PendingResource) -> Self { + match value { + PendingResource::PluginExecutableNotReady => Self { + resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady, + missing_model_names: vec![], + }, + PendingResource::OllamaServerNotReady => Self { + resource_type: LackOfAIResourceTypePB::OllamaServerNotReady, + missing_model_names: vec![], + }, + PendingResource::MissingModel(model_name) => Self { + resource_type: LackOfAIResourceTypePB::MissingModel, + missing_model_names: vec![model_name], + }, + } + } } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 03b014c89d..b63401b829 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -1,5 +1,5 @@ use crate::ai_manager::AIUserService; -use crate::entities::{LackOfAIResourcePB, LocalAIPB, RunningStatePB}; +use crate::entities::{LocalAIPB, RunningStatePB}; use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, @@ -539,9 +539,7 @@ async fn initialize_ai_plugin( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(LackOfAIResourcePB { - resource_desc: lack_of_resource, - }) + .payload(lack_of_resource) .send(); return Ok(()); diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index 3eddcf2039..c40a9a9b88 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -47,22 +47,13 @@ pub enum WatchDiskEvent { Remove, } +#[derive(Debug, Clone)] pub enum PendingResource { PluginExecutableNotReady, OllamaServerNotReady, MissingModel(String), } -impl PendingResource { - pub fn desc(self) -> String { - match self { - PendingResource::PluginExecutableNotReady => "The Local AI app was not installed correctly. Please follow the instructions to install the Local AI application".to_string(), - PendingResource::OllamaServerNotReady => "Ollama is not ready. Please follow the instructions to install Ollama".to_string(), - PendingResource::MissingModel(model) => format!("Cannot find the model: {}. Please use the ollama pull command to install the model", model), - } - } -} - pub struct LocalAIResourceController { user_service: Arc, resource_service: Arc, @@ -128,10 +119,10 @@ impl LocalAIResourceController { return false; } - match self.calculate_pending_resources().await { - Ok(res) => res.is_empty(), - Err(_) => false, - } + self + .calculate_pending_resources() + .await + .is_ok_and(|r| r.is_none()) } pub async fn get_plugin_download_link(&self) -> FlowyResult { @@ -147,36 +138,35 @@ impl LocalAIResourceController { #[instrument(level = "info", skip_all, err)] pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { self.resource_service.store_setting(setting)?; - let mut resources = self.calculate_pending_resources().await?; - if let Some(resource) = resources.pop() { + if let Some(resource) = self.calculate_pending_resources().await? { chat_notification_builder( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(LackOfAIResourcePB { - resource_desc: resource.desc(), - }) + .payload(LackOfAIResourcePB::from(resource)) .send(); } Ok(()) } - pub async fn get_lack_of_resource(&self) -> Option { - let mut resources = self.calculate_pending_resources().await.ok()?; - resources.pop().map(|r| r.desc()) + pub async fn get_lack_of_resource(&self) -> Option { + self + .calculate_pending_resources() + .await + .ok()? + .map(Into::into) } - pub async fn calculate_pending_resources(&self) -> FlowyResult> { - let mut resources = vec![]; + pub async fn calculate_pending_resources(&self) -> FlowyResult> { let app_path = ollama_plugin_path(); if !is_plugin_ready() { trace!("[LLM Resource] offline app not found: {:?}", app_path); - resources.push(PendingResource::PluginExecutableNotReady); - return Ok(resources); + return Ok(Some(PendingResource::PluginExecutableNotReady)); } let setting = self.get_llm_setting(); let client = Client::builder().timeout(Duration::from_secs(5)).build()?; + match client.get(&setting.ollama_server_url).send().await { Ok(resp) if resp.status().is_success() => { info!( @@ -189,8 +179,7 @@ impl LocalAIResourceController { "[LLM Resource] Ollama server is not responding at {}", setting.ollama_server_url ); - resources.push(PendingResource::OllamaServerNotReady); - return Ok(resources); + return Ok(Some(PendingResource::OllamaServerNotReady)); }, } @@ -201,12 +190,8 @@ impl LocalAIResourceController { match client.get(&tags_url).send().await { Ok(resp) if resp.status().is_success() => { - let tags: TagsResponse = resp.json().await.map_err(|e| { - log::error!( - "[LLM Resource] Failed to parse /api/tags JSON response: {:?}", - e - ); - e + let tags: TagsResponse = resp.json().await.inspect_err(|e| { + log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") })?; // Check each required model is present in the response. for required in &required_models { @@ -215,9 +200,7 @@ impl LocalAIResourceController { "[LLM Resource] required model '{}' not found in API response", required ); - resources.push(PendingResource::MissingModel(required.clone())); - // Optionally, you could continue checking all models rather than returning early. - return Ok(resources); + return Ok(Some(PendingResource::MissingModel(required.clone()))); } } }, @@ -226,12 +209,11 @@ impl LocalAIResourceController { "[LLM Resource] Failed to fetch models from {} (GET /api/tags)", setting.ollama_server_url ); - resources.push(PendingResource::OllamaServerNotReady); - return Ok(resources); + return Ok(Some(PendingResource::OllamaServerNotReady)); }, } - Ok(resources) + Ok(None) } #[instrument(level = "info", skip_all)] From 7ed36f8736ef1a0f03969649212380e1d061e9b4 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 31 Mar 2025 13:07:42 +0800 Subject: [PATCH 078/207] feat: support i18n in delete my account operation (#7635) --- .../settings/pages/account/account_deletion.dart | 7 ++++--- frontend/resources/translations/ar-SA.json | 4 ++-- frontend/resources/translations/de-DE.json | 4 ++-- frontend/resources/translations/en.json | 4 ++-- frontend/resources/translations/fr-FR.json | 4 ++-- frontend/resources/translations/ko-KR.json | 4 ++-- frontend/resources/translations/tr-TR.json | 4 ++-- frontend/resources/translations/vi-VN.json | 4 ++-- frontend/resources/translations/zh-CN.json | 9 ++++++++- 9 files changed, 26 insertions(+), 18 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart index 5b11e2d139..31139492ad 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -14,7 +14,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; -const _confirmText = 'DELETE MY ACCOUNT'; const _acceptableConfirmTexts = [ 'delete my account', 'deletemyaccount', @@ -135,7 +134,8 @@ class _AccountDeletionDialog extends StatelessWidget { ), const VSpace(12.0), FlowyTextField( - hintText: _confirmText, + hintText: + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(), controller: controller, ), const VSpace(16), @@ -176,7 +176,8 @@ class _AccountDeletionDialog extends StatelessWidget { bool _isConfirmTextValid(String text) { // don't convert the text to lower case or upper case, // just check if the text is in the list - return _acceptableConfirmTexts.contains(text); + return _acceptableConfirmTexts.contains(text) || + text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(); } Future deleteMyAccount( diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 6642644580..bf7e9f4d6a 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -2610,12 +2610,12 @@ "dialogTitle": "حذف الحساب", "dialogContent1": "هل أنت متأكد أنك تريد حذف حسابك نهائياً؟", "dialogContent2": "لا يمكن التراجع عن هذا الإجراء، وسوف يؤدي إلى إزالة الوصول من جميع مساحات العمل، ومسح حسابك بالكامل، بما في ذلك مساحات العمل الخاصة، وإزالتك من جميع مساحات العمل المشتركة.", - "confirmHint1": "من فضلك اكتب \"حذف حسابي\" للتأكيد.", + "confirmHint1": "من فضلك اكتب \"@:newSettings.myAccount.deleteAccount.confirmHint3\" للتأكيد.", "confirmHint2": "أفهم أن هذا الإجراء لا رجعة فيه وسيؤدي إلى حذف حسابي وجميع البيانات المرتبطة به بشكل دائم.", "confirmHint3": "حذف حسابي", "checkToConfirmError": "يجب عليك تحديد المربع لتأكيد الحذف", "failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم", - "confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"حذف حسابي\"", + "confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "تم حذف الحساب بنجاح" } }, diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index 996ed03b7d..e95b40d26d 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -2517,11 +2517,11 @@ "dialogTitle": "Benutzerkonto löschen", "dialogContent1": "Bist du sicher, dass du dein Benutzerkonto unwiderruflich löschen möchtest?", "dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, dein gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und du aus allen freigegebenen Arbeitsbereichen entfernt wirst.", - "confirmHint1": "Geben Sie zur Bestätigung bitte „MEIN KONTO LÖSCHEN“ ein.", + "confirmHint1": "Geben Sie zur Bestätigung bitte „@:newSettings.myAccount.deleteAccount.confirmHint3“ ein.", "confirmHint3": "MEIN KONTO LÖSCHEN", "checkToConfirmError": "Sie müssen das Kontrollkästchen aktivieren, um das Löschen zu bestätigen", "failedToGetCurrentUser": "Aktuelle Benutzer-E-Mail konnte nicht abgerufen werden.", - "confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „MEIN KONTO LÖSCHEN“ überein.", + "confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „@:newSettings.myAccount.deleteAccount.confirmHint3“ überein.", "deleteAccountSuccess": "Konto erfolgreich gelöscht" } }, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index dcb39527c8..e0c735e2ff 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2599,12 +2599,12 @@ "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", "dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.", - "confirmHint1": "Please type \"DELETE MY ACCOUNT\" to confirm.", + "confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.", "confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "You must check the box to confirm deletion", "failedToGetCurrentUser": "Failed to get current user email", - "confirmTextValidationFailed": "Your confirmation text does not match \"DELETE MY ACCOUNT\"", + "confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Account deleted successfully" } }, diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index 270b92d72f..0d542e916b 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -2519,12 +2519,12 @@ "dialogTitle": "Supprimer le compte", "dialogContent1": "Êtes-vous sûr de vouloir supprimer définitivement votre compte ?", "dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés.", - "confirmHint1": "Veuillez taper « SUPPRIMER MON COMPTE » pour confirmer.", + "confirmHint1": "Veuillez taper « @:newSettings.myAccount.deleteAccount.confirmHint3 » pour confirmer.", "confirmHint2": "Je comprends que cette action est irréversible et supprimera définitivement mon compte et toutes les données associées.", "confirmHint3": "SUPPRIMER MON COMPTE", "checkToConfirmError": "Vous devez cocher la case pour confirmer la suppression", "failedToGetCurrentUser": "Impossible d'obtenir l'e-mail de l'utilisateur actuel", - "confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « SUPPRIMER MON COMPTE »", + "confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « @:newSettings.myAccount.deleteAccount.confirmHint3 »", "deleteAccountSuccess": "Compte supprimé avec succès" } }, diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 8e7192cf0b..66240d954a 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -2579,12 +2579,12 @@ "dialogTitle": "계정 삭제", "dialogContent1": "계정을 영구적으로 삭제하시겠습니까?", "dialogContent2": "이 작업은 되돌릴 수 없으며, 모든 작업 공간에서 액세스를 제거하고, 개인 작업 공간을 포함한 전체 계정을 삭제하며, 모든 공유 작업 공간에서 제거됩니다.", - "confirmHint1": "\"내 계정 삭제\"를 입력하여 확인하세요.", + "confirmHint1": "\"@:newSettings.myAccount.deleteAccount.confirmHint3\"를 입력하여 확인하세요.", "confirmHint2": "이 작업은 되돌릴 수 없으며, 계정과 모든 관련 데이터를 영구적으로 삭제합니다.", "confirmHint3": "내 계정 삭제", "checkToConfirmError": "삭제를 확인하려면 확인란을 선택해야 합니다", "failedToGetCurrentUser": "현재 사용자 이메일을 가져오지 못했습니다", - "confirmTextValidationFailed": "확인 텍스트가 \"내 계정 삭제\"와 일치하지 않습니다", + "confirmTextValidationFailed": "확인 텍스트가 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"와 일치하지 않습니다", "deleteAccountSuccess": "계정이 성공적으로 삭제되었습니다" } }, diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index d23566e512..346742f295 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -2518,12 +2518,12 @@ "dialogTitle": "Hesabı sil", "dialogContent1": "Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz?", "dialogContent2": "Bu işlem GERİ ALINAMAZ ve tüm çalışma alanlarından erişiminizi kaldıracak, özel çalışma alanları dahil tüm hesabınızı silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır.", - "confirmHint1": "Onaylamak için lütfen \"HESABIMI SİL\" yazın.", + "confirmHint1": "Onaylamak için lütfen \"@:newSettings.myAccount.deleteAccount.confirmHint3\" yazın.", "confirmHint2": "Bu işlemin GERİ ALINAMAZ olduğunu ve hesabımı ve ilişkili tüm verileri kalıcı olarak sileceğini anlıyorum.", "confirmHint3": "HESABIMI SİL", "checkToConfirmError": "Silme işlemini onaylamak için kutuyu işaretlemelisiniz", "failedToGetCurrentUser": "Mevcut kullanıcı e-postası alınamadı", - "confirmTextValidationFailed": "Onay metniniz \"HESABIMI SİL\" ile eşleşmiyor", + "confirmTextValidationFailed": "Onay metniniz \"@:newSettings.myAccount.deleteAccount.confirmHint3\" ile eşleşmiyor", "deleteAccountSuccess": "Hesap başarıyla silindi" } }, diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index a506a84db3..7e27e8a648 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -2270,12 +2270,12 @@ "dialogTitle": "Xóa tài khoản", "dialogContent1": "Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không?", "dialogContent2": "Không thể hoàn tác hành động này và sẽ xóa quyền truy cập khỏi mọi không gian làm việc nhóm, xóa toàn bộ tài khoản của bạn, bao gồm cả không gian làm việc riêng tư và xóa bạn khỏi mọi không gian làm việc được chia sẻ.", - "confirmHint1": "Vui lòng nhập \"XÓA TÀI KHOẢN CỦA TÔI\" để xác nhận.", + "confirmHint1": "Vui lòng nhập \"@:newSettings.myAccount.deleteAccount.confirmHint3\" để xác nhận.", "confirmHint2": "Tôi hiểu rằng hành động này là không thể đảo ngược và sẽ xóa vĩnh viễn tài khoản của tôi cùng mọi dữ liệu liên quan.", "confirmHint3": "XÓA TÀI KHOẢN CỦA TÔI", "checkToConfirmError": "Bạn phải đánh dấu vào ô để xác nhận việc xóa", "failedToGetCurrentUser": "Không lấy được email người dùng hiện tại", - "confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"XÓA TÀI KHOẢN CỦA TÔI\"", + "confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Tài khoản đã được xóa thành công" } }, diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index ce0c760f3d..557916b5b5 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -1920,7 +1920,14 @@ "deleteMyAccount": "删除我的账户", "dialogTitle": "删除帐户", "dialogContent1": "你确定要永久删除您的帐户吗?", - "dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。" + "dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。", + "confirmHint1": "请输入 \"@:newSettings.myAccount.deleteAccount.confirmHint3\" 以确认。", + "confirmHint2": "我理解此操作是不可逆的,并且将永久删除我的帐户和所有关联数据。", + "confirmHint3": "删除我的账户", + "checkToConfirmError": "你必须勾选以确认删除。", + "failedToGetCurrentUser": "获取当前用户邮箱失败", + "confirmTextValidationFailed": "你的确认文本不匹配 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", + "deleteAccountSuccess": "账户删除成功" } }, "workplace": { From 0b298ea426ca15c9d12c7a6c4b6ca597b9c4df30 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:06:39 +0800 Subject: [PATCH 079/207] chore: use emoji instead of Local for copy (#7656) --- .../settings/pages/setting_ai_view/model_selection.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 861ef72432..30ff4addde 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -54,9 +54,8 @@ class AIModelSelection extends StatelessWidget { (model) => buildDropdownMenuEntry( context, value: model, - label: model.isLocal - ? "${model.i18n} (Local)" - : model.i18n, + label: + model.isLocal ? "${model.i18n} 🔐" : model.i18n, subLabel: model.desc, maximumHeight: height, ), From 3c99105b23bad0c083a938154cab49bf98beb55d Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:06:50 +0800 Subject: [PATCH 080/207] fix: show cursor at end of selection when generating (#7655) --- .../ai/widgets/ai_writer_scroll_wrapper.dart | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart index 90bce596a0..6b5f27e028 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -208,11 +208,26 @@ class _AiWriterScrollWrapperState extends State { throttler.call(() { if (aiWriterCubit.aiWriterNode != null) { final path = aiWriterCubit.aiWriterNode!.path; - if (path.isNotEmpty) { - widget.editorState.updateSelectionWithReason( - Selection.collapsed(Position(path: path)), - ); + + if (path.isEmpty) { + return; } + + if (path.previous.isNotEmpty) { + final node = widget.editorState.getNodeAtPath(path.previous); + if (node != null && node.delta != null && node.delta!.isNotEmpty) { + widget.editorState.updateSelectionWithReason( + Selection.collapsed( + Position(path: path, offset: node.delta!.length), + ), + ); + return; + } + } + + widget.editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + ); } }); } From 5e1a8b1ec74c27bd8471d26c82ae911afb7cfd6c Mon Sep 17 00:00:00 2001 From: Morn Date: Mon, 31 Mar 2025 15:16:56 +0800 Subject: [PATCH 081/207] fix: toolbar link launch review issues (#7639) * fix: some toolbar link launch review issues * fix: support check link format for link menus * fix: toolbar and link hover menu will not display together * fix: filter link search result with current document id * fix: remove error text while link menu is not focus * fix: some issues * fix: test errors * fix: add tooltip for link menu --- .../document/presentation/editor_page.dart | 76 +++--- .../desktop_floating_toolbar.dart | 62 ++++- .../link/link_create_menu.dart | 89 +++++-- .../desktop_toolbar/link/link_edit_menu.dart | 235 +++++++++++------- .../desktop_toolbar/link/link_hover_menu.dart | 162 +++++++++--- .../link/link_search_text_field.dart | 60 +++-- .../desktop_toolbar/link/link_styles.dart | 24 +- .../desktop_toolbar/toolbar_cubit.dart | 17 -- .../custom_format_toolbar_items.dart | 3 +- .../custom_hightlight_color_toolbar_item.dart | 3 +- .../custom_link_toolbar_item.dart | 26 +- .../custom_text_color_toolbar_item.dart | 3 +- .../more_option_toolbar_item.dart | 12 +- .../appflowy_flutter/lib/startup/startup.dart | 4 + 14 files changed, 529 insertions(+), 247 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index a6cff2ae07..ee3a852cc1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -432,44 +432,50 @@ class _AppFlowyEditorPageState extends State ); } return Center( - child: FloatingToolbar( - floatingToolbarHeight: 40, - padding: EdgeInsets.symmetric(horizontal: 6), - style: FloatingToolbarStyle( - backgroundColor: Theme.of(context).cardColor, - toolbarActiveColor: Color(0xffe0f8fd), - ), - items: toolbarItems, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).cardColor, - boxShadow: [ - BoxShadow( - offset: Offset(0, 4), - blurRadius: 24, - color: themeV2.shadow_medium, + child: BlocProvider.value( + value: context.read(), + child: FloatingToolbar( + floatingToolbarHeight: 40, + padding: EdgeInsets.symmetric(horizontal: 6), + style: FloatingToolbarStyle( + backgroundColor: Theme.of(context).cardColor, + toolbarActiveColor: Color(0xffe0f8fd), + ), + items: toolbarItems, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 24, + color: themeV2.shadow_medium, + ), + ], + ), + toolbarBuilder: (_, child, onDismiss, isMetricsChanged) => + BlocProvider.value( + value: context.read(), + child: DesktopFloatingToolbar( + editorState: editorState, + onDismiss: onDismiss, + enableAnimation: false, + child: child, ), - ], - ), - toolbarBuilder: (context, child, onDismiss, isMetricsChanged) => - DesktopFloatingToolbar( + ), + placeHolderBuilder: (_) => customPlaceholderItem, editorState: editorState, - onDismiss: onDismiss, - enableAnimation: !isMetricsChanged, - child: child, + editorScrollController: editorScrollController, + textDirection: textDirection, + tooltipBuilder: (context, id, message, child) => + widget.styleCustomizer.buildToolbarItemTooltip( + context, + id, + message, + child, + ), + child: editor, ), - placeHolderBuilder: (_) => customPlaceholderItem, - editorState: editorState, - editorScrollController: editorScrollController, - textDirection: textDirection, - tooltipBuilder: (context, id, message, child) => - widget.styleCustomizer.buildToolbarItemTooltip( - context, - id, - message, - child, - ), - child: editor, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart index daa066d50c..03fc12a37c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -1,10 +1,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'toolbar_animation.dart'; -import 'toolbar_cubit.dart'; class DesktopFloatingToolbar extends StatefulWidget { const DesktopFloatingToolbar({ @@ -28,6 +27,7 @@ class _DesktopFloatingToolbarState extends State { EditorState get editorState => widget.editorState; _Position? position; + final toolbarController = getIt(); @override void initState() { @@ -39,24 +39,32 @@ class _DesktopFloatingToolbarState extends State { final selectionRect = editorState.selectionRects(); if (selectionRect.isEmpty) return; position = calculateSelectionMenuOffset(selectionRect.first); + toolbarController._addCallback(dismiss); + } + + @override + void dispose() { + toolbarController._removeCallback(dismiss); + super.dispose(); } @override Widget build(BuildContext context) { if (position == null) return Container(); - return BlocProvider( - create: (_) => ToolbarCubit(widget.onDismiss), - child: Positioned( - left: position!.left, - top: position!.top, - right: position!.right, - child: widget.enableAnimation - ? ToolbarAnimationWidget(child: widget.child) - : widget.child, - ), + return Positioned( + left: position!.left, + top: position!.top, + right: position!.right, + child: widget.enableAnimation + ? ToolbarAnimationWidget(child: widget.child) + : widget.child, ); } + void dismiss() { + widget.onDismiss.call(); + } + _Position calculateSelectionMenuOffset( Rect rect, ) { @@ -92,3 +100,33 @@ class _Position { final double? top; final double? right; } + +class FloatingToolbarController { + final Set _dismissCallbacks = {}; + final Set _displayListeners = {}; + + void _addCallback(VoidCallback callback) { + _dismissCallbacks.add(callback); + for (final listener in Set.of(_displayListeners)) { + listener.call(); + } + } + + void _removeCallback(VoidCallback callback) => + _dismissCallbacks.remove(callback); + + bool get isToolbarShowing => _dismissCallbacks.isNotEmpty; + + void addDisplayListener(VoidCallback listener) => + _displayListeners.add(listener); + + void removeDisplayListener(VoidCallback listener) => + _displayListeners.remove(listener); + + void hideToolbar() { + if (_dismissCallbacks.isEmpty) return; + for (final callback in _dismissCallbacks) { + callback.call(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart index 2f5edc195d..bc14073193 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -19,11 +19,15 @@ class LinkCreateMenu extends StatefulWidget { required this.onSubmitted, required this.onDismiss, required this.alignment, + required this.currentViewId, + required this.initialText, }); final EditorState editorState; final void Function(String link, bool isPage) onSubmitted; final VoidCallback onDismiss; + final String currentViewId; + final String initialText; final LinkMenuAlignment alignment; @override @@ -32,6 +36,8 @@ class LinkCreateMenu extends StatefulWidget { class _LinkCreateMenuState extends State { late LinkSearchTextField searchTextField = LinkSearchTextField( + currentViewId: widget.currentViewId, + initialSearchText: widget.initialText, onEnter: () { searchTextField.onSearchResult( onLink: () => onSubmittedLink(), @@ -48,17 +54,28 @@ class _LinkCreateMenuState extends State { }, ); - bool get isButtonEnable => searchText.isNotEmpty; + bool get isTextfieldEnable => searchTextField.isTextfieldEnable; String get searchText => searchTextField.searchText; bool get showAtTop => widget.alignment.isTop; + bool showErrorText = false; + @override void initState() { super.initState(); searchTextField.requestFocus(); searchTextField.searchRecentViews(); + final focusNode = searchTextField.focusNode; + bool hasFocus = focusNode.hasFocus; + focusNode.addListener(() { + if (hasFocus != focusNode.hasFocus && mounted) { + setState(() { + hasFocus = focusNode.hasFocus; + }); + } + }); } @override @@ -98,32 +115,45 @@ class _LinkCreateMenuState extends State { Widget buildSearchContainer() { return Container( width: 320, - height: 48, decoration: buildToolbarLinkDecoration(context), padding: EdgeInsets.all(8), child: ValueListenableBuilder( valueListenable: searchTextField.textEditingController, builder: (context, _, __) { - return Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: searchTextField.buildTextField()), - HSpace(8), - FlowyTextButton( - LocaleKeys.document_toolbar_insert.tr(), - mainAxisAlignment: MainAxisAlignment.center, - padding: EdgeInsets.zero, - constraints: BoxConstraints(maxWidth: 72, minHeight: 32), - fontSize: 14, - fontColor: - isButtonEnable ? Colors.white : LinkStyle.textTertiary, - fillColor: isButtonEnable - ? LinkStyle.fillThemeThick - : LinkStyle.borderColor, - hoverColor: LinkStyle.fillThemeThick, - lineHeight: 20 / 14, - fontWeight: FontWeight.w600, - onPressed: isButtonEnable ? () => onSubmittedLink() : null, + Row( + children: [ + Expanded( + child: searchTextField.buildTextField(context: context), + ), + HSpace(8), + FlowyTextButton( + LocaleKeys.document_toolbar_insert.tr(), + mainAxisAlignment: MainAxisAlignment.center, + padding: EdgeInsets.zero, + constraints: BoxConstraints(maxWidth: 72, minHeight: 32), + fontSize: 14, + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + lineHeight: 20 / 14, + fontWeight: FontWeight.w600, + onPressed: onSubmittedLink, + ), + ], ), + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), ], ); }, @@ -131,7 +161,15 @@ class _LinkCreateMenuState extends State { ); } - void onSubmittedLink() => widget.onSubmitted(searchText, false); + void onSubmittedLink() { + if (!isTextfieldEnable) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onSubmitted(searchText, false); + } void onSubmittedPageLink(ViewPB view) async { final workspaceId = context @@ -152,13 +190,16 @@ void showLinkCreateMenu( BuildContext context, EditorState editorState, Selection selection, + String currentViewId, ) { + if (!context.mounted) return; final (left, top, right, bottom, alignment) = _getPosition(editorState); final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; } + final selectedText = editorState.getTextInSelection(selection).join(); OverlayEntry? overlay; @@ -178,12 +219,18 @@ void showLinkCreateMenu( builder: (context) { return LinkCreateMenu( alignment: alignment, + initialText: selectedText, + currentViewId: currentViewId, editorState: editorState, onSubmitted: (link, isPage) async { await editorState.formatDelta(selection, { BuiltInAttributeKey.href: link, kIsPageLink: isPage, }); + await editorState.updateSelectionWithReason( + null, + reason: SelectionUpdateReason.uiEvent, + ); dismissOverlay(); }, onDismiss: dismissOverlay, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart index e10b46411b..b6a4ad89ba 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart @@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -10,7 +11,8 @@ 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'; - +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; import 'link_create_menu.dart'; import 'link_search_text_field.dart'; import 'link_styles.dart'; @@ -22,12 +24,14 @@ class LinkEditMenu extends StatefulWidget { required this.onDismiss, required this.onApply, required this.onRemoveLink, + required this.currentViewId, }); final LinkInfo linkInfo; final ValueChanged onApply; - final VoidCallback onRemoveLink; + final ValueChanged onRemoveLink; final VoidCallback onDismiss; + final String currentViewId; @override State createState() => _LinkEditMenuState(); @@ -36,7 +40,7 @@ class LinkEditMenu extends StatefulWidget { class _LinkEditMenuState extends State { ValueChanged get onApply => widget.onApply; - VoidCallback get onRemoveLink => widget.onRemoveLink; + ValueChanged get onRemoveLink => widget.onRemoveLink; VoidCallback get onDismiss => widget.onDismiss; @@ -47,9 +51,7 @@ class _LinkEditMenuState extends State { late LinkSearchTextField searchTextField; bool isShowingSearchResult = false; ViewPB? currentView; - - bool get enableApply => - linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty; + bool showErrorText = false; @override void initState() { @@ -58,18 +60,9 @@ class _LinkEditMenuState extends State { if (isPageLink) getPageView(); searchTextField = LinkSearchTextField( initialSearchText: isPageLink ? '' : linkInfo.link, - onEnter: () { - searchTextField.onSearchResult( - onLink: onLinkSelected, - onRecentViews: () => - onPageSelected(searchTextField.currentRecentView), - onSearchViews: () => - onPageSelected(searchTextField.currentSearchedView), - onEmpty: () { - searchTextField.unfocus(); - }, - ); - }, + initialViewId: linkInfo.viewId, + currentViewId: widget.currentViewId, + onEnter: onConfirm, onEscape: () { if (isShowingSearchResult) { hideSearchResult(); @@ -95,6 +88,7 @@ class _LinkEditMenuState extends State { Widget build(BuildContext context) { final showingRecent = searchTextField.showingRecent && isShowingSearchResult; + final errorHeight = showErrorText ? 20.0 : 0.0; return GestureDetector( onTap: onDismiss, child: Container( @@ -107,9 +101,8 @@ class _LinkEditMenuState extends State { onTap: hideSearchResult, child: Container( width: 400, - height: 192, + height: 192 + errorHeight, decoration: buildToolbarLinkDecoration(context), - padding: EdgeInsets.fromLTRB(20, 16, 20, 16), ), ), Positioned( @@ -123,7 +116,7 @@ class _LinkEditMenuState extends State { ), ), Positioned( - top: 80, + top: 80 + errorHeight, left: 20, child: FlowyText.semibold( LocaleKeys.document_toolbar_linkName.tr(), @@ -133,12 +126,12 @@ class _LinkEditMenuState extends State { ), ), Positioned( - top: 152, + top: 144 + errorHeight, left: 20, child: buildButtons(), ), Positioned( - top: 108, + top: 100 + errorHeight, left: 20, child: buildNameTextField(), ), @@ -155,29 +148,53 @@ class _LinkEditMenuState extends State { Widget buildLinkField() { final showPageView = linkInfo.isPage && !isShowingSearchResult; - if (showPageView) return buildPageView(); - if (!isShowingSearchResult) return buildLinkView(); - return SizedBox( - width: 360, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 360, - height: 32, - child: searchTextField.buildTextField( - autofocus: true, + Widget child; + if (showPageView) { + child = buildPageView(); + } else if (!isShowingSearchResult) { + child = buildLinkView(); + } else { + return SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 360, + height: 32, + child: searchTextField.buildTextField( + autofocus: true, + context: context, + ), + ), + VSpace(6), + searchTextField.buildResultContainer( + context: context, + width: 360, + onPageLinkSelected: onPageSelected, + onLinkSelected: onLinkSelected, + ), + ], + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + child, + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, ), ), - VSpace(6), - searchTextField.buildResultContainer( - context: context, - width: 360, - onPageLinkSelected: onPageSelected, - onLinkSelected: onLinkSelected, - ), - ], - ), + ], ); } @@ -197,15 +214,15 @@ class _LinkEditMenuState extends State { preferBelow: false, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), - border: Border.all(color: LinkStyle.borderColor), + border: Border.all(color: LinkStyle.borderColor(context)), ), - onPressed: onRemoveLink, + onPressed: () => onRemoveLink.call(linkInfo), ), Spacer(), DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), - border: Border.all(color: LinkStyle.borderColor), + border: Border.all(color: LinkStyle.borderColor(context)), ), child: FlowyTextButton( LocaleKeys.button_cancel.tr(), @@ -214,7 +231,9 @@ class _LinkEditMenuState extends State { constraints: BoxConstraints(maxWidth: 78, minHeight: 32), fontSize: 14, lineHeight: 20 / 14, - fontColor: LinkStyle.textPrimary, + fontColor: Theme.of(context).isLightMode + ? LinkStyle.textPrimary + : Theme.of(context).iconTheme.color, fillColor: Colors.transparent, fontWeight: FontWeight.w400, onPressed: onDismiss, @@ -232,14 +251,26 @@ class _LinkEditMenuState extends State { fontSize: 14, lineHeight: 20 / 14, hoverColor: LinkStyle.fillThemeThick.withAlpha(200), - fontColor: - enableApply ? Colors.white : LinkStyle.textTertiary, - fillColor: enableApply - ? LinkStyle.fillThemeThick - : LinkStyle.borderColor, + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, fontWeight: FontWeight.w400, - onPressed: - enableApply ? () => widget.onApply.call(linkInfo) : null, + onPressed: () { + if (isShowingSearchResult) { + onConfirm(); + return; + } + if (linkInfo.link.isEmpty) { + widget.onRemoveLink(linkInfo); + return; + } + if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onApply.call(linkInfo); + }, ); }, ), @@ -272,6 +303,7 @@ class _LinkEditMenuState extends State { }, decoration: LinkStyle.buildLinkTextFieldInputDecoration( LocaleKeys.document_toolbar_linkNameHint.tr(), + context, ), ), ); @@ -288,24 +320,34 @@ class _LinkEditMenuState extends State { ), ); } else { + final viewName = view.name; + final displayName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; child = GestureDetector( onTap: showSearchResult, child: MouseRegion( cursor: SystemMouseCursors.click, - child: Container( - height: 32, - padding: EdgeInsets.fromLTRB(8, 6, 8, 6), - child: Row( - children: [ - searchTextField.buildIcon(view), - HSpace(8), - Flexible( - child: FlowyText.regular( - view.name, - overflow: TextOverflow.ellipsis, + child: FlowyTooltip( + preferBelow: false, + message: displayName, + child: Container( + height: 32, + padding: EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Row( + children: [ + searchTextField.buildIcon(view), + HSpace(4), + Flexible( + child: FlowyText.regular( + displayName, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), ), - ), - ], + ], + ), ), ), ), @@ -324,24 +366,28 @@ class _LinkEditMenuState extends State { width: 360, height: 32, decoration: buildDecoration(), - child: GestureDetector( - onTap: showSearchResult, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: EdgeInsets.fromLTRB(8, 6, 8, 6), - child: Row( - children: [ - FlowySvg(FlowySvgs.toolbar_link_earth_m), - HSpace(8), - Flexible( - child: FlowyText.regular( - linkInfo.link, - overflow: TextOverflow.ellipsis, - figmaLineHeight: 20, + child: FlowyTooltip( + preferBelow: false, + message: linkInfo.link, + child: GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + FlowySvg(FlowySvgs.toolbar_link_earth_m), + HSpace(8), + Flexible( + child: FlowyText.regular( + linkInfo.link, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + ), ), - ), - ], + ], + ), ), ), ), @@ -349,12 +395,21 @@ class _LinkEditMenuState extends State { ); } + void onConfirm() { + searchTextField.onSearchResult( + onLink: onLinkSelected, + onRecentViews: () => onPageSelected(searchTextField.currentRecentView), + onSearchViews: () => onPageSelected(searchTextField.currentSearchedView), + onEmpty: () { + searchTextField.unfocus(); + }, + ); + } + Future getPageView() async { if (!linkInfo.isPage) return; - final link = linkInfo.link; - final viewId = link.split('/').lastOrNull ?? ''; final (view, isInTrash, isDeleted) = - await ViewBackendService.getMentionPageStatus(viewId); + await ViewBackendService.getMentionPageStatus(linkInfo.viewId); if (mounted) { setState(() { currentView = view; @@ -413,7 +468,7 @@ class _LinkEditMenuState extends State { BoxDecoration buildDecoration() => BoxDecoration( borderRadius: BorderRadius.circular(8), - border: Border.all(color: LinkStyle.borderColor), + border: Border.all(color: LinkStyle.borderColor(context)), ); } @@ -426,4 +481,6 @@ class LinkInfo { Attributes toAttribute() => {AppFlowyRichTextKeys.href: link, kIsPageLink: isPage}; + + String get viewId => isPage ? link.split('/').lastOrNull ?? '' : ''; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart index e47386699b..b07d2949d9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -3,20 +3,25 @@ import 'dart:math'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.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/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'link_create_menu.dart'; import 'link_edit_menu.dart'; -import 'link_styles.dart'; class LinkHoverTrigger extends StatefulWidget { const LinkHoverTrigger({ @@ -45,6 +50,7 @@ class LinkHoverTrigger extends StatefulWidget { class _LinkHoverTriggerState extends State { final hoverMenuController = PopoverController(); final editMenuController = PopoverController(); + final toolbarController = getIt(); bool isHoverMenuShowing = false; bool isHoverMenuHovering = false; bool isHoverTriggerHovering = false; @@ -57,12 +63,13 @@ class _LinkHoverTriggerState extends State { Attributes get attribute => widget.attribute; - HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection); + late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection); @override void initState() { super.initState(); getIt()._add(triggerKey, showLinkHoverMenu); + toolbarController.addDisplayListener(onToolbarShow); } @override @@ -70,6 +77,7 @@ class _LinkHoverTriggerState extends State { hoverMenuController.close(); editMenuController.close(); getIt()._remove(triggerKey, showLinkHoverMenu); + toolbarController.removeDisplayListener(onToolbarShow); super.dispose(); } @@ -132,9 +140,9 @@ class _LinkHoverTriggerState extends State { tryToDismissLinkHoverMenu(); }, onOpenLink: openLink, - onCopyLink: copyLink, + onCopyLink: () => copyLink(context), onEditLink: showLinkEditMenu, - onRemoveLink: () => removeLink(editorState, selection, true), + onRemoveLink: () => removeLink(editorState, selection), ), child: child, ); @@ -144,6 +152,7 @@ class _LinkHoverTriggerState extends State { final href = attribute.href ?? '', isPage = attribute.isPage, title = editorState.getTextInSelection(selection).join(); + final currentViewId = context.read()?.documentId ?? ''; return AppFlowyPopover( controller: editMenuController, direction: PopoverDirection.bottomWithLeftAligned, @@ -159,6 +168,7 @@ class _LinkHoverTriggerState extends State { minHeight: 282, ), popupBuilder: (context) => LinkEditMenu( + currentViewId: currentViewId, linkInfo: LinkInfo(name: title, link: href, isPage: isPage), onDismiss: () => editMenuController.close(), onApply: (info) async { @@ -173,14 +183,19 @@ class _LinkHoverTriggerState extends State { editMenuController.close(); await editorState.apply(transaction); }, - onRemoveLink: () => removeLink(editorState, selection, true), + onRemoveLink: (linkinfo) => + onRemoveAndReplaceLink(editorState, selection, linkinfo.name), ), child: child, ); } + void onToolbarShow() => hoverMenuController.close(); + void showLinkHoverMenu() { - if (isHoverMenuShowing) return; + if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) { + return; + } keepEditorFocusNotifier.increase(); hoverMenuController.show(); } @@ -219,20 +234,24 @@ class _LinkHoverTriggerState extends State { } } - Future copyLink() async { + Future copyLink(BuildContext context) async { final href = widget.attribute.href ?? ''; if (href.isEmpty) return; await getIt() .setData(ClipboardServiceData(plainText: href)); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } hoverMenuController.close(); } void removeLink( EditorState editorState, Selection selection, - bool isHref, ) { - if (!isHref) return; final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; @@ -251,9 +270,34 @@ class _LinkHoverTriggerState extends State { ); editorState.apply(transaction); } + + void onRemoveAndReplaceLink( + EditorState editorState, + Selection selection, + String text, + ) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..replaceText( + node, + index, + length, + text, + attributes: { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); + } } -class LinkHoverMenu extends StatelessWidget { +class LinkHoverMenu extends StatefulWidget { const LinkHoverMenu({ super.key, required this.attribute, @@ -275,17 +319,31 @@ class LinkHoverMenu extends StatelessWidget { final VoidCallback onEditLink; final VoidCallback onRemoveLink; + @override + State createState() => _LinkHoverMenuState(); +} + +class _LinkHoverMenuState extends State { + ViewPB? currentView; + late bool isPage = widget.attribute.isPage; + late String href = widget.attribute.href ?? ''; + + @override + void initState() { + super.initState(); + if (isPage) getPageView(); + } + @override Widget build(BuildContext context) { - final href = attribute.href ?? ''; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MouseRegion( - onEnter: onEnter, - onExit: onExit, + onEnter: widget.onEnter, + onExit: widget.onExit, child: SizedBox( - width: max(320, triggerSize.width), + width: max(320, widget.triggerSize.width), height: 48, child: Align( alignment: Alignment.centerLeft, @@ -293,21 +351,15 @@ class LinkHoverMenu extends StatelessWidget { width: 320, height: 48, decoration: buildToolbarLinkDecoration(context), - padding: EdgeInsets.all(8), + padding: EdgeInsets.fromLTRB(12, 8, 8, 8), child: Row( children: [ - Expanded( - child: FlowyText.regular( - href, - fontSize: 14, - figmaLineHeight: 20, - overflow: TextOverflow.ellipsis, - ), - ), + Expanded(child: buildLinkWidget()), Container( height: 20, width: 1, - color: LinkStyle.borderColor, + color: Color(0xffE8ECF3) + .withAlpha(Theme.of(context).isLightMode ? 255 : 40), margin: EdgeInsets.symmetric(horizontal: 6), ), FlowyIconButton( @@ -315,21 +367,21 @@ class LinkHoverMenu extends StatelessWidget { tooltipText: LocaleKeys.editor_copyLink.tr(), width: 36, height: 32, - onPressed: onCopyLink, + onPressed: widget.onCopyLink, ), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_edit_m), tooltipText: LocaleKeys.editor_editLink.tr(), width: 36, height: 32, - onPressed: onEditLink, + onPressed: widget.onEditLink, ), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), tooltipText: LocaleKeys.editor_removeLink.tr(), width: 36, height: 32, - onPressed: onRemoveLink, + onPressed: widget.onRemoveLink, ), ], ), @@ -339,13 +391,13 @@ class LinkHoverMenu extends StatelessWidget { ), MouseRegion( cursor: SystemMouseCursors.click, - onEnter: onEnter, - onExit: onExit, + onEnter: widget.onEnter, + onExit: widget.onExit, child: GestureDetector( - onTap: onOpenLink, + onTap: widget.onOpenLink, child: Container( - width: triggerSize.width, - height: triggerSize.height, + width: widget.triggerSize.width, + height: widget.triggerSize.height, color: Colors.black.withAlpha(1), ), ), @@ -353,6 +405,46 @@ class LinkHoverMenu extends StatelessWidget { ], ); } + + Future getPageView() async { + final viewId = href.split('/').lastOrNull ?? ''; + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (mounted) { + setState(() { + currentView = view; + }); + } + } + + Widget buildLinkWidget() { + final view = currentView; + if (isPage && view == null) { + return SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ); + } + String text = ''; + if (isPage && view != null) { + text = view.name; + if (text.isEmpty) { + text = LocaleKeys.document_title_placeholder.tr(); + } + } else { + text = href; + } + return FlowyTooltip( + message: text, + preferBelow: false, + child: FlowyText.regular( + text, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ); + } } class HoverTriggerKey { @@ -367,7 +459,11 @@ class HoverTriggerKey { other is HoverTriggerKey && runtimeType == other.runtimeType && nodeId == other.nodeId && - selection == other.selection; + isSelectionSame(other.selection); + + bool isSelectionSame(Selection other) => + (selection.start == other.start && selection.end == other.end) || + (selection.start == other.end && selection.end == other.start); @override int get hashCode => nodeId.hashCode ^ selection.hashCode; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart index 4fcd9e3b8b..97fd6abdad 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart @@ -12,6 +12,8 @@ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -25,12 +27,16 @@ class LinkSearchTextField { this.onEscape, this.onEnter, this.onDataRefresh, + this.initialViewId = '', + required this.currentViewId, String? initialSearchText, }) : textEditingController = TextEditingController( - text: initialSearchText ?? '', + text: isUri(initialSearchText ?? '') ? initialSearchText : '', ); final TextEditingController textEditingController; + final String initialViewId; + final String currentViewId; final ItemScrollController searchController = ItemScrollController(); late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); final List searchedViews = []; @@ -43,7 +49,7 @@ class LinkSearchTextField { String get searchText => textEditingController.text; - bool get isButtonEnable => searchText.isNotEmpty; + bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText); bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty; @@ -58,7 +64,11 @@ class LinkSearchTextField { recentViews.clear(); } - Widget buildTextField({bool autofocus = false}) { + Widget buildTextField({ + bool autofocus = false, + bool showError = false, + required BuildContext context, + }) { return TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, autofocus: autofocus, @@ -81,6 +91,8 @@ class LinkSearchTextField { }, decoration: LinkStyle.buildLinkTextFieldInputDecoration( LocaleKeys.document_toolbar_linkInputHint.tr(), + context, + showErrorBorder: showError, ), ); } @@ -177,29 +189,41 @@ class LinkSearchTextField { bool isSelected, ValueChanged? onSubmittedPageLink, ) { + final viewName = view.name; + final displayName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; + final isCurrent = initialViewId == view.id; return SizedBox( height: 32, child: FlowyButton( isSelected: isSelected, - leftIcon: buildIcon(view), + leftIcon: buildIcon(view, padding: EdgeInsets.zero), text: FlowyText.regular( - view.name, + displayName, overflow: TextOverflow.ellipsis, fontSize: 14, figmaLineHeight: 20, ), + rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () => onSubmittedPageLink?.call(view), ), ); } - Widget buildIcon(ViewPB view) { + Widget buildIcon( + ViewPB view, { + EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4), + }) { if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20)); final iconData = view.icon.toEmojiIconData(); - return RawEmojiIconWidget( - emoji: iconData, - emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20, - lineHeight: 1, + return Padding( + padding: padding, + child: RawEmojiIconWidget( + emoji: iconData, + emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20, + lineHeight: 1, + ), ); } @@ -288,6 +312,7 @@ class LinkSearchTextField { final views = sectionViews .unique((e) => e.item.id) .map((e) => e.item) + .where((e) => e.id != currentViewId) .take(5) .toList(); recentViews.clear(); @@ -303,13 +328,14 @@ class LinkSearchTextField { ?.items .where( (view) => - view.name.toLowerCase().contains(search.toLowerCase()) || - (view.name.isEmpty && search.isEmpty) || - (view.name.isEmpty && - LocaleKeys.menuAppHeader_defaultNewPageName - .tr() - .toLowerCase() - .contains(search.toLowerCase())), + (view.id != currentViewId) && + (view.name.toLowerCase().contains(search.toLowerCase()) || + (view.name.isEmpty && search.isEmpty) || + (view.name.isEmpty && + LocaleKeys.menuAppHeader_defaultNewPageName + .tr() + .toLowerCase() + .contains(search.toLowerCase()))), ) .take(10) .toList(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart index ee63269dfc..f29583c9b4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart @@ -1,19 +1,33 @@ +import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; class LinkStyle { - static const borderColor = Color(0xFFE8ECF3); static const textTertiary = Color(0xFF99A1A8); + static const textStatusError = Color(0xffE71D32); static const fillThemeThick = Color(0xFF00B5FF); static const shadowMedium = Color(0x1F22251F); static const textPrimary = Color(0xFF1F2329); - static InputDecoration buildLinkTextFieldInputDecoration(String hintText) { - const border = OutlineInputBorder( + static Color borderColor(BuildContext context) => + Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0xffbdbdbd); + + static InputDecoration buildLinkTextFieldInputDecoration( + String hintText, + BuildContext context, { + bool showErrorBorder = false, + }) { + final border = OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8.0)), - borderSide: BorderSide(color: LinkStyle.borderColor), + borderSide: BorderSide( + color: borderColor(context), + ), ); final enableBorder = border.copyWith( - borderSide: BorderSide(color: LinkStyle.fillThemeThick), + borderSide: BorderSide( + color: showErrorBorder + ? LinkStyle.textStatusError + : LinkStyle.fillThemeThick, + ), ); const hintStyle = TextStyle( fontSize: 14, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart deleted file mode 100644 index 8e114bf4c8..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:ui'; - -import 'package:bloc/bloc.dart'; - -class ToolbarCubit extends Cubit { - ToolbarCubit(this.onDismissCallback) : super(ToolbarState._()); - - final VoidCallback onDismissCallback; - - void dismiss() { - onDismissCallback.call(); - } -} - -class ToolbarState { - const ToolbarState._(); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart index d665230a59..77d94451c8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; @@ -68,7 +69,7 @@ class _FormatToolbarItem extends ToolbarItem { final hoverColor = isHighlight ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); - final isDark = Theme.of(context).brightness == Brightness.dark; + final isDark = !Theme.of(context).isLightMode; final child = FlowyIconButton( width: 36, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart index 2f2f75f0f0..8b32ebdde9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -78,7 +79,7 @@ class _HighlightColorPickerWidgetState } Widget buildChild(BuildContext context) { - final iconColor = Theme.of(context).iconTheme.color; + final iconColor = AFThemeExtensionV2.of(context).icon_primary; final child = FlowyIconButton( width: 36, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index fb241d5309..693c7a64ce 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -1,15 +1,17 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; - import 'toolbar_id_enum.dart'; const kIsPageLink = 'is_page_link'; @@ -28,10 +30,10 @@ final customLinkItem = ToolbarItem( ); }); + final isDark = !Theme.of(context).isLightMode; final hoverColor = isHref ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); - final toolbarCubit = context.read(); final child = FlowyIconButton( width: 36, @@ -41,16 +43,20 @@ final customLinkItem = ToolbarItem( icon: FlowySvg( FlowySvgs.toolbar_link_m, size: Size.square(20.0), - color: Theme.of(context).iconTheme.color, + color: (isDark && isHref) + ? Color(0xFF282E3A) + : AFThemeExtensionV2.of(context).icon_primary, ), onPressed: () { - toolbarCubit?.dismiss(); - if (isHref) { - getIt().call( - HoverTriggerKey(nodes.first.id, selection), - ); + getIt().hideToolbar(); + if (!isHref) { + final viewId = context.read()?.documentId ?? ''; + showLinkCreateMenu(context, editorState, selection, viewId); } else { - showLinkCreateMenu(context, editorState, selection); + WidgetsBinding.instance.addPostFrameCallback((_) { + getIt() + .call(HoverTriggerKey(nodes.first.id, selection)); + }); } }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart index 189cf64bd4..525eebe917 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_to import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; @@ -77,7 +78,7 @@ class _TextColorPickerWidgetState extends State { } Widget buildChild(BuildContext context) { - final iconColor = Theme.of(context).iconTheme.color; + final iconColor = AFThemeExtensionV2.of(context).icon_primary; final child = FlowyIconButton( width: 36, height: 32, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart index fde59eee51..46b707a8d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart @@ -1,9 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; @@ -288,7 +289,7 @@ class _MoreOptionActionListState extends State { popoverController: suggestionsPopoverController, popoverDirection: PopoverDirection.leftWithTopAligned, showOffset: Offset(-8, height), - onSelect: () => context.read()?.dismiss(), + onSelect: () => getIt().hideToolbar(), child: buildCommandItem( MoreOptionCommand.suggestions, rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), @@ -308,7 +309,7 @@ class _MoreOptionActionListState extends State { popoverController: textAlignPopoverController, popoverDirection: PopoverDirection.leftWithTopAligned, showOffset: Offset(-8, 0), - onSelect: () => context.read()?.dismiss(), + onSelect: () => getIt().hideToolbar(), highlightColor: highlightColor, child: buildCommandItem( MoreOptionCommand.textAlign, @@ -379,13 +380,14 @@ enum MoreOptionCommand { (attributes) => attributes[AppFlowyRichTextKeys.href] != null, ); }); - context.read()?.dismiss(); + getIt().hideToolbar(); if (isHref) { getIt().call( HoverTriggerKey(nodes.first.id, selection), ); } else { - showLinkCreateMenu(context, editorState, selection); + final viewId = context.read()?.documentId ?? ''; + showLinkCreateMenu(context, editorState, selection, viewId); } } else if (this == strikethrough) { await editorState.toggleAttribute(name); diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index bf5dcfdafc..7a282b3856 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; @@ -187,6 +188,9 @@ Future initGetIt( getIt.registerSingleton(PluginSandbox()); getIt.registerSingleton(ViewExpanderRegistry()); getIt.registerSingleton(LinkHoverTriggers()); + getIt.registerSingleton( + FloatingToolbarController(), + ); await DependencyResolver.resolve(getIt, mode); } From 5d33d723e9fbdcc13fb2b358a47a94d5f7760f1a Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 1 Apr 2025 09:25:49 +0800 Subject: [PATCH 082/207] fix: the checklist item will disappear when reordering (#7659) --- .../desktop_row_detail_checklist_cell.dart | 20 ++++---- .../cell_editor/checklist_cell_editor.dart | 18 ++++---- .../document/application/document_bloc.dart | 2 +- frontend/appflowy_flutter/macos/Podfile.lock | 46 +++++++++---------- 4 files changed, 40 insertions(+), 46 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart index b10d63d2d4..ab0533819a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; @@ -16,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../editable_cell_skeleton/checklist.dart'; @@ -201,19 +200,16 @@ class _ChecklistItems extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), proxyDecorator: (child, index, _) => Material( color: Colors.transparent, - child: Stack( - children: [ - BlocProvider.value( + child: MouseRegion( + cursor: UniversalPlatform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: IgnorePointer( + child: BlocProvider.value( value: context.read(), child: child, ), - MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grabbing, - child: const SizedBox.expand(), - ), - ], + ), ), ), buildDefaultDragHandles: false, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart index b788d6bd38..9853f9c1bd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart @@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; import 'checklist_cell_textfield.dart'; @@ -125,19 +126,16 @@ class ChecklistItemList extends StatelessWidget { shrinkWrap: true, proxyDecorator: (child, index, _) => Material( color: Colors.transparent, - child: Stack( - children: [ - BlocProvider.value( + child: MouseRegion( + cursor: UniversalPlatform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: IgnorePointer( + child: BlocProvider.value( value: context.read(), child: child, ), - MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grabbing, - child: const SizedBox.expand(), - ), - ], + ), ), ), buildDefaultDragHandles: false, diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index 20703659d0..e044ffc4e5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -271,7 +271,7 @@ class DocumentBloc extends Bloc { return; } - if (options.inMemoryUpdate) { + if (options.inMemoryUpdate && enableDocumentInternalLog) { Log.trace('skip transaction for in-memory update'); return; } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index b4a1a3d20d..30ee626f09 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 - auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c - irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 - local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 From b52f681e3ceca315724de4c4fcd4463fce78c599 Mon Sep 17 00:00:00 2001 From: Morn Date: Tue, 1 Apr 2025 09:33:54 +0800 Subject: [PATCH 083/207] fix: launch review issues for non-search-bar emoji picker (#7654) * fix: some launch review issues for emoji picker * fix: revamp emoji picker which is created by character * fix: add padding for non-searchbar emoji picker --- .../lib/plugins/emoji/emoji_handler.dart | 212 +++++++++++------- .../lib/plugins/emoji/emoji_menu.dart | 114 ++++++---- .../lib/widget/flowy_tooltip.dart | 66 ++++++ 3 files changed, 265 insertions(+), 127 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart index ec86de7e8d..2109414ff4 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:math'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'emoji_menu.dart'; @@ -38,27 +40,31 @@ class EmojiHandler extends StatefulWidget { } class _EmojiHandlerState extends State { - final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler'); - final ItemScrollController controller = ItemScrollController(); + final focusNode = FocusNode(debugLabel: 'emoji_menu_handler'); + final scrollController = ScrollController(); late EmojiData emojiData; final List searchedEmojis = []; bool loaded = false; int invalidCounter = 0; late int startOffset; String _search = ''; + double emojiHeight = 36.0; + final configuration = EmojiPickerConfiguration( + defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, + ); set search(String search) { _search = search; _doSearch(); } - final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); + final ValueNotifier selectedIndexNotifier = ValueNotifier(0); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( - (_) => _focusNode.requestFocus(), + (_) => focusNode.requestFocus(), ); startOffset = widget.editorState.selection?.endIndex ?? 0; @@ -77,8 +83,9 @@ class _EmojiHandlerState extends State { @override void dispose() { - _focusNode.dispose(); - _selectedIndexNotifier.dispose(); + focusNode.dispose(); + selectedIndexNotifier.dispose(); + scrollController.dispose(); super.dispose(); } @@ -86,10 +93,11 @@ class _EmojiHandlerState extends State { Widget build(BuildContext context) { final noEmojis = searchedEmojis.isEmpty; return Focus( - focusNode: _focusNode, + focusNode: focusNode, onKeyEvent: onKeyEvent, child: Container( - constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300), + constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360), + padding: const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6.0), color: Theme.of(context).cardColor, @@ -101,52 +109,73 @@ class _EmojiHandlerState extends State { ), ], ), - child: noEmojis - ? SizedBox( - width: 400, - height: 40, - child: Center( - child: SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(), - ), - ), - ) - : ScrollablePositionedList.builder( - itemCount: searchedEmojis.length, - itemScrollController: controller, - padding: EdgeInsets.all(8), - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - itemBuilder: (ctx, index) { - return ValueListenableBuilder( - valueListenable: _selectedIndexNotifier, - builder: (context, value, __) { - final selectedEmoji = searchedEmojis[index]; - final displayedEmoji = - emojiData.getEmojiById(selectedEmoji.id); - final isSelected = value == index; - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium( - '$displayedEmoji ${selectedEmoji.name}', - lineHeight: 1.0, - overflow: TextOverflow.ellipsis, - ), - isSelected: isSelected, - onTap: () => onSelect(index), - ), - ); - }, - ); - }, - ), + child: noEmojis ? buildLoading() : buildEmojis(), ), ); } - void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index; + Widget buildLoading() { + return SizedBox( + width: 400, + height: 40, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ), + ), + ); + } + + Widget buildEmojis() { + return SizedBox( + height: + (searchedEmojis.length / configuration.perLine).ceil() * emojiHeight, + child: GridView.builder( + controller: scrollController, + itemCount: searchedEmojis.length, + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: configuration.perLine, + ), + itemBuilder: (context, index) { + final currentEmoji = searchedEmojis[index]; + final emojiId = currentEmoji.id; + final emoji = emojiData.getEmojiById( + emojiId, + skinTone: configuration.defaultSkinTone, + ); + return ValueListenableBuilder( + valueListenable: selectedIndexNotifier, + builder: (context, value, child) { + final isSelected = value == index; + return SizedBox.square( + dimension: emojiHeight, + child: FlowyButton( + isSelected: isSelected, + margin: EdgeInsets.zero, + radius: Corners.s8Border, + text: ManualTooltip( + key: ValueKey('$emojiId-$isSelected'), + message: currentEmoji.name, + showAutomaticlly: isSelected, + preferBelow: false, + child: FlowyText.emoji( + emoji, + fontSize: configuration.emojiSize, + ), + ), + onTap: () => onSelect(index), + ), + ); + }, + ); + }, + ), + ); + } + + void changeSelectedIndex(int index) => selectedIndexNotifier.value = index; void loadEmojis(EmojiData data) { emojiData = data; @@ -170,6 +199,7 @@ class _EmojiHandlerState extends State { searchedEmojis.clear(); searchedEmojis.addAll(searchEmojiData.emojis.values); changeSelectedIndex(0); + _scrollToItem(); }); if (searchedEmojis.isEmpty) { widget.onDismiss.call(); @@ -177,17 +207,19 @@ class _EmojiHandlerState extends State { } KeyEventResult onKeyEvent(focus, KeyEvent event) { - if (event is! KeyDownEvent) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } const moveKeys = [ LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, ]; if (event.logicalKey == LogicalKeyboardKey.enter) { - onSelect(_selectedIndexNotifier.value); + onSelect(selectedIndexNotifier.value); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.escape) { // Workaround to bring focus back to editor @@ -214,11 +246,7 @@ class _EmojiHandlerState extends State { return KeyEventResult.handled; } else if (event.character != null && - ![ - ...moveKeys, - LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.arrowRight, - ].contains(event.logicalKey)) { + !moveKeys.contains(event.logicalKey)) { /// Prevents dismissal of context menu by notifying the parent /// that the selection change occurred from the handler. widget.onSelectionUpdate(); @@ -259,7 +287,7 @@ class _EmojiHandlerState extends State { /// focus goes back to the editor, this makes sure this handler /// receives the next keypress. /// - _focusNode.requestFocus(); + focusNode.requestFocus(); return KeyEventResult.handled; } @@ -304,41 +332,63 @@ class _EmojiHandlerState extends State { } void _moveSelection(LogicalKeyboardKey key) { - bool didChange = false; - final index = _selectedIndexNotifier.value; - if (key == LogicalKeyboardKey.arrowUp || - (key == LogicalKeyboardKey.tab && - HardwareKeyboard.instance.isShiftPressed)) { + final index = selectedIndexNotifier.value, + perLine = configuration.perLine, + remainder = index % perLine, + length = searchedEmojis.length, + currentLine = index ~/ perLine, + maxLine = (length / perLine).ceil(); + + final heightBefore = currentLine * emojiHeight; + if (key == LogicalKeyboardKey.arrowUp) { + if (currentLine == 0) { + final exceptLine = max(0, maxLine - 1); + changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1)); + } else if (currentLine > 0) { + changeSelectedIndex(index - perLine); + } + } else if (key == LogicalKeyboardKey.arrowDown) { + if (currentLine == maxLine - 1) { + changeSelectedIndex(remainder); + } else if (currentLine < maxLine - 1) { + changeSelectedIndex(min(index + perLine, length - 1)); + } + } else if (key == LogicalKeyboardKey.arrowLeft) { if (index == 0) { - changeSelectedIndex(max(0, searchedEmojis.length - 1)); - didChange = true; + changeSelectedIndex(length - 1); } else if (index > 0) { changeSelectedIndex(index - 1); - didChange = true; } - } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab] - .contains(key)) { - if (index < searchedEmojis.length - 1) { - changeSelectedIndex(index + 1); - didChange = true; - } else if (index == searchedEmojis.length - 1) { + } else if (key == LogicalKeyboardKey.arrowRight) { + if (index == length - 1) { changeSelectedIndex(0); - didChange = true; + } else if (index < length - 1) { + changeSelectedIndex(index + 1); } } + final heightAfter = + (selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight; - if (mounted && didChange) { - _scrollToItem(); + if (mounted && (heightAfter != heightBefore)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToItem(); + }); } } void _scrollToItem() { final noEmojis = searchedEmojis.isEmpty; - if (noEmojis) return; - controller.scrollTo( - index: _selectedIndexNotifier.value, - duration: const Duration(milliseconds: 200), - alignment: 0.5, + if (noEmojis || !mounted) return; + final currentItem = selectedIndexNotifier.value; + final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight; + final maxExtent = scrollController.position.maxScrollExtent; + final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight) + ? exceptHeight + : min(exceptHeight, maxExtent); + scrollController.animateTo( + jumpTo, + duration: Duration(milliseconds: 300), + curve: Curves.linear, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart index 58269a32d5..8a9ab472a6 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart @@ -16,14 +16,19 @@ class EmojiMenu extends EmojiMenuService { required this.editorState, this.startCharAmount = 1, this.cancelBySpaceHandler, + this.menuHeight = 400, + this.menuWidth = 300, }); final BuildContext context; final EditorState editorState; + final double menuHeight; + final double menuWidth; final bool Function()? cancelBySpaceHandler; final int startCharAmount; - + Offset _offset = Offset.zero; + Alignment _alignment = Alignment.topLeft; OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; @@ -62,41 +67,11 @@ class EmojiMenu extends EmojiMenuService { return; } - const menuHeight = 400.0, menuWidth = 300.0; - const Offset menuOffset = Offset(0, 10); - final Offset editorOffset = - editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final Size editorSize = editorState.renderBox!.size; - final editorHeight = editorSize.height, editorWidth = editorSize.width; - // Default to opening the overlay below - Alignment alignment = Alignment.topLeft; - final firstRect = selectionRects.first; - Offset offset = firstRect.bottomRight + menuOffset; + calculateSelectionMenuOffset(selectionRects.first); - // Show above - if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { - offset = firstRect.topRight - menuOffset; - alignment = Alignment.bottomLeft; - offset = Offset( - offset.dx, - editorHeight - offset.dy, - ); - } - - // Show on the left - if (offset.dx > (editorWidth - menuWidth)) { - alignment = alignment == Alignment.topLeft - ? Alignment.topRight - : Alignment.bottomRight; - - offset = Offset( - editorWidth - offset.dx, - offset.dy, - ); - } - - final (left, top, right, bottom) = _getPosition(alignment, offset); + final (left, top, right, bottom) = _getPosition(); _menuEntry = OverlayEntry( builder: (context) => SizedBox( @@ -175,30 +150,77 @@ class EmojiMenu extends EmojiMenuService { selectionChangedByMenu = false; } - (double? left, double? top, double? right, double? bottom) _getPosition( - Alignment alignment, - Offset offset, - ) { + (double? left, double? top, double? right, double? bottom) _getPosition() { double? left, top, right, bottom; - switch (alignment) { + switch (_alignment) { case Alignment.topLeft: - left = offset.dx; - top = offset.dy; + left = _offset.dx; + top = _offset.dy; break; case Alignment.bottomLeft: - left = offset.dx; - bottom = offset.dy; + left = _offset.dx; + bottom = _offset.dy; break; case Alignment.topRight: - right = offset.dx; - top = offset.dy; + right = _offset.dx; + top = _offset.dy; break; case Alignment.bottomRight: - right = offset.dx; - bottom = offset.dy; + right = _offset.dx; + bottom = _offset.dy; break; } return (left, top, right, bottom); } + + void calculateSelectionMenuOffset(Rect rect) { + // Workaround: We can customize the padding through the [EditorStyle], + // but the coordinates of overlay are not properly converted currently. + // Just subtract the padding here as a result. + const menuOffset = Offset(0, 10); + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + // show below default + _alignment = Alignment.topLeft; + final bottomRight = rect.bottomRight; + final topRight = rect.topRight; + var offset = bottomRight + menuOffset; + _offset = Offset( + offset.dx, + offset.dy, + ); + + // show above + if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { + offset = topRight - menuOffset; + _alignment = Alignment.bottomLeft; + + _offset = Offset( + offset.dx, + editorHeight + editorOffset.dy - offset.dy, + ); + } + + // show on right + if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) { + _offset = Offset( + _offset.dx, + _offset.dy, + ); + } else if (offset.dx - editorOffset.dx > menuWidth) { + // show on left + _alignment = _alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + _offset = Offset( + editorWidth - _offset.dx + editorOffset.dx, + _offset.dy, + ); + } + } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart index c770452fcd..8813cad09e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart @@ -50,6 +50,72 @@ class FlowyTooltip extends StatelessWidget { } } +class ManualTooltip extends StatefulWidget { + const ManualTooltip({ + super.key, + this.message, + this.richMessage, + this.preferBelow, + this.margin, + this.verticalOffset, + this.padding, + this.showAutomaticlly = false, + this.child, + }); + + final String? message; + final InlineSpan? richMessage; + final bool? preferBelow; + final EdgeInsetsGeometry? margin; + final Widget? child; + final double? verticalOffset; + final EdgeInsets? padding; + final bool showAutomaticlly; + + @override + State createState() => _ManualTooltipState(); +} + +class _ManualTooltipState extends State { + final key = GlobalKey(); + + @override + void initState() { + if (widget.showAutomaticlly) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) key.currentState?.ensureTooltipVisible(); + }); + } + super.initState(); + } + + + @override + Widget build(BuildContext context) { + return Tooltip( + key: key, + margin: widget.margin, + verticalOffset: widget.verticalOffset ?? 16.0, + triggerMode: widget.showAutomaticlly ? TooltipTriggerMode.manual : null, + padding: widget.padding ?? + const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + decoration: BoxDecoration( + color: context.tooltipBackgroundColor(), + borderRadius: BorderRadius.circular(10.0), + ), + waitDuration: _tooltipWaitDuration, + message: widget.message, + textStyle: widget.message != null ? context.tooltipTextStyle() : null, + richMessage: widget.richMessage, + preferBelow: widget.preferBelow, + child: widget.child, + ); + } +} + extension FlowyToolTipExtension on BuildContext { double tooltipFontSize() => 14.0; From edd59cf4627b678880ce69b38b99e050094b3b57 Mon Sep 17 00:00:00 2001 From: Morn Date: Tue, 1 Apr 2025 14:17:04 +0800 Subject: [PATCH 084/207] fix: link menu issues (#7661) * fix: duplicated link menu issue * fix: support toolbar animation * chore: update appflowy_editor * fix: update pubspect.lock * fix: testing error --------- Co-authored-by: Lucas.Xu --- .../document/document_toolbar_test.dart | 40 +++++++++++++++++++ .../shared/document_test_operations.dart | 8 ++-- .../document/presentation/editor_page.dart | 2 +- .../desktop_toolbar/link/link_hover_menu.dart | 9 ++--- .../desktop_toolbar/link/link_styles.dart | 6 +-- .../text_suggestions_toolbar_item.dart | 5 ++- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 8 files changed, 59 insertions(+), 17 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart index aaecd0ff10..f455cd479d 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart @@ -184,6 +184,45 @@ void main() { 3, ); }); + + testWidgets('toolbar will not rebuild after click item', (tester) async { + const text = 'Test rebuilding'; + await prepareForToolbar(tester, text); + Finder toolbar = find.byType(DesktopFloatingToolbar); + Element toolbarElement = toolbar.evaluate().first; + final elementHashcode = toolbarElement.hashCode; + final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m), + underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m), + italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m); + + /// tap format buttons + await tester.tapButton(boldButton); + await tester.tapButton(underlineButton); + await tester.tapButton(italicButton); + toolbar = find.byType(DesktopFloatingToolbar); + toolbarElement = toolbar.evaluate().first; + + /// check if the toolbar is not rebuilt + expect(elementHashcode, toolbarElement.hashCode); + final editorState = tester.editor.getCurrentEditorState(); + + /// check text formats + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold), + true, + ); + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic), + true, + ); + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline), + true, + ); + }); }); group('document toolbar: link', () { @@ -228,6 +267,7 @@ void main() { expect(toolbar, findsNothing); /// show toolbar again + await tester.editor.tapLineOfEditorAt(0); await selectText(tester, text); await tester.tapButton(linkButton); diff --git a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart index 491ac9432c..398a3f9657 100644 --- a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart @@ -307,9 +307,11 @@ class EditorOperations { Future openTurnIntoMenu(Path path) async { await hoverAndClickOptionMenuButton(path); await tester.tapButton( - find.findTextInFlowyText( - LocaleKeys.document_plugins_optionAction_turnInto.tr(), - ), + find + .findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_turnInto.tr(), + ) + .first, ); await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu)); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index ee3a852cc1..c2c18e48eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -459,7 +459,7 @@ class _AppFlowyEditorPageState extends State child: DesktopFloatingToolbar( editorState: editorState, onDismiss: onDismiss, - enableAnimation: false, + enableAnimation: !isMetricsChanged, child: child, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart index b07d2949d9..0d71611516 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -221,7 +221,7 @@ class _LinkHoverTriggerState extends State { if (isPage) { final viewId = href.split('/').lastOrNull ?? ''; if (viewId.isEmpty) { - await afLaunchUrlString(href); + await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); } else { final (view, isInTrash, isDeleted) = await ViewBackendService.getMentionPageStatus(viewId); @@ -230,7 +230,7 @@ class _LinkHoverTriggerState extends State { } } } else { - await afLaunchUrlString(href); + await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); } } @@ -486,8 +486,7 @@ class LinkHoverTriggers { void call(HoverTriggerKey key) { final callbacks = _map[key] ?? {}; - for (final callback in callbacks) { - callback.call(); - } + if (callbacks.isEmpty) return; + callbacks.first.call(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart index f29583c9b4..cabc00a312 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart @@ -9,7 +9,7 @@ class LinkStyle { static const textPrimary = Color(0xFF1F2329); static Color borderColor(BuildContext context) => - Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0xffbdbdbd); + Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD); static InputDecoration buildLinkTextFieldInputDecoration( String hintText, @@ -18,9 +18,7 @@ class LinkStyle { }) { final border = OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8.0)), - borderSide: BorderSide( - color: borderColor(context), - ), + borderSide: BorderSide(color: borderColor(context)), ); final enableBorder = border.copyWith( borderSide: BorderSide( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart index c7f3b513f3..a0aded3f8e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -84,12 +84,14 @@ class _SuggestionsActionListState extends State { void initState() { super.initState(); refreshSuggestions(); + editorState.selectionNotifier.addListener(refreshSuggestions); } @override void dispose() { - super.dispose(); + editorState.selectionNotifier.removeListener(refreshSuggestions); popoverController.close(); + super.dispose(); } @override @@ -290,6 +292,7 @@ class _SuggestionsActionListState extends State { } currentSuggestionItem = suggestions.where((item) => item.type == suggestionType).first; + if (mounted) setState(() {}); } } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 1e156a4530..5c8beaaed8 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: "5ad9d77" - resolved-ref: "5ad9d771a8496dea95a9c5b1ec77f76df3983037" + ref: d5c9f64 + resolved-ref: d5c9f64ebe23642f8c0e12282816992da8b8180a url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index f623932a16..59871276b8 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -184,7 +184,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "5ad9d77" + ref: "d5c9f64" appflowy_editor_plugins: git: From e2ff12415c23ed57b1355c3bfabf855cc30d5e06 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 1 Apr 2025 17:35:22 +0800 Subject: [PATCH 085/207] chore: update translation (#7666) --- .../home/setting/settings_popup_menu.dart | 16 +++++++- .../widgets/float_bubble/question_bubble.dart | 39 ++++++++++++------- .../float_bubble/social_media_section.dart | 25 +++++------- .../16x/help_and_documentation.svg | 3 ++ frontend/resources/translations/el-GR.json | 4 +- frontend/resources/translations/en.json | 8 ++-- 6 files changed, 61 insertions(+), 34 deletions(-) create mode 100644 frontend/resources/flowy_icons/16x/help_and_documentation.svg diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart index cbbda8362a..521fca4fdf 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem { members, trash, help, + helpAndDocumentation, } class HomePageSettingsPopupMenu extends StatelessWidget { @@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget { text: LocaleKeys.settings_popupMenuItem_trash.tr(), ), const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.helpAndDocumentation, + svg: FlowySvgs.help_and_documentation_s, + text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(), + ), + const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.help, svg: FlowySvgs.message_support_s, - text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(), + text: LocaleKeys.settings_popupMenuItem_getSupport.tr(), ), ], onSelected: (_MobileSettingsPopupMenuItem value) { @@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget { case _MobileSettingsPopupMenuItem.help: _openHelpPage(context); break; + case _MobileSettingsPopupMenuItem.helpAndDocumentation: + _openHelpAndDocumentationPage(context); + break; } }, child: const Padding( @@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget { void _openSettingsPage(BuildContext context) { context.push(MobileHomeSettingPage.routeName); } + + void _openHelpAndDocumentationPage(BuildContext context) { + afLaunchUrlString('https://appflowy.com/guide'); + } } class _PopupButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart index d666e606f6..e3117c7f86 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart @@ -86,7 +86,7 @@ class _BubbleActionListState extends State { ), buildChild: (controller) { return FlowyTooltip( - message: LocaleKeys.questionBubble_help.tr(), + message: LocaleKeys.questionBubble_getSupport.tr(), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -121,22 +121,22 @@ class _BubbleActionListState extends State { if (action is BubbleActionWrapper) { switch (action.inner) { case BubbleAction.whatsNews: - afLaunchUrlString("https://www.appflowy.io/what-is-new"); + afLaunchUrlString('https://www.appflowy.io/what-is-new'); break; - case BubbleAction.help: - afLaunchUrlString("https://discord.gg/9Q2xaN37tV"); + case BubbleAction.getSupport: + afLaunchUrlString('https://discord.gg/9Q2xaN37tV'); break; case BubbleAction.debug: _DebugToast().show(); break; case BubbleAction.shortcuts: afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/shortcuts", + 'https://docs.appflowy.io/docs/appflowy/product/shortcuts', ); break; case BubbleAction.markdown: afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/markdown", + 'https://docs.appflowy.io/docs/appflowy/product/markdown', ); break; case BubbleAction.github: @@ -144,6 +144,11 @@ class _BubbleActionListState extends State { 'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose', ); break; + case BubbleAction.helpAndDocumentation: + afLaunchUrlString( + 'https://appflowy.com/guide', + ); + break; } } @@ -155,7 +160,7 @@ class _BubbleActionListState extends State { class _DebugToast { void show() async { - String debugInfo = ""; + String debugInfo = ''; debugInfo += await _getDeviceInfo(); debugInfo += await _getDocumentPath(); await Clipboard.setData(ClipboardData(text: debugInfo)); @@ -168,20 +173,21 @@ class _DebugToast { final deviceInfo = await deviceInfoPlugin.deviceInfo; return deviceInfo.data.entries - .fold('', (prev, el) => "$prev${el.key}: ${el.value}\n"); + .fold('', (prev, el) => '$prev${el.key}: ${el.value}\n'); } Future _getDocumentPath() async { return appFlowyApplicationDataDirectory().then((directory) { final path = directory.path.toString(); - return "Document: $path\n"; + return 'Document: $path\n'; }); } } enum BubbleAction { whatsNews, - help, + helpAndDocumentation, + getSupport, debug, shortcuts, markdown, @@ -204,8 +210,10 @@ extension QuestionBubbleExtension on BubbleAction { switch (this) { case BubbleAction.whatsNews: return LocaleKeys.questionBubble_whatsNew.tr(); - case BubbleAction.help: - return LocaleKeys.questionBubble_help.tr(); + case BubbleAction.helpAndDocumentation: + return LocaleKeys.questionBubble_helpAndDocumentation.tr(); + case BubbleAction.getSupport: + return LocaleKeys.questionBubble_getSupport.tr(); case BubbleAction.debug: return LocaleKeys.questionBubble_debug_name.tr(); case BubbleAction.shortcuts: @@ -221,7 +229,12 @@ extension QuestionBubbleExtension on BubbleAction { switch (this) { case BubbleAction.whatsNews: return const FlowySvg(FlowySvgs.star_s); - case BubbleAction.help: + case BubbleAction.helpAndDocumentation: + return const FlowySvg( + FlowySvgs.help_and_documentation_s, + size: Size.square(16.0), + ); + case BubbleAction.getSupport: return const FlowySvg(FlowySvgs.message_support_s); case BubbleAction.debug: return const FlowySvg(FlowySvgs.debug_s); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart index 43b3ab9b62..8b58557455 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart @@ -25,18 +25,13 @@ class SocialMediaSection extends CustomActionCell { action: SocialMediaWrapper(social), itemHeight: ActionListSizes.itemHeight, onSelected: (action) { - switch (action.inner) { - case SocialMedia.reddit: - afLaunchUrlString( - 'https://www.reddit.com/r/AppFlowy/', - ); - case SocialMedia.twitter: - afLaunchUrlString( - 'https://x.com/appflowy', - ); - case SocialMedia.forum: - afLaunchUrlString('https://forum.appflowy.io/'); - } + final url = switch (action.inner) { + SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/', + SocialMedia.twitter => 'https://x.com/appflowy', + SocialMedia.forum => 'https://forum.appflowy.com/', + }; + + afLaunchUrlString(url); }, ); }, @@ -85,11 +80,11 @@ extension QuestionBubbleExtension on SocialMedia { String get name { switch (this) { case SocialMedia.forum: - return "Community Forum"; + return 'Community Forum'; case SocialMedia.twitter: - return "Twitter – @appflowy"; + return 'Twitter – @appflowy'; case SocialMedia.reddit: - return "Reddit – r/appflowy"; + return 'Reddit – r/appflowy'; } } diff --git a/frontend/resources/flowy_icons/16x/help_and_documentation.svg b/frontend/resources/flowy_icons/16x/help_and_documentation.svg new file mode 100644 index 0000000000..e4c68c2583 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/help_and_documentation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/el-GR.json b/frontend/resources/translations/el-GR.json index 34f43b87ac..6d6524a8bf 100644 --- a/frontend/resources/translations/el-GR.json +++ b/frontend/resources/translations/el-GR.json @@ -723,7 +723,7 @@ }, "url": { "launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης", - "copy": "Copy link to clipboard", + "copy": "Copied link to clipboard", "textFieldHint": "Enter a URL", "copiedNotification": "Copied to clipboard!" }, @@ -1403,4 +1403,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index e0c735e2ff..f498538a1a 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -307,7 +307,8 @@ "questionBubble": { "shortcuts": "Shortcuts", "whatsNew": "What's new?", - "help": "Help & Support", + "helpAndDocumentation": "Help & documentation", + "getSupport": "Get support", "markdown": "Markdown", "debug": { "name": "Debug Info", @@ -507,7 +508,8 @@ "settings": "Settings", "members": "Members", "trash": "Trash", - "helpAndSupport": "Help & Support" + "helpAndDocumentation": "Help & documentation", + "getSupport": "Get Support" }, "sites": { "title": "Sites", @@ -1645,7 +1647,7 @@ }, "url": { "launch": "Open link in browser", - "copy": "Copy link to clipboard", + "copy": "Copied link to clipboard", "textFieldHint": "Enter a URL", "copiedNotification": "Copied to clipboard!" }, From 031a88f4c45dd481edfac857771e1a58a09162ab Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 1 Apr 2025 21:26:24 +0800 Subject: [PATCH 086/207] fix: translation hotfix --- frontend/resources/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index f498538a1a..27b98fdce5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -862,7 +862,7 @@ "localAIRunning": "Local AI is running", "localAINotReadyRetryLater": "Local AI is initializing, please retry later", "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", - "localAIInitializing": "Local AI is loading. This may take a few minutes depending on your device", + "localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI.", "restartLocalAI": "Restart", From 7f41feb959aeac076f77b271f2f05979f2dcccd0 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 1 Apr 2025 21:41:20 +0800 Subject: [PATCH 087/207] chore: update changelog (#7671) --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db399773b..289f7fd004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ # Release Notes +## Version 0.8.8 - 01/04/2025 +### New Features +- Added support for selecting AI models in AI writer +- Revamped link menu in toolbar +- Added support for using ":" to add emojis in documents +- Passed the history of past AI prompts and responses to AI writer +### Bug Fixes +- Improved AI writer scrolling user experience +- Fixed issue where checklist items would disappear during reordering +- Fixed numbered lists generated by AI to maintain the same index as the input + ## Version 0.8.7 - 18/03/2025 ### New Features - Made local AI free and integrated with Ollama From f8a17dac00262e69f7f6ba003df94b0529c93eaf Mon Sep 17 00:00:00 2001 From: FakhriAzzouz Date: Wed, 2 Apr 2025 02:50:50 +0100 Subject: [PATCH 088/207] =?UTF-8?q?chore:=20update=20translations=20with?= =?UTF-8?q?=20Fink=20=F0=9F=90=A6=20(#7669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/resources/translations/ar-SA.json | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index bf7e9f4d6a..cc5d11719d 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -256,6 +256,12 @@ "bulletWithImageDescription": "@:chat.changeFormat.bullet مع الصورة", "tableWithImageDescription": "@:chat.changeFormat.table مع الصورة" }, + "switchModel": { + "label": "تبديل النموذج", + "localModel": "النموذج المحلي", + "cloudModel": "نموذج السحابة", + "autoModel": "آلي" + }, "selectBanner": { "saveButton": "أضف إلى...", "selectMessages": "حدد الرسائل", @@ -858,6 +864,8 @@ "localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...", "localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي", "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل", + "localAINotReadyRetryLater": "جاري تهيئة الذكاء الاصطناعي المحلي، يرجى المحاولة مرة أخرى لاحقًا", + "localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.", "localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك", "localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي", "failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي", @@ -875,9 +883,13 @@ "activeOfflineAI": "نشط", "downloadOfflineAI": "التنزيل", "openModelDirectory": "افتح المجلد", + "laiNotReady": "لم يتم تثبيت تطبيق الذكاء الاصطناعي المحلي بشكل صحيح.", + "ollamaNotReady": "خادم Ollama غير جاهز.", "pleaseFollowThese": "اتبع هؤلاء", "instructions": "التعليمات", "installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل", + "modelsMissing": "لم يتم العثور على النماذج المطلوبة.", + "downloadModel": "لتنزيلها.", "startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي" } }, @@ -2133,6 +2145,7 @@ "toolbar": { "resetToDefaultFont": "إعادة تعيين إلى الافتراضي", "textSize": "حجم النص", + "textColor": "لون النص", "h1": "العنوان 1", "h2": "العنوان 2", "h3": "العنوان 3", @@ -2143,9 +2156,15 @@ "textAlign": "محاذاة النص", "moreOptions": "المزيد من الخيارات", "font": "الخط", + "inlineCode": "الكود المضمن", "suggestions": "اقتراحات", "turnInto": "تحول إلى", - "equation": "معادلة" + "equation": "معادلة", + "insert": "إدراج", + "linkInputHint": "لصق الرابط أو البحث عن الصفحات", + "pageOrURL": "الصفحة أو عنوان URL", + "linkName": "اسم الرابط", + "linkNameHint": "اسم رابط الإدخال" }, "errorBlock": { "theBlockIsNotSupported": "الإصدار الحالي لا يدعم هذا الحقل.", @@ -2255,11 +2274,11 @@ "layoutDateField": "تقويم التخطيط بواسطة", "changeLayoutDateField": "تغيير حقل التخطيط", "noDateTitle": "بدون تاريخ", + "noDateHint": "ستظهر الأحداث غير المجدولة هنا", "unscheduledEventsTitle": "الأحداث غير المجدولة", "clickToAdd": "انقر للإضافة إلى التقويم", "name": "تخطيط التقويم", - "clickToOpen": "انقر لفتح السجل", - "noDateHint": "ستظهر الأحداث غير المجدولة هنا" + "clickToOpen": "انقر لفتح السجل" }, "referencedCalendarPrefix": "نظرا ل", "quickJumpYear": "انتقل إلى", @@ -3187,7 +3206,8 @@ "editing": "تحرير", "analyzing": "تحليل", "continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ", - "continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!" + "continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!", + "more": "أكثر" }, "autoUpdate": { "criticalUpdateTitle": "التحديث ضروري للمتابعة", From 120f22c6fc98b2614dc02a3ac8933e3b13415769 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 2 Apr 2025 13:08:45 +0800 Subject: [PATCH 089/207] chore: notify model change after applying local ai model --- .../lib/flutter/af_dropdown_menu.dart | 19 +++++++- .../setting_ai_view/model_selection.dart | 6 ++- .../settings/shared/settings_dropdown.dart | 3 ++ frontend/rust-lib/flowy-ai/src/ai_manager.rs | 44 +++++++++++++++++-- .../rust-lib/flowy-ai/src/event_handler.rs | 5 +-- .../flowy-ai/src/local_ai/controller.rs | 6 ++- .../flowy-ai/src/local_ai/resource.rs | 4 +- 7 files changed, 73 insertions(+), 14 deletions(-) diff --git a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart index 56a61e120b..157be012b1 100644 --- a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart +++ b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart @@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0; const double _kDefaultHorizontalPadding = 12.0; +typedef CompareFunction = bool Function(T? left, T? right); + // Navigation shortcuts to move the selected menu items up or down. final Map _kMenuTraversalShortcuts = { @@ -86,6 +88,7 @@ class AFDropdownMenu extends StatefulWidget { this.requestFocusOnTap, this.expandedInsets, this.searchCallback, + this.selectOptionCompare, required this.dropdownMenuEntries, }); @@ -267,6 +270,11 @@ class AFDropdownMenu extends StatefulWidget { /// which contains the contents of the text input field. final SearchCallback? searchCallback; + /// Defines the compare function for the menu items. + /// + /// Defaults to null. If this is null, the menu items will be sorted by the label. + final CompareFunction? selectOptionCompare; + @override State> createState() => _AFDropdownMenuState(); } @@ -301,7 +309,16 @@ class _AFDropdownMenuState extends State> { filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); final int index = filteredEntries.indexWhere( - (DropdownMenuEntry entry) => entry.value == widget.initialSelection, + (DropdownMenuEntry entry) { + if (widget.selectOptionCompare != null) { + return widget.selectOptionCompare!( + entry.value, + widget.initialSelection, + ); + } else { + return entry.value == widget.initialSelection; + } + }, ); if (index != -1) { _textEditingController.value = TextEditingValue( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 30ff4addde..7357c2951c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -21,7 +21,6 @@ class AIModelSelection extends StatelessWidget { previous.availableModels != current.availableModels, builder: (context, state) { final models = state.availableModels?.models; - if (models == null) { return const SizedBox( // Using same height as SettingsDropdown to avoid layout shift @@ -31,6 +30,7 @@ class AIModelSelection extends StatelessWidget { final localModels = models.where((model) => model.isLocal).toList(); final cloudModels = models.where((model) => !model.isLocal).toList(); + final selectedModel = state.availableModels!.selectedModel; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), @@ -48,7 +48,9 @@ class AIModelSelection extends StatelessWidget { onChanged: (model) => context .read() .add(SettingsAIEvent.selectModel(model)), - selectedOption: state.availableModels!.selectedModel, + selectedOption: selectedModel, + selectOptionCompare: (left, right) => + left?.name == right?.name, options: [...localModels, ...cloudModels] .map( (model) => buildDropdownMenuEntry( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart index 3b2e883210..e392ed91f0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart @@ -16,9 +16,11 @@ class SettingsDropdown extends StatefulWidget { this.onChanged, this.actions, this.expandWidth = true, + this.selectOptionCompare, }); final T selectedOption; + final CompareFunction? selectOptionCompare; final List> options; final void Function(T)? onChanged; final List? actions; @@ -52,6 +54,7 @@ class _SettingsDropdownState extends State> { expandedInsets: widget.expandWidth ? EdgeInsets.zero : null, initialSelection: widget.selectedOption, dropdownMenuEntries: widget.options, + selectOptionCompare: widget.selectOptionCompare, textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( fontFamily: fontFamilyUsed, fontWeight: FontWeight.w400, diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 1aac5b0109..5e50f768f1 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -3,7 +3,7 @@ use crate::entities::{ AIModelPB, AvailableModelsPB, ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, }; -use crate::local_ai::controller::LocalAIController; +use crate::local_ai::controller::{LocalAIController, LocalAISetting}; use crate::middleware::chat_service_mw::AICloudServiceMiddleware; use crate::persistence::{insert_chat, read_chat_metadata, ChatTable}; use std::collections::HashMap; @@ -280,6 +280,24 @@ impl AIManager { Ok(()) } + pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + let previous_model = self.local_ai.get_local_ai_setting().chat_model_name; + self.local_ai.update_local_ai_setting(setting).await?; + let current_model = self.local_ai.get_local_ai_setting().chat_model_name; + + if previous_model != current_model { + info!( + "[AI Plugin] update global active model, previous: {}, current: {}", + previous_model, current_model + ); + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + let model = AIModel::local(current_model, "".to_string()); + self.update_selected_model(source_key, model).await?; + } + + Ok(()) + } + async fn get_workspace_select_model(&self) -> FlowyResult { let workspace_id = self.user_service.workspace_id()?; let model = self @@ -358,6 +376,10 @@ impl AIManager { } pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> { + info!( + "[Model Selection] update {} selected model: {:?}", + source, model + ); let source_key = ai_available_models_key(&source); self .store_preferences @@ -418,9 +440,15 @@ impl AIManager { trace!("[Model Selection]: Available models: {:?}", models); + let mut current_active_local_ai_model = None; + // If user enable local ai, then add local ai model to the list. if let Some(local_model) = self.local_ai.get_plugin_chat_model() { - models.push(AIModel::local(local_model, "".to_string())); + let model = AIModel::local(local_model, "".to_string()); + current_active_local_ai_model = Some(model.clone()); + + trace!("[Model Selection] current local ai model: {}", model.name); + models.push(model); } if models.is_empty() { @@ -454,8 +482,16 @@ impl AIManager { user_selected_model = local_ai_model.clone(); } }, - Some(model) => { - trace!("[Model Selection] user select model: {:?}", model); + Some(mut model) => { + trace!("[Model Selection] user previous select model: {:?}", model); + if model.is_local { + if let Some(local_ai_model) = ¤t_active_local_ai_model { + if local_ai_model.name != model.name { + model = local_ai_model.clone(); + } + } + } + user_selected_model = model; }, } diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 4a36a5d37a..72a271f5a1 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -369,9 +369,6 @@ pub(crate) async fn update_local_ai_setting_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager - .local_ai - .update_local_ai_setting(data.into()) - .await?; + ai_manager.update_local_ai_setting(data.into()).await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index b63401b829..1a125a4b47 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -292,8 +292,10 @@ impl LocalAIController { setting, std::thread::current().id() ); - self.resource.set_llm_setting(setting).await?; - self.reload().await?; + + if let Ok(_) = self.resource.set_llm_setting(setting).await { + self.reload().await?; + } Ok(()) } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index c40a9a9b88..b384b30f4a 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -139,12 +139,14 @@ impl LocalAIResourceController { pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { self.resource_service.store_setting(setting)?; if let Some(resource) = self.calculate_pending_resources().await? { + let resource = LackOfAIResourcePB::from(resource); chat_notification_builder( APPFLOWY_AI_NOTIFICATION_KEY, ChatNotification::LocalAIResourceUpdated, ) - .payload(LackOfAIResourcePB::from(resource)) + .payload(resource.clone()) .send(); + return Err(FlowyError::local_ai().with_context(format!("{:?}", resource))); } Ok(()) } From ed5334a7d6abaf890e4648298f073f2c17b6aea7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 2 Apr 2025 13:38:29 +0800 Subject: [PATCH 090/207] chore: clippy --- frontend/rust-lib/flowy-ai/src/local_ai/controller.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 1a125a4b47..05f6313655 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -293,7 +293,7 @@ impl LocalAIController { std::thread::current().id() ); - if let Ok(_) = self.resource.set_llm_setting(setting).await { + if self.resource.set_llm_setting(setting).await.is_ok() { self.reload().await?; } Ok(()) From 913924d8d3de8d3a2019fe12e80d8deea21bb29d Mon Sep 17 00:00:00 2001 From: Morn Date: Wed, 2 Apr 2025 14:15:14 +0800 Subject: [PATCH 091/207] =?UTF-8?q?fix:=20emoji=20menu=20should=20only=20b?= =?UTF-8?q?e=20triggered=20when=20=E2=80=9C:=E2=80=9D=20has=20a=20followed?= =?UTF-8?q?=20letter=20(#7672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../uncategorized/emoji_shortcut_test.dart | 15 ++---- .../plugins/emoji/emoji_actions_command.dart | 49 +++++++++++++---- .../lib/plugins/emoji/emoji_handler.dart | 52 +++++-------------- .../lib/plugins/emoji/emoji_menu.dart | 15 ++++-- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 6 files changed, 69 insertions(+), 68 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart index f539835b9b..aba3a7be06 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -43,12 +43,12 @@ void main() { }); group('insert emoji by colon', () { - Future createNewDocumentAndShowEmojiList(WidgetTester tester) async { + Future createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await tester.editor.tapLineOfEditorAt(0); - await tester.ime.insertText(':'); + await tester.ime.insertText(':${search ?? 'a'}'); await tester.pumpAndSettle(Duration(seconds: 1)); } @@ -106,11 +106,10 @@ void main() { }); testWidgets('insert with searching', (tester) async { - await createNewDocumentAndShowEmojiList(tester); + await createNewDocumentAndShowEmojiList(tester, search: 's'); /// search for `smiling eyes`, IME is not working, use keyboard input final searchText = [ - LogicalKeyboardKey.keyS, LogicalKeyboardKey.keyM, LogicalKeyboardKey.keyI, LogicalKeyboardKey.keyL, @@ -138,16 +137,10 @@ void main() { }); testWidgets('start searching with sapce', (tester) async { - await createNewDocumentAndShowEmojiList(tester); + await createNewDocumentAndShowEmojiList(tester, search: ' '); /// emoji list is showing final emojiHandler = find.byType(EmojiHandler); - expect(emojiHandler, findsOneWidget); - - /// input space - await tester.simulateKeyEvent(LogicalKeyboardKey.space); - - /// emoji list is dismissed expect(emojiHandler, findsNothing); }); }); diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart index 1942a1fd98..ffe25be8bd 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart @@ -1,21 +1,27 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/cupertino.dart'; import 'package:universal_platform/universal_platform.dart'; import 'emoji_menu.dart'; -const emojiCharacter = ':'; +const _emojiCharacter = ':'; +final _letterRegExp = RegExp(r'^[a-zA-Z]$'); CharacterShortcutEvent emojiCommand(BuildContext context) => CharacterShortcutEvent( key: 'Opens Emoji Menu', - character: emojiCharacter, - handler: (editorState) { - emojiMenuService ??= EmojiMenu( + character: '', + regExp: _letterRegExp, + handler: (editorState) async { + return false; + }, + handlerWithCharacter: (editorState, character) { + emojiMenuService = EmojiMenu( context: context, editorState: editorState, ); - return emojiCommandHandler(editorState, context); + return emojiCommandHandler(editorState, context, character); }, ); @@ -24,6 +30,7 @@ EmojiMenuService? emojiMenuService; Future emojiCommandHandler( EditorState editorState, BuildContext context, + String character, ) async { final selection = editorState.selection; @@ -35,10 +42,30 @@ Future emojiCommandHandler( await editorState.deleteSelection(selection); } - await editorState.insertTextAtPosition( - emojiCharacter, - position: selection.start, - ); - emojiMenuService?.show(); - return true; + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || + delta == null || + delta.isEmpty || + node.type == CodeBlockKeys.type) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != _emojiCharacter) return false; + if (!context.mounted) return false; + + await editorState.insertTextAtPosition( + character, + position: selection.start, + ); + + emojiMenuService?.show(character); + return true; + } + + return false; } diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart index 2109414ff4..26769ba7d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:math'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; @@ -25,6 +24,7 @@ class EmojiHandler extends StatefulWidget { required this.onEmojiSelect, this.startCharAmount = 1, this.cancelBySpaceHandler, + this.initialSearchText = '', }); final EditorState editorState; @@ -33,6 +33,7 @@ class EmojiHandler extends StatefulWidget { final VoidCallback onSelectionUpdate; final SelectEmojiItemHandler onEmojiSelect; final int startCharAmount; + final String initialSearchText; final bool Function()? cancelBySpaceHandler; @override @@ -47,7 +48,7 @@ class _EmojiHandlerState extends State { bool loaded = false; int invalidCounter = 0; late int startOffset; - String _search = ''; + late String _search = widget.initialSearchText; double emojiHeight = 36.0; final configuration = EmojiPickerConfiguration( defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, @@ -67,7 +68,7 @@ class _EmojiHandlerState extends State { (_) => focusNode.requestFocus(), ); - startOffset = widget.editorState.selection?.endIndex ?? 0; + startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1; if (kCachedEmojiData != null) { loadEmojis(kCachedEmojiData!); @@ -186,11 +187,14 @@ class _EmojiHandlerState extends State { loaded = true; }); } + WidgetsBinding.instance.addPostFrameCallback((_) { + _doSearch(); + }); } - Future _doSearch() async { - if (!loaded) return; - if (_search.startsWith(' ')) { + void _doSearch() { + if (!loaded || !mounted) return; + if (_search.startsWith(' ') || _search.isEmpty) { widget.onDismiss.call(); return; } @@ -266,42 +270,13 @@ class _EmojiHandlerState extends State { return KeyEventResult.handled; } - if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight] - .contains(event.logicalKey)) { - widget.onSelectionUpdate(); - - event.logicalKey == LogicalKeyboardKey.arrowLeft - ? widget.editorState.moveCursorForward() - : widget.editorState.moveCursorBackward(SelectionMoveRange.character); - - /// If cursor moves before @ then dismiss menu - /// If cursor moves after @search.length then dismiss menu - final selection = widget.editorState.selection; - if (selection != null && - (selection.endIndex < startOffset || - selection.endIndex > (startOffset + _search.length))) { - widget.onDismiss.call(); - } - - /// Workaround: When using the move cursor methods, it seems the - /// focus goes back to the editor, this makes sure this handler - /// receives the next keypress. - /// - focusNode.requestFocus(); - - return KeyEventResult.handled; - } - return KeyEventResult.handled; } void onSelect(int index) { widget.onEmojiSelect.call( context, - ( - startOffset - widget.startCharAmount, - _search.length + widget.startCharAmount - ), + (startOffset - widget.startCharAmount, startOffset + _search.length), emojiData.getEmojiById(searchedEmojis[index].id), ); widget.onDismiss.call(); @@ -324,8 +299,7 @@ class _EmojiHandlerState extends State { .getTextInSelection( selection.copyWith( start: selection.start.copyWith(offset: startOffset), - end: selection.start - .copyWith(offset: startOffset + _search.length + 1), + end: selection.start.copyWith(offset: startOffset + _search.length + 1), ), ) .join(); @@ -406,7 +380,7 @@ class _EmojiHandlerState extends State { search = delta.toPlainText().substring( startOffset, - startOffset - 1 + _search.length, + startOffset + _search.length - 1, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart index 8a9ab472a6..4aff4cf6cb 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart @@ -5,7 +5,7 @@ import 'emoji_actions_command.dart'; import 'emoji_handler.dart'; abstract class EmojiMenuService { - void show(); + void show(String character); void dismiss(); } @@ -31,6 +31,7 @@ class EmojiMenu extends EmojiMenuService { Alignment _alignment = Alignment.topLeft; OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; + String initialCharacter = ''; @override void dismiss() { @@ -56,7 +57,8 @@ class EmojiMenu extends EmojiMenuService { void _onSelectionUpdate() => selectionChangedByMenu = true; @override - void show() { + void show(String character) { + initialCharacter = character; WidgetsBinding.instance.addPostFrameCallback((_) => _show()); } @@ -97,6 +99,7 @@ class EmojiMenu extends EmojiMenuService { onSelectionUpdate: _onSelectionUpdate, startCharAmount: startCharAmount, cancelBySpaceHandler: cancelBySpaceHandler, + initialSearchText: initialCharacter, onEmojiSelect: ( BuildContext context, (int, int) replacement, @@ -109,10 +112,14 @@ class EmojiMenu extends EmojiMenuService { editorState.document.nodeAtPath(selection.end.path); if (node == null) return; final transaction = editorState.transaction - ..replaceText( + ..deleteText( + node, + replacement.$1, + replacement.$2 - replacement.$1, + ) + ..insertText( node, replacement.$1, - replacement.$2, emoji, ); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 5c8beaaed8..083f945636 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: d5c9f64 - resolved-ref: d5c9f64ebe23642f8c0e12282816992da8b8180a + ref: "552f95f" + resolved-ref: "552f95fd15627e10a138c6db2a6d0a8089bc9a25" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 59871276b8..e6e377fa24 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -184,7 +184,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "d5c9f64" + ref: "552f95f" appflowy_editor_plugins: git: From d74b0bf6e14440ae8f8ad7bbe142db3f70d18f89 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 2 Apr 2025 16:09:52 +0800 Subject: [PATCH 092/207] fix: only log document event when enableDocumentInternalLog is enabled (#7676) --- .../lib/plugins/document/application/document_bloc.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index e044ffc4e5..0a54ec5753 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -271,8 +271,10 @@ class DocumentBloc extends Bloc { return; } - if (options.inMemoryUpdate && enableDocumentInternalLog) { - Log.trace('skip transaction for in-memory update'); + if (options.inMemoryUpdate) { + if (enableDocumentInternalLog) { + Log.trace('skip transaction for in-memory update'); + } return; } From 10dd0fa438d7223c40f5981f859300041904227a Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 4 Apr 2025 14:41:13 +0800 Subject: [PATCH 093/207] feat: implement new color tokens design (#7684) * fix: missing AFThemeExtensionV2 on mobile * feat: add appflowy_ui package --- .../appflowy_flutter/analysis_options.yaml | 1 + frontend/appflowy_flutter/ios/Podfile.lock | 46 +- .../appearance/mobile_appearance.dart | 7 + .../packages/appflowy_ui/.gitignore | 31 + .../packages/appflowy_ui/.metadata | 10 + .../packages/appflowy_ui/CHANGELOG.md | 3 + .../packages/appflowy_ui/LICENSE | 1 + .../packages/appflowy_ui/README.md | 39 + .../appflowy_ui/analysis_options.yaml | 29 + .../packages/appflowy_ui/example/.gitignore | 45 ++ .../packages/appflowy_ui/example/.metadata | 30 + .../packages/appflowy_ui/example/README.md | 41 + .../appflowy_ui/example/analysis_options.yaml | 28 + .../appflowy_ui/example/lib/main.dart | 109 +++ .../example/lib/src/buttons/buttons_page.dart | 287 +++++++ .../lib/src/textfield/textfield_page.dart | 77 ++ .../appflowy_ui/example/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + .../macos/Runner.xcodeproj/project.pbxproj | 705 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 +++++++++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + .../example/macos/Runner/Info.plist | 32 + .../macos/Runner/MainFlutterWindow.swift | 15 + .../example/macos/Runner/Release.entitlements | 8 + .../macos/RunnerTests/RunnerTests.swift | 12 + .../packages/appflowy_ui/example/pubspec.yaml | 24 + .../appflowy_ui/example/test/widget_test.dart | 30 + .../packages/appflowy_ui/lib/appflowy_ui.dart | 4 + .../component/button/base_button/base.dart | 54 ++ .../button/base_button/base_button.dart | 79 ++ .../button/base_button/base_text_button.dart | 51 ++ .../lib/src/component/button/button.dart | 16 + .../button/filled_button/filled_button.dart | 125 ++++ .../filled_icon_text_button.dart | 199 +++++ .../filled_button/filled_text_button.dart | 141 ++++ .../button/ghost_button/ghost_button.dart | 96 +++ .../ghost_button/ghost_icon_text_button.dart | 141 ++++ .../ghost_button/ghost_text_button.dart | 116 +++ .../outlined_button/outlined_button.dart | 168 +++++ .../outlined_icon_text_button.dart | 226 ++++++ .../outlined_button/outlined_text_button.dart | 204 +++++ .../lib/src/component/component.dart | 2 + .../src/component/textfield/textfield.dart | 177 +++++ .../lib/src/theme/appflowy_theme.dart | 72 ++ .../theme/border_radius/border_radius.dart | 17 + .../background/background_color_scheme.dart | 15 + .../theme/color_scheme/base/base_scheme.dart | 33 + .../lib/src/theme/color_scheme/base/blue.dart | 27 + .../src/theme/color_scheme/base/green.dart | 24 + .../src/theme/color_scheme/base/magenta.dart | 25 + .../src/theme/color_scheme/base/neutral.dart | 49 ++ .../src/theme/color_scheme/base/orange.dart | 25 + .../src/theme/color_scheme/base/purple.dart | 25 + .../lib/src/theme/color_scheme/base/red.dart | 27 + .../src/theme/color_scheme/base/subtle.dart | 265 +++++++ .../src/theme/color_scheme/base/yellow.dart | 24 + .../src/theme/color_scheme/border/border.dart | 49 ++ .../border/border_color_scheme.dart | 49 ++ .../brand/brand_color_scheme.dart | 25 + .../src/theme/color_scheme/color_scheme.dart | 8 + .../color_scheme/fill/fill_color_scheme.dart | 93 +++ .../color_scheme/icon/icon_color_theme.dart | 21 + .../surface/surface_color_scheme.dart | 11 + .../color_scheme/text/text_color_scheme.dart | 47 ++ .../lib/src/theme/data/builder.dart | 324 ++++++++ .../appflowy_ui/lib/src/theme/data/data.dart | 238 ++++++ .../appflowy_ui/lib/src/theme/dimensions.dart | 17 + .../lib/src/theme/spacing/spacing.dart | 17 + .../text_style/base/default_text_style.dart | 298 ++++++++ .../lib/src/theme/text_style/text_style.dart | 17 + .../appflowy_ui/lib/src/theme/theme.dart | 7 + .../packages/appflowy_ui/pubspec.yaml | 17 + .../flowy_icons/20x/anonymous_mode.svg | 3 + 91 files changed, 5852 insertions(+), 23 deletions(-) create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/.gitignore create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/.metadata create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/LICENSE create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/README.md create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/README.md create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/border_radius/border_radius.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/background/background_color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border_color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/brand/brand_color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/fill/fill_color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/surface/surface_color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/text/text_color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/spacing/spacing.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml create mode 100644 frontend/resources/flowy_icons/20x/anonymous_mode.svg diff --git a/frontend/appflowy_flutter/analysis_options.yaml b/frontend/appflowy_flutter/analysis_options.yaml index 8da401ef26..4579b2d8c5 100644 --- a/frontend/appflowy_flutter/analysis_options.yaml +++ b/frontend/appflowy_flutter/analysis_options.yaml @@ -4,6 +4,7 @@ analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" + - "packages/**/*.dart" linter: rules: diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 92e52a1a79..4b7ed5d639 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -181,37 +181,37 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0 - appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88 - connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 + appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a + connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc + file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 + flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c - image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 - keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 - open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 + keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87 + saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index eda3153459..7778f1c9ce 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -4,6 +4,7 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; class MobileAppearance extends BaseAppearance { @@ -29,6 +30,7 @@ class MobileAppearance extends BaseAppearance { ); final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); + final isLight = brightness == Brightness.light; final theme = brightness == Brightness.light ? appTheme.lightTheme @@ -283,6 +285,11 @@ class MobileAppearance extends BaseAppearance { toolbarHoverColor: theme.toolbarHoverColor, ), ToolbarColorExtension.fromBrightness(brightness), + isLight + ? lightAFThemeV2 + : darkAFThemeV2.copyWith( + icon_primary: theme.icon, + ), ], ); } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore new file mode 100644 index 0000000000..da0bb7ce97 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/.metadata b/frontend/appflowy_flutter/packages/appflowy_ui/.metadata new file mode 100644 index 0000000000..79932b61d5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "[user-branch]" + +project_type: package diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md new file mode 100644 index 0000000000..41cc7d8192 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE b/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE new file mode 100644 index 0000000000..ba75c69f7f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/README.md new file mode 100644 index 0000000000..75218b1842 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/README.md @@ -0,0 +1,39 @@ +# AppFlowy UI + +AppFlowy UI is a Flutter package that provides a collection of reusable UI components following the AppFlowy design system. These components are designed to be consistent, accessible, and easy to use. + +## Features + +- **Design System Components**: Buttons, text fields, and more UI components that follow the AppFlowy design system +- **Theming**: Consistent theming across all components with light and dark mode support + +## Installation + +Add the following to your *app's* `pubspec.yaml` file: + +```yaml +dependencies: + appflowy_ui: ^1.0.0 +``` + +## Supported components + +- [x] Button +- [x] TextField +- [ ] Avatar +- [ ] Checkbox +- [ ] Grid +- [ ] Link +- [ ] Loading & Progress Indicator +- [ ] Menu +- [ ] Message Box +- [ ] Navigation Bar +- [ ] Popover +- [ ] Scroll Bar +- [ ] Tab Bar +- [ ] Toggle +- [ ] Tooltip + +## Reference + +Figma: https://www.figma.com/design/aphWa2OgkqyIragpatdk7a/Design-System diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml new file mode 100644 index 0000000000..abba19b4fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml @@ -0,0 +1,29 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - require_trailing_commas + + - prefer_collection_literals + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + + - sized_box_for_whitespace + - use_decorated_box + + - unnecessary_parenthesis + - unnecessary_await_in_return + - unnecessary_raw_strings + + - avoid_unnecessary_containers + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + + - always_declare_return_types + + - sort_constructors_first + - unawaited_futures + +errors: + invalid_annotation_target: ignore diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata b/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata new file mode 100644 index 0000000000..777c932a64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: macos + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md new file mode 100644 index 0000000000..2ccc9e658d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md @@ -0,0 +1,41 @@ +# AppFlowy UI Example + +This example demonstrates how to use the `appflowy_ui` package in a Flutter application. + +## Getting Started + +To run this example: + +1. Ensure you have Flutter installed and set up on your machine +2. Clone this repository +3. Navigate to the example directory: + ```bash + cd example + ``` +4. Get the dependencies: + ```bash + flutter pub get + ``` +5. Run the example: + ```bash + flutter run + ``` + +## Features Demonstrated + +- Basic app structure using AppFlowy UI components +- Material 3 design integration +- Responsive layout + +## Project Structure + +- `lib/main.dart`: The main application file +- `pubspec.yaml`: Project dependencies and configuration + +## Additional Resources + +For more information about the AppFlowy UI package, please refer to: + +- The main package documentation +- [AppFlowy Website](https://appflowy.io) +- [AppFlowy GitHub Repository](https://github.com/AppFlowy-IO/AppFlowy) diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart new file mode 100644 index 0000000000..067e42858b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -0,0 +1,109 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:appflowy_ui_example/src/buttons/buttons_page.dart'; +import 'package:appflowy_ui_example/src/textfield/textfield_page.dart'; +import 'package:flutter/material.dart'; + +enum ThemeMode { + light, + dark, +} + +final themeMode = ValueNotifier(ThemeMode.light); + +void main() { + runApp( + const MyApp(), + ); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, themeMode, child) { + return AppFlowyTheme( + data: themeMode == ThemeMode.light + ? AppFlowyThemeData.light() + : AppFlowyThemeData.dark(), + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'AppFlowy UI Example', + theme: themeMode == ThemeMode.light + ? ThemeData.light() + : ThemeData.dark(), + home: const MyHomePage( + title: 'AppFlowy UI', + ), + ), + ); + }, + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({ + super.key, + required this.title, + }); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final tabs = [ + Tab(text: 'Button'), + Tab(text: 'TextField'), + ]; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text( + widget.title, + style: theme.textStyle.title.enhanced( + color: theme.textColorScheme.primary, + ), + ), + actions: [ + IconButton( + icon: Icon( + theme.brightness == Brightness.light + ? Icons.dark_mode + : Icons.light_mode, + ), + onPressed: _toggleTheme, + tooltip: 'Toggle theme', + ), + ], + ), + body: TabBarView( + children: [ + ButtonsPage(), + TextFieldPage(), + ], + ), + bottomNavigationBar: TabBar( + tabs: tabs, + ), + floatingActionButton: null, + ), + ); + } + + void _toggleTheme() { + themeMode.value = + themeMode.value == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart new file mode 100644 index 0000000000..0d0c018222 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart @@ -0,0 +1,287 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ButtonsPage extends StatelessWidget { + const ButtonsPage({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + 'Filled Text Buttons', + [ + AFFilledTextButton.primary( + text: 'Primary Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.destructive( + text: 'Destructive Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Filled Icon Text Buttons', + [ + AFFilledButton.primary( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + const SizedBox(width: 8), + Text( + 'Primary Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFFilledButton.destructive( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + const SizedBox(width: 8), + Text( + 'Destructive Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFFilledButton.disabled( + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Disabled Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Outlined Text Buttons', + [ + AFOutlinedTextButton.normal( + text: 'Normal Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFOutlinedTextButton.destructive( + text: 'Destructive Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFOutlinedTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Outlined Icon Text Buttons', + [ + AFOutlinedButton.normal( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Normal Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.primary, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFOutlinedButton.destructive( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.error, + ), + const SizedBox(width: 8), + Text( + 'Destructive Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.error, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFOutlinedButton.disabled( + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Disabled Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Ghost Buttons', + [ + AFGhostTextButton.primary( + text: 'Primary Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFGhostTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Button with alignment', + [ + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Left Button', + onTap: () {}, + alignment: Alignment.centerLeft, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Center Button', + onTap: () {}, + alignment: Alignment.center, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Right Button', + onTap: () {}, + alignment: Alignment.centerRight, + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Button Sizes', + [ + AFFilledTextButton.primary( + text: 'Small Button', + onTap: () {}, + size: AFButtonSize.s, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Medium Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Large Button', + onTap: () {}, + size: AFButtonSize.l, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Extra Large Button', + onTap: () {}, + size: AFButtonSize.xl, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart new file mode 100644 index 0000000000..280e43818c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class TextFieldPage extends StatelessWidget { + const TextFieldPage({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + 'TextField with hint text', + [ + AFTextField( + hintText: 'Please enter your name', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with initial text', + [ + AFTextField( + initialText: 'https://appflowy.com', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with validator ', + [ + AFTextField( + validator: (controller) { + if (controller.text.isEmpty) { + return (true, 'This field is required'); + } + + final emailRegex = + RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(controller.text)) { + return (true, 'Please enter a valid email address'); + } + + return (false, ''); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..345181d730 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "appflowy_ui_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..04d5b736e6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 zcmeEugo5nb1G~3xi~y`}h6XHx5j$(L*3|5S2UfkG$|UCNI>}4f?MfqZ+HW-sRW5RKHEm z^unW*Xx{AH_X3Xdvb%C(Bh6POqg==@d9j=5*}oEny_IS;M3==J`P0R!eD6s~N<36C z*%-OGYqd0AdWClO!Z!}Y1@@RkfeiQ$Ib_ z&fk%T;K9h`{`cX3Hu#?({4WgtmkR!u3ICS~|NqH^fdNz>51-9)OF{|bRLy*RBv#&1 z3Oi_gk=Y5;>`KbHf~w!`u}!&O%ou*Jzf|Sf?J&*f*K8cftMOKswn6|nb1*|!;qSrlw= zr-@X;zGRKs&T$y8ENnFU@_Z~puu(4~Ir)>rbYp{zxcF*!EPS6{(&J}qYpWeqrPWW< zfaApz%<-=KqxrqLLFeV3w0-a0rEaz9&vv^0ZfU%gt9xJ8?=byvNSb%3hF^X_n7`(fMA;C&~( zM$cQvQ|g9X)1AqFvbp^B{JEX$o;4iPi?+v(!wYrN{L}l%e#5y{j+1NMiT-8=2VrCP zmFX9=IZyAYA5c2!QO96Ea-6;v6*$#ZKM-`%JCJtrA3d~6h{u+5oaTaGE)q2b+HvdZ zvHlY&9H&QJ5|uG@wDt1h99>DdHy5hsx)bN`&G@BpxAHh$17yWDyw_jQhhjSqZ=e_k z_|r3=_|`q~uA47y;hv=6-o6z~)gO}ZM9AqDJsR$KCHKH;QIULT)(d;oKTSPDJ}Jx~G#w-(^r<{GcBC*~4bNjfwHBumoPbU}M)O za6Hc2ik)2w37Yyg!YiMq<>Aov?F2l}wTe+>h^YXcK=aesey^i)QC_p~S zp%-lS5%)I29WfywP(r4@UZ@XmTkqo51zV$|U|~Lcap##PBJ}w2b4*kt7x6`agP34^ z5fzu_8rrH+)2u*CPcr6I`gL^cI`R2WUkLDE5*PX)eJU@H3HL$~o_y8oMRoQ0WF9w| z6^HZDKKRDG2g;r8Z4bn+iJNFV(CG;K-j2>aj229gl_C6n12Jh$$h!}KVhn>*f>KcH z;^8s3t(ccVZ5<{>ZJK@Z`hn_jL{bP8Yn(XkwfRm?GlEHy=T($8Z1Mq**IM`zxN9>-yXTjfB18m_$E^JEaYn>pj`V?n#Xu;Z}#$- zw0Vw;T*&9TK$tKI7nBk9NkHzL++dZ^;<|F6KBYh2+XP-b;u`Wy{~79b%IBZa3h*3^ zF&BKfQ@Ej{7ku_#W#mNJEYYp=)bRMUXhLy2+SPMfGn;oBsiG_6KNL8{p1DjuB$UZB zA)a~BkL)7?LJXlCc}bB~j9>4s7tlnRHC5|wnycQPF_jLl!Avs2C3^lWOlHH&v`nGd zf&U!fn!JcZWha`Pl-B3XEe;(ks^`=Z5R zWyQR0u|do2`K3ec=YmWGt5Bwbu|uBW;6D8}J3{Uep7_>L6b4%(d=V4m#(I=gkn4HT zYni3cnn>@F@Wr<hFAY3Y~dW+3bte;70;G?kTn4Aw5nZ^s5|47 z4$rCHCW%9qa4)4vE%^QPMGf!ET!^LutY$G zqdT(ub5T5b+wi+OrV}z3msoy<4)`IPdHsHJggmog0K*pFYMhH!oZcgc5a)WmL?;TPSrerTVPp<#s+imF3v#!FuBNNa`#6 z!GdTCF|IIpz#(eV^mrYKThA4Bnv&vQet@%v9kuRu3EHx1-2-it@E`%9#u`)HRN#M? z7aJ{wzKczn#w^`OZ>Jb898^Xxq)0zd{3Tu7+{-sge-rQ z&0PME&wIo6W&@F|%Z8@@N3)@a_ntJ#+g{pUP7i?~3FirqU`rdf8joMG^ld?(9b7Iv z>TJgBg#)(FcW)h!_if#cWBh}f+V08GKyg|$P#KTS&%=!+0a%}O${0$i)kn9@G!}En zv)_>s?glPiLbbx)xk(lD-QbY(OP3;MSXM5E*P&_`Zks2@46n|-h$Y2L7B)iH{GAAq19h5-y0q>d^oy^y+soJu9lXxAe%jcm?=pDLFEG2kla40e!5a}mpe zdL=WlZ=@U6{>g%5a+y-lx)01V-x;wh%F{=qy#XFEAqcd+m}_!lQ)-9iiOL%&G??t| z?&NSdaLqdPdbQs%y0?uIIHY7rw1EDxtQ=DU!i{)Dkn~c$LG5{rAUYM1j5*G@oVn9~ zizz{XH(nbw%f|wI=4rw^6mNIahQpB)OQy10^}ACdLPFc2@ldVi|v@1nWLND?)53O5|fg`RZW&XpF&s3@c-R?aad!$WoH6u0B|}zt)L($E^@U- zO#^fxu9}Zw7Xl~nG1FVM6DZSR0*t!4IyUeTrnp@?)Z)*!fhd3)&s(O+3D^#m#bAem zpf#*aiG_0S^ofpm@9O7j`VfLU0+{$x!u^}3!zp=XST0N@DZTp!7LEVJgqB1g{psNr za0uVmh3_9qah14@M_pi~vAZ#jc*&aSm$hCNDsuQ-zPe&*Ii#2=2gP+DP4=DY z_Y0lUsyE6yaV9)K)!oI6+*4|spx2at*30CAx~6-5kfJzQ`fN8$!lz%hz^J6GY?mVH zbYR^JZ(Pmj6@vy-&!`$5soyy-NqB^8cCT40&R@|6s@m+ZxPs=Bu77-+Os7+bsz4nA3DrJ8#{f98ZMaj-+BD;M+Jk?pgFcZIb}m9N z{ct9T)Kye&2>l^39O4Q2@b%sY?u#&O9PO4@t0c$NUXG}(DZJ<;_oe2~e==3Z1+`Zo zFrS3ns-c}ZognVBHbg#e+1JhC(Yq7==rSJQ8J~}%94(O#_-zJKwnBXihl#hUd9B_>+T& z7eHHPRC?5ONaUiCF7w|{J`bCWS7Q&xw-Sa={j-f)n5+I=9s;E#fBQB$`DDh<^mGiF zu-m_k+)dkBvBO(VMe2O4r^sf3;sk9K!xgXJU>|t9Vm8Ty;fl5pZzw z9j|}ZD}6}t;20^qrS?YVPuPRS<39d^y0#O1o_1P{tN0?OX!lc-ICcHI@2#$cY}_CY zev|xdFcRTQ_H)1fJ7S0*SpPs8e{d+9lR~IZ^~dKx!oxz?=Dp!fD`H=LH{EeC8C&z-zK$e=!5z8NL=4zx2{hl<5z*hEmO=b-7(k5H`bA~5gT30Sjy`@-_C zKM}^so9Ti1B;DovHByJkTK87cfbF16sk-G>`Q4-txyMkyQS$d}??|Aytz^;0GxvOs zPgH>h>K+`!HABVT{sYgzy3CF5ftv6hI-NRfgu613d|d1cg^jh+SK7WHWaDX~hlIJ3 z>%WxKT0|Db1N-a4r1oPKtF--^YbP=8Nw5CNt_ZnR{N(PXI>Cm$eqi@_IRmJ9#)~ZHK_UQ8mi}w^`+4$OihUGVz!kW^qxnCFo)-RIDbA&k-Y=+*xYv5y4^VQ9S)4W5Pe?_RjAX6lS6Nz#!Hry=+PKx2|o_H_3M`}Dq{Bl_PbP(qel~P@=m}VGW*pK96 zI@fVag{DZHi}>3}<(Hv<7cVfWiaVLWr@WWxk5}GDEbB<+Aj;(c>;p1qmyAIj+R!`@#jf$ zy4`q23L-72Zs4j?W+9lQD;CYIULt%;O3jPWg2a%Zs!5OW>5h1y{Qof!p&QxNt5=T( zd5fy&7=hyq;J8%86YBOdc$BbIFxJx>dUyTh`L z-oKa=OhRK9UPVRWS`o2x53bAv+py)o)kNL6 z9W1Dlk-g6Ht@-Z^#6%`9S9`909^EMj?9R^4IxssCY-hYzei^TLq7Cj>z$AJyaU5=z zl!xiWvz0U8kY$etrcp8mL;sYqGZD!Hs-U2N{A|^oEKA482v1T%cs%G@X9M?%lX)p$ zZoC7iYTPe8yxY0Jne|s)fCRe1mU=Vb1J_&WcIyP|x4$;VSVNC`M+e#oOA`#h>pyU6 z?7FeVpk`Hsu`~T3i<_4<5fu?RkhM;@LjKo6nX>pa%8dSdgPO9~Jze;5r>Tb1Xqh5q z&SEdTXevV@PT~!O6z|oypTk7Qq+BNF5IQ(8s18c=^0@sc8Gi|3e>VKCsaZ?6=rrck zl@oF5Bd0zH?@15PxSJIRroK4Wa?1o;An;p0#%ZJ^tI=(>AJ2OY0GP$E_3(+Zz4$AQ zW)QWl<4toIJ5TeF&gNXs>_rl}glkeG#GYbHHOv-G!%dJNoIKxn)FK$5&2Zv*AFic! z@2?sY&I*PSfZ8bU#c9fdIJQa_cQijnj39-+hS@+~e*5W3bj%A}%p9N@>*tCGOk+cF zlcSzI6j%Q|2e>QG3A<86w?cx6sBtLNWF6_YR?~C)IC6_10SNoZUHrCpp6f^*+*b8` zlx4ToZZuI0XW1W)24)92S)y0QZa);^NRTX6@gh8@P?^=#2dV9s4)Q@K+gnc{6|C}& zDLHr7nDOLrsH)L@Zy{C_2UrYdZ4V{|{c8&dRG;wY`u>w%$*p>PO_}3`Y21pk?8Wtq zGwIXTulf7AO2FkPyyh2TZXM1DJv>hI`}x`OzQI*MBc#=}jaua&czSkI2!s^rOci|V zFkp*Vbiz5vWa9HPFXMi=BV&n3?1?%8#1jq?p^3wAL`jgcF)7F4l<(H^!i=l-(OTDE zxf2p71^WRIExLf?ig0FRO$h~aA23s#L zuZPLkm>mDwBeIu*C7@n@_$oSDmdWY7*wI%aL73t~`Yu7YwE-hxAATmOi0dmB9|D5a zLsR7OQcA0`vN9m0L|5?qZ|jU+cx3_-K2!K$zDbJ$UinQy<9nd5ImWW5n^&=Gg>Gsh zY0u?m1e^c~Ug39M{{5q2L~ROq#c{eG8Oy#5h_q=#AJj2Yops|1C^nv0D1=fBOdfAG z%>=vl*+_w`&M7{qE#$xJJp_t>bSh7Mpc(RAvli9kk3{KgG5K@a-Ue{IbU{`umXrR3ra5Y7xiX42+Q%N&-0#`ae_ z#$Y6Wa++OPEDw@96Zz##PFo9sADepQe|hUy!Zzc2C(L`k9&=a8XFr+!hIS>D2{pdGP1SzwyaGLiH3j--P>U#TWw90t8{8Bt%m7Upspl#=*hS zhy|(XL6HOqBW}Og^tLX7 z+`b^L{O&oqjwbxDDTg2B;Yh2(fW>%S5Pg8^u1p*EFb z`(fbUM0`afawYt%VBfD&b3MNJ39~Ldc@SAuzsMiN%E}5{uUUBc7hc1IUE~t-Y9h@e7PC|sv$xGx=hZiMXNJxz5V(np%6u{n24iWX#!8t#>Ob$in<>dw96H)oGdTHnU zSM+BPss*5)Wz@+FkooMxxXZP1{2Nz7a6BB~-A_(c&OiM)UUNoa@J8FGxtr$)`9;|O z(Q?lq1Q+!E`}d?KemgC!{nB1JJ!B>6J@XGQp9NeQvtbM2n7F%v|IS=XWPVZY(>oq$ zf=}8O_x`KOxZoGnp=y24x}k6?gl_0dTF!M!T`={`Ii{GnT1jrG9gPh)R=RZG8lIR| z{ZJ6`x8n|y+lZuy${fuEDTAf`OP!tGySLXD}ATJO5UoZv|Xo3%7O~L63+kw}v)Ci=&tWx3bQJfL@5O18CbPlkR^IcKA zy1=^Vl-K-QBP?9^R`@;czcUw;Enbbyk@vJQB>BZ4?;DM%BUf^eZE+sOy>a){qCY6Y znYy;KGpch-zf=5|p#SoAV+ie8M5(Xg-{FoLx-wZC9IutT!(9rJ8}=!$!h%!J+vE2e z(sURwqCC35v?1>C1L)swfA^sr16{yj7-zbT6Rf26-JoEt%U?+|rQ zeBuGohE?@*!zR9)1P|3>KmJSgK*fOt>N>j}LJB`>o(G#Dduvx7@DY7};W7K;Yj|8O zGF<+gTuoIKe7Rf+LQG3-V1L^|E;F*}bQ-{kuHq}| ze_NwA7~US19sAZ)@a`g*zkl*ykv2v3tPrb4Og2#?k6Lc7@1I~+ew48N&03hW^1Cx+ zfk5Lr4-n=#HYg<7ka5i>2A@ZeJ60gl)IDX!!p zzfXZQ?GrT>JEKl7$SH!otzK6=0dIlqN)c23YLB&Krf9v-{@V8p+-e2`ujFR!^M%*; ze_7(Jh$QgoqwB!HbX=S+^wqO15O_TQ0-qX8f-|&SOuo3ZE{{9Jw5{}>MhY}|GBhO& zv48s_B=9aYQfa;d>~1Z$y^oUUaDer>7ve5+Gf?rIG4GZ!hRKERlRNgg_C{W_!3tsI2TWbX8f~MY)1Q`6Wj&JJ~*;ay_0@e zzx+mE-pu8{cEcVfBqsnm=jFU?H}xj@%CAx#NO>3 z_re3Rq%d1Y7VkKy{=S73&p;4^Praw6Y59VCP6M?!Kt7{v#DG#tz?E)`K95gH_mEvb z%$<~_mQ$ad?~&T=O0i0?`YSp?E3Dj?V>n+uTRHAXn`l!pH9Mr}^D1d@mkf+;(tV45 zH_yfs^kOGLXlN*0GU;O&{=awxd?&`{JPRr$z<1HcAO2K`K}92$wC}ky&>;L?#!(`w z68avZGvb728!vgw>;8Z8I@mLtI`?^u6R>sK4E7%=y)jpmE$fH!Dj*~(dy~-2A5Cm{ zl{1AZw`jaDmfvaB?jvKwz!GC}@-Dz|bFm1OaPw(ia#?>vF7Y5oh{NVbyD~cHB1KFn z9C@f~X*Wk3>sQH9#D~rLPslAd26@AzMh=_NkH_yTNXx6-AdbAb z{Ul89YPHslD?xAGzOlQ*aMYUl6#efCT~WI zOvyiewT=~l1W(_2cEd(8rDywOwjM-7P9!8GCL-1<9KXXO=6%!9=W++*l1L~gRSxLVd8K=A7&t52ql=J&BMQu{fa6y zXO_e>d?4X)xp2V8e3xIQGbq@+vo#&n>-_WreTTW0Yr?|YRPP43cDYACMQ(3t6(?_k zfgDOAU^-pew_f5U#WxRXB30wcfDS3;k~t@b@w^GG&<5n$Ku?tT(%bQH(@UHQGN)N|nfC~7?(etU`}XB)$>KY;s=bYGY#kD%i9fz= z2nN9l?UPMKYwn9bX*^xX8Y@%LNPFU>s#Ea1DaP%bSioqRWi9JS28suTdJycYQ+tW7 zrQ@@=13`HS*dVKaVgcem-45+buD{B;mUbY$YYULhxK)T{S?EB<8^YTP$}DA{(&)@S zS#<8S96y9K2!lG^VW-+CkfXJIH;Vo6wh)N}!08bM$I7KEW{F6tqEQ?H@(U zAqfi%KCe}2NUXALo;UN&k$rU0BLNC$24T_mcNY(a@lxR`kqNQ0z%8m>`&1ro40HX} z{{3YQ;2F9JnVTvDY<4)x+88i@MtXE6TBd7POk&QfKU-F&*C`isS(T_Q@}K)=zW#K@ zbXpcAkTT-T5k}Wj$dMZl7=GvlcCMt}U`#Oon1QdPq%>9J$rKTY8#OmlnNWBYwafhx zqFnym@okL#Xw>4SeRFejBnZzY$jbO)e^&&sHBgMP%Ygfi!9_3hp17=AwLBNFTimf0 zw6BHNXw19Jg_Ud6`5n#gMpqe%9!QB^_7wAYv8nrW94A{*t8XZu0UT&`ZHfkd(F{Px zD&NbRJP#RX<=+sEeGs2`9_*J2OlECpR;4uJie-d__m*(aaGE}HIo+3P{my@;a~9Y$ zHBXVJ83#&@o6{M+pE9^lI<4meLLFN_3rwgR4IRyp)~OF0n+#ORrcJ2_On9-78bWbG zuCO0esc*n1X3@p1?lN{qWS?l7J$^jbpeel{w~51*0CM+q9@9X=>%MF(ce~om(}?td zjkUmdUR@LOn-~6LX#=@a%rvj&>DFEoQscOvvC@&ZB5jVZ-;XzAshwx$;Qf@U41W=q zOSSjQGQV8Qi3*4DngNMIM&Cxm7z*-K`~Bl(TcEUxjQ1c=?)?wF8W1g;bAR%sM#LK( z_Op?=P%)Z+J!>vpN`By0$?B~Out%P}kCriDq@}In&fa_ZyKV+nLM0E?hfxuu%ciUz z>yAk}OydbWNl7{)#112j&qmw;*Uj&B;>|;Qwfc?5wIYIHH}s6Mve@5c5r+y)jK9i( z_}@uC(98g)==AGkVN?4>o@w=7x9qhW^ zB(b5%%4cHSV?3M?k&^py)j*LK16T^Ef4tb05-h-tyrjt$5!oo4spEfXFK7r_Gfv7#x$bsR7T zs;dqxzUg9v&GjsQGKTP*=B(;)be2aN+6>IUz+Hhw-n>^|`^xu*xvjGPaDoFh2W4-n z@Wji{5Y$m>@Vt7TE_QVQN4*vcfWv5VY-dT0SV=l=8LAEq1go*f zkjukaDV=3kMAX6GAf0QOQHwP^{Z^=#Lc)sh`QB)Ftl&31jABvq?8!3bt7#8vxB z53M{4{GR4Hl~;W3r}PgXSNOt477cO62Yj(HcK&30zsmWpvAplCtpp&mC{`2Ue*Bwu zF&UX1;w%`Bs1u%RtGPFl=&sHu@Q1nT`z={;5^c^^S~^?2-?<|F9RT*KQmfgF!7=wD@hytxbD;=9L6PZrK*1<4HMObNWehA62DtTy)q5H|57 z9dePuC!1;0MMRRl!S@VJ8qG=v^~aEU+}2Qx``h1LII!y{crP2ky*R;Cb;g|r<#ryo zju#s4dE?5CTIZKc*O4^3qWflsQ(voX>(*_JP7>Q&$%zCAIBTtKC^JUi@&l6u&t0hXMXjz_y!;r@?k|OU9aD%938^TZ>V? zqJmom_6dz4DBb4Cgs_Ef@}F%+cRCR%UMa9pi<-KHN;t#O@cA%(LO1Rb=h?5jiTs93 zPLR78p+3t>z4|j=<>2i4b`ketv}9Ax#B0)hn7@bFl;rDfP8p7u9XcEb!5*PLKB(s7wQC2kzI^@ae)|DhNDmSy1bOLid%iIap@24A(q2XI!z_hkl-$1T10 z+KKugG4-}@u8(P^S3PW4x>an;XWEF-R^gB{`t8EiP{ZtAzoZ!JRuMRS__-Gg#Qa3{<;l__CgsF+nfmFNi}p z>rV!Y6B@cC>1up)KvaEQiAvQF!D>GCb+WZsGHjDeWFz?WVAHP65aIA8u6j6H35XNYlyy8>;cWe3ekr};b;$9)0G`zsc9LNsQ&D?hvuHRpBxH)r-1t9|Stc*u<}Ol&2N+wPMom}d15_TA=Aprp zjN-X3*Af$7cDWMWp##kOH|t;c2Pa9Ml4-)o~+7P;&q8teF-l}(Jt zTGKOQqJTeT!L4d}Qw~O0aanA$Vn9Rocp-MO4l*HK)t%hcp@3k0%&_*wwpKD6ThM)R z8k}&7?)YS1ZYKMiy?mn>VXiuzX7$Ixf7EW8+C4K^)m&eLYl%#T=MC;YPvD&w#$MMf zQ=>`@rh&&r!@X&v%ZlLF42L_c=5dSU^uymKVB>5O?AouR3vGv@ei%Z|GX5v1GK2R* zi!!}?+-8>J$JH^fPu@)E6(}9$d&9-j51T^n-e0Ze%Q^)lxuex$IL^XJ&K2oi`wG}QVGk2a7vC4X?+o^z zsCK*7`EUfSuQA*K@Plsi;)2GrayQOG9OYF82Hc@6aNN5ulqs1Of-(iZQdBI^U5of^ zZg2g=Xtad7$hfYu6l~KDQ}EU;oIj(3nO#u9PDz=eO3(iax7OCmgT2p_7&^3q zg7aQ;Vpng*)kb6=sd5?%j5Dm|HczSChMo8HHq_L8R;BR5<~DVyU$8*Tk5}g0eW5x7 z%d)JFZ{(Y<#OTKLBA1fwLM*fH7Q~7Sc2Ne;mVWqt-*o<;| z^1@vo_KTYaMnO$7fbLL+qh#R$9bvnpJ$RAqG+z8h|} z3F5iwG*(sCn9Qbyg@t0&G}3fE0jGq3J!JmG2K&$urx^$z95) z7h?;4vE4W=v)uZ*Eg3M^6f~|0&T)2D;f+L_?M*21-I1pnK(pT$5l#QNlT`SidYw~o z{`)G)Asv#cue)Ax1RNWiRUQ(tQ(bzd-f2U4xlJK+)ZWBxdq#fp=A>+Qc%-tl(c)`t z$e2Ng;Rjvnbu7((;v4LF9Y1?0el9hi!g>G{^37{ z`^s-03Z5jlnD%#Mix19zkU_OS|86^_x4<0(*YbPN}mi-$L?Z4K(M|2&VV*n*ZYN_UqI?eKZi3!b)i z%n3dzUPMc-dc|q}TzvPy!VqsEWCZL(-eURDRG4+;Eu!LugSSI4Fq$Ji$Dp08`pfP_C5Yx~`YKcywlMG;$F z)R5!kVml_Wv6MSpeXjG#g?kJ0t_MEgbXlUN3k|JJ%N>|2xn8yN>>4qxh!?dGI}s|Y zDTKd^JCrRSN+%w%D_uf=Tj6wIV$c*g8D96jb^Kc#>5Fe-XxKC@!pIJw0^zu;`_yeb zhUEm-G*C=F+jW%cP(**b61fTmPn2WllBr4SWNdKe*P8VabZsh0-R|?DO=0x`4_QY) zR7sthW^*BofW7{Sak&S1JdiG?e=SfL24Y#w_)xrBVhGB-13q$>mFU|wd9Xqe-o3{6 zSn@@1@&^)M$rxb>UmFuC+pkio#T;mSnroMVZJ%nZ!uImi?%KsIX#@JU2VY(`kGb1A z7+1MEG)wd@)m^R|a2rXeviv$!emwcY(O|M*xV!9%tBzarBOG<4%gI9SW;Um_gth4=gznYzOFd)y8e+3APCkL)i-OI`;@7-mCJgE`js(M} z;~ZcW{{FMVVO)W>VZ}ILouF#lWGb%Couu}TI4kubUUclW@jEn6B_^v!Ym*(T*4HF9 zWhNKi8%sS~viSdBtnrq!-Dc5(G^XmR>DFx8jhWvR%*8!m*b*R8e1+`7{%FACAK`7 zzdy8TmBh?FVZ0vtw6npnWwM~XjF2fNvV#ZlGG z?FxHkXHN>JqrBYoPo$)zNC7|XrQfcqmEXWud~{j?La6@kbHG@W{xsa~l1=%eLly8B z4gCIH05&Y;6O2uFSopNqP|<$ml$N40^ikxw0`o<~ywS1(qKqQN!@?Ykl|bE4M?P+e zo$^Vs_+x)iuw?^>>`$&lOQOUkZ5>+OLnRA)FqgpDjW&q*WAe(_mAT6IKS9;iZBl8M z<@=Y%zcQUaSBdrs27bVK`c$)h6A1GYPS$y(FLRD5Yl8E3j0KyH08#8qLrsc_qlws; znMV%Zq8k+&T2kf%6ZO^2=AE9>?a587g%-={X}IS~P*I(NeCF9_9&`)|ok0iiIun zo+^odT0&Z4k;rn7I1v87=z!zKU(%gfB$(1mrRYeO$sbqM22Kq68z9wgdg8HBxp>_< zn9o%`f?sVO=IN#5jSX&CGODWlZfQ9A)njK2O{JutYwRZ?n0G_p&*uwpE`Md$iQxrd zoQfF^b8Ou)+3BO_3_K5y*~?<(BF@1l+@?Z6;^;U>qlB)cdro;rxOS1M{Az$s^9o5sXDCg8yD<=(pKI*0e zLk>@lo#&s0)^*Q+G)g}C0IErqfa9VbL*Qe=OT@&+N8m|GJF7jd83vY#SsuEv2s{Q> z>IpoubNs>D_5?|kXGAPgF@mb_9<%hjU;S0C8idI)a=F#lPLuQJ^7OnjJlH_Sks9JD zMl1td%YsWq3YWhc;E$H1<0P$YbSTqs`JKY%(}svsifz|h8BHguL82dBl+z0^YvWk8 zGy;7Z0v5_FJ2A$P0wIr)lD?cPR%cz>kde!=W%Ta^ih+Dh4UKdf7ip?rBz@%y2&>`6 zM#q{JXvW9ZlaSk1oD!n}kSmcDa2v6T^Y-dy+#fW^y>eS8_%<7tWXUp8U@s$^{JFfKMjDAvR z$YmVB;n3ofl!ro9RNT!TpQpcycXCR}$9k5>IPWDXEenQ58os?_weccrT+Bh5sLoiH zZ_7~%t(vT)ZTEO= zb0}@KaD{&IyK_sd8b$`Qz3%UA`nSo zn``!BdCeN!#^G;lK@G2ron*0jQhbdw)%m$2;}le@z~PSLnU-z@tL)^(p%P>OO^*Ff zNRR9oQ`W+x^+EU+3BpluwK77|B3=8QyT|$V;02bn_LF&3LhLA<#}{{)jE)}CiW%VEU~9)SW+=F%7U-iYlQ&q!#N zwI2{(h|Pi&<8_fqvT*}FLN^0CxN}#|3I9G_xmVg$gbn2ZdhbmGk7Q5Q2Tm*ox8NMo zv`iaZW|ZEOMyQga5fts?&T-eCCC9pS0mj7v0SDkD=*^MxurP@89v&Z#3q{FM!a_nr zb?KzMv`BBFOew>4!ft@A&(v-kWXny-j#egKef|#!+3>26Qq0 zv!~8ev4G`7Qk>V1TaMT-&ziqoY3IJp8_S*%^1j73D|=9&;tDZH^!LYFMmME4*Wj(S zRt~Q{aLb_O;wi4u&=}OYuj}Lw*j$@z*3>4&W{)O-oi@9NqdoU!=U%d|se&h?^$Ip# z)BY+(1+cwJz!yy4%l(aLC;T!~Ci>yAtXJb~b*yr&v7f{YCU8P|N1v~H`xmGsG)g)y z4%mv=cPd`s7a*#OR7f0lpD$ueP>w8qXj0J&*7xX+U!uat5QNk>zwU$0acn5p=$88L=jn_QCSYkTV;1~(yUem#0gB`FeqY98sf=>^@ z_MCdvylv~WL%y_%y_FE1)j;{Szj1+K7Lr_y=V+U zk6Tr;>XEqlEom~QGL!a+wOf(@ZWoxE<$^qHYl*H1a~kk^BLPn785%nQb$o;Cuz0h& za9LMx^bKEbPS%e8NM33Jr|1T|ELC(iE!FUci38xW_Y7kdHid#2ie+XZhP;2!Z;ZAM zB_cXKm)VrPK!SK|PY00Phwrpd+x0_Aa;}cDQvWKrwnQrqz##_gvHX2ja?#_{f#;bz`i>C^^ zTLDy;6@HZ~XQi7rph!mz9k!m;KchA)uMd`RK4WLK7)5Rl48m#l>b(#`WPsl<0j z-sFkSF6>Nk|LKnHtZ`W_NnxZP62&w)S(aBmmjMDKzF%G;3Y?FUbo?>b5;0j8Lhtc4 zr*8d5Y9>g@FFZaViw7c16VsHcy0u7M%6>cG1=s=Dtx?xMJSKIu9b6GU8$uSzf43Y3 zYq|U+IWfH;SM~*N1v`KJo!|yfLxTFS?oHsr3qvzeVndVV^%BWmW6re_S!2;g<|Oao z+N`m#*i!)R%i1~NO-xo{qpwL0ZrL7hli;S z3L0lQ_z}z`fdK39Mg~Zd*%mBdD;&5EXa~@H(!###L`ycr7gW`f)KRuqyHL3|uyy3h zSS^td#E&Knc$?dXs*{EnPYOp^-vjAc-h4z#XkbG&REC7;0>z^^Z}i8MxGKerEY z>l?(wReOlXEsNE5!DO&ZWyxY)gG#FSZs%fXuzA~XIAPVp-%yb2XLSV{1nH6{)5opg z(dZKckn}Q4Li-e=eUDs1Psg~5zdn1>ql(*(nn6)iD*OcVkwmKL(A{fix(JhcVB&}V zVt*Xb!{gzvV}dc446>(D=SzfCu7KB`oMjv6kPzSv&B>>HLSJP|wN`H;>oRw*tl#N) z*zZ-xwM7D*AIsBfgqOjY1Mp9aq$kRa^dZU_xw~KxP;|q(m+@e+YSn~`wEJzM|Ippb zzb@%;hB7iH4op9SqmX?j!KP2chsb79(mFossBO-Zj8~L}9L%R%Bw<`^X>hjkCY5SG z7lY!8I2mB#z)1o;*3U$G)3o0A&{0}#B;(zPd2`OF`Gt~8;0Re8nIseU z_yzlf$l+*-wT~_-cYk$^wTJ@~7i@u(CZs9FVkJCru<*yK8&>g+t*!JqCN6RH%8S-P zxH8+Cy#W?!;r?cLMC(^BtAt#xPNnwboI*xWw#T|IW^@3|q&QYY6Ehxoh@^URylR|T zne-Y6ugE^7p5bkRDWIh)?JH5V^ub82l-LuVjDr7UT^g`q4dB&mBFRWGL_C?hoeL(% zo}ocH5t7|1Mda}T!^{Qt9vmA2ep4)dQSZO>?Eq8}qRp&ZJ?-`Tnw+MG(eDswP(L*X3ahC2Ad0_wD^ff9hfzb%Jd`IXx5 zae@NMzBXJDwJS?7_%!TB^E$N8pvhOHDK$7YiOelTY`6KX8hK6YyT$tk*adwN>s^Kp zwM3wGVPhwKU*Yq-*BCs}l`l#Tej(NQ>jg*S0TN%D+GcF<14Ms6J`*yMY;W<-mMN&-K>((+P}+t+#0KPGrzjP zJ~)=Bcz%-K!L5ozIWqO(LM)l_9lVOc4*S65&DKM#TqsiWNG{(EZQw!bc>qLW`=>p-gVJ;T~aN2D_- z{>SZC=_F+%hNmH6ub%Ykih0&YWB!%sd%W5 zHC2%QMP~xJgt4>%bU>%6&uaDtSD?;Usm}ari0^fcMhi_)JZgb1g5j zFl4`FQ*%ROfYI}e7RIq^&^a>jZF23{WB`T>+VIxj%~A-|m=J7Va9FxXV^%UwccSZd zuWINc-g|d6G5;95*%{e;9S(=%yngpfy+7ao|M7S|Jb0-4+^_q-uIqVS&ufU880UDH*>(c)#lt2j zzvIEN>>$Y(PeALC-D?5JfH_j+O-KWGR)TKunsRYKLgk7eu4C{iF^hqSz-bx5^{z0h ze2+u>Iq0J4?)jIo)}V!!m)%)B;a;UfoJ>VRQ*22+ncpe9f4L``?v9PH&;5j{WF?S_C>Lq>nkChZB zjF8(*v0c(lU^ZI-)_uGZnnVRosrO4`YinzI-RSS-YwjYh3M`ch#(QMNw*)~Et7Qpy z{d<3$4FUAKILq9cCZpjvKG#yD%-juhMj>7xIO&;c>_7qJ%Ae8Z^m)g!taK#YOW3B0 zKKSMOd?~G4h}lrZbtPk)n*iOC1~mDhASGZ@N{G|dF|Q^@1ljhe=>;wusA&NvY*w%~ zl+R6B^1yZiF)YN>0ms%}qz-^U-HVyiN3R9k1q4)XgDj#qY4CE0)52%evvrrOc898^ z*^)XFR?W%g0@?|6Mxo1ZBp%(XNv_RD-<#b^?-Fs+NL^EUW=iV|+Vy*F%;rBz~pN7%-698U-VMfGEVnmEz7fL1p)-5sLT zL;Iz>FCLM$p$c}g^tbkGK1G$IALq1Gd|We@&TtW!?4C7x4l*=4oF&&sr0Hu`x<5!m zhX&&Iyjr?AkNXU_5P_b^Q3U9sy#f6ZF@2C96$>1k*E-E%DjwvA{VL0PdU~suN~DZo zm{T!>sRdp`Ldpp9olrH@(J$QyGq!?#o1bUo=XP2OEuT3`XzI>s^0P{manUaE4pI%! zclQq;lbT;nx7v3tR9U)G39h?ryrxzd0xq4KX7nO?piJZbzT_CU&O=T(Vt;>jm?MgC z2vUL#*`UcMsx%w#vvjdamHhmN!(y-hr~byCA-*iCD};#l+bq;gkwQ0oN=AyOf@8ow>Pj<*A~2*dyjK}eYdN);%!t1 z6Y=|cuEv-|5BhA?n2Db@4s%y~(%Wse4&JXw=HiO48%c6LB~Z0SL1(k^9y?ax%oj~l zf7(`iAYLdPRq*ztFC z7VtAb@s{as%&Y;&WnyYl+6Wm$ru*u!MKIg_@01od-iQft0rMjIj8e7P9eKvFnx_X5 zd%pDg-|8<>T2Jdqw>AII+fe?CgP+fL(m0&U??QL8YzSjV{SFi^vW~;wN@or_(q<0Y zRt~L}#JRcHOvm$CB)T1;;7U>m%)QYBLTR)KTARw%zoDxgssu5#v{UEVIa<>{8dtkm zXgbCGp$tfue+}#SD-PgiNT{Zu^YA9;4BnM(wZ9-biRo_7pN}=aaimjYgC=;9@g%6< zxol5sT_$<8{LiJ6{l1+sV)Z_QdbsfEAEMw!5*zz6)Yop?T0DMtR_~wfta)E6_G@k# zZRP11D}$ir<`IQ`<(kGfAS?O-DzCyuzBq6dxGTNNTK?r^?zT30mLY!kQ=o~Hv*k^w zvq!LBjW=zzIi%UF@?!g9vt1CqdwV(-2LYy2=E@Z?B}JDyVkluHtzGsWuI1W5svX~K z&?UJ45$R7g>&}SFnLnmw09R2tUgmr_w6mM9C}8GvQX>nL&5R#xBqnp~Se(I>R42`T zqZe9p6G(VzNB3QD><8+y%{e%6)sZDRXTR|MI zM#eZmao-~_`N|>Yf;a;7yvd_auTG#B?Vz5D1AHx=zpVUFe7*hME z+>KH5h1In8hsVhrstc>y0Q!FHR)hzgl+*Q&5hU9BVJlNGRkXiS&06eOBV^dz3;4d5 zeYX%$62dNOprZV$px~#h1RH?_E%oD6y;J;pF%~y8M)8pQ0olYKj6 zE+hd|7oY3ot=j9ZZ))^CCPADL6Jw%)F@A{*coMApcA$7fZ{T@3;WOQ352F~q6`Mgi z$RI6$8)a`Aaxy<8Bc;{wlDA%*%(msBh*xy$L-cBJvQ8hj#FCyT^%+Phw1~PaqyDou^JR0rxDkSrmAdjeYDFDZ`E z)G3>XtpaSPDlydd$RGHg;#4|4{aP5c_Om z2u5xgnhnA)K%8iU==}AxPxZCYC)lyOlj9as#`5hZ=<6<&DB%i_XCnt5=pjh?iusH$ z>)E`@HNZcAG&RW3Ys@`Ci{;8PNzE-ZsPw$~Wa!cP$ye+X6;9ceE}ah+3VY7Mx}#0x zbqYa}eO*FceiY2jNS&2cH9Y}(;U<^^cWC5Ob&)dZedvZA9HewU3R;gRQ)}hUdf+~Q zS_^4ds*W1T#bxS?%RH&<739q*n<6o|mV;*|1s>ly-Biu<2*{!!0#{_234&9byvn0* z5=>{95Zfb{(?h_Jk#ocR$FZ78O*UTOxld~0UF!kyGM|nH%B*qf)Jy}N!uT9NGeM19 z-@=&Y0yGGo_dw!FD>juk%P$6$qJkj}TwLBoefi;N-$9LAeV|)|-ET&culW9Sb_pc_ zp{cXI0>I0Jm_i$nSvGnYeLSSj{ccVS2wyL&0x~&5v;3Itc82 z5lIAkfn~wcY-bQB$G!ufWt%qO;P%&2B_R5UKwYxMemIaFm)qF1rA zc>gEihb=jBtsXCi0T%J37s&kt*3$s7|6)L(%UiY)6axuk{6RWIS8^+u;)6!R?Sgap z9|6<0bx~AgVi|*;zL@2x>Pbt2Bz*uv4x-`{F)XatTs`S>unZ#P^ZiyjpfL_q2z^fqgR-fbOcG=Y$q>ozkw1T6dH8-)&ww+z?E0 zR|rV(9bi6zpX3Ub>PrPK!{X>e$C66qCXAeFm)Y+lX8n2Olt7PNs*1^si)j!QmFV#t z0P2fyf$N^!dyTot&`Ew5{i5u<8D`8U`qs(KqaWq5iOF3x2!-z65-|HsyYz(MAKZ?< zCpQR;E)wn%s|&q(LVm0Ab>gdmCFJeKwVTnv@Js%!At;I=A>h=l=p^&<4;Boc{$@h< z38v`3&2wJtka@M}GS%9!+SpJ}sdtoYzMevVbnH+d_eMxN@~~ zZq@k)7V5f8u!yAX2qF3qjS7g%n$JuGrMhQF!&S^7(%Y{rP*w2FWj(v_J{+Hg*}wdWOd~pHQ19&n3RWeljK9W%sz&Y3Tm3 zR`>6YR54%qBHGa)2xbs`9cs_EsNHxsfraEgZ)?vrtooeA0sPKJK7an){ngtV@{SBa zkO6ORr1_Xqp+`a0e}sC*_y(|RKS13ikmHp3C^XkE@&wjbGWrt^INg^9lDz#B;bHiW zkK4{|cg08b!yHFSgPca5)vF&gqCgeu+c82%&FeM^Bb}GUxLy-zo)}N;#U?sJ2?G2BNe*9u_7kE5JeY!it=f`A_4gV3} z`M!HXZy#gN-wS!HvHRqpCHUmjiM;rVvpkC!voImG%OFVN3k(QG@X%e``VJSJ@Z7tb z*Onlf>z^D+&$0!4`IE$;2-NSO9HQWd+UFW(r;4hh;(j^p4H-~6OE!HQp^96v?{9Zt z;@!ZcccV%C2s6FMP#qvo4kG6C04A>XILt>JW}%0oE&HM5f6 zYLD!;My>CW+j<~=Wzev{aYtx2ZNw|ptTFV(4;9`6Tmbz6K1)fv4qPXa2mtoPt&c?P zhmO+*o8uP3ykL6E$il00@TDf6tOW7fmo?Oz_6GU^+5J=c22bWyuH#aNj!tT-^IHrJ zu{aqTYw@q;&$xDE*_kl50Jb*dp`(-^p={z}`rqECTi~3 z>0~A7L6X)=L5p#~$V}gxazgGT7$3`?a)zen>?TvAuQ+KAIAJ-s_v}O6@`h9n-sZk> z`3{IJeb2qu9w=P*@q>iC`5wea`KxCxrx{>(4{5P+!cPg|pn~;n@DiZ0Y>;k5mnKeS z!LIfT4{Lgd=MeysR5YiQKCeNhUQ;Os1kAymg6R!u?j%LF z4orCszIq_n52ulpes{(QN|zirdtBsc{9^Z72Ycb2ht?G^opkT_#|4$wa9`)8k3ilU z%ntAi`nakS1r10;#k^{-ZGOD&Z2|k=p40hRh5D7(&JG#Cty|ECOvwsSHkkSa)36$4 z?;v#%@D(=Raw(HP5s>#4Bm?f~n1@ebH}2tv#7-0l-i^H#H{PC|F@xeNS+Yw{F-&wH z07)bj8MaE6`|6NoqKM~`4%X> zKFl&7g1$Z3HB>lxn$J`P`6GSb6CE6_^NA1V%=*`5O!zP$a7Vq)IwJAki~XBLf=4TF zPYSL}>4nOGZ`fyHChq)jy-f{PKFp6$plHB2=;|>%Z^%)ecVue(*mf>EH_uO^+_zm? zJATFa9SF~tFwR#&0xO{LLf~@}s_xvCPU8TwIJgBs%FFzjm`u?1699RTui;O$rrR{# z1^MqMl5&6)G%@_k*$U5Kxq84!AdtbZ!@8FslBML}<`(Jr zenXrC6bFJP=R^FMBg7P?Pww-!a%G@kJH_zezKvuWU0>m1uyy}#Vf<$>u?Vzo3}@O% z1JR`B?~Tx2)Oa|{DQ_)y9=oY%haj!80GNHw3~qazgU-{|q+Bl~H94J!a%8UR?XsZ@ z0*ZyQugyru`V9b(0OrJOKISfi89bSVR zQy<+i_1XY}4>|D%X_`IKZUPz6=TDb)t1mC9eg(Z=tv zq@|r37AQM6A%H%GaH3szv1L^ku~H%5_V*fv$UvHl*yN4iaqWa69T2G8J2f3kxc7UE zOia@p0YNu_q-IbT%RwOi*|V|&)e5B-u>4=&n@`|WzH}BK4?33IPpXJg%`b=dr_`hU z8JibW_3&#uIN_#D&hX<)x(__jUT&lIH$!txEC@cXv$7yB&Rgu){M`9a`*PH} zRcU)pMWI2O?x;?hzR{WdzKt^;_pVGJAKKd)F$h;q=Vw$MP1XSd<;Mu;EU5ffyKIg+ z&n-Nb?h-ERN7(fix`htopPIba?0Gd^y(4EHvfF_KU<4RpN0PgVxt%7Yo99X*Pe|zR z?ytK&5qaZ$0KSS$3ZNS$$k}y(2(rCl=cuYZg{9L?KVgs~{?5adxS))Upm?LDo||`H zV)$`FF3icFmxcQshXX*1k*w3O+NjBR-AuE70=UYM*7>t|I-oix=bzDwp2*RoIwBp@r&vZukG; zyi-2zdyWJ3+E?{%?>e2Ivk`fAn&Ho(KhGSVE4C-zxM-!j01b~mTr>J|5={PrZHOgO zw@ND3=z(J7D>&C7aw{zT>GHhL2BmUX0GLt^=31RRPSnjoUO9LYzh_yegyPoAKhAQE z>#~O27dR4&LdQiak6={9_{LN}Z>;kyVYKH^d^*!`JVSXJlx#&r4>VnP$zb{XoTb=> zZsLvh>keP3fkLTIDdpf-@(ADfq4=@X=&n>dyU0%dwD{zsjCWc;r`-e~X$Q3NTz_TJ zOXG|LMQQIjGXY3o5tBm9>k6y<6XNO<=9H@IXF;63rzsC=-VuS*$E{|L_i;lZmHOD< zY92;>4spdeRn4L6pY4oUKZG<~+8U-q7ZvNOtW0i*6Q?H`9#U3M*k#4J;ek(MwF02x zUo1wgq9o6XG#W^mxl>pAD)Ll-V5BNsdVQ&+QS0+K+?H-gIBJ-ccB1=M_hxB6qcf`C zJ?!q!J4`kLhAMry4&a_0}up{CFevcjBl|N(uDM^N5#@&-nQt2>z*U}eJGi}m5f}l|IRVj-Q;a>wcLpK5RRWJ> zysdd$)Nv0tS?b~bw1=gvz3L_ZAIdDDPj)y|bp1;LE`!av!rODs-tlc}J#?erTgXRX z$@ph%*~_wr^bQYHM7<7=Q=45v|Hk7T=mDpW@OwRy3A_v`ou@JX5h!VI*e((v*5Aq3 zVYfB4<&^Dq5%^?~)NcojqK`(VXP$`#w+&VhQOn%;4pCkz;NEH6-FPHTQ+7I&JE1+Ozq-g43AEZV>ceQ^9PCx zZG@OlEF~!Lq@5dttlr%+gNjRyMwJdJU(6W_KpuVnd{3Yle(-p#6erIRc${l&qx$HA z89&sp=rT7MJ=DuTL1<5{)wtUfpPA|Gr6Q2T*=%2RFm@jyo@`@^*{5{lFPgv>84|pv z%y{|cVNz&`9C*cUely>-PRL)lHVErAKPO!NQ3<&l5(>Vp(MuJnrOf^4qpIa!o3D7( z1bjn#Vv$#or|s7Hct5D@%;@48mM%ISY7>7@ft8f?q~{s)@BqGiupoK1BAg?PyaDQ1 z`YT8{0Vz{zBwJ={I4)#ny{RP{K1dqzAaQN_aaFC%Z>OZ|^VhhautjDavGtsQwx@WH zr|1UKk^+X~S*RjCY_HN!=Jx>b6J8`Q(l4y|mc<6jnkHVng^Wk(A13-;AhawATsmmE#H%|8h}f1frs2x@Fwa_|ea+$tdG2Pz{7 z!ox^w^>^Cv4e{Xo7EQ7bxCe8U+LZG<_e$RnR?p3t?s^1Mb!ieB z#@45r*PTc_yjh#P=O8Zogo+>1#|a2nJvhOjIqKK1U&6P)O%5s~M;99O<|Y9zomWTL z666lK^QW`)cXV_^Y05yQZH3IRCW%25BHAM$c0>w`x!jh^15Zp6xYb!LoQ zr+RukTw0X2mxN%K0%=8|JHiaA3pg5+GMfze%9o5^#upx0M?G9$+P^DTx7~qq9$Qoi zV$o)yy zuUq>3c{_q+HA5OhdN*@*RkxRuD>Bi{Ttv_hyaaB;XhB%mJ2Cb{yL;{Zu@l{N?!GKE7es6_9J{9 zO(tmc0ra2;@oC%SS-8|D=omQ$-Dj>S)Utkthh{ovD3I%k}HoranSepC_yco2Q8 zY{tAuPIhD{X`KbhQIr%!t+GeH%L%q&p z3P%<-S0YY2Emjc~Gb?!su85}h_qdu5XN2XJUM}X1k^!GbwuUPT(b$Ez#LkG6KEWQB z7R&IF4srHe$g2R-SB;inW9T{@+W+~wi7VQd?}7||zi!&V^~o0kM^aby7YE_-B63^d zf_uo8#&C77HBautt_YH%v6!Q>H?}(0@4pv>cM6_7dHJ)5JdyV0Phi!)vz}dv{*n;t zf(+#Hdr=f8DbJqbMez)(n>@QT+amJ7g&w6vZ-vG^H1v~aZqG~u!1D(O+jVAG0EQ*aIsr*bsBdbD`)i^FNJ z&B@yxqPFCRGT#}@dmu-{0vp47xk(`xNM6E=7QZ5{tg6}#zFrd8Pb_bFg7XP{FsYP8 zbvWqG6#jfg*4gvY9!gJxJ3l2UjP}+#QMB(*(?Y&Q4PO`EknE&Cb~Yb@lCbk;-KY)n zzbjS~W5KZ3FV%y>S#$9Sqi$FIBCw`GfPDP|G=|y32VV-g@a1D&@%_oAbB@cAUx#aZ zlAPTJ{iz#Qda8(aNZE&0q+8r3&z_Ln)b=5a%U|OEcc3h1f&8?{b8ErEbilrun}mh3 z$1o^$-XzIiH|iGoJA`w`o|?w3m*NX|sd$`Mt+f*!hyJvQ2fS*&!SYn^On-M|pHGlu z4SC5bM7f6BAkUhGuN*w`97LLkbCx=p@K5RL2p>YpDtf{WTD|d3ucb6iVZ-*DRtoEA zCC5(x)&e=giR_id>5bE^l%Mxx>0@FskpCD4oq@%-Fg$8IcdRwkfn;DsjoX(v;mt3d z_4Mnf#Ft4x!bY!7Hz?RRMq9;5FzugD(sbt4up~6j?-or+ch~y_PqrM2hhTToJjR_~ z)E1idgt7EW>G*9%Q^K;o_#uFjX!V2pwfpgi>}J&p_^QlZki!@#dkvR`p?bckC`J*g z=%3PkFT3HAX2Q+dShHUbb1?ZcK8U7oaufLTCB#1W{=~k0Jabgv>q|H+GU=f-y|{p4 zwN|AE+YbCgx=7vlXE?@gkXW9PaqbO#GB=4$o0FkNT#EI?aLVd2(qnPK$Yh%YD%v(mdwn}bgsxyIBI^)tY?&G zi^2JfClZ@4b{xFjyTY?D61w@*ez2@5rWLpG#34id?>>oPg{`4F-l`7Lg@D@Hc}On} zx%BO4MsLYosLGACJ-d?ifZ35r^t*}wde>AAWO*J-X%jvD+gL9`u`r=kP zyeJ%FqqKfz8e_3K(M1RmB?gIYi{W7Z<THP2ihue0mbpu5n(x_l|e1tw(q!#m5lmef6ktqIb${ zV+ee#XRU}_dDDUiV@opHZ@EbQ<9qIZJMDsZDkW0^t3#j`S)G#>N^ZBs8k+FJhAfu< z%u!$%dyP3*_+jUvCf-%{x#MyDAK?#iPfE<(@Q0H7;a125eD%I(+!x1f;Sy`e<9>nm zQH4czZDQmW7^n>jL)@P@aAuAF$;I7JZE5a8~AJI5CNDqyf$gjloKR7C?OPt9yeH}n5 zNF8Vhmd%1O>T4EZD&0%Dt7YWNImmEV{7QF(dy!>q5k>Kh&Xy8hcBMUvVV~Xn8O&%{ z&q=JCYw#KlwM8%cu-rNadu(P~i3bM<_a{3!J*;vZhR6dln6#eW0^0kN)Vv3!bqM`w z{@j*eyzz=743dgFPY`Cx3|>ata;;_hQ3RJd+kU}~p~aphRx`03B>g4*~f%hUV+#D9rYRbsGD?jkB^$3XcgB|3N1L& zrmk9&Dg450mAd=Q_p?gIy5Zx7vRL?*rpNq76_rysFo)z)tp0B;7lSb9G5wX1vC9Lc z5Q8tb-alolVNWFsxO_=12o}X(>@Mwz1mkYh1##(qQwN=7VKz?61kay8A9(94Ky(4V zq6qd2+4a20Z0QRrmp6C?4;%U?@MatfXnkj&U6bP_&2Ny}BF%4{QhNx*Tabik9Y-~Z z@0WV6XD}aI(%pN}oW$X~Qo_R#+1$@J8(31?zM`#e`#(0f<-AZ^={^NgH#lc?oi(Mu zMk|#KR^Q;V@?&(sh5)D;-fu)rx%gXZ1&5)MR+Mhssy+W>V%S|PRNyTAd}74<(#J>H zR(1BfM%eIv0+ngHH6(i`?-%_4!6PpK*0X)79SX0X$`lv_q>9(E2kkkP;?c@rW2E^Q zs<;`9dg|lDMNECFrD3jTM^Mn-C$44}9d9Kc z#>*k&e#25;D^%82^1d@Yt{Y91MbEu0C}-;HR4+IaCeZ`l?)Q8M2~&E^FvJ?EBJJ(% zz1>tCW-E~FB}DI}z#+fUo+=kQME^=eH>^%V8w)dh*ugPFdhMUi3R2Cg}Zak4!k_8YW(JcR-)hY8C zXja}R7@%Q0&IzQTk@M|)2ViZDNCDRLNI)*lH%SDa^2TG4;%jE4n`8`aQAA$0SPH2@ z)2eWZuP26+uGq+m8F0fZn)X^|bNe z#f{qYZS!(CdBdM$N2(JH_a^b#R2=>yVf%JI_ieRFB{w&|o9txwMrVxv+n78*aXFGb z>Rkj2yq-ED<)A46T9CL^$iPynv`FoEhUM10@J+UZ@+*@_gyboQ>HY9CiwTUo7OM=w zd~$N)1@6U8H#Zu(wGLa_(Esx%h@*pmm5Y9OX@CY`3kPYPQx@z8yAgtm(+agDU%4?c zy8pR4SYbu8vY?JX6HgVq7|f=?w(%`m-C+a@E{euXo>XrGmkmFGzktI*rj*8D z)O|CHKXEzH{~iS+6)%ybRD|JRQ6j<+u_+=SgnJP%K+4$st+~XCVcAjI9e5`RYq$n{ zzy!X9Nv7>T4}}BZpSj9G9|(4ei-}Du<_IZw+CB`?fd$w^;=j8?vlp(#JOWiHaXJjB0Q00RHJ@sG6N#y^H7t^&V} z;VrDI4?75G$q5W9mV=J2iP24NHJy&d|HWHva>FaS#3AO?+ohh1__FMx;?`f{HG3v0 ztiO^Wanb>U4m9eLhoc_2B(ca@YdnHMB*~aYO+AE(&qh@?WukLbf_y z>*3?Xt-lxr?#}y%kTv+l8;!q?Hq8XSU+1E8x~o@9$)zO2z9K#(t`vPDri`mKhv|sh z{KREcy`#pnV>cTT7dm7M9B@9qJRt3lfo(C`CNkIq@>|2<(yn!AmVN?ST zbX_`JjtWa3&N*U{K7FYX8})*D#2@KBae` zhKS~s!r%SrXdhCsv~sF}7?ocyS?afya6%rDBu6g^b2j#TOGp^1zrMR}|70Z>CeYq- z1o|-=FBKlu{@;pm@QQJ_^!&hzi;0Z_Ho){x3O1KQ#TYk=rAt9`YKC0Y^}8GWIN{QW znYJyVTrmNvl!L=YS1G8BAxGmMUPi+Q7yb0XfG`l+L1NQVSbe^BICYrD;^(rke{jWCEZOtVv3xFze!=Z&(7}!)EcN;v0Dbit?RJ6bOr;N$ z=nk8}H<kCEE+IK3z<+3mkn4q!O7TMWpKShWWWM)X*)m6k%3luF6c>zOsFccvfLWf zH+mNkh!H@vR#~oe=ek}W3!71z$Dlj0c(%S|sJr>rvw!x;oCek+8f8s!U{DmfHcNpO z9>(IKOMfJwv?ey`V2ysSx2Npeh_x#bMh)Ngdj$al;5~R7Ac5R2?*f{hI|?{*$0qU- zY$6}ME%OGh^zA^z9zJUs-?a4ni8cw_{cYED*8x{bWg!Fn9)n;E9@B+t;#k}-2_j@# zg#b%R(5_SJAOtfgFCBZc`n<&z6)%nOIu@*yo!a% zpLg#36KBN$01W{b;qWN`Tp(T#jh%;Zp_zpS64lvBVY2B#UK)p`B4Oo)IO3Z&D6<3S zfF?ZdeNEnzE{}#gyuv)>;z6V{!#bx)` zY;hL*f(WVD*D9A4$WbRKF2vf;MoZVdhfWbWhr{+Db5@M^A4wrFReuWWimA4qp`GgoL2`W4WPUL5A=y3Y3P z%G?8lLUhqo@wJW8VDT`j&%YY7xh51NpVYlsrk_i4J|pLO(}(b8_>%U2M`$iVRDc-n zQiOdJbroQ%*vhN{!{pL~N|cfGooK_jTJCA3g_qs4c#6a&_{&$OoSQr_+-O^mKP=Fu zGObEx`7Qyu{nHTGNj(XSX*NPtAILL(0%8Jh)dQh+rtra({;{W2=f4W?Qr3qHi*G6B zOEj7%nw^sPy^@05$lOCjAI)?%B%&#cZ~nC|=g1r!9W@C8T0iUc%T*ne z)&u$n>Ue3FN|hv+VtA+WW)odO-sdtDcHfJ7s&|YCPfWaVHpTGN46V7Lx@feE#Od%0XwiZy40plD%{xl+K04*se zw@X4&*si2Z_0+FU&1AstR)7!Th(fdaOlsWh`d!y=+3m!QC$Zlkg8gnz!}_B7`+wSz z&kD?6{zPnE3uo~Tv8mLP%RaNt2hcCJBq=0T>%MW~Q@Tpt2pPP1?KcywH>in5@ zx+5;xu-ltFfo5vLU;2>r$-KCHjwGR&1XZ0YNyrXXAUK!FLM_7mV&^;;X^*YH(FLRr z`0Jjg7wiq2bisa`CG%o9i)o1`uG?oFjU_Zrv1S^ipz$G-lc^X@~6*)#%nn+RbgksJfl{w=k31(q>7a!PCMp5YY{+Neh~mo zG-3dd!0cy`F!nWR?=9f_KP$X?Lz&cLGm_ohy-|u!VhS1HG~e7~xKpYOh=GmiiU;nu zrZ5tWfan3kp-q_vO)}vY6a$19Q6UL0r znJ+iSHN-&w@vDEZ0V%~?(XBr|jz&vrBNLOngULxtH(Rp&U*rMY42n;05F11xh?k;n_DX2$4|vWIkXnbwfC z=ReH=(O~a;VEgVO?>qsP*#eOC9Y<_9Yt<6X}X{PyF7UXIA$f)>NR5P&4G_Ygq(9TwwQH*P>Rq>3T4I+t2X(b5ogXBAfNf!xiF#Gilm zp2h{&D4k!SkKz-SBa%F-ZoVN$7GX2o=(>vkE^j)BDSGXw?^%RS9F)d_4}PN+6MlI8*Uk7a28CZ)Gp*EK)`n5i z){aq=0SFSO-;sw$nAvJU-$S-cW?RSc7kjEBvWDr1zxb1J7i;!i+3PQwb=)www?7TZ zE~~u)vO>#55eLZW;)F(f0KFf8@$p)~llV{nO7K_Nq-+S^h%QV_CnXLi)p*Pq&`s!d zK2msiR;Hk_rO8`kqe_jfTmmv|$MMo0ll}mI)PO4!ikVd(ZThhi&4ZwK?tD-}noj}v zBJ?jH-%VS|=t)HuTk?J1XaDUjd_5p1kPZi6y#F6$lLeRQbj4hsr=hX z4tXkX2d5DeLMcAYTeYm|u(XvG5JpW}hcOs4#s8g#ihK%@hVz|kL=nfiBqJ{*E*WhC zht3mi$P3a(O5JiDq$Syu9p^HY&9~<#H89D8 zJm84@%TaL_BZ+qy8+T3_pG7Q%z80hnjN;j>S=&WZWF48PDD%55lVuC0%#r5(+S;WH zS7!HEzmn~)Ih`gE`faPRjPe^t%g=F ztpGVW=Cj5ZkpghCf~`ar0+j@A=?3(j@7*pq?|9)n*B4EQTA1xj<+|(Y72?m7F%&&& zdO44owDBPT(8~RO=dT-K4#Ja@^4_0v$O3kn73p6$s?mCmVDUZ+Xl@QcpR6R3B$=am z%>`r9r2Z79Q#RNK?>~lwk^nQlR=Hr-ji$Ss3ltbmB)x@0{VzHL-rxVO(++@Yr@Iu2 zTEX)_9sVM>cX$|xuqz~Y8F-(n;KLAfi*63M7mh&gsPR>N0pd9h!0bm%nA?Lr zS#iEmG|wQd^BSDMk0k?G>S-uE$vtKEF8Dq}%vLD07zK4RLoS?%F1^oZZI$0W->7Z# z?v&|a`u#UD=_>i~`kzBGaPj!mYX5g?3RC4$5EV*j0sV)>H#+$G6!ci=6`)85LWR=FCp-NUff`;2zG9nU6F~ z;3ZyE*>*LvUgae+uMf}aV}V*?DCM>{o31+Sx~6+sz;TI(VmIpDrN3z+BUj`oGGgLP z>h9~MP}Pw#YwzfGP8wSkz`V#}--6}7S9yZvb{;SX?6PM_KuYpbi~*=teZr-ga2QqIz{QrEyZ@>eN*qmy;N@FCBbRNEeeoTmQyrX;+ zCkaJ&vOIbc^2BD6_H+Mrcl?Nt7O{xz9R_L0ZPV_u!sz+TKbXmhK)0QWoe-_HwtKJ@@7=L+ z+K8hhf=4vbdg3GqGN<;v-SMIzvX=Z`WUa_91Yf89^#`G(f-Eq>odB^p-Eqx}ENk#&MxJ+%~Ad2-*`1LNT>2INPw?*V3&kE;tt?rQyBw? zI+xJD04GTz1$7~KMnfpkPRW>f%n|0YCML@ODe`10;^DXX-|Hb*IE%_Vi#Pn9@#ufA z_8NY*1U%VseqYrSm?%>F@`laz+f?+2cIE4Jg6 z_VTcx|DSEA`g!R%RS$2dSRM|9VQClsW-G<~=j5T`pTbu-x6O`R z98b;}`rPM(2={YiytrqX+uh65f?%XiPp`;4CcMT*E*dQJ+if9^D>c_Dk8A(cE<#r=&!& z_`Z01=&MEE+2@yr!|#El=yM}v>i=?w^2E_FLPy(*4A9XmCNy>cBWdx3U>1RylsItO z4V8T$z3W-qqq*H`@}lYpfh=>C!tieKhoMGUi)EpWDr;yIL&fy};Y&l|)f^QE*k~4C zH>y`Iu%#S)z)YUqWO%el*Z)ME#p{1_8-^~6UF;kBTW zMQ!eXQuzkR#}j{qb(y9^Y!X7&T}}-4$%4w@w=;w+>Z%uifR9OoQ>P?0d9xpcwa>7kTv2U zT-F?3`Q`7xOR!gS@j>7In>_h){j#@@(ynYh;nB~}+N6qO(JO1xA z@59Pxc#&I~I64slNR?#hB-4XE>EFU@lUB*D)tu%uEa))B#eJ@ZOX0hIulfnDQz-y8 z`CX@(O%_VC{Ogh&ot``jlDL%R!f>-8yq~oLGxBO?+tQb5%k@a9zTs!+=NOwSVH-cR zqFo^jHeXDA_!rx$NzdP;>{-j5w3QUrR<;}=u2|FBJ;D#v{SK@Z6mjeV7_kFmWt95$ zeGaF{IU?U>?W`jzrG_9=9}yN*LKyzz))PLE+)_jc#4Rd$yFGol;NIk(qO1$5VXR)+ zxF7%f4=Q!NzR>DVXUB&nUT&>Nyf+5QRF+Z`X-bB*7=`|Go5D1&h~ zflKLw??kpiRm0h3|1GvySC2^#kcFz^5{79KKlq@`(leBa=_4CgV9sSHr{RIJ^KwR_ zY??M}-x^=MD+9`v@I3jue=OCn0kxno#6i>b(XKk_XTp_LpI}X*UA<#* zsgvq@yKTe_dTh>q1aeae@8yur08S(Q^8kXkP_ty48V$pX#y9)FQa~E7P7}GP_CbCm zc2dQxTeW(-~Y6}im24*XOC8ySfH*HMEnW3 z4CXp8iK(Nk<^D$g0kUW`8PXn2kdcDk-H@P0?G8?|YVlIFb?a>QunCx%B9TzsqQQ~HD!UO7zq^V!v9jho_FUob&Hxi ztU1nNOK)a!gkb-K4V^QVX05*>-^i|{b`hhvQLyj`E1vAnj0fbqqO%r z6Q;X1x0dL~GqMv%8QindZ4CZ%7pYQW~ z9)I*#Gjref-q(4Z*E#1c&rE0-_(4;_M(V7rgH_7H;ps1s%GBmU z{4a|X##j#XUF2n({v?ZUUAP5k>+)^F)7n-npbV3jAlY8V3*W=fwroDS$c&r$>8aH` zH+irV{RG3^F3oW2&E%5hXgMH9>$WlqX76Cm+iFmFC-DToTa`AcuN9S!SB+BT-IA#3P)JW1m~Cuwjs`Ep(wDXE4oYmt*aU z!Naz^lM}B)JFp7ejro7MU9#cI>wUoi{lylR2~s)3M!6a=_W~ITXCPd@U9W)qA5(mdOf zd3PntGPJyRX<9cgX?(9~TZB5FdEHW~gkJXY51}?s4ZT_VEdwOwD{T2E-B>oC8|_ZwsPNj=-q(-kwy%xX2K0~H z{*+W`-)V`7@c#Iuaef=?RR2O&x>W0A^xSwh5MsjTz(DVG-EoD@asu<>72A_h<39_# zawWVU<9t{r*e^u-5Q#SUI6dV#p$NYEGyiowT>>d*or=Ps!H$-3={bB|An$GPkP5F1 zTnu=ktmF|6E*>ZQvk^~DX(k!N`tiLut*?3FZhs$NUEa4ccDw66-~P;x+0b|<!ZN7Z%A`>2tN#CdoG>((QR~IV_Gj^Yh%!HdA~4C3jOXaqb6Ou z21T~Wmi9F6(_K0@KR@JDTh3-4mv2=T7&ML<+$4;b9SAtv*Uu`0>;VVZHB{4?aIl3J zL(rMfk?1V@l)fy{J5DhVlj&cWKJCcrpOAad(7mC6#%|Sn$VwMjtx6RDx1zbQ|Ngg8N&B56DGhu;dYg$Z{=YmCNn+?ceDclp65c_RnKs4*vefnhudSlrCy6-96vSB4_sFAj# zftzECwmNEOtED^NUt{ZDjT7^g>k1w<=af>+0)%NA;IPq6qx&ya7+QAu=pk8t>KTm` zEBj9J*2t|-(h)xc>Us*jHs)w9qmA>8@u21UqzKk*Ei#0kCeW6o z-2Q+Tvt25IUkb}-_LgD1_FUJ!U8@8OC^9(~Kd*0#zr*8IQkD)6Keb(XFai5*DYf~` z@U?-{)9X&BTf!^&@^rjmvea#9OE~m(D>qfM?CFT9Q4RxqhO0sA7S)=--^*Q=kNh7Y zq%2mu_d_#23d`+v`Ol263CZ<;D%D8Njj6L4T`S*^{!lPL@pXSm>2;~Da- zBX97TS{}exvSva@J5FJVCM$j4WDQuME`vTw>PWS0!;J7R+Kq zVUy6%#n5f7EV(}J#FhDpts;>=d6ow!yhJj8j>MJ@Wr_?x30buuutIG97L1A*QFT$c ziC5rBS;#qj=~yP-yWm-p(?llTwDuhS^f&<(9vA9@UhMH2-Fe_YAG$NvK6X{!mvPK~ zuEA&PA}meylmaIbbJXDOzuIn8cJNCV{tUA<$Vb?57JyAM`*GpEfMmFq>)6$E(9e1@W`l|R%-&}38#bl~levA#fx2wiBk^)mPj?<=S&|gv zQO)4*91$n08@W%2b|QxEiO0KxABAZC{^4BX^6r>Jm?{!`ZId9jjz<%pl(G5l));*`UU3KfnuXSDj2aP>{ zRIB$9pm7lj3*Xg)c1eG!cb+XGt&#?7yJ@C)(Ik)^OZ5><4u$VLCqZ#q2NMCt5 z6$|VN(RWM;5!JV?-h<JkEZ(SZF zC(6J+>A6Am9H7OlOFq6S62-2&z^Np=#xXsOq0WUKr zY_+Ob|CQd1*!Hirj5rn*=_bM5_zKmq6lG zn*&_=x%?ATxZ8ZTzd%biKY_qyNC#ZQ1vX+vc48N>aJXEjs{Y*3Op`Q7-oz8jyAh>d zNt_qvn`>q9aO~7xm{z`ree%lJ3YHCyC`q`-jUVCn*&NIml!uuMNm|~u3#AV?6kC+B z?qrT?xu2^mobSlzb&m(8jttB^je0mx;TT8}`_w(F11IKz83NLj@OmYDpCU^u?fD{) z&=$ptwVw#uohPb2_PrFX;X^I=MVXPDpqTuYhRa>f-=wy$y3)40-;#EUDYB1~V9t%$ z^^<7Zbs0{eB93Pcy)96%XsAi2^k`Gmnypd-&x4v9rAq<>a(pG|J#+Q>E$FvMLmy7T z5_06W=*ASUyPRfgCeiPIe{b47Hjqpb`9Xyl@$6*ntH@SV^bgH&Fk3L9L=6VQb)Uqa z33u#>ecDo&bK(h1WqSH)b_Th#Tvk&%$NXC@_pg5f-Ma#7q;&0QgtsFO~`V&{1b zbSP*X)jgLtd@9XdZ#2_BX4{X~pS8okF7c1xUhEV9>PZco>W-qz7YMD`+kCGULdK|^ zE7VwQ-at{%&fv`a+b&h`TjzxsyQX05UB~a0cuU-}{*%jR48J+yGWyl3Kdz5}U>;lE zgkba*yI5>xqIPz*Y!-P$#_mhHB!0Fpnv{$k-$xxjLAc`XdmHd1k$V@2QlblfJPrly z*~-4HVCq+?9vha>&I6aRGyq2VUon^L1a)g`-Xm*@bl2|hi2b|UmVYW|b+Gy?!aS-p z86a}Jep6Mf>>}n^*Oca@Xz}kxh)Y&pX$^CFAmi#$YVf57X^}uQD!IQSN&int=D> zJ>_|au3Be?hmPKK)1^JQ(O29eTf`>-x^jF2xYK6j_9d_qFkWHIan5=7EmDvZoQWz5 zZGb<{szHc9Nf@om)K_<=FuLR<&?5RKo3LONFQZ@?dyjemAe4$yDrnD zglU#XYo6|~L+YpF#?deK6S{8A*Ou;9G`cdC4S0U74EW18bc5~4>)<*}?Z!1Y)j;Ot zosEP!pc$O^wud(={WG%hY07IE^SwS-fGbvpP?;l8>H$;}urY2JF$u#$q}E*ZG%fR# z`p{xslcvG)kBS~B*^z6zVT@e}imYcz_8PRzM4GS52#ms5Jg9z~ME+uke`(Tq1w3_6 zxUa{HerS7!Wq&y(<9yyN@P^PrQT+6ij_qW3^Q)I53iIFCJE?MVyGLID!f?QHUi1tq z0)RNIMGO$2>S%3MlBc09l!6_(ECxXTU>$KjWdZX^3R~@3!SB zah5Za2$63;#y!Y}(wg1#shMePQTzfQfXyJ-Tf`R05KYcyvo8UW9-IWGWnzxR6Vj8_la;*-z5vWuwUe7@sKr#Tr51d z2PWn5h@|?QU3>k=s{pZ9+(}oye zc*95N_iLmtmu}H-t$smi49Y&ovX}@mKYt2*?C-i3Lh4*#q5YDg1Mh`j9ovRDf9&& zp_UMQh`|pC!|=}1uWoMK5RAjdTg3pXPCsYmRkWW}^m&)u-*c_st~gcss(`haA)xVw zAf=;s>$`Gq_`A}^MjY_BnCjktBNHY1*gzh(i0BFZ{Vg^F?Pbf`8_clvdZ)5(J4EWzAP}Ba5zX=S(2{gDugTQ3`%!q`h7kYSnwC`zEWeuFlODKiityMaM9u{Z%E@@y1jmZA#ⅅ8MglG&ER{i5lN315cO?EdHNLrg? zgxkP+ytd)OMWe7QvTf8yj4;V=?m172!BEt@6*TPUT4m3)yir}esnIodFGatGnsSfJ z**;;yw=1VCb2J|A7cBz-F5QFOQh2JDQFLarE>;4ZMzQ$s^)fOscIVv2-o{?ct3~Zv zy{0zU>3`+-PluS|ADraI9n~=3#Tvfx{pDr^5i$^-h5tL*CV@AeQFLxv4Y<$xI{9y< zZ}li*WIQ+XS!IK;?IVD0)C?pNBA(DMxqozMy1L#j+ba1Cd+2w&{^d-OEWSSHmNH>9 z%1Ldo(}5*>a8rjQF&@%Ka`-M|HM+m<^E#bJtVg&YM}uMb7UVJ|OVQI-zt-*BqQ zG&mq`Bn7EY;;+b%Obs9i{gC^%>kUz`{Qnc=ps7ra_UxEP$!?f&|5fHnU(rr?7?)D z$3m9e{&;Zu6yfa1ixTr;80IP7KLgkKCbgv1%f_weZK6b7tY+AS%fyjf6dR(wQa9TD zYG9`#!N4DqpMim|{uViKVf0B+Vmsr7p)Y+;*T~-2HFr!IOedrpiXXz+BDppd5BTf3 ztsg4U?0wR?9@~`iV*nwGmtYFGnq`X< zf?G%=o!t50?gk^qN#J(~!sxi=_yeg?Vio04*w<2iBT+NYX>V#CFuQGLsX^u8dPIkP zPraQK?ro`rqA4t7yUbGYk;pw6Z})Bv=!l-a5^R5Ra^TjoXI?=Qdup)rtyhwo<(c9_ zF>6P%-6Aqxb8gf?wY1z!4*hagIch)&A4treifFk=E9v@kRXyMm?V*~^LEu%Y%0u(| z52VvVF?P^D<|fG)_au(!iqo~1<5eF$Sc5?)*$4P3MAlSircZ|F+9T66-$)0VUD6>e zl2zlSl_QQ?>ULUA~H?QbWazYeh61%B!!u;c(cs`;J|l z=7?q+vo^T#kzddr>C;VZ5h*;De8^F2y{iA#9|(|5@zYh4^FZ-3r)xej=GghMN3K2Y z=(xE`TM%V8UHc4`6Cdhz4%i0OY^%DSguLUXQ?Y3LP+5x3jyN)-UDVhEC}AI5wImt; zHY|*=UW}^bS3va-@L$-fJz2P2LbCl)XybkY)p%2MjPJd-FzkdyWW~NBC@NlPJkz{v z+6k6#nif`E>>KCGaP34oY*c#nBFm#G8a0^px1S6mm6Cs+d}E8{J;DX=NEHb|{fZm0 z@Ors@ebTgbf^Jg&DzVS|h&Or)56$+;%&sh0)`&6VkS@QxQ=#6WxF5g+FWSr7Lp9uF zV#rc`yLe?f*u6oZoi3WpOkKFf^>lHb2GC6t!)dyGaQbK7&BNZ7oyP)hUX1Y(LdW-I z6LI2$i%+g!zsjT(5l}5ROLb)8`9kkldbklcq6tfLSrAyh#s(C1U2Sz9`h3#T9eX#Hryi1AU^!uv*&6I~qdM_B7-@`~8#O^jN&t7+S zTKI6;T$1@`Kky-;;$rU1*TdY;cUyg$JXalGc&3-Rh zJ&7kx=}~4lEx*%NUJA??g8eIeavDIDC7hTvojgRIT$=MlpU}ff0BTTTvjsZ0=wR)8 z?{xmc((XLburb0!&SA&fc%%46KU0e&QkA%_?9ZrZU%9Wt{*5DCUbqIBR%T#Ksp?)3 z%qL(XlnM!>F!=q@jE>x_P?EU=J!{G!BQq3k#mvFR%lJO2EU2M8egD?0r!2s*lL2Y} zdrmy`XvEarM&qTUz4c@>Zn}39Xi2h?n#)r3C4wosel_RUiL8$t;FSuga{9}-%FuOU z!R9L$Q!njtyY!^070-)|#E8My)w*~4k#hi%Y77)c5zfs6o(0zaj~nla0Vt&7bUqfD zrZmH~A50GOvk73qiyfXX6R9x3Qh)K=>#g^^D65<$5wbZjtrtWxfG4w1f<2CzsKj@e zvdsQ$$f6N=-%GJk~N7G(+-29R)Cbz8SIn_u|(VYVSAnlWZhPp8z6qm5=hvS$Y zULkbE?8HQ}vkwD!V*wW7BDBOGc|75qLVkyIWo~3<#nAT6?H_YSsvS+%l_X$}aUj7o z>A9&3f2i-`__#MiM#|ORNbK!HZ|N&jKNL<-pFkqAwuMJi=(jlv5zAN6EW`ex#;d^Z z<;gldpFcVD&mpfJ1d7><79BnCn~z8U*4qo0-{i@1$CCaw+<$T{29l1S2A|8n9ccx0!1Pyf;)aGWQ15lwEEyU35_Y zQS8y~9j9ZiByE-#BV7eknm>ba75<_d1^*% zB_xp#q`bpV1f9o6C(vbhN((A-K+f#~3EJtjWVhRm+g$1$f2scX!eZkfa%EIZd2ZVG z6sbBo@~`iwZQC4rH9w84rlHjd!|fHc9~12Il&?-FldyN50A`jzt~?_4`OWmc$qkgI zD_@7^L@cwg4WdL(sWrBYmkH;OjZGE^0*^iWZM3HBfYNw(hxh5>k@MH>AerLNqUg*Og9LiYmTgPw zX9IiqU)s?_obULF(#f~YeK#6P>;21x+cJ$KTL}|$xeG?i`zO;dAk0{Uj6GhT-p-=f zP2NJUcRJ{fZy=bbsN1Jk3q}(!&|Fkt_~GYdcBd7^JIt)Q!!7L8`3@so@|GM9b(D$+ zlD&69JhPnT>;xlr(W#x`JJvf*DPX(4^OQ%1{t@)Lkw5nc5zLVmRt|s+v zn(25v*1Z(c8RP@=3l_c6j{{=M$=*aO^ zPMUbbEKO7m2Q$4Xn>GIdwm#P_P4`or_w0+J+joK&qIP#uEiCo&RdOaP_7Z;PvfMh@ zsXUTn>ppdoEINmmq5T1BO&57*?QNLolW-8iz-jv7VAIgoV&o<<-vbD)--SD%FFOLd z>T$u+V>)4Dl6?A24xd1vgm}MovrQjf-@YH7cIk6tP^eq-xYFymnoSxcw}{lsbCP1g zE_sX|c_nq(+INR3iq+Oj^TwkjhbdOo}FmpPS2*#NGxNgl98|H0M*lu)Cu0TrA|*t=i`KIqoUl(Q7jN zb6!H-rO*!&_>-t)vG5jG>WR6z#O9O&IvA-4ho9g;as~hSnt!oF5 z6w(4pxz|WpO?HO<>sC_OB4MW)l`-E9DZJ$!=ytzO}fWXwnP>`8yWm5tYw`b1KDdg zp@oD;g===H+sj+^v6DCpEu7R?fh7>@pz>f74V5&#PvBN+95?28`mIdGR@f*L@j2%% z%;Rz5R>l#1U zYCS_5_)zUjgq#0SdO#)xEfYJ)JrHLXfe8^GK3F*CA(Y)jsSPJ{j&Ae!SeWN%Ev727 zxdd3Y0n^OBOtBSKdglEBL)i5=NdKfqK=1n~6LX`ja;#Tr!II$AAH{Z#sp%`rwNGT5 zvHT%(LJB+kD{5N}7c_Rk6}@tikIeq%@MqxX%$P!(238YD(H<_d;xxo*oMiv^1io>g zt5z&6`}cjci90q2r0hutQXr!UA~|4e*u=k81D(Cp7n{4LVCa+u0%-8Uha+sqI#Om~ z!&)KN(#Zone^~&@Ja{|l?X64Dxk)q>tLRv{=0|t$`Kdaj z#{AJr>{_BtpS|XEgTVJ4WMvBRk-(mk@ZYGdY1VwI z81;z(MBGV|2j*Cj%dvl8?b2{{B#e0B7&7wfv+>g`R2^Ai5C_WUx|CnTrHm+RFGXrt zs<~zBtk@?Niu%|o6IEL+y60Q>zJlv``ePCa07C%*O~lj?74|}&A0!uA)3V7ST8b_- z6CBP1;x+S@xTzgOY2#s%@=bhZ@i@BwmS)neQG&=9KUtRf^K=MvjC5JnqLqykCE_P0 zjf#V4SdH2#%2EuDb!>FLHK7j;nd6VLW|$3gJuegpEl3DZ`BpJU$<}}A(rW?<6OB@9 zKP9G3An?T5BztrLdlximA;{>Tr7GAeSU=^<*y;%RHj+7;v+tonyh(8d;Izn}2{oz& zW)fsZ9gHYpI?B|uekS3zHUue3mI zb7?0+&Zm>Kq(F>~%VYEn)0b32I3~O^?Wx-HI|Zu?1-OA2yfyJ;gWygLOeU;)vRm3u z5J4vDIQYztnEm=QauX2(WJO{yzI0HUFl+oO&isMf!Yh2pu@p}65)|0EdWRbg(@J6qo5_Els>#|_2a1p0&y&UP z8x#Z69q=d663NPPi>DHx3|QhJl5Ka$Cfqbvl*oRLYYXiH>g8*vriy!0XgmT~&jh3l z+!|~l=oCj<*PD>1EY*#+^a{rVk3T(66rJ^DxGt|~XTNnJf$vix1v1qdYu+d@Jn~bh z!7`a`y+IEcS#O*fSzA;I`e_T~XYzpW7alC%&?1nr);tSkNwO&J`JnX+7X1Q8fRh_d zx%)Xh_YjI3hwTCmGUeq_Z@H#ovkk_b(`osa$`aNmt`9A#t&<^jvuf z1E1DrW(%7PpAOQGwURz@luEW9-)L!`Jy*aC*4mcD?Si~mb=3Kn#M#1il9%`C0wkZ` zbpJ-qEPaOE5Y5iv_z%Wr{y4jh#U+o^KtP{pPCq-Qf&!=Uu)cEE(Iu9`uT#oHwHj+w z_R=kr7vmr~{^5sxXkj|WzNhAlXkW^oB4V)BZ{({~4ylOcM#O>DR)ZhD;RWwmf|(}y zDn)>%iwCE=*82>zP0db>I4jN#uxcYWod+<;#RtdMGPDpQW;riE;3cu``1toL|FaWa zK)MVA%ogXt3q55(Q&q+sjOG`?h=UJE9P;8i#gI*#f}@JbV(DuGEkee;La*9{p&Z?;~lE!&-kUFCtoDHY*MS zzj+S$L9+aTs(F^4ufZe6>SBg;m@>0&+kEZMFmD*~p~sx?rx=!>Ge;KYw<33y#*&77 zFZI`YE(Iz?+tH;Fq;y=MaSqT{Ayh*HFv0(z{_?Q+7@nE%p?S8%X6c!+y;!0NLXwJV8Co_}R3*7>n+oMsQpv8}8ZS-P@(Rg|gmxZHzf=nMOUAAY}AZGfWVzZjE@4$=7xkIrs8BE%606aVU%kxz_04ipig51k& z(>c9rJL2q%xvU%Zj#GR9C9)HLCR;#zQBB@x;e_9$ayn(JmSg_*0G?+wOF?&iu@}S{ zt$;TPf*Lj$3=d<}Q3o!Hq@3~lFxoiCyeEt}o3fihIn{x2s1)e2@3##&GYDq~YO|!q zUs0P-zy)+ohl-VQ`bhvUpC{-d$lkpML_M%Kl6@#_@A}w{jWCDsPa#cSbWA#C4Sf|*C*&Z{ zz?hOU7Cc`?>H$WGqITA2P~fYudnQHxB8^;0ZFKC;19F#~n_2P@{cE{Czq-#K5L_8| zc3aOEwq4%zL5>YU_mc9fc-p~{fBTWUkxTiZvxt9FOqC{s#TBp(#dWc+{Ee{dZ#B!g zHnaOJ8;KO1G;QU2ciodE+#Z$Wuz*Hc6NRO!AUMi|gov=>=cwcZeL&`>Jfn!35hV1J z;B2@0!bIR853w%T*m6)gQ?DPnQ)o6EtKaN3L;o?*q<83d&lG&U=A|6hcT?f0)4h6{ zGIZ0|!}-?*n{zr}-}cC}qWxEN%g60+{my)o^57{QEn(tSrmD7o)|r0+HVpQPopFu; z0<S}pW8W2vXzSxEqGD+qePj^x?R$e2LO&*ewsLo{+_Z)Wl|Z1K47j zsKoNRlX)h2z^ls_>IZ0!2X5t&irUs%RAO$Dr>0o$-D+$!Kb9puSgpoWza1jnX6(eG zTg-U z6|kf1atI!_>#@|=d01Ro@Rg)BD?mY3XBsG7U9%lmq>4;Gf&2k3_oyEOdEN&X6Hl5K zCz^hyt67G;IE&@w1n~%ji_{sob_ssP#Ke|qd!Xx?J&+|2K=^`WfwZ-zt|sklFouxC zXZeDgluD2a?Zd3e{MtE$gQfAY9eO@KLX;@8N`(?1-m`?AWp!a8bA%UN>QTntIcJX zvbY+C-GD&F?>E?jo$xhyKa@ps9$Dnwq>&)GB=W~2V3m)k;GNR$JoPRk%#f3#hgVdZ zhW3?cSQ*((Fog26jiEeNvum-6ID-fbfJ?q1ZU#)dgnJ^FCm`+sdP?g;d4VD$3XKx{ zs|Y4ePJp|93fpu)RL+#lIN9Ormd;<_5|oN!k5CENnpO>{60X;DN>vgHCX$QZYtgrj z*1{bEA1LKi8#U%oa!4W-4G+458~`5O4S1&tuyv>%H9DjLip7cC~RRS@HvdJ<|c z$TxEL=)r)XTfTgVxaG!gtZhLL`$#=gz1X=j|I@n~eHDUCW39r=o_ml@B z0cDx$5;3OA2l)&41kiKY^z7sO_U%1=)Ka4gV(P#(<^ z_zhThw=}tRG|2|1m4EP|p{Swfq#eNzDdi&QcVWwP+7920UQB*DpO0(tZHvLVMIGJl zdZ5;2J%a!N1lzxFwAkq05DPUg2*6SxcLRsSNI6dLiK0&JRuYAqwL}Z!YVJ$?mdnDF z82)J_t=jbY&le6Hq$Qs}@AOZGpB1}$Ah#i;&SzD1QQNwi6&1ddUf7UG0*@kX?E zDCbHypPZ9+H~KnDwBeOXZ-W-Y80wpoGB*A) z_;26Z`#s0tKrf~QBi2rl2=>;CS1w)rcD3-sB!8NI*1iQo59PJ>OLnqeV4iK7`RBi^ zFW{*6;nlD&cSunmU3v4JKj|K4xeN(q>H%;SsY8yDdw5BJ75q8>Ov)&D5OPZ`XiRHl z;)mAA0Woy6f!xCK(9H2rq?qzp83liZAIpBPl-dQ&$2=&H?Im~%g;vnIw1I+8q|kr! z36&^9}CMmR(U2rf|j12oG=vb%Ypsq8u9Kq}U*ANX*)9uK}fAi8;V_7Z;0_4*iydDxN-? zv?qJ=T*{MzL~-xUv{_Kh_q9#F{8gPV!yPUUS8pEq*=}2-#1d=sC_|U-rX~F0 zBLawgCWy#?#ax{~DAnDvh^`}wyUO`ioMK~jgh%L7^}#h?beSyvQ_g>+`2`}`-1h7# zg*?qJdm=53hwN8~B=^|LPmYtOVrQ(W{sNm4uofq=4P@dUA%$onWbw_m-KWia&n9iv zi)!9#OJ#^}eg8tE{wSb9(c0D^PS1 z9EBS5*ypSiVRS_G0v?$hyoZOS7hFWlp4qbYkf9Y&{%OzhsIdHskLptn96@k6@^K@U zszd8POehITDK+AyW#JKpnWY;ju#MC$JjB1Y*~(E6N%{p#kO+bVxG3X<34n3fW=k{A zCZt|KP%x^GQ9%mU)KE0{LA=vaZvRQbxSlK~eAkwWo2Z<{j5eS5NVTMe`m%re8%~7K zZLtU&b~YDN%~uA9wPf>x2=PI=MA6_oVe>Ek$s5&&Z=8vvF5EODP4Av(b|dlNgF1O8 zy83W0WRdzjz2iNA~t1piEqlyU&`$yZtqR`6X_PmuP>W+D|8iH;FQ zN{JuU#Tz9mV=4R_IewROL1|mK^`lLat#LcIBfggzM(iO$pQT*-c_ z94^LUWw#5B9~sp2W1p`c)Y(xfR<{O^9n4E6vDDw{#-R4UMBKo{>Hqlqn*a9rl_>+0 zS5MwJC~nCC`1X%VCyWFsiDX;bfAJQAUkU#105f_s5U-8rqO}n8fA1{b>Fr6Q|Ea(V z5B11Lo^ooWF?`^{-U#?iatokWI-e$632frzY?Yzzx(xJc@LFM4A~-eg!u|tl{)8Nx ztZLXsSC*68g%9TFu(f&J9nmc^9hgyy#uUOMJFCaifSaDcyQ&6=8e9=t zIFEAQ{EK{|73{($!a4=!wj4ABcQrUQp#+gGM?wEUp(w@+Fzi{!lt}|3`PM%&d-seeR zB$}BrFGD3R10CE>Hsb>;PrP}pd` zaY4}6+Wu(`#uAV+E5SV7VIT7ES#b(U0%%DgN1}USJH>)mm;CHPv>}B18&0F~Kj@1= z&^Jyo+z-E)GRT4U*7$8wJO1OibWg0Jw>C$%Ge|=YwV@Y1(4fR>cV#6aGtRoF@I`*w_V4;)V231NzNqb6g@jdpjmjv*<2j02yU$F8ZS$fTvCC`%|Yn#x< zXUnP&b!GLpOY-TY3d?<-Hhxom_LM9`JC9LEX2{t1P-Nj%nG+0Vq)vQwvO^}coPH-> zAo8w#s>Je^Yy*#PlK=XDxpVS~pFe-j#jN-(As&LRewOf(kN-aKF(H+s*{*!0xrlZw zchJu@XAvQWX7DI1E8?F}Wc8m46eT+C<0eXVB+Z^(g=Kl@FG-cn@u$suj)1V2(KNg_ zh29ws6&6(q~+sOAoHY^o86A<#n*?Pg2)cK$+y;cY$hJLq4)4V84=j+3ShSr##Tk5kgmxB zkW+8A1GtceEx~^Ebhwm36U?oA)h)!mt=eg0QE$D1QsLNZ_T3NH?=B&0j~#298!6iv zhc0|-{46*3`Rx&nKSXnf1&w-Rs>#PGAGuY@cBTU-j|Fxbn3z49S#6KBaP^Lx*AOXxIibr z!1ysMi(&kr!1wwQB5w`BDH2~>T4bI`T1}A2RM0zd7ikC&kuBRsB`Z2@J!Udm{AmSN zrr0k6_qCZL**=)xRW`MFu(OY=OT;3G8eF~ z2mmkXZ9X(sjuKmq+_<=LSjphB$~R1o^Yb=rO!j!(4ErIox^x55o{pXSE9X$!76^*$ zoKhlAX6y%n^U=C~@!vIlEgXQGD@>oOU=_(aXF-Sjas*$AKESfRzxQ8#3yOj|y0OCU z>6Z-0%LCcjla&7I+CXm&caKp@@jQ!5M`(_{CL=@4#JJ}cHeZw>^b6fpv269LSV?gV5Q{kk?4;;y9RIsy5vk%DIRiL(9xe1aA@4!VX zDh2}xgUd5X?6nji%&7-%QuyKSYA-Z{PwJijUQ}In+EJl|x@dF1P<5bPa5W3&&?^h$ zZCo8LepKo0a(Fsln*cHL;D(gu9MMkoiM0*n31u)jHqX5x^F95tnI&^}^yKx3YwEm@ zo8?EZ710ykx@19{=yz5IXb8w4yjdveWb{IVL6Z(Cs>!a_0X^1E27o!4e&b43+J*u2Gb(59k2uK0goLwhO{ujLS ziI9LA9`&x~Y$6JNX!aEXR``}LUI}Gr#=<^wBHmg%v<)zRWDVtq)kT$-P7iU1R)2XZ zi~bYhV@EZ`@prgK(cs{>2jn$pxg$<|KjJ7%26Km>%KcXh^bU@y@V_Lf@=j1x%R4{v zOcQn{I}!2W<~08FOVnoV>zOTH=+>v9!jFo|q)ucqIe!N4{U5_G`>>*sVD{8I~4FqyU8imZ**-Gy`~Xd z4w35GMf%7^i65HdX{Iz|f2Kg193#KhPIeR)-=eYx3Z!%RM=JjwLrdk^B#6rg!ym2w zPbFqYyO4>W_Z6PonAwiu7?!h=x%sR-T+_*xZOGh2wWhWr%}%2^$$ zQvACIB~pi=m|`hXIMvoq`TOCx=J_D2>pi6$NPy3&8#vy|oX)=kM0Z}$BR$r0G}MzOk-OqG+VmZtOZoj6x4(tLh|5h) zBv64Y{DPHsy&_H(5_l(&Y}FhVvr9m_*_Q~Zy-}V9+VmGnvndEjYW4qt4K~N&Y&6g| zfpz*V=A#^mVmuOAz)(KVI<%v5NY0%Goy!{9&o41upsPWk(yFuRP|A4q6NMnX%V~MT zi_Rb-Bno2kI+j0Cw`@ydy{e%ARS#Z%b6I%_yfo_ZKXr4BLVoHzBKJ^ZG z-2>2IzU)55@9C|?_P$ew^-7zEiAKG1XAi{!3h%1m#9s%^pGy6S9wKFYY4<$djeoJP z{GI}Vd%idY$4_fh(7NXm7#;cC!DS&-{tGr!Qze{^%bUx2jgG@-kMta^q-EwrKB}d8 z{%FT>rFk_bzW<{lc%eYlrsiYTZXGgzD1&lmRyp+c1O=0=zAX=KV62bx-a~JP{cPF4 zU$-XT#(9&T>l@bMu3nSr{)%-5lV+0t&bxip4DVJ~vlL$J2P6X~ zd{FS8vm{Lhrieul*7&(AgPuXhjpGila%6_?-+k#b)cdk#M1jB*nE>G6NGOr+Ek{`= z9b%S1`$`=g0CC$>0$Db;l_szReLYVmce*(()9%Zz1`*fNXhI*oRlerWHarD(v^W^c zuc1Vuw6Gbp7ZsoRH>QGt#&lv;5G~Ovt$%7VFd*-rN2>UjbOWBFGNGO`bru7CFB4tn zL`^?69Lj_g_TA&`9`dSI8s|)K|QM0 zybvV7!>xDY|6c6y;Q}qs`){1+WQu_5Dgd8Qe|q}}bxjH+joQQtqs1IVZn6{e7T{ia zF|=^xa%eWO%(x<7j*QZbcU_;aVaVP!arexOLOtoSNt*hvsRL%}%)jPetSich(`b-^ zMZ$PM9%s@%*jPVz0Z^W*cK_>G4f}+eEVX`HOaHg#!B`<4v;x}zDLMR*M27`kNfp!! zOfdt(>k-g>7jf^{Se@3$8<+;R*cYtw+wD_Z8Pl~!JDCUEPq{Ea*!J9`%ihyNJZ30i zmfve}S5<$Uso}_?SuI$ks|{-ddGLu9WR9`^9)Kdi@Vs;x#SY-xp}wHPU0|vEA7234 z@BN1z7OF=OOQtPF$4twn3!HTVlUVD_)ubMM7PEPoiC6lQgL2q9PK4~e8v-OuH%lie z?NgBLkIdPMG$QBq(>r^AOHB`|*1#*!2Z? zuU8H|FD`OBRu^(R?Z-Vhr0j;FLpS~a34KREnd}B=EYHS*>Hm+f%tgJt!4J8Q`qn^4 z9F=tO#JRJ}tzA`vx$nZ)O%wC?Uiv0+_nz}5Lj4ki*&=K&*#U`=rv z`Q@Q{+IhAj@6lrNK2B=8Yln!O2%zomfRehFT~;!O@(@Xy|1Jlw*uOB-M$#6K^)QBm z_7%#QVUDPwnW{iOV-grMQQU|3{=BQMh}c5(yMGdoQf*)k9-B zMQ(^GdJh+y)>qJprknS!%WxqM>HlHOP#7UVdy>%PW$!l72J`n-p7j(DBKoGxXWh(Y z>BFDZl|7knU_jg_SSbvFk8)39%2)Hu5W0}HKlh>EaqvFoXI&56Yy)3) zQkE4X^P0QnPn?iUUVHJZXzPp`s5uv?pG{K9IgGoHvcmlBxubi|iF7n{)mhenIcxGs zgr0OpQy#Y#u=5lOyiECfE_Sn?Fj1LyoRKcbTgX{p<T*v!CGkPc)pcA2D=4Ekp0Gb*wpy7S88C%Ywsbr?MI(3UdsCM?XJ1X%*hNjB)XqZ*W(qDdtSb z<3XN74ARXL3=c^bfW~F%NM^5*Zx92>Wq`&M625p~j$8mYwLbk%Kf)jbn#<2z$%vP5 zy#b>-tF-S2_AB4;R^K&^-1LJrUmi@9rB^FLF)-k&YHK8P+k@RCJ1qSTZ@=kHxA3l$ zmK_ZG)l6(nmCR1a8|;QF-B5e_ELnjJ1$m-;4UXX?WytF_wz7#&AjwZYTMVieLbq@R z3t-q|G4^BB#EpNu4uyfDebB+-uu_$9>y-dzB30Y9F=R zrW-Heqnj*InPTWHgR9v^R7~hokldh&h8=HDhMW(EFfim1*{)5Lc1-+eBVkK-2!u=N zuZKABgJs3I--NbjE;>Undg6uK`^U>AQ6V zhc!RhYgvrmeGNsftr+(C<_MtuV$`5RZTf#5r=DR?gWG->#})#=(td%C3`oO+2B7im zUqY}&a_QNTn?s+?=mNXiREN%x_=(H)L|DtYPY>SR3pQfBOel7G_jR_{!9`dSj8Up-`JgcB;=Oor)U=_EVjF3C5{Sqh8cq=~bRjoBpoc$kJCgtTyZGSpQ4= zYi$6b$-dGmuTDF&@amhV?cU05g(AZV&v2$4m&j_~GZk;&keSO(@LRESRZ&p`dV*6w z2$em~p*8yM6j;SYorw`M5K2mluJq7P5Yn$VtZj8DEs2Zk=O@4T&Q}>~f31Z{uk}`E z{Dp{KObh1kk~~MfLUod72{Pk6G@T$_0_N??lOrdR=Z;VV#m0l)&@hz{Z?)@sgImi-&i1@95g53rON83v!yVPDHRU*Mzc4yZ(-Fr z{8{WXmIJf7jeswk$;6s~Qac6QyM3W&`}m#gRt=rr95A+Ad&wSAgvXZ|F))rBJVJ5W1CsjN`QaOzct2ocq#0!v zmj#075)C!3oS>&N;aHS@<+c>RHL)8j^p)k(8#7$LEx!1g_1^02!4_qA=;uhKW=+ix zGX%+vBMiRiF^^jm{mdO(?GdWJ#unO#_F^7mhT8)s(z_WlwFyJ#Xh)k5+RG2f;LC*K**1dr`#}~6A=0B=I&V;%zDA1)d@G!X#Rng)7G*2k8Kg447r0ox> z5NK`d(H-afBwo9feDOUi>;BbPsu!2|=@g=3j*PY}@YrOb+SX6?#Yb2xaaK!?>SX1J z_!VsB`2n1=wwSftkydm!39|-1?c%Epx?TO<(#GO~I&{f4+)XwRk<7RQ1~5>QcKH|D z?!}j1ueO0Lk;FZ{k4FA_(S`Ot0w~tl&m0duID*f6RY#bkw||o;kZ# zISYNTb|{~|X$m$Q-Jv#uxyw)eM0gIv`V#wOAp&Vv@>X4_tSZ&L#juM@$S9 zx_X_tLh<_^-F;LAQ09s@sPb%PMTrcw*HUV0P=RYSlM&AXEOI&&R&YCm_S<7DRBx^L zA^R^iwW+LMk(r*$Pq-fKU5X@=mQ=`ErO30H@@&qqnI7zJcrbSh+H<V ze&7Uli0xj@WrW#&-9%*FP~kPYF_YYM_hs5~|ExMynQ%qvq`leRB6W0yhC@pCb8>_P zlf=F~WMv_u*-DV=UaVu#2rlzK{q8D95VwZrfV?gj@rSNWXFvktUq)V5+YrlxwX302ae(;aG4e>L-M@3J+-f3IT{b9l!kg*2M zC1+ND9}6m^()LE87Mt+^Q|)!y#suc&v26C=0W88%a{?)E8Yvo@kM&KNMaOst#|-_CbUTm}WS@-c>nRb;&z^ zYr)+IE$1=jov(CZ%3uR+`~NI>1&Gs6W(jaamjcN$a`2!*nO}l|b%?)Q%%UWzw>A`C zR@px(P*7j$TK?jbv*%x)e^|jcLsv}aF(Z0=7(%Oa7+1wY>{B>d+i&ZA$}k(qgZPZY z;VkW~8eWnU&HPIAbco?&tc2O1$6=7n{u|^Y*nXoac{o1W-6aXfy~KlNbJfLoq~6;+ zDYmnv--Fhqrl+UV#k@_(1=gWNtqhyVKN=9CZ-{Ohi>e=~bm4IKbhM%%W zW8oXE!rGpV7Wt(_^4nndH1_imheaWzDi|I})9ZVZ9>pN+P%dVc5wG`Ze*4`@rjn1^ z`ln(;vPBHQUb}y8S>=8q__r7g+=z$>!pReVB0@XKchAvyGjLQs-u>+w%`frV4FeIG zj=7n~hGrwx*&5aHy(7X$bDZ7YhcP%(*>G^lAYMK;qG~V8Jz@b7oNg;IA1z$9@TbzW z;@I51@Ekef#qbxnG$Y8Z%bm~ibZ=4#%yKr%#b)CDrfKN`ujIY?tA4h9)i~dZ4E;ZM znvb$n2)zn$Wx&zlW%mJZDh28ox$@%`w3i7YFepXUChw}$UXKI=-TM51`M#FH=tdr*mQ!c=aB1296Lu>iTTKZWss0f z5~ihdImPN$aTle_AdbYC^31}_^EK|9R&l#%3hbx;8vJ+Gp^tm{9JDILu*1PW!rh^Dn9p<)h#Sl4kKM%nm<+!ESSk* zC;lLNT$fgr-!+{aBsSx$41b}yy6o>r3F#1&iv3cfY2N<+`0qJ+>=&Qxs}JOEkD?^l-F5i`t5+zNuvJf z3Fh4$mNqiFXL-aq4U4K@Ae$fq-TDT`rvrx;gqx96w^*@s=mcthCaIyPe(w)6kI{EqV10tcShHU9eeAPs)s?6#vrq}>y3FeTJu$Udha+z zs7}rmA@yR(L&>35sNjQqrw}o^)UitMU!5g6nnG)(tgst!^`FKJEzI1(d@j_w@;^hr zgYxlIRYjho4U$bhczfq&YySCqCE(5_d>l(4tk1v9!V7PB%Vx{QO=G2NC@c1%3rEzw zN<6i?h;CJX>h)kn49Sr)g#Em6km6ESP`1qc5C3ZHizN>r>V-fSS=X1nT{+Thh@kC! z(H=PlqDt7V6gOYezXUK-dretz!1?IUD6&eL2b!4=9h+HUO&DYZKMM>|YhlEEg?q?S z^XT4$2Fd|zT=x3U#L1|F;-#`to-Y6hiYkWdO=rRC)meY72pIfl`3zEGDU8($iWR^K zI$nq80aSJII<;#W5Pj>^_T&013BJ*O89Uoq z5>;Paa^E}xar^r=!pexg&OTM8wluk4R~Ru=)Hgk`Y#i_$jk{jc8hx}?(dW*X!l4vs z6_%$s#duJJFmaFc-5#>v6Yea=I~)s_pXGS>Tkz?s+WS}>Qp<9MappMLXpkXpSM~SmH6u)`Z5>o02kJs;w@KhdiZ3}29y*xr|6tMo zBHzGic+b+dTd!xOJ;p{Rguh^corJ;K?R6daayQKm+0rf7|AXg0qs!R9eS7t4{G=fs z1$=?kK1Ih=gEkI>@jgXDWHZt*C7FUEWs|u^pE3Z``^K|1KEC^sbN*4nQUfRc_AyE0 zn)?RrGjgPkzfE~_s!rDB!fDsV+*|kEX4+DyS#8%!cshn;s8svwBXSsDGX2ZRa0={* z=`p1F{zD17*Rk>Uk_cw3t5j=9-d6$}MoM~z{v{t^M!g75-+o8_XkP@CZWUQ2z!^26 zCNOu~hgrrK)y>bgqb{`Q_1^zrG4;cGarP!nb4E~(ZKWc`LVeEq;IewVneLp^ZU2+% z95PgN*M5v7Q;ZlGvM#`&u2NdHm%&gZ{bZM5wBCp&?HeZhwU87wyT_z!n4z+1?=RvXZ^72d*%+R1s1$KbAFtR|= zw;MEq=O7pMIKpFwKH6$OOszJAf<_Z<1)36cB>D>|Z6$gJL~jH`n3MMou$#Si%rDAu z4pSkJspG|^CJ86vg6kkfXsA_`8@8iOryOe!Qhn8SV6}mPlof3=WJRVqAr_b;e->`Z zMR(p|K|$L0^6;u~USxg#B6-ZNc%E1dv*^P=|2k*^NOBni#G%9Y?##{=)8KZwh85OL zSBG9|gb|hdmY^gn(ziY&O5#@I?W)W;361Yb^VQNpz0A7&^(7HRAsUvw#)fvhocvja zLxV65J0_$>&cVRctJFsn^qLos^tG`+B0_gQ{NeOwKt-!C^gGFufdtPT*Vi>l#X1|V z2XxsAcixN)Ekq=a##_^=k_^BFH5_zpvPDRP>u6+3$}i&b zy0@FdzAHw?i9OqnlTts_w5D@Nd#eM)KKEuN#m{|AJyscxa}(eA?z4&4yvXo{OBS65 z-?gW;<+;+ntM}U_yTmHm6*2zj0Imj<&ZgE9Wj|gfsXhrVH-c0p$7HXnR8bxDYOi z=_r3FA~u`L&2;Vir8}P3)k|@c?sK1U@&iWo{HEXcoy>6wQSuJ+b4l%aTBuigs&k@Y<2c=S3Ef?p zH>ki4yDuXdo_eu>X1{E$g(Q-u#zVXN^&%70guoizo7x(kQ0OZ}H$O9UB}(FaX8Ct1 zFpx~}EbHf2r6V;x=@8GH$C2|6*?K~?LrtMYd^bw*WYXhA z_))@RMH;nZedW3+qfWbv<|_#BYOxX^rhbN+!za)|!|8K*LRs(R$O*2SDM{g9k7e{u zN4VIdi}e#0&h?sBxu$>Yy%)j(k1V2fuhp8r!}gfF@b;F?U`6}YnnMh1&sSU&lR^?# zu!61+lGsuFEfDraX3+$QZibCbKzc{75G^T7@WZSQ)j5898G1AOXB*H*TSd`f<`IK# zm1%&t?i|2Z-a&r!pJehzg@!awNp)R)aa?q_SqGrxE5u+T#f?K2;GAHV?O&>!W@Q*k)7=g2vDW+7K zbyY9i{|nOF*SbMYoRQSAbSH2y$bE5(@d6xKxcF#@TE~X#3o=;`0sc!RupdRmQsML? z&>SCwS{FOpSr+@6Uuz3m`hj}(^g`Jz|6?({!%WVJn$H|ugxW+x-GEA?J&U^ugj3Nb z;65~)W<}iH2PJ@st8LtLfSOLXYgj=9<;?ih7rq$bXW9J#!B8!Wu6#U`A$wlcoC*&` z_9Js~7%m79#+edeT&P`@_Ng@e&5J+pqpx%31tAF71)pcz~-yJ>P5yX(nuM4;bUHDa8E(~~l{j~JeCGkX>nHJDpgSf&bTHEf)qw8{Q~CBPEVen|MW2P3vmf`8X9-g|>>ddp zcgfjbl~(?3Wa*NzQH>4nsM$3}Ul>pX1xC0oF3TZXe7=V!9!n?WgvH|R zpbruczmB%z=zkZ>=1R|gXwGThLELqD5KCUhtiRGT*JwKIvzbzV%ZU!e!VcNHSSX3> zObH|oohc8nvQZ2}q??C}@>!fe3gH+HF@4(qWqi>;ag~md#D;cl8&gQb^?2a@5cikT z=7r78@&5gV3Ggc9f=<<8v~yz`NcEGvbX1V_`IL(&+Z>LB zM~$ok2qXzod@1$TEl*U~H$V5g$er{Uj^($sWb7Nr{gsIbE(`$LRGECTOraXiU%=uq z0zvpi1S%)RxTjzoVcR4#10)fs()4Mtsa@e?9j)Bk!LsYyXIZga2q7d%`vQE!V@<1Y zmkpH3LeXJNO9f7l>F84g;huc=4nk(UnU}RLZmYk2TtB#lv34K(?8~gyx-mN%g=U44 zOPdr_!j-;IEbe|l9-buuKEy^Q9MLjSKG$S6dz)!U_32{1)N}L)3+COmlg=nY1@od$ zJ<0z-B%sisAR1yh>z-RfQQb6M4i-d#vxvb~f69M{JLPZv1JSCh1$gQ*LxOF-tH9!k zbQ0ZW)S7)qCSF|=2`q_A3}OHBNBueZwTTz^ar~gz#2KA74&&D)KHt~m4F_nK<^*7_ z!!pN@xiGkq%>1N(rNxw$zu-=1t*IpAy$ z4~dD0w%9;E?(greVWZ3(o9ux`elM>Rek#0 zO=#-(4p5B+wFzlEU7^k{3EdL6sIp|K*>xrriI`}E8ze|z-$YpN`^_teL_7P`%e>IN z7tNiH619P+0Q1hBR|W#POOta)1|LkIRtgz zMJ9VOxXN#o)mlXS=u%`Q>~PBuKEmOWsIuQRp{y%!ty{fEyL0gV)$LQeL#pqX3L@SR zJ2Gb^E9+KVd?;joVOXlGie3?z6>(>u(i!(qGz(W( ze~^xj&IRF<98ypEis{Y_FoHn%C0bW(XeF#Lj=2WUEBqKNPPFppEH?_a3}-h906X}C zSYKcZFU`Om5YlWhh@ogzCn3NvuM~F9jOX|xe-X*!YL+#ceh_tJoHXz`aTnvSrOAZ| zOtdGz?QdT!oAJr3(XL2G(p%2X4{xEohU&vd_zQ(U%ihHOlKPWnb$&YYhx48?|R++>`5?sxvM?!;ru|9 zZ#nwuTK^S%ce<+ggdJBE&fRrXN7O!{nu`%q`M{2Ef_+IRad2cf01P9pST9AOK>y75c!9}~)Et^6$`&Nm{wzWcm4c0j9DF!xJTpGrMp3esI4D_iiDe`sswXSu{dQZE_`^A11 z?Z@Hw=65mVu^%X`>;$mciK}XiZ{xw7I_!t)S00^JuxdCXhIRO~S*lPS(S^je`DH4E zxbKNs8RL`N?gCQ@YSOU=>0FE#Ku#DRO7JA&fu-X8b;3!^#{=7`WsDXUxfUsE(FKSQ z&=N`A7IwLq%+vt(F;z+T=uZNl=@K4|E%p{p^o5(BGjsE|WOR`%8+XgGW8xJTFJc4L zVY#L`OdnSM{HyS$fX1)3_JuNNH1aDsDqi>CzCT5=kY5zV<~29bX)c^I8R5n&ymHkx zj(QC4t#mDK;2xi8O%V;C{HqDQeM64=b4@sa*N_K0a&ro4+8LY6cFHz< ze|!g}zF|tDrP=`+U7KwKl20gdW1%!iN>1=uxA|NZJ2peruBOj?RBPb~8G;s6xIi6- z?_odhafsxoxiBf zwZZ)c*)FLc0#wE~bXw0TPBYl+h9hs|DYr_B4LR_YL@S1hQs=p zNEh%_fUvWZCbJtaF#kP5=(O#{8|g&Kmz1&8{@Lufw^DhtvKx955~aqxi2C=)Z-!Kd z+m-u+#^U4(HYn6a1w652kO0bYBt&goyx(n?MR^kI+{Q?0Y{G~W2) z0dS3fuJ?SU(6ZDp=kUley%PK}K_;YQyK|U|?7t9SHiyIfpT4a_kUVIhH4PSaj@3mo z`z}|mHhx1Pq?@(3vTBb5HTXuFAzFZEt0D-fw_kd=XvwIUh3VXTm{wbDA~cESd5cI1 zd>6=&AvG3yu+)`9oxmfrDQ(1fzv(_0l?bp{a364dXLRRBI8kBv!KsL;brY)#E3`o{ z3TlWUsS0{Voci?6MejccG9x_KiqN>So*1{25r6BSl9jUyR}1TgXBLL7Pr6Wv~Nu47;fbiU7TbL}>qmtl36YSZ() zVf@nqW(As~#`@bIC+AxSw!O5Pocf&rYaCFm?Jd?XR)p#@{!|5^Ws@wd855)mI^8y{ zws+VvGXW6%xoj@JkGb=~%oJ~7m6+uhOv?bH+jJJ~eFgp+}~*^C+3>R-MY!IZQoabCh( zN(T+z@Oyc^C)WqQESmh{d!!T8zS(!wX=R#hEKxMXy(eg zZ+Cwm1a%?;RH$h2_ws|nRjn8ZY!>3gn+6Ep4xT|AeFox7!rac2Lw?jsz}JqPE?5JG zok0}q1P;cuzs%Yrze|&d$oTr<`Lx{fbq2OV=!3v-ODq(n?|WxuhtmwJBIoW^^FB+D z-?Ok9HBKc5@)L(W&vmI{prL?4^OE9TR)bELS=<>*w%&aKjzi*@;5#P3moG@dm{Eke zhE#Is;&=o|{2GWai}7LYEI+gmc^Kj4K7w7n)+9godg?yB2?xs}pF1<*!Sv?D~Uvbkgs9xx9s#6zBv9l@ox>d#H6eqw^KZO;Vg}h!q zI33^$4}yF*q+q{DsJsa(SsV!YQ#zi^IF9MQV6i{SiN4dWWCi%YQ+hNc1r!^+<(YnB zG62-D`M3w3Q2;@X{S`n`{QO>migDpz0FK`->sYDOESs6u>-~<}_XN_6><2g7U#XC{ z$#Ig;n{_yEMnlvx-lP*;ts#DHV0r8j518>~33?Ak#jocW>uk>6V||p7{4rov#RS9c zdPD6r`qF1om9r!zS4Jk1>7fn#GCnmD=JIt1Na`X)=*LP7R!3XATgk`;&U*P<(0d z9p<0T&eYqQ9jot39FxpfuPSPYlfQ$s-*;+c1KL+cHIVcG5`H~^Ryu1Hk7%Nf$TCwR!SzG31@NHpm`mcp8v!wyWM49TjTxASJ-8JP*MTHLC}hF==PUOh8kaaXeGFGd<|e29vSDaS ztPeu&zv0^wN}Hahi`$pcDs~FVt2F;K!q}q*Y@{7i#stWfU`u2La4aerBKhV`^zG~j zJWvtZpcHIP7x*tfLSQcng6D(`HVp4=LWp_0Xt=2wEHjK)!DSz_Z?5J@>awRyk?azj zU-kdSs~cp))*pfJ_q7u`IsCq8F|OShB~D56S(Mwwlt?{yURE7#eI&WcpVq(@9Fd~g zeUiD!a4w51Nj(YzLnau+O3MDub|?loF0=<#jLztAM>PruE7yNDD0L}y=Ayuc?^?Ni zf~%GK=iEhn2}xKp7GonJx!JpDmDsco$|$XtRdUDwbM9$9s7x9-of2nKNj~?b@UOKz z9{`=Irz^ba-c&1vSQxSh;I2`cKc8-4)aCy%#bam;3_8vSJ-jw`_}lyukEC~z00EbC zI*dU3F21A)dSZr{qA5QF+{a%D`h#?8o%M?)*hWxuqnQD(TpcmfNq&UN$BmB)0!r8) zxno@Q?$_D&*4(rW6b+?-Y^5|*P`DHmJ%pI<6*yP)o}2^?>d7P#bd2j=vvx2mfLW@R zQLD`%buR*}nzNYNf%68w-D$7%v|=bXg1mYrdZy~}(@RRZ-U+Gx=nmCjVxr5Ag# zLw3R29-MHJl|`mRxj#sv@EfyR#-q>BE-XFEENbV$#dWM?!VjU8~kKZsd@G=HPrI{HiqN&j<92*-3$^M*;n@rG*i! zvi#?j;lc5w>@+r!6*CVUrN9as=S3?(ZBT979$5R#ZpPm?2VjIyQcEFp9orGR>f;G? zK<~FiYY6ow-&}|v7k?+03TC++so$)2~rN``u z>N%j$AbNQLX_!evzG8abf=15260vIXdz7K^a$YS)iw{@x5<|Rr#ii|ov=LJ{eu>dZYe_ip$ZuzvRu1dpjQK1BvP zH~m#t=2_wy>9+YkdNF-z` zQ*#7=^r%R*pIi2AI`>n9>(QJVE1k8?Ilav<)NUjW^O$}^yZZ{_Uwn!4Fq1`aslX;Y zj`XDIm`E1sz|wShA=?a@ZGKDSMU#Z3$E!1nZ)g^Eg3ZDoSN6@RXrGVCHvMIauS7d> zuJltXf9)LdTWdF!n%-iA9b#2$W#i??K)zYho^((ZqluvhAr@{H{diy0%@-~VW zKYC|2Ma)2^=skdLT@ZVqJfiCDqS@~qIGexL(BKy6Aw9ch0hoHN&E+m3*uka9+AIh3gTWdSe~W({-&^oFw`!j7$DcsF$7`pO?kRMK<9h=SV?cmyJIe`$4|zoI(6u9#qY9zM?#zNe^!Dl2>Z^dH`>`wSY# ztU;V*+g0R0DH6EnJA$U{QL&T~&s{`smeC2I-5mzv=v$l@iF;yN0hMibU=CG^e>J;+9k`Si9PzLaj$>}QKI6lWmO_o+_( zmhxA*0|-Na`+*J1qEMIXZf9rb#;pcOw>EDeDjb!|GumQ2!1ac;YqU|X;F@l1_lemzTN0J|U zFJF(kO21aHg)*KfuKT=BA{VDkOvlx(b{f|A9D69_BHUm#S$F>~`Mt@GesjLp3;reY zP~q>6Tt;`XkjqV?i7lqPbWGh`y<7dq<}pDHl-dDA4QG6`QDq)+vq_&HfW!}P6Cp4d zt>Qnli5ri*I1ILEOGD~3Y!@2^Jmcy1xDXmKolC?at}_6;neEfca0rLHT}NLpoUYh` zDbCtfZnYN&>}m-(F{5d1=)bBuZ?OcP`GmsQV@kn%JMJUIep`Avon#8=ATpEo-@hg& z12f-)R=HCD%pUjvbWa|P!}u)=wInpZG*LHKrZDMeC>Qils^IyY)x;kDRs4c3!DDOG zAptSsf#1X>kSli|Qka@S)6O4un-2aKL?bcV;$*>KSxHovjrfZ^-+c#>;(42yj71K| zzRyFiLrwv$rPcNA{mtv=o(*JDA0kS93>OE0D{KMJzLk$cc_5dCLWnJcFJd6_>BpE< z?aW9;^!;arQcIjloW&YL+~MkNO&a>N=pmhg>{SM<@`a&VeUA`ay*P@R$_+WS2%r?_ zs&Z%c`>ie+%!I=Lz>$9$7a`-`hoc&*dl60^whsaQ;~9~@JYn1Oc_bmgVVyAzUOYgZ z#j{`#D_YZ)(wa5;qzR#zo4a|-ANJjBB90r4Iun3*BkMxw_Ti>SjhktsmR|BPCLt>9 zZ_3eQjweI*-8+HNt)$9^s|+10w@sU!PY{`#BnF!ULS=#{k0Zr5`yOS?p8PfWbKT`6 z@T+PeRJ4`fj5t8bMs)0>o9|C>mBTlfQ*nFG#Rri-Q7}E}+eaz`LmO!`Y_pHkoAruu z`&!5VNnA3IG$}Pz)V&pt&AF!$E{J-;or3vWv3&Sl&9KzG+ae73Zf}=aP*SCI1{?0T z9SAC)W(?DSKOkcmW$(K5Bl?c@(5#>J#j@eq#ctX~$TIjkl>Wrfv%Ey+bl1Z-v?NxJ zwZ9!ae-MsHPUx&_W22?9$mCE%&~lzVG?hDXM%~gXGk+Q!Jf0BspkMWxy;^!n<6JIrSYjv z6F%~$8)0^qbUho9Sdf97b_n({$;|XH9-RHrohHuPcro@03KEPFejN&q?&nJFoIQY; zSI#uL6>2^^yOR!51OLO65xGas55dPG;3=uQ35ZYW04#+~byXQf^7Vq`G z zKpxF`G*X(YOz2^@7i#D+s-~A1E;3&x%%qL5hkiy^JhYjJ74{hvVmAx*6BH`M`!qGC zO9pjEsR)A-n1`6KLACSL%FS_Kcm+?4*z-V?WAZPs?RkzoijIr~I+oh1^~T`q^dCFvG$Gbd8AnTYBjLKYUmayaQz#S1le7Q^Hyr#;X&h*1wDpm+gZC!rSKom zq|+o&UGpeXtlQ1;?@JukKG!8PGS1Io0z6O}ZeL&DsON^I0K+>Mxv#ohK+;ByAZ`Eb z2orY{j0Pa3edA(#-pJA0AaJ6h& z81Gl(pd#j~mrizktoid14K5ig7u8FvZmLLP%l@dl05IprCyqDB?mA2fc*6UB+49lb zZ8`V9epdo=OeZoiY%zw-w`8DNwTORV_>>3T{r)1-YsGSo0E2s>tix9OBqKFBjg#}G z`pgkCblKMYs!Z)r^(qT_c+}gLhR|gnq!1~Qr|~kt&2@_yswx{i$KEn`8J1W8BGljl zr@GEG#W(s#AKKyuqLp+cl1C}7%`m#-!$15XF{M(M*-fD%+i#mFbP35jlgN3{8#A-dmj&OQtG)!031jTwGMal=&YtPfq2AUWekP9J-JT(p099!L`+yen$ zVH1?kRrhV7(mGKkm_jPP_U@Xd;x=ppk}4WY0Rbr> z0MJM_;$GGxL*P68y%KBqHntF{>X&<{aeI4m6+{TQ%~Zp}v%Pujr)zg5mV;cFKqeA- zQm5`#Sd{B6Rc*4PS-rO(vf>YEdXmOK?>K@`L5}|9q}#t_IE%g+U<-1qw3mr5&v;2A zCQ}BEn9_u;;>n5N#dP0RhCF-_UplC+U(i~Zjh>U5+b8%@p3HK(R*IMQwE!uritb}< zF)AK2?+0@-aE3LYkg`B*&N&m~JWB9>(Z>`aqRwgioU)0w{U1K4?>-#i|ZfhNa9hV)2)(%ch zJMH1twoeZWwkE@I!dz$ma+;9GeACv>Ncupl@+gBSeU_uzfj!$+h&@EACkZG_vwLGA z(?^;rcJu1$5H~xI@6lHIYC-$+b&hF1p`AoAOKqw{t0Fu#X`OGt$)7Q!nmJ=&)xjq@ zHoxT4pcYKSPT5(4yzIuQ^S*N2NJpR4v0?rB-^JuaXNLis?E(l>Jo8mUw(gsFLLOy? zEszHWGaCn|lw$LSwoj{G7Uq(zK0W^VVWu#ms8BMRlF2z%-g`fOXmndgC(na8fc)s` zz$GAoxP+l|+T_S4$r1sLwkV77ew1Gug*`|HiE*?FGLm1q; z^p0A0eqqbmk3?|!CB9DBN1Zof6d7+ zJSn!`VD~tVaqy<*Mw^8dM5v3Bvj2VdVFb=)U3L2eDM3@>n(P z?Rr_=I17+r4fE{>1LBQG0&o97nef67n-aNnVP<{dd6*B!Q344 zZbsAof&jw+;CLeK2d87t9s~YZ5?6Qwf&{NPEBN+)LbjOcZRXNcR&h)x`TtdpI+b!>$E~h0o1L*2OddpR9!Gw~-E^Cj(7i69S<66ak$)AYMv|xG+;uR(`;h zGIV3}?+Qxdjz)s;s}jHY{JPmeo@-tN$H@hxaV@)}K?y~ts~E6H(F|SlsN5oH8g7*h zGiC!8c1doE3U|D}Vul1yPmXuCk*hmyU4MG2ml#V0+(G5I+`L_=3cD$%$I=@*8m-LU-!fn&-sZO1%ls63+w}AiAK`Jv z>`q~ztr&&(gCkFpci+*1Ekdv*MhBCzGfPBj9dM|YEjZk(tWBuz4?MGeq+*)t>Q=z6UXF_w z{QDUT4^JQ8J%hW;d2xGB>Fl4Y-bRT!ttP2GE5jYoI1e(eVK0&V5W+>zludt=nf|UN zi1IV;MK$Fy%$yw<oGeW?JIGjmfGLH$Y;l|T0p1V!N*Jvu zHSAG0WpwPip0vm7%VRq8$2O2>P5b!WBfTz*6dZ4Wd6O9Y(8A;nOuG((y?F`ac_u2( z#~17CoTK)1G<~~Z4jXlout{e&nZbDHyHf(=a?OtaJ(2Q(!g#)Ugw-QQ?A?mN#yN%T zBtJ`sA6Lpg`k>Pi8a7GssiY$eG0Be8LCoQL{GDqi-;j0pLmT!Z)szldvbN7GVcu*S zzb1rEq|M)1qa7rM*I8!<#w7FnQ?{v^? z0`MlS3+`#ZB5$DT4+`7e-Hlp_2G0`*F@STbRJ|!tk3cC~1T%NR-p4s=sTT+RqsMjF zyrp-Jv?CD4Y3N&Zb1gr=%`MFR8;|r)uxQ6*X{OpEhQ~+tu}^n8Wijiy`pSMw0uKNi zSNX^Z1y;WirM0o_x%zft0U2GcLm_2BS`b{Z>g|9VOVr%QF*R?pTpiJsEbj4jLVAyd zTA;x15=f~b0^(e*Vo;Tn;WTJSxpI9LmL($Lxob<^S!k7mGhnnVNnAC*g!$ms0#Q|q zs=25I0<>fUw_&+KU`}5P9wlmjRWdMYh%Np6n?AAHQ;JzG?s(Z9UR`pNh79Nzk~DF+ zX~jy>>f-2bl?drlM8 z3NfIQnrT@pLmv+QA6efWPv!sqe;mh3_RcOj5>Ya;4hhN13dtx*_TJ-=kX_kZQDkPz zIw}#e_dK%au@1*L&iUP^cfH?zf1iK)tHv=t|>-9mMT!;;Vg|svSzWkN7q#t$c4N$Q;tl3EYwef_4q>GO<#I89VhY;`X*hz$n*GZ%f+;uViG z?uLlxD1OIeid}0r9%Ssoc7@vJjZIsZlU9zvYpjhYiOrzD5sq3OC zpf-X;Nb!DLpxqX^zDIK%=46-Z3%i-bac`RIBS5*wcw5Pu>G|kF>TQP$dGRYh#1hwD z{|cbbTOKL>Gb1-;X6?vWLC+KJ_^Ij?KzJ7eZ?^8XNgoYU9^z&>d zsIjX*uOK`#Wu!`>L@y!=XpQcW+mBaRjm|XrB@etLdr}Ob57e7EkE;7a*t7=M#XFL6 za;KHHk-rBNTjp-gS^;ehKNv>K>+_jPQ45J%4><1HyKJ?;T9#~k_23?xD}B&@Wp{%H z($hU+nWR?g!9dsJkgVz(J_Yrdns+m~9V_gQ7Sb`&F4wZZ!k}##j$>O{4{?avCbCZfyW zO$)m7LE=P?$CXHDU_RUD+sYwT;nKI7 zSs_XTv!BuxpJ!7(b~uYfsgzt~mj5(vf2r~`LHwpePs!o2A3zEr@#sxo8HEe8>V||d zBiz0@e&6}p*}!6jsm}I0bN9Mc2(c#jg@;Nu6!Kv&4&P8-UcQ-00WJIO%4OuUn;^jU z;I3r=T3KQtiMQ7&x32eVtB`mCe)9ws^7u%2P`B%Xc}=Qc&O^{FmS^{~Rho}^s`B+H z=1_T);9LRK?{$Vx22!5m)Er8aoPOA8&{7fyt`t@~Vw%gtx~+g3qs8LFR%(2Uny28A6dFYnNQgcUa>Sq=%alFh&8#@1o_qgwve* zVFimnUtL{4aHP6s?FB%bu2SP=e*VGqXC8iuZ-JOc{5%Lx0g|VvyWkdh&FD^Gkc!0N zhoolXvp6GC8wj?Y+V;r*EN+<1ac`-+!8Mqb@Nz)=OqV?4gxhR^t7*+^+AfxxVt(n{ z+fkk|-xSGqmkZa@Q%`;;r`-Z|? z0fR6b@l%pTwK*@xY+(MwBUwf^z+F*~piC64BWTrz}-HS1-XF-IA%?Zs_#F8 zcmUuEZ6Of>YIJOe$&{V;3vIBw7|jSGPeS6cvTMdj96Y~pI-z7InGW;(DhFqaiTTO9@KWvQi9__j0btLZ9 zAa~-Po%^sDFfme4@Yiq}r`BgnYK2eTwCjg9_zC4V{{&_GTm-!qHGVR6JXDjw;}GzF z6lXA{xo1+tQM{9vwb1&sRXPdGDHbEMbnwh}t+%tvcw5p4J4r#hEpDl=A{;Mjc%0)T zsG}v<$^HhdcE)5IJ^iBWK{7?Zn)vb%c!5eIj4 zbT}CGO*u)Od@^LuIC@_2{=AP2-O99NglFudj{!T}0e8wtTQcB@F9QW6$J!0Ye`T+U zXDx84b$!hD#4YzSyZLy~!IIZuFa3%eU zG4eg5?}sZ6Yj29P^-PcXG*8%VzLL$0!oL?c(!oQ+G!kORsa+lsf5YER>PX83R4LgF zgPNQJ#Bo#)MXU%J9k?RWD;c>|as5b5p>xAwau=X5XbERX`_ZHB8_XSNDe`s?n(e>) zGF$G%n6o+W{6A-@4hsIK0*J%jpB#Y*G^B48eQD(CDZR5oBl-P=)r7fH^PLf?!aK6V zwkIM35?l*I6p@;^H}JIDNs-fF*IFN?k?kj(M)QKM%%?dSkf1d$Nly2z(>)oq8z}0H zH?Qa{x&36#W@y04!9zx@x7un@ob$&)V8#f~0n1|jF0kFs4aZ{ND1~QjWHToIY5)LY zrgKDCj@dFCx&-w$QMi=CqD*=`$NqC~2k366pPXl#>Y7A=iQD}f`)+B-pS@LIW_M?9 zlBS_)(vGz!L$#P`?<3Hvonw@B1uJ244y)M?0)z0-hq++sJ0GZ+{oiiH;lFi&wy(C! z0Bv9z^M;`4@)USP)7dhg@K5K&U&|7&-@I0Sk>I+ZH75_xEn>qh9qmc%aA@NEKBsVBgUuK zC=b{w-0oU|)~tAVI zyJ3BAB}%rsjz7qZ?x_XCWe6!_u-{e_3u68Asso0IvwKdxq1lN#%4w>J zi>}P;$JZ>58(ZAjsmSJl6BWUTe`0eGEf3f_yS#H6vx;UJWO7CCK!{)4C}`C$j5gNj|k znb$4QRurEE3tPEe!JzG-a0DmvXePO zSD#Q-qOAjTMm|=aBSnvwHoEbgyVIz@J$hT*legak-hhb}e#%cm2$nR2 zV9A{kc)WT$np=5coPQIskbGMO@Fn2NxPv$@SJZdG6}jV;+%(cH+*RFQ(+DjsJlman zy`D(yN?8MCtjWD3w}Q|jQccb$}BDW%M$zZZnri2+5ls)@@(wQD`jt_GpTKL_^CO&SSCcHbfMX#JXYFI^*947 zPh&S-G=l*C@`E5CU1$m7ao(Q&oSmY7)ZZ#5_fEyYzLsFJwJ%GfErFeRN@7lUbUrL| z$6;gQSNsI91LJvT+$Zb0>g<4g8T{B!U05lfKmoSRH^pB^^8sJ3{8PzVq0NeypMF5k zU3qOqksdq{>AUjm3O~dZx^vS6C$ldgCWszl?xd8-sJ;-kPnISB*-f=L*8XggOx$?u zg%B-QovSjBbj}%sShZv~r?`*6PiiQW;nee<-=+y4}S#}q_BgXIJoSOf$YbE7vXt4;Np zrKzZf6Ny0aES8(-cqmnIGMg&ieYWryBZ0VTB=4<*@auP4NdIk&q(Mt(OLPm|Yl za!0OpC9sA#tk>OsaCSx0;!$5r6naw ztzLBo>#LKaxxsO=yWe%yGilL`A|6E#TK! z+1VRQlo*D?(k0-mlRM+`OMT8kVB*-%ZGv}Aj1u^j!wu*~>L<-T+u?6sX!3C}lQte- zk(6_=iwXsQ0JbRvJDwMnk!c99w~s~uD_4vMB=m~-ft-*|z~$*g4g;pgG~Ap1m@@Fx zWS)8IKSN6`^vVQ8hv^Oc+O(Rt7!U%wVsGP+Y6fyS%GG+v+dIdVfCXPzAV~~li+3m5 ztFQmbE)(#2#Oi@k$1#zUS6ijD_yYsa{+BHZAw+^zAEI3bc(h0qm?|pNf?oS}Km#OG zrOfCKn_-CVO;}DXu|5YE#d8I2o>}vUxYlv&>=+I28WY>a1;uI)HUM_IvpF;Ln4ROT zf!=1rpKihNFUo=R@sD-pT!EOm%%ncl43f;aem^;|A#s3`b6vjeAzO!M-gwc`-Kj~{ zBX)tq64*kJl#TrgW4o%hTY3x$P01nD6a6s2#MmwM$vyX5PU|YngU*wXGK*?f?#Eg$~^OWW3I@of-=XVuu-b%A1Z|nqY_2 z;~jD&=QnB#WGU>;RwFq(I< z34K1fCMwf9F}G%k(&?~2EY&)W*-_z0ReS$;7+I1)zz`)M zpAF{5ZHLPMJhYU z;GE*@hM1NM{G{L94dL$!Y-h6A9K9W=I6AYb`Y=v{(tpyLQz^^Aibea(q()R*TU|-m zozpyr!|-BZ_Dn+$*2|vq2Y@ghHo!-`WjVtU-bab(SJp2*2i-}$UP9^qnF_OIFS~-< zYj^VS!)Wu}vn6!LDIt!HJ1SU-@ce>z8f4cT4R9V@O^Xg9)4`VpjsXm*~@%l^Ux;Rf#Zck`BNXu0Y(!C zj%Z}UAmD00nsOS%Uull)dU(fZgJ$bo>3Oa`8h~Wt)EM?v(ndlTS1p0|E9Pg>=&>58 zghD~%R;YpqZAw;F;M(lx5b_wkVbnd+ER+6A-SYj^1XUgNGn0I~ES|f|5emjyPIW)S z0z8i6)BZt&h(qQxih4HbFYa6~jyeKbc_`QEdLD@9SBGButjw|b^l*oQjDk<7Nig08IK zb`ATVGzK%LP+>9aFM0hr8t+m`uNr?h&8o3Rp$T&ql||K}7GgobFhCViaDH~+F#yC- zt>7T3&_PZ*feTKTyd6vlF~JmEA1f+*>CCE4ex}5N^$4o)YuxX&3T$P0(IS!+kan^J z_p>v#1J8bWELml|S02YAQe-&yVew+kipZr~H-I@yc$=8#rZ-8L<_nDx&Qv3dJDwUX z!)@=h1`~R2M{$J8bM^1O&Gy2oxe1T;K?NA{iv_eYuhpLyc3%xu%z`dVc}Z}%cHGHQ<7P!Q|e?dwnSpL!AUf!B^!?#^Q#W!Ry+7ofwPZ1mZq z(Id0{htmX1W?2cAYWZo_lOtT#+Us-nlP$=CGK|Ri4x0Xh>(|iN9y1 z=9y26A4Y}ViRi9Fxzm{>J`YM>GX1D|$4BY9xJrY{oY2~Z&};B{Zq9Pp!pox`8e#0C z-h~@fohA74(#ws!{7kIe4v6XUX<)9bd)g66Bz%^Y4p0~OF+rY;l$v&7T<3~4y!bv> zR$r#LblZcVgy2lq!ff+>yuR4qCcljQa03x|dTcG7`CHcxh#POtGKt6ymNd_0qF7Wf zBj_KC8{jl!zZ>0neDp19n3sD?HC=|WM3!}cK4zCnu6Uoj*hbV1<#F2BD)@A~y%@VXx+u}Hcn=_s-({PxzmMZ^xJ1SV zoZMY*FarYvO_@z8Lr2ep)%HgIL7rhYa~#X&&V8oYSw zA4m{3{hw1Vb~~26K^xro&e7i9eg^SqK0i}kG3z(!_~E?sjJlSWIWXJqKiHAWTG*SpPcCMD`kEc1gx`R^YkYWz zEN4vEIkj@&e4tC!(_~x`-K$w6CU%X7U2Y z)Y}T5stEyoSsB{H{+xfST3tov~6@lO}2gx#N(rHXiOAHT!dp6FiV8V)B4{L_P_% zmX0rPa^-{1xG6|#uEGo+!v)QAOjRe|jg2ICcXU!|Cr+LMbLHlhJ)ErR*P9*z$NLlt zmYjAUbljq004ZyOco?HJovV7M*Wb2nF8vT2D;3kGi%F)6Kr#TVW>}zTHnUQxoGmD0CY9J`|d%8@}n;_co2q zWr98`R_c@PQbMi}x3bWo4XZj{it6qYj+o*XvNoS4>rF;7WNn;vA*|A!3H}Wh-uk@n z*hV0S+XnX;K;BOoz?&*9_{NnM25s4^^QUt|>R!()^Z6#G3OmL{CU^-IG_M7_a~B+& zCrV;ouC1ljbK(K=ygqAE_-}ewnH2&&t0enS7}I4i0wJgNvCf|P$`|DHku`K`HfDa2=n@DCg8MRi_)vpMR2Mxy4PE2Qe! zD||kNXy=0WeU(43v%md9Hg9Zu#CP%d%C67gk_#pfXs8lf>M=betm(}0fdDKq0{26# z_c?J!Cgo-~*=wswLXkR|W8d+rDdV00`22Ouv=_Hod9bmB!=D$I4r@7DZX7e+0tO!9 zR{0d}A6^K#yRx@ykotO4(WUJsmFvN)d-o-wZ(wcDSUS`8jO-JSAMa4y@MK4fDP`(P zzxQ2})ofiauWKj9{Rm$Yw^?g=?`oO(Vf|T^I+-A+o1#F`>tn59d=FtgVJAV=y;G&` z0GMvtEeil5;e$Ln8-41(UeMl2kYLk%vPl?0+Egg_;g)494o5FsvdeZKP;&&fjw7o{ z|B+e%Z|)8Ts?=>@p|hr!nYXgV=ZjI4Cp#$E>+g^6r7Nd3<>-t=G%B5IyZUI{e{49G zqnIXEB=M@5Ndf1J#l5YWcLG=A4ufF8S{z5Kz-uM?Ni{{%mr);=l0=473h#cIc{K3> zZ-VUw_Ng5^HgWQhs5tQU@qv-YBej9`R$a^|lknX<*+sSVXue8M0#EPBJ6_Liwl*8l z_zoD#!l%WIXJZ$jm?|zUu0LdeP&8IW*(|39&QzKGnem$6--u{ZGtHt#Hro*h)?lu zXGKo-4Hv1WP*VLj;uA6UwGSV*6ro%PRbwR{@tXoCOb=OFTB4ru-|Id!rP5Y6LF*-D zy|t0qDSVPo$ffyoj#CIZV?l3VsPRYye$F^xxv~Z78_fwlCWbwW!nYCR2nx0_+@tg3C_UDMVa2Br=X3hfP}^Cp4Yg=#OK}K zKYVY`V9jEKD!UrCbSX6Xym2T-cg}!n;?;o{mM|zWj0P@D|FO-rQ zKt#ApEh#AX%_f%9!G6`I*K=bSnMIhQ%W5&BOMntzVr*eS;WR;FgM)+k`#+Vze*z&V zkU^I-R|!Nwy<~>eeQ~hJqa2|DdpX15kD=6U73Du;T|VarycBP^n#IZeIJ&H3S9#@oec~poZELqX$DAc>XZyuIqd^GK0Jq~0kI=d zA7gMo8%zmkEdnqMh)tkp?V0I;Tm3`>aU3^~dXw zlhdd3=iygnUgYu#GRhxln}4D?Gokczq?T;RjCk0=fUHy18$lt!-q!%sNxee7No^+N$9d?Es*``)0UJ4SC&FNY0pf z_MlbGdUy$|F}YDvJ9GTCkZbsNKj3DL5;=BGBx8xI;n)=A0d0j6MP7Mi6MQdk@Tux2Qy`oI_&*%EQ0bE?|R>P$rDhcFa8O?JIK zPOpFDa?-L*+Q7RrCg#y5z$l0d>n@+OYo3g>-Z*x&`Jj5|=*UOYaJer6;FAbdtt0O? zrFGUE?!XeUG}G8wMgeTs%+r;3uUU;Nq5EuU{h-g&UOBKhdS`;J=m!~xn*ztv_p@dD zR)tR!P=~5kX)FRsx9)uyuu?0dh%Ht7`PTM@e#Cq!z2ts;O;L)tQ1ipDiWqbGz@o_p z^D=UKR#`S7HAt4vQtD(_SeWyj_av~#tJKlb9>-s5Ykuzx_E1ZNl4)~f=zG$*;-y=T z2ozmFva9az<{2&63fQ?(Q8{IPx@t1LuFcxP-LXVctWh3AwazVTt2)w^*Zn-#eB`bD zSHoAusjOBK5(>uQPGj=ijdOH3jqG?(<5#C{*JQ?Lt~@zow=Ii4Al$Vr!#+Cf-gx)A z`_h(>b@7?*6bYM8%628gGW^rwWoG$mK_eCk`}B&llStfwHf12*{5spmTeNH$4{gCY z@Yuwr*k@%m;T<60bw9z6^WpWi@Bu^qe-g;YAzI+VjgsuZaGA=^G*I{KLy@rIjSpWb zFQNsCp2T;S$VaJtZ<(waRu8y7^X;>YhsWp zM)mKgCeE@K;J4vQSV z&-(Gl5AJCp>K*2-`U|4i;u3p8xo6(isu-38>cY zml1Eo&FBBKJpour?}q&nggpFiGM%m+YX`ng8P+uRnJiMyWcv*_AZ8KAB$w;rfmN8C z<-2EB6TqZO>A~P{*<);wYqZgxQS8E*syOXvGkGxF@s(scud0uv?T)fQ z(DGrwM7lvpitUG~6!*}kZUpBn9PuP`5^nMK@($xI^0Q~axP5qU>L~uF{R_<9&m z({}$$WuD1y-QzMVb3jLPk`~bDJNkw(Dv-6cKUb4uzD= z-w?i0NZ2K}AbT}Zi^uOZ32xmSxJw+6(3j%a!~Tdy-@RxVx6YUw2|V6JX+mSJNclfl zF~SD#eo+lnB=ZpHLl{)E+`sI^-V1Vn!6#Ml_W4aH*Pe(++sNI`M=5L3?X1z0;CJeE zJiX5Mp6JH*=R9W0t(1@>>1y=lP^F=yJil6JxU~I}EpTsBx?rJ5LbCbQ zuLBmmX1MO&!E}khx=+#hCesIB53`IWwqyFtR{AUv7vJ{Q^dn1S0@*^UOmRwctFy&> zd={(J@avBzmu$MbyamRMt_$kfHY<*v)%%&nY4hUDH=$k)$8LHlUG0G3Kv#T~-vQjw z)hXbsNIg?~b-jRw)ir5Q(gfwM+Zk+0haf z+4ER%>T8RnKAoJ-(s&tu&-iZ@A?^J|d z6md=9C4am*v2r=aa&a?~37bc($n#wQ<8UGXL+!RtrRXGSj-2INJ#+3J=}e6nOC}G8 zN~lvCS@rxoq7w$CLg-wx!%V%ymw>~xhUw4cADX*$A}D~{21F$!Y61aHwpdL!QcrsN zl~$s5kk%7HWHkZ43%mOcwlk3RcbKGQ*}K(Fxput)rpE0zH0vY(EyY=blQZ`odG#hD z)~{&r6XkSE(^csqsaMm>2c%xsT2&g_Nab1bTY%fIoNHatDY@C@Ei~v@19|F?szU6SWRS)uDXqNY!48RlAb;S*ijqus; zp;bteR835>3BXML2CewOM<^q3M*ubU`}gnI-oS&(vf=GF|JJB-inGOH_dc1xb|iqR zWgrcNy?1*8)vAlAaiBE%K3Q>5Ygy-#Wf$>FqL|Kvgb&6H?iQC*Z|PN)xZJhH#d#=a z@s9O0oea6Lg}submzNZ{iZ*_okZ$6G*h5YO!dE=7c4=YA9g$y%1xjkVl#|1DShEjM zH3(sS?uRfB3mhW5Wrm} zrY>KpBxM&CC;s5Ie_{o}upN{vdb8x<_$5iiQN49`z`+Zz`&E`yLAim;X&}$HAfKmT zkO2Dgdno95mWMH~h2c4);H=MigT8hyzl|4g;dU7F;p^X>w!fa0zf{^rf?>~ z0w{=F_R}ru{g5i@&xwC%R-!-1x|(k6pSb5_)$f`zyErIvSCs{z`iVvU4x_znFKti!!av6BkRX_=+kEc;*`_rla zB`g4ruCJGT3XVTTrlh3Yj>1>PNIy?sV%Yo*=qaBIOY87_?P04yx6TV?_{~K? zOHEo3|2EA2JAMPYZM!H<{|!s-$r>l5{19icxV`Wf-{<0I>{v&H4FZaCy$B6Ludz{v zRH!!HV#JGP?5(L!Zp#}NlOODgWqjO+yo~+LasPYxH+ht2KjdfCFQr(oovP3?vkFK^5FvPJ4^LD=DpYQi4tUXuY1;erJaBQ79 zHcp(>mKvoD+)bq5SX9siR>(%CL??*D>Snn%p}NfGO4(RY^puLI+j$Pw)NZLb5bKo{s|0L~ z-A3R~;QHMg0bHSgESOM&N&@oF4|8gkPF-nVM=sQ;d}wcS{{!iW-)yQ``D6t#xlh(O zRF0Z@O>0uMz9g)u{P))ptV5lH2(gC8I5i(FDRG5Gp1bgBydKgxJy5gBfK(#D7NzZU zatG}S^z#KL*Do5=K*F7hk(`mbdgI1XoM!8*-};#UzNtEG@Nki#`7)GfV;VlfW^)=` zBaAjK5>gx@wf_D!B!2C6xBK^K4%x|+#?P@5N7tlfWo6xWJD~Wz^cnPfFF($Ixt4!j z9%x^1$on56XZB0Irm^kw-*rd1YVO;(*LbB21@7OPJspo%WO676#~oUMws(zP#+shG+$ns0IC3W z_{kYU>N5<_6=j>*0d}r-?8U+--eXfy2M+opoYL|=I932TMp=&k#tzJ^72OtRJ8BVOvTYPh;@EE=LJLeOk`y?d|Dd9%fWlhON^LnB^6x0LyZqz@imyogJ`$C@Lr9Z4o)ZQz>NCavG$$@e2#r3 z4I=}I5KgV>wl)~_Ja7gLQGju0c1{h%cV&6c`doWWv$>q*=ZLc8J{hBiKXNK?zx2Nr zz!pph;BLU2OaZTv>Pzj(VpSp2&OWNCF<~>NgL!nezhxEgj;&2 zl>z@V#>sykFCnFL?|(j)J3SFr|FFa`n@KbhC2pZB7 z#3>qIn&~mG_Vki=p8_x&CFeD4V7MvgJlk^G7H;(apFxr+7Gc0+1KfI6$@aeF+d7DJ~_-A|H=0?Da#&^Cqb=!=fVz>giW5nw=jWQBS%L^t1EZ@ zCm9;qlG{($@0W3T&l17ownc5pWhfM8Mwn-fLtb7H|IYl)8@QikEc_Le+s60x?&B*m z5kObB5{BD}gGr7l84~vP{N)C~3V;xhBWd%=^j0&KBw3T3-HU`;hqWA3OWW~<8nl-M zfYn-BI0_?g`3$_;&Exw<(G{QM|8)Kq28x9NF-F$>r@_BO)t^T*i-U1bX01<)zC_uE zR@8qEQQ#cm$YbXIUPVO?z7KI$pw@r=-V{V@>dC9Hn==1QBVy_b;#*jR+&f*$AwCl?o&G?2Uk4=*Ej zFK^Yvw*HTO9n!XRBWe++o3)4O!OC9PC=_l_<$M(W8(Akk`zv5?nJifb^rH3N?Hhio zo$=nNmSEz_QFHj|XF!vQEcdqPyZz_4|M_GBH)k)KA9XGRlTJD;3*y1c#?ZWkeaQM* z^`Bf04#Z)ARgrE4rMmlk8E5F=NpaW8xKNd3)-orW$m+kh(W12jQbQ7oi z)=#qbmhkplt}u`FC0sV9sdnb5$E!zX_xlA{4wW&j0*DCm`=1;Sh_sB1xiH@C89Z93;8d)EUk=lPNIZ`o3H`Vd+Ig`=CV}#?PAXvzWk{x96fn z0(rYh<>?PJ>Hd8v@c8=*vm+)>P1k@i2>yMaKw2nihLV6Z;wcdc*E2{8=xNh(FkEe3 zq_pc;ISw&}`?lqKx<4vIa67!xu|P}G$c3MDyg?u^InS?uM6Zzys0QM9ChW>g-ypzA zkOUSfvhTTWq{_>TJ{+kpgwX{@>P5ptiJ1NTO5)8 z8BiLUY_!*AJ$V386^TicK@z0qOPWP#Ea5?}!$_&fQ zOcRKuR^tLX*&CM(ahYftiNg!a=uU|He)2nU2(~iX@Yo|foZp906;o=d%aK09YEW7_ z-yX*;XE#z@?zZ&fQ?2fYX!T8@-$(K5Jo+AkyOM+(944x4B%2NR&avFFJY^9_br5UtzSX5@gmYYm@ z@S$jtqFn18bXQr0IYhQ=+2~ZDB_DRW3d=*B+3q`-*1P$i!GVIG(AMp=vBQ#^_mNxp z(;4Iz#_~&9jZ}}7oW?R;_x8&h?b0N326NJq4~>W^TeI^!o4=G5G{|9ff|`NN5+?ns zL@IWva(*@PXPmVGQ#rgIOY*nnoqNDDy$hd2uMT>wBgzg>YT&BV2U{k1ah1(1j_v0` z@o;6~SUGW=!+j!oa9ko_2^G75?VolPmWk=Pb-h{k=phZga( z88Rp7QzbHkpYG!aug9e^DF63Bi|1#CeAW^CpakO9DTT!p$yhuT8Aq10^cl2O@Zl-2RXr`+zCPj#_FqXs}W2{Qvn2Y{BmNsG45? zB{BF_rVgT$u0 zE8o6|@C>uOK1Ba}!V zx!M$9J1B7#_JSs90cKlucib?T&HqQpLE9YV1?v{gh2NWKEt9FX8;3DePnCL5Z=k)Flp=?-i$<5H4zc z`?2ZZ+p~Y8FYr;m3Vn2(u5Z`Av6#S}zkpQpZ|vNP0DY^I-oa$HXzg+ajQC7%wldRN zfOAL!UwFtuphqqR41v|3He4cQF5;UU9M~lti-k<HSTs^#>-Tf|C2&~#m%6WZAy1jz!Q_-IbpZP z8ht8}UG13lz+N-7+01+RlE)6OT^3px7fn@1|_b7^{bhPet}< z_)77(<^>8-qQ2X(n4faVhm@T0@Z{5HFSWs~EDXtV@7IAMbVUP6;v8^%l3PZ#wOZ-* z*Vk4lRj6OYpAZ_$*`t|tYKmLar&&{5{d+5cst)rQTn`n8>Xi+0zXc6YbTPMgzewFg z23F=+`8=FXXF6b*CDVN$v3|6iy;TSFSYh$qrbhKDcT^U9l zj}3g#zty{k*>s8S+>t|cng#3@Rz`z}njy{*?90mV6_Mkvv=iL9pb0ttHf$7;TxkX1 z-klTGb`2~-Mxx6~+{b-KiFd3XG`p?+6-0PMorB#Q@TY_CH5)En#5WrmHqj;@Fvi1A zeGpO@wuYIPOgRY&02e-U+j7!$LZ#5mS72R3MJS^gfheL5`kQV_n{8}KXaj)V%4b~As zFrQ7yZal}~{ELX@8c#V?2LlM@)g(|;VvcBjEuTJ=`WkOem{DL!+7Lr!U;F!mGm_^~ z+V^T?%bz+8noq9{ybcq16Gzd^fS2`skac)@6|;8X8l6Q19epZ@l^3@1ES!x2XLNA4 z_FI8#x5sq7hXVr83D;_5$sU!*Ye}zyx1wMC?Q{DSgrUx#fM?_Fj@{syA2x2yL^J{S zPPLkQ#O+9E9a^H*USdriL6rGHDt$B!vu~t7^)@_e=(<|SVd!MenX48AP(Z$4WoC9_ zeN;I;hEAr{ZvB^gK*1AWfI~5H0a{Y#2UBjn9`7;3JDrI5leeufemoZol*pDlVTSHP z3#8@6kxsJwUFg9(;)>Xm!{nsFC<7}Xwv_?o=eP)$>vvvj>yw z=YS7{pIOg(u@mJ%G0G^TM@L6>l)?_{_e`(yLxmX%h*D zMJS13@e!}HFR{?GNtq;%=4#zUgfFP^$g|Ax1<`vC&qIPbwGNo}3>ZM?=Evk6r|J&S zi$UD-za)A$kcqu)8)1mG z{FI*zS4{wM6S3;RP-!$0&8!6*;>|%T%HJxZt}cmap#~4vD0Pkx22gBbPo~=2iEMFa zSN<~qRz>jf54?e)>3%j;Gc6C1_YO0C|CDQDt7+bE({$0($tizZ)xn2L?@6_ zR3$`yiwH?E%X*^k*^oQ=z!1GA|E&fXHPR=rIEGq4%0=SGvror2Y%k#d`aPmx5@~7a zdkmPa1d-<`6M%& zp9rn|?C(5SRowEcasXoE$)s`=GvJk9wPt|2VX31T2F}6x3#(&IMqZND*a1muBh9?X zX_HSLo?$y$a;qFx^U1W|YAd%)Gaf|AEHqZ*{PW96FF*&nO-@c?c6t5=K_z@2f$8<^ zY}d|9NRviy7sF$61>@bV$B3*VeDg4DX3qScxVTL~5Go^T?}aG+th- z2`EduJx~ZcSssR;yX%oW&ze|$TF?;>HGHp~Eq?$w&SAD?d#s$$|4F@l*T7}X$7>}7 zRvPwxrPaLO5X-qYiQ7{P^4Ui2GDbq&DJ3Yu`)8zfMi1{>HEq`+uR1bJ4x!#n0D6_M8Zs_# z3mc%u30aK|avL-!XI&?{^%v4OXUr4OzaL*|-HV&M5GPx)SUqYMWw@Ex;%DHx^&FOD zncjYHD@AiYbGx1O(rsKW>Eg}cid)6bqA}!r!G{?x#)c?^k+q_uv%Xh3ha^A^{%wnpRPY({1LqK{NQy>!UjUc8f7x2` zgyLiGpsKlFO75ee2#drn3Glyna)PvUP}e(t6P z(8^W6g23+fzT5gZQQ^L-Yg#^P;QK8FTZAe)*|CKS6(I>8a2aoN+XEkYf2jAF!Zi3! zjS($tF@bu(ypeC>`IZtF;jz`F6A-Y7ZUQBuZxp&q4zHb9cc*!1`T3p9xL9`nWhNVr z!2lf=fCA>;1E&E|yfmrHqB#XnUCu28b*4#eZ{lLL(42#`ui?BO&uZj|d_Fh!Bw8g$ zn@2uezsJz@^XM(T{!CEw+EyG*eaF`FuTN%C zOZg)khBpDobCl(3ud$bhr>EdmuQ^l^Cic|y2m>LM+gsZGYKUAeJE5YUX9}j^JDoojv<}Cm&t+agmp?JE0%d#fo}m_cYogpjn5&egilTvDFz-Df}1i zB4)bXfn$dqb!cCa13DdCgMNehaa&${n5Mw&bxeKfNmHq%e{T_H@WB!H3QgFK2gNpB zP<;xkez-y-Lr(0^P^G!YH~WLut`0=mPXbVN64iv6Nd`s=eUQ;?V((+QU0&B4SF3*{Pm$AVrq;v&)c>VLy_UCe45VEsI@ZWM2TaB# zRU6XaLx0^H=0)Z!$rIu`3*s{Z!W7pU@6aHvX*vUuzME+!B5H}k_gFD)3=f;nI zi1|B!@iO%p;L{!JSEI~vyUByf_{HY=;RuAK##-h!06XFwxYi?xl}oWStJ*P{OcVe~ z_v(y8!+BaLQB`(D(XrL0ReKMn$R)8mU2@$q$Pq; zbZq-$IkP4V(`m}e<)cwnZLrjiA-X0@VY~Gi5-PKX20#Eag!JOw1br%7Rr}`(v@d!u zCo@&wE1SwM=zt~$K!eJ**9GAv!}Cogn9(d0X~BwPkU4gaWh?WVRcE3N?C%_R_D)Vw z(YmJTJ_0~fhItqHPqoIFGQYE2!~?aSRa{vjcDWhy5>oT zGOMFTWfL`aLx-!QL(9r?~D6y9Uhq=af8z!rqg#p zXk%gE-;=@G>MUv7p@P#ni@zP*$YQwA0Dlc21`%pV;p!_F@xI(^eA5&SZ{rU?^Wj}! z6Y%C^eMYilc_~MAwqV`h=I0;WA)MqJ^$IvyJ-O0)*RuLYjTL1TWd|(NbhIZ;nOop( z`4bc=fsxaeI@zc!vvYFFetFRKSMjef2_#oIzzPIxZ4oB0sxKOzX4Wltz#G@LD2Qr5 zm9o~xF;EU*_!O`}IigC{sU%1^$$B@>Fa_H0*>*1Amc^7tnKxcPpr8zZTme`6(0@J| zXfBE;0)lcuv%tqq05V8P2B^)Nhq~qdR|1KCfe>(GeuFaNc)T~zvma>o)FZv;sVD@D zynx%jpd8m<{zI zz44BQcmN85TNhy2plu`Nt$b;sKELSBpW)my@*ZnL{lFaD|7-8c-;zw*wh@(1yH+~o zQd6mwOU~P(B4CS|mX=v+F44&NRvMbQpcpDmU!|BhndzGgrsa}~;RGs*v>~aLX|A9$ zxrCyC3y6ZiciVh3@BH@t1LJY%FM8{e94DY4JQ} zYS0fcOC|N!{@iq*a@H$Qe9ONriBWJrhLhC?o5K2)!=~i)0hGh-mMd~RkqdIGCB(fU zy5*IvHssJ&gxudt>g(3w2{)axskJ_#h96qTc~<{c!`n^f zg+SOfdm8=UI!4%}d%RkXd}yWU1H66h)eDTsQr!qkcZE^zbI#F$k(dn7l7z}@YSv1+ zIcEYw{HJjfg()x7R@zQ&o;LdJ2vi6Fkl?OHM-Ga!%w}co(6=I5LZ>n{9pr~6!z|S$ zq_VfE7##n|{H(t$wPI-D`~L#((@V(MZ>p6Eb8k%4{lIGT;hZ9cg%~HhcbDCd%0RbM zs?uZG1wSL{Z0f+NzDiO?w9~XT^dWptKJ@M~0(@5*az*ZgabU465JN9eFY7vD8Wdz_ zlAIonnlivB;uDXov3sIgoKx2>G6a;@?v0qg;r`RnZ{4wMw2%}(e*c8k`R7sNT@>H} zfUU~mHR~8!4rJTHVlT=v3wz2kx&95Nz?@Tj8)s5E}t{|AFA=d_Y zOTqb{ATx>U``k~NJ2hYk3r#Gn1}|1Xj}jq!9%;{k(?9!WZt1z#{OATvapC-}#$LWi zi2R>~v0v6A<|?Eg)Ye#VyRyr7RJ$N4vFEFfmb1jHF(yZN^rc!ULDen>KWu(D9Z5!P ze(qg(G2HmSqyi2B&W`vo@N=3l?+dXbWn-`1LrY1^_mSilpKLLxQp}@s?=Tqw6Do5Pui*IhPZtaT|GAE&MF$;(4s9Bt5f+vbITElRv3( ze&@3GgY%ltiz;PZXq||TeA+sP9bc(#*G<2ck&zF3W?0$Bxit`EwvZb7jke;810>h3 zb}}!oS_xUbJ^$_PWrSlJ-;v4qq!@|L9uM#ALcMu|+|fni+AqPpu+CtjBrs#Y1jKVU zEc6L$d!2l-MgMi5&7?{Dfxj)qn;mIZudn7I6V$88%05A!PtCQTGSxXKMGh;qXa|fE zJBUmhM!}@e#A?s%bajm+=Ka1WxHZWaj;k#XT{T#;bH9c5zA8txVHEz(EeE*PP9eD9 z<2|evdxmVLj_n@`lp>6@ zy_ZTczm54_lGjPwPaq$dF1HdIks&Mp;%bge$QZnnp${}#&Z3)z95ei@b9;c=kJpY- z$G#RZbgyTi3&d4=3%+gXOSp|g^~^%K1id>re4gTka;7m@WA}bFo`GUbT8-n19VVdO}IkuW(H_iil_S}@$xy(Q*fCcNaD60 zxqsWK5lESLWnKgy^ci@da#k9^aW5)oLzbFxlUVBA&UM~79PF7=rW@Ot`>9(Gju3N{A4%EK0dPuz{=J_LUv|Pe^*x3eq_ExMNjB3?{$+xH^_Y z;e5pH)*~Lo@y=;b=P$Iqp9KR|j(>D-kaI4WeI&&HPFRtbZBMiQ^PwE`pF$Z7#(@UF zP2~&InXDTNx3`4)H2mD8yHl{Jk(|C(VA2vwY}3IRqo*qy9HvN7a!$$hlZqjmb6tZy zp1fLd^be5LmcI`_d3@@A`jLDS!b0qXVvP%y>+DfL86Ie=*TZ)PL??Lk^F};4=dwv; zPRBV>*)f&NE0vtjYHw@vs9l(Dk*g-}ARSciwv!f)E361d_9y<;9b7)PBw$3dh`AZi zAY4)BVh3t>;gR=s)nZW3PT_3bOLDK)eTZT^*m%P!HdC!FvK=Z=_iA>Bg!`SsC|P3u zz+oMr^PUcTebccFK>bqp475+?5RUC{Y7klp^p=Q;ZM+c8Zq6wBtH*5c=QHlp7wZS%6AszeebN>>_2^H7uuK@g%1{vF}DT>U{h`}c+u5ubXcFMH)fZ6-l z!y=qVN>jqgj)3T!mALcM;1!8}PDcMCU6<9?l#euNff${zE=b0d%;TcPFfw`y>zjLg#_WgnwatH|t}Y&WrR32m5W_AWNa`OqIc{ zW{_mX(Ck1psRCgMhJ*hXhcAG1ocb_kuY)%9rlYzq8h$K;X}=5m+8CYpJ4Yw6zLi%S zpu}dkAc_hVv>NfWy9eLsQ-6OzoBl{WAkRi|U;anmJ5dFwz(C9~-A(!Vfw z(E!S5ua;@}(q5GrIc6|PAOSPg{il$s$UBI}tk5xuP-VedGyZd}xqXvWvU_`{;Cf0> z5fN79T(#iq-q$RLb(of0ZA0lfepj^!a2-6 zv{v^7r2J*xmj&XVgZ>Wd=RqwGGe1`-Svll~bz(-y7*N1ooU5J*aY@&5ea5ss6n(a? z`N9l?w~=^1g2wLDVRD5ovqLc^Z#YRDFR+QYV4emH*fzOpzer3>Pudh??f``be>dD3 z)xB}1O6bZpnt=j(m92Fxq0dz89n>B05xx10QDL-YDz&e>h_u@9+RG)Pv4{2IYNiMy z8auH}j+fW*;q%Ymtbq+KI_r4gxGUeYJ>hq~vbe!N3%NntH+Dyh7I70!cu(qE_`Vp; z07NvH4Q2s#9;mKj;>umoviK|H+#CbgGq`D+QxI*$r6&D`yf%-M^{H;6gi4*j3?c9c z8$}NK?0I4%b?c`p2;SvL3*xY`0fe_KIZqPm`M%{DCrPUt{bS|zlhbHBNlUe7zcK}E z$L2zIl+z#Z!thJW!}{G&JAC@Pg`H(}GLM_m;uV}C9Yt(vF+F0Dy7{`k zY&v=ZZf?8^qSD>~2iP#{qQK632aMplZye6Q3X>dctS@JHSz2)zJaqXvFEZlr>9$oY z^&9^4pN`1EJcEw_wi@P{zJqQX470?WZTB*5Y7F!3#xJO^z|Gw@)bFoY5#daTP5OgI zcbKI$Ok(|9g_%#If*$3ga=U0_n%|#}eWwyeW~(19Te+!xF*(rd=LU(nM15;<7Z&oA zrqIw#r7}&_qgCdvS7+!|3?8w7JNRtHQ$~8Yyw(xC+n=- z7SQBo3+)tbg2NJn^=lukNOCkiEsgt~4tCrZ{aSnrHRMk@_?1^whFrEn3mT1NSC9B&c-(JrWu@FUhSNf+(>-_%kX#@LYnzq`^M#XX}(*!_LZCY za24(5Y$WH^=;GY^#0c{Y4{_!GPvm_bd#&6ypUpfwu%|+=UEe^Q+oe$7cXnyF@O67L3%SKO#rdayD^4^vH2hG{w%vp|_*jKf4 z=jb?40UP4S+Mi~(Uz(^cvgVB+r+Rt|;wnFRYcz(i=&Q14Ok=V-tTPw4%v&;ZrxI#w z6&rvLjj#yzBr5~N*7o09CkIE=>EWwo`ceL*@Y=504RB*xY#SY{)p3Gvn9zBL_FCN0 zl^axu8p~su8HpiDNi{%5ojAv1{0?t7*mflF9&Y_x4#)X(jyLl~c+s6*I1G7{zBI;tH*_ z94)o##4$cU4ohj~e#C^E><)3E`d;ftdwTQZpDmp)9)n5^+h%BE?)8LI2A`L!zjTBL zPYE&+#0&jDFc&4Tg}VC}E@4ZGyWbiK2dvn6Mpu!cQT_^6!RG!7)fE>V>?PNFm?vc5 z>A8gcW=5Xm2#LEW_;XgMQ$=Y-#lc|zs2}}2ny_4Kb%D@Vrtu6rOmUe!ph7;;L`XHi zXcDHc;OYbIk44?|A9-=Ml{Xap)^{jb5$Kl?v`CIT`bDXV*x{h+UARtzOd}#US>a%X zOdU`5^_P@lkQxB*B<&RQB?FgJOH2-~rMnXf_{5%~s&OlUM^i30FeOM{`XOXs)3_BU zEAyNr%bz8RJ=Cvw8y=)3p z`K|i!j$l~LqQ)kabHK}7WeyB$x*({t#cQWf98qh&X{R*Y--9)~g)?XCL>&z;v9#hY zTFY?DV&1fPE&*z}6Ki`Y5#(-eVYB;OzZjPSDnN%ArA8D>wODpQT4Jt}ah556JE+G_! z_P0uQ!qDhR94VdpAqajIOl4~>oTaQ8H5yXaTZUOb%cRAkWYV?KSNlTqgSM=Wgf)JP zz=?Q5f5zPEVO!NbOCbqEwP^Ff_O_`gdm67#U{Mp^_bKcq2IoO%zcJb(M5z`cjv1Ck z+!awNRhwjj6CQqu+xC#{UWo^3+h?6ymzq3r?3JV}<|u_9x=MWAm`1AqAnOsJ*@)^4 zr|`FkZlg{Cd!#Chmhn=_ZQe;~-DTUOv>)Tbmh0{z_42vWa|vNUO% z_5KA1xNHBgw0zjUH|s5xg$b4k z@Koa#-AFizrr6h2#$k*41tm7_jp$yL4X*DZcklq!u+>9E0WnhcOFPn7Vh^ao@~tno z@RwY)*+8&|Hpdq)`a=L*Teuw;_B@u;o!a!YaOO@bs-?*gqpm?nRkXl~mKFfF z+OVzE%RlC`M5-+KM_GXZ@9b;=2C(sq+R&Ko_RzZ%5P~kDieK3yzV4BN*{$E%KY;4k z)s?*vacHYN~u+?SoI`e@S2!9Co!cdvz;@N@{yj`0-9^8osR(V7PR-O&gM)x3owqs5oJpIwc zgY`#VzjI$V>YYDrIr8D;0JK<10@ycefw z;;oV(!gUR*xBg%xTl-#d>u(5}#jFrLKo}q0b{IuuZhuO7n++ zo@9)d#`(AT$mbW5g;c;&z>1_2Nk%;L?TIhfeK%PYp>5N<5wdihxw4-qvVsN6t@bol zDFgi~t`B&ZU3ek!#fXVE5Ao$7AwI+@amT_m2SclwQE{cLcv3kwhokq+!S%>Fe_*(Z z75)vhq@YqZqa~Hf$0S?T@nr_%mV%*aT${~4)6|(P@Bq_Q!VC4tZa`7?ra`4?oV+wSr2`TVSUmKS_>V@3%0*S#!+L=3f@oF=4k9U9xv0p1;Fx&}V;X2J~h zcz^}G3|;s8JyEFR*LB*fPUm+?f+ofnBQ5uK%NrwA+RV_~h<6-mw_wU?NGRI!zNTh% z&>ty6x8&gW75gdW)?p->&%?{*brS|k@b|(>&<^nyO55Pi_q*eK)=J*Uunw2cw--p%E!VXuDa? ztZ$HPKJ6$Sh7!UrpxVBLFSnpZOw$(ftvg!Nk1LVfL+FL(u zh1Abu(oCSmgqQ2IrE;Zz2f2DAD%T4XO6tU&)2IB}vV3{^xpz1MYFEPy_09RP2QvmA zIqw<(UaCnCs!mFX$+3sjnV*(O5)y`jW!*wzF-l^K`Bxgap+0Ej z@c^nf{Ic`6I5#9bcE7fwiiP8JZ9dr3FsD~SBiW_`8{UgFt*{$@qj#E)90JYra>Zs3 z$sCTuzOye2GdTO;4@;wgJK@!ij-|c--insluCR}{#q=D6Xz#nL6;`rkc*UzLTR%Y{ zN2YK;Zcz4YY=+|(0_?E=#~3U@I1fIyRiBF zIeWj=id+b|L;kSMs>NMfeB^(={IdrC;NYJy_$L+olL`OdOqgH0OpSa?FTRhwb<|%A Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 zcmaiYXH?Tqu=Xz`p-L#B_gI#0we$cm_HcmYFP$?wjD#BaCN4mzC5#`>w9y6=ThxrYZc0WPXprg zYjB`UsV}0=eUtY$(P6YW}npdd;%9pi?zS3k-nqCob zSX_AQEf|=wYT3r?f!*Yt)ar^;l3Sro{z(7deUBPd2~(SzZ-s@0r&~Km2S?8r##9-< z)2UOSVaHqq6}%sA9Ww;V2LG=PnNAh6mA2iWOuV7T_lRDR z&N8-eN=U)-T|;wo^Wv=34wtV0g}sAAe}`Ph@~!|<;z7*K8(qkX0}o=!(+N*UWrkEja*$_H6mhK1u{P!AC39} z|3+Z(mAOq#XRYS)TLoHv<)d%$$I@+x+2)V{@o~~J-!YUI-Q9%!Ldi4Op&Lw&B>jj* zwAgC#Y>gbIqv!d|J5f!$dbCXoq(l3GR(S>(rtZ~Z*agXMMKN!@mWT_vmCbSd3dUUm z4M&+gz?@^#RRGal%G3dDvj7C5QTb@9+!MG+>0dcjtZEB45c+qx*c?)d<%htn1o!#1 zpIGonh>P1LHu3s)fGFF-qS}AXjW|M*2Xjkh7(~r(lN=o#mBD9?jt74=Rz85I4Nfx_ z7Z)q?!};>IUjMNM6ee2Thq7))a>My?iWFxQ&}WvsFP5LP+iGz+QiYek+K1`bZiTV- zHHYng?ct@Uw5!gquJ(tEv1wTrRR7cemI>aSzLI^$PxW`wL_zt@RSfZ1M3c2sbebM* ze0=;sy^!90gL~YKISz*x;*^~hcCoO&CRD)zjT(A2b_uRue=QXFe5|!cf0z1m!iwv5GUnLw9Dr*Ux z)3Lc!J@Ei;&&yxGpf2kn@2wJ2?t6~obUg;?tBiD#uo$SkFIasu+^~h33W~`r82rSa ztyE;ehFjC2hjpJ-e__EH&z?!~>UBb=&%DS>NT)1O3Isn-!SElBV2!~m6v0$vx^a<@ISutdTk1@?;i z<8w#b-%|a#?e5(n@7>M|v<<0Kpg?BiHYMRe!3Z{wYc2hN{2`6(;q`9BtXIhVq6t~KMH~J0~XtUuT06hL8c1BYZWhN zk4F2I;|za*R{ToHH2L?MfRAm5(i1Ijw;f+0&J}pZ=A0;A4M`|10ZskA!a4VibFKn^ zdVH4OlsFV{R}vFlD~aA4xxSCTTMW@Gws4bFWI@xume%smAnuJ0b91QIF?ZV!%VSRJ zO7FmG!swKO{xuH{DYZ^##gGrXsUwYfD0dxXX3>QmD&`mSi;k)YvEQX?UyfIjQeIm! z0ME3gmQ`qRZ;{qYOWt}$-mW*>D~SPZKOgP)T-Sg%d;cw^#$>3A9I(%#vsTRQe%moT zU`geRJ16l>FV^HKX1GG7fR9AT((jaVb~E|0(c-WYQscVl(z?W!rJp`etF$dBXP|EG z=WXbcZ8mI)WBN>3<@%4eD597FD5nlZajwh8(c$lum>yP)F}=(D5g1-WVZRc)(!E3} z-6jy(x$OZOwE=~{EQS(Tp`yV2&t;KBpG*XWX!yG+>tc4aoxbXi7u@O*8WWFOxUjcq z^uV_|*818$+@_{|d~VOP{NcNi+FpJ9)aA2So<7sB%j`$Prje&auIiTBb{oD7q~3g0 z>QNIwcz(V-y{Ona?L&=JaV5`o71nIsWUMA~HOdCs10H+Irew#Kr(2cn>orG2J!jvP zqcVX0OiF}c<)+5&p}a>_Uuv)L_j}nqnJ5a?RPBNi8k$R~zpZ33AA4=xJ@Z($s3pG9 zkURJY5ZI=cZGRt_;`hs$kE@B0FrRx(6K{`i1^*TY;Vn?|IAv9|NrN*KnJqO|8$e1& zb?OgMV&q5|w7PNlHLHF) zB+AK#?EtCgCvwvZ6*u|TDhJcCO+%I^@Td8CR}+nz;OZ*4Dn?mSi97m*CXXc=};!P`B?}X`F-B5v-%ACa8fo0W++j&ztmqK z;&A)cT4ob9&MxpQU41agyMU8jFq~RzXOAsy>}hBQdFVL%aTn~M>5t9go2j$i9=(rZ zADmVj;Qntcr3NIPPTggpUxL_z#5~C!Gk2Rk^3jSiDqsbpOXf^f&|h^jT4|l2ehPat zb$<*B+x^qO8Po2+DAmrQ$Zqc`1%?gp*mDk>ERf6I|42^tjR6>}4`F_Mo^N(~Spjcg z_uY$}zui*PuDJjrpP0Pd+x^5ds3TG#f?57dFL{auS_W8|G*o}gcnsKYjS6*t8VI<) zcjqTzW(Hk*t-Qhq`Xe+x%}sxXRerScbPGv8hlJ;CnU-!Nl=# zR=iTFf9`EItr9iAlAGi}i&~nJ-&+)Y| zMZigh{LXe)uR+4D_Yb+1?I93mHQ5{pId2Fq%DBr7`?ipi;CT!Q&|EO3gH~7g?8>~l zT@%*5BbetH)~%TrAF1!-!=)`FIS{^EVA4WlXYtEy^|@y@yr!C~gX+cp2;|O4x1_Ol z4fPOE^nj(}KPQasY#U{m)}TZt1C5O}vz`A|1J!-D)bR%^+=J-yJsQXDzFiqb+PT0! zIaDWWU(AfOKlSBMS};3xBN*1F2j1-_=%o($ETm8@oR_NvtMDVIv_k zlnNBiHU&h8425{MCa=`vb2YP5KM7**!{1O>5Khzu+5OVGY;V=Vl+24fOE;tMfujoF z0M``}MNnTg3f%Uy6hZi$#g%PUA_-W>uVCYpE*1j>U8cYP6m(>KAVCmbsDf39Lqv0^ zt}V6FWjOU@AbruB7MH2XqtnwiXS2scgjVMH&aF~AIduh#^aT1>*V>-st8%=Kk*{bL zzbQcK(l2~)*A8gvfX=RPsNnjfkRZ@3DZ*ff5rmx{@iYJV+a@&++}ZW+za2fU>&(4y`6wgMpQGG5Ah(9oGcJ^P(H< zvYn5JE$2B`Z7F6ihy>_49!6}(-)oZ(zryIXt=*a$bpIw^k?>RJ2 zQYr>-D#T`2ZWDU$pM89Cl+C<;J!EzHwn(NNnWpYFqDDZ_*FZ{9KQRcSrl5T>dj+eA zi|okW;6)6LR5zebZJtZ%6Gx8^=2d9>_670!8Qm$wd+?zc4RAfV!ZZ$jV0qrv(D`db zm_T*KGCh3CJGb(*X6nXzh!h9@BZ-NO8py|wG8Qv^N*g?kouH4%QkPU~Vizh-D3<@% zGomx%q42B7B}?MVdv1DFb!axQ73AUxqr!yTyFlp%Z1IAgG49usqaEbI_RnbweR;Xs zpJq7GKL_iqi8Md?f>cR?^0CA+Uk(#mTlGdZbuC*$PrdB$+EGiW**=$A3X&^lM^K2s zzwc3LtEs5|ho z2>U(-GL`}eNgL-nv3h7E<*<>C%O^=mmmX0`jQb6$mP7jUKaY4je&dCG{x$`0=_s$+ zSpgn!8f~ya&U@c%{HyrmiW2&Wzc#Sw@+14sCpTWReYpF9EQ|7vF*g|sqG3hx67g}9 zwUj5QP2Q-(KxovRtL|-62_QsHLD4Mu&qS|iDp%!rs(~ah8FcrGb?Uv^Qub5ZT_kn%I^U2rxo1DDpmN@8uejxik`DK2~IDi1d?%~pR7i#KTS zA78XRx<(RYO0_uKnw~vBKi9zX8VnjZEi?vD?YAw}y+)wIjIVg&5(=%rjx3xQ_vGCy z*&$A+bT#9%ZjI;0w(k$|*x{I1c!ECMus|TEA#QE%#&LxfGvijl7Ih!B2 z6((F_gwkV;+oSKrtr&pX&fKo3s3`TG@ye+k3Ov)<#J|p8?vKh@<$YE@YIU1~@7{f+ zydTna#zv?)6&s=1gqH<-piG>E6XW8ZI7&b@-+Yk0Oan_CW!~Q2R{QvMm8_W1IV8<+ zQTyy=(Wf*qcQubRK)$B;QF}Y>V6d_NM#=-ydM?%EPo$Q+jkf}*UrzR?Nsf?~pzIj$ z<$wN;7c!WDZ(G_7N@YgZ``l;_eAd3+;omNjlpfn;0(B7L)^;;1SsI6Le+c^ULe;O@ zl+Z@OOAr4$a;=I~R0w4jO`*PKBp?3K+uJ+Tu8^%i<_~bU!p%so z^sjol^slR`W@jiqn!M~eClIIl+`A5%lGT{z^mRbpv}~AyO%R*jmG_Wrng{B9TwIuS z0!@fsM~!57K1l0%{yy(#no}roy#r!?0wm~HT!vLDfEBs9x#`9yCKgufm0MjVRfZ=f z4*ZRc2Lgr(P+j2zQE_JzYmP0*;trl7{*N341Cq}%^M^VC3gKG-hY zmPT>ECyrhIoFhnMB^qpdbiuI}pk{qPbK^}0?Rf7^{98+95zNq6!RuV_zAe&nDk0;f zez~oXlE5%ve^TmBEt*x_X#fs(-En$jXr-R4sb$b~`nS=iOy|OVrph(U&cVS!IhmZ~ zKIRA9X%Wp1J=vTvHZ~SDe_JXOe9*fa zgEPf;gD^|qE=dl>Qkx3(80#SE7oxXQ(n4qQ#by{uppSKoDbaq`U+fRqk0BwI>IXV3 zD#K%ASkzd7u>@|pA=)Z>rQr@dLH}*r7r0ng zxa^eME+l*s7{5TNu!+bD{Pp@2)v%g6^>yj{XP&mShhg9GszNu4ITW=XCIUp2Xro&1 zg_D=J3r)6hp$8+94?D$Yn2@Kp-3LDsci)<-H!wCeQt$e9Jk)K86hvV^*Nj-Ea*o;G zsuhRw$H{$o>8qByz1V!(yV{p_0X?Kmy%g#1oSmlHsw;FQ%j9S#}ha zm0Nx09@jmOtP8Q+onN^BAgd8QI^(y!n;-APUpo5WVdmp8!`yKTlF>cqn>ag`4;o>i zl!M0G-(S*fm6VjYy}J}0nX7nJ$h`|b&KuW4d&W5IhbR;-)*9Y0(Jj|@j`$xoPQ=Cl literal 0 HcmV?d00001 diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(-uuz(rC1}QWNE&K#jR^;j87-Auq zoUlN^K{r-Q+XN;zI ze|?*NFmgt#V#GwrSWaz^2G&@SBmck6ZcIFMww~vE<1E?M2#KUn1CzsB6D2+0SuRV@ zV2kK5HvIGB{HX-hQzs0*AB%5$9RJ@a;)Ahq#p$GSP91^&hi#6sg*;a~dt}4AclK>h z_3MoPRQ{i;==;*1S-mY<(JFzhAxMI&<61&m$J0NDHdJ3tYx~j0%M-uN6Zl8~_0DOkGXc0001@sz3l12C6Xg{AT~( zm6w64BA|AX`Ve)YY-glyudNN>MAfkXz-T7`_`fEolM;0T0BA)(02-OaW z0*cW7Z~ec94o8&g0D$N>b!COu{=m}^%oXZ4?T8ZyPZuGGBPBA7pbQMoV5HYhiT?%! zcae~`(QAN4&}-=#2f5fkn!SWGWmSeCISBcS=1-U|MEoKq=k?_x3apK>9((R zuu$9X?^8?@(a{qMS%J8SJPq))v}Q-ZyDm6Gbie0m92=`YlwnQPQP1kGSm(N2UJ3P6 z^{p-u)SSCTW~c1rw;cM)-uL2{->wCn2{#%;AtCQ!m%AakVs1K#v@(*-6QavyY&v&*wO_rCJXJuq$c$7ZjsW+pJo-$L^@!7X04CvaOpPyfw|FKvu;e(&Iw>Tbg zL}#8e^?X%TReXTt>gsBByt0kSU20oQx*~P=4`&tcZ7N6t-6LiK{LxX*p6}9c<0Pu^ zLx1w_P4P2V>bX=`F%v$#{sUDdF|;rbI{p#ZW`00Bgh(eB(nOIhy8W9T>3aQ=k8Z9% zB+TusFABF~J?N~fAd}1Rme=@4+1=M{^P`~se7}e3;mY0!%#MJf!XSrUC{0uZqMAd7%q zQY#$A>q}noIB4g54Ue)x>ofVm3DKBbUmS4Z-bm7KdKsUixva)1*&z5rgAG2gxG+_x zqT-KNY4g7eM!?>==;uD9Y4iI(Hu$pl8!LrK_Zb}5nv(XKW{9R144E!cFf36p{i|8pRL~p`_^iNo z{mf7y`#hejw#^#7oKPlN_Td{psNpNnM?{7{R-ICBtYxk>?3}OTH_8WkfaTLw)ZRTfxjW+0>gMe zpKg~`Bc$Y>^VX;ks^J0oKhB#6Ukt{oQhN+o2FKGZx}~j`cQB%vVsMFnm~R_1Y&Ml? zwFfb~d|dW~UktY@?zkau>Owe zRroi(<)c4Ux&wJfY=3I=vg)uh;sL(IYY9r$WK1$F;jYqq1>xT{LCkIMb3t2jN8d`9 z=4(v-z7vHucc_fjkpS}mGC{ND+J-hc_0Ix4kT^~{-2n|;Jmn|Xf9wGudDk7bi*?^+ z7fku8z*mbkGm&xf&lmu#=b5mp{X(AwtLTf!N`7FmOmX=4xwbD=fEo8CaB1d1=$|)+ z+Dlf^GzGOdlqTO8EwO?8;r+b;gkaF^$;+#~2_YYVH!hD6r;PaWdm#V=BJ1gH9ZK_9 zrAiIC-)z)hRq6i5+$JVmR!m4P>3yJ%lH)O&wtCyum3A*})*fHODD2nq!1@M>t@Za+ zH6{(Vf>_7!I-APmpsGLYpl7jww@s5hHOj5LCQXh)YAp+y{gG(0UMm(Ur z3o3n36oFwCkn+H*GZ-c6$Y!5r3z*@z0`NrB2C^q#LkOuooUM8Oek2KBk}o1PU8&2L z4iNkb5CqJWs58aR394iCU^ImDqV;q_Pp?pl=RB2372(Io^GA^+oKguO1(x$0<7w3z z)j{vnqEB679Rz4i4t;8|&Zg77UrklxY9@GDq(ZphH6=sW`;@uIt5B?7Oi?A0-BL}(#1&R;>2aFdq+E{jsvpNHjLx2t{@g1}c~DQcPNmVmy| zNMO@ewD^+T!|!DCOf}s9dLJU}(KZy@Jc&2Nq3^;vHTs}Hgcp`cw&gd7#N}nAFe3cM1TF%vKbKSffd&~FG9y$gLyr{#to)nxz5cCASEzQ}gz8O)phtHuKOW6p z@EQF(R>j%~P63Wfosrz8p(F=D|Mff~chUGn(<=CQbSiZ{t!e zeDU-pPsLgtc#d`3PYr$i*AaT!zF#23htIG&?QfcUk+@k$LZI}v+js|yuGmE!PvAV3 ztzh90rK-0L6P}s?1QH`Ot@ilbgMBzWIs zIs6K<_NL$O4lwR%zH4oJ+}JJp-bL6~%k&p)NGDMNZX7)0kni&%^sH|T?A)`z z=adV?!qnWx^B$|LD3BaA(G=ePL1+}8iu^SnnD;VE1@VLHMVdSN9$d)R(Wk{JEOp(P zm3LtAL$b^*JsQ0W&eLaoYag~=fRRdI>#FaELCO7L>zXe6w*nxN$Iy*Q*ftHUX0+N- zU>{D_;RRVPbQ?U+$^%{lhOMKyE5>$?U1aEPist+r)b47_LehJGTu>TcgZe&J{ z{q&D{^Ps~z7|zj~rpoh2I_{gAYNoCIJmio3B}$!5vTF*h$Q*vFj~qbo%bJCCRy509 zHTdDh_HYH8Zb9`}D5;;J9fkWOQi%Y$B1!b9+ESj+B@dtAztlY2O3NE<6HFiqOF&p_ zW-K`KiY@RPSY-p9Q99}Hcd05DT79_pfb{BV7r~?9pWh=;mcKBLTen%THFPo2NN~Nf zriOtFnqx}rtO|A6k!r6 zf-z?y-UD{dT0kT9FJ`-oWuPHbo+3wBS(}?2ql(+e@VTExmfnB*liCb zmeI+v5*+W_L;&kQN^ChW{jE0Mw#0Tfs}`9bk3&7UjxP^Ke(%eJu2{VnW?tu7Iqecm zB5|=-QdzK$=h50~{X3*w4%o1FS_u(dG2s&427$lJ?6bkLet}yYXCy)u_Io1&g^c#( z-$yYmSpxz{>BL;~c+~sxJIe1$7eZI_9t`eB^Pr0)5CuA}w;;7#RvPq|H6!byRzIJG ziQ7a4y_vhj(AL`8PhIm9edCv|%TX#f50lt8+&V+D4<}IA@S@#f4xId80oH$!_!q?@ zFRGGg2mTv&@76P7aTI{)Hu%>3QS_d)pQ%g8BYi58K~m-Ov^7r8BhX7YC1D3vwz&N8{?H*_U7DI?CI)+et?q|eGu>42NJ?K4SY zD?kc>h@%4IqNYuQ8m10+8xr2HYg2qFNdJl=Tmp&ybF>1>pqVfa%SsV*BY$d6<@iJA ziyvKnZ(~F9xQNokBgMci#pnZ}Igh0@S~cYcU_2Jfuf|d3tuH?ZSSYBfM(Y3-JBsC|S9c;# zyIMkPxgrq};0T09pjj#X?W^TFCMf1-9P{)g88;NDI+S4DXe>7d3Mb~i-h&S|Jy{J< zq3736$bH?@{!amD!1Ys-X)9V=#Z={fzsjVYMX5BG6%}tkzwC#1nQLj1y1f#}8**4Y zAvDZHw8)N)8~oWC88CgzbwOrL9HFbk4}h85^ptuu7A+uc#$f^9`EWv1Vr{5+@~@Uv z#B<;-nt;)!k|fRIg;2DZ(A2M2aC65kOIov|?Mhi1Sl7YOU4c$T(DoRQIGY`ycfkn% zViHzL;E*A{`&L?GP06Foa38+QNGA zw3+Wqs(@q+H{XLJbwZzE(omw%9~LPZfYB|NF5%j%E5kr_xE0u;i?IOIchn~VjeDZ) zAqsqhP0vu2&Tbz3IgJvMpKbThC-@=nk)!|?MIPP>MggZg{cUcKsP8|N#cG5 zUXMXxcXBF9`p>09IR?x$Ry3;q@x*%}G#lnB1}r#!WL88I@uvm}X98cZ8KO&cqT1p> z+gT=IxPsq%n4GWgh-Bk8E4!~`r@t>DaQKsjDqYc&h$p~TCh8_Mck5UB84u6Jl@kUZCU9BA-S!*bf>ZotFX9?a_^y%)yH~rsAz0M5#^Di80_tgoKw(egN z`)#(MqAI&A84J#Z<|4`Co8`iY+Cv&iboMJ^f9ROUK0Lm$;-T*c;TCTED_0|qfhlcS zv;BD*$Zko#nWPL}2K8T-?4}p{u)4xon!v_(yVW8VMpxg4Kh^J6WM{IlD{s?%XRT8P|yCU`R&6gwB~ zg}{At!iWCzOH37!ytcPeC`(({ovP7M5Y@bYYMZ}P2Z3=Y_hT)4DRk}wfeIo%q*M9UvXYJq!-@Ly79m5aLD{hf@BzQB>FdQ4mw z6$@vzSKF^Gnzc9vbccii)==~9H#KW<6)Uy1wb~auBn6s`ct!ZEos`WK8e2%<00b%# zY9Nvnmj@V^K(a_38dw-S*;G-(i(ETuIwyirs?$FFW@|66a38k+a%GLmucL%Wc8qk3 z?h_4!?4Y-xt)ry)>J`SuY**fuq2>u+)VZ+_1Egzctb*xJ6+7q`K$^f~r|!i?(07CD zH!)C_uerf-AHNa?6Y61D_MjGu*|wcO+ZMOo4q2bWpvjEWK9yASk%)QhwZS%N2_F4& z16D18>e%Q1mZb`R;vW{+IUoKE`y3(7p zplg5cBB)dtf^SdLd4n60oWie|(ZjgZa6L*VKq02Aij+?Qfr#1z#fwh92aV-HGd^_w zsucG24j8b|pk>BO7k8dS86>f-jBP^Sa}SF{YNn=^NU9mLOdKcAstv&GV>r zLxKHPkFxpvE8^r@MSF6UA}cG`#yFL8;kA7ccH9D=BGBtW2;H>C`FjnF^P}(G{wU;G z!LXLCbPfsGeLCQ{Ep$^~)@?v`q(uI`CxBY44osPcq@(rR-633!qa zsyb>?v%@X+e|Mg`+kRL*(;X>^BNZz{_kw5+K;w?#pReiw7eU8_Z^hhJ&fj80XQkuU z39?-z)6Fy$I`bEiMheS(iB6uLmiMd1i)cbK*9iPpl+h4x9ch7x- z1h4H;W_G?|)i`z??KNJVwgfuAM=7&Apd3vm#AT8uzQZ!NII}}@!j)eIfn53h{NmN7 zAKG6SnKP%^k&R~m5#@_4B@V?hYyHkm>0SQ@PPiw*@Tp@UhP-?w@jW?nxXuCipMW=L zH*5l*d@+jXm0tIMP_ec6Jcy6$w(gKK@xBX8@%oPaSyG;13qkFb*LuVx3{AgIyy&n3 z@R2_DcEn|75_?-v5_o~%xEt~ONB>M~tpL!nOVBLPN&e5bn5>+7o0?Nm|EGJ5 zmUbF{u|Qn?cu5}n4@9}g(G1JxtzkKv(tqwm_?1`?YSVA2IS4WI+*(2D*wh&6MIEhw z+B+2U<&E&|YA=3>?^i6)@n1&&;WGHF-pqi_sN&^C9xoxME5UgorQ_hh1__zzR#zVC zOQt4q6>ME^iPJ37*(kg4^=EFqyKH@6HEHXy79oLj{vFqZGY?sVjk!BX^h$SFJlJnv z5uw~2jLpA)|0=tp>qG*tuLru?-u`khGG2)o{+iDx&nC}eWj3^zx|T`xn5SuR;Aw8U z`p&>dJw`F17@J8YAuW4=;leBE%qagVTG5SZdh&d)(#ZhowZ|cvWvGMMrfVsbg>_~! z19fRz8CSJdrD|Rl)w!uznBF&2-dg{>y4l+6(L(vzbLA0Bk&`=;oQQ>(M8G=3kto_) zP8HD*n4?MySO2YrG6fwSrVmnesW+D&fxjfEmp=tPd?RKLZJcH&K(-S+x)2~QZ$c(> zru?MND7_HPZJVF%wX(49H)+~!7*!I8w72v&{b={#l9yz+S_aVPc_So%iF8>$XD1q1 zFtucO=rBj0Ctmi0{njN8l@}!LX}@dwl>3yMxZ;7 z0Ff2oh8L)YuaAGOuZ5`-p%Z4H@H$;_XRJQ|&(MhO78E|nyFa158gAxG^SP(vGi^+< zChY}o(_=ci3Wta#|K6MVljNe0T$%Q5ylx-v`R)r8;3+VUpp-)7T`-Y&{Zk z*)1*2MW+_eOJtF5tCMDV`}jg-R(_IzeE9|MBKl;a7&(pCLz}5<Zf+)T7bgNUQ_!gZtMlw=8doE}#W+`Xp~1DlE=d5SPT?ymu!r4z%&#A-@x^=QfvDkfx5-jz+h zoZ1OK)2|}_+UI)i9%8sJ9X<7AA?g&_Wd7g#rttHZE;J*7!e5B^zdb%jBj&dUDg4&B zMMYrJ$Z%t!5z6=pMGuO-VF~2dwjoXY+kvR>`N7UYfIBMZGP|C7*O=tU z2Tg_xi#Q3S=1|=WRfZD;HT<1D?GMR%5kI^KWwGrC@P2@R>mDT^3qsmbBiJc21kip~ zZp<7;^w{R;JqZ)C4z-^wL=&dBYj9WJBh&rd^A^n@07qM$c+kGv^f+~mU5_*|eePF| z3wDo-qaoRjmIw<2DjMTG4$HP{z54_te_{W^gu8$r=q0JgowzgQPct2JNtWPUsjF8R zvit&V8$(;7a_m%%9TqPkCXYUp&k*MRcwr*24>hR! z$4c#E=PVE=P4MLTUBM z7#*RDe0}=B)(3cvNpOmWa*eH#2HR?NVqXdJ=hq);MGD07JIQQ7Y0#iD!$C+mk7x&B zMwkS@H%>|fmSu#+ zI!}Sb(%o29Vkp_Th>&&!k7O>Ba#Om~B_J{pT7BHHd8(Ede(l`7O#`_}19hr_?~JP9 z`q(`<)y>%)x;O7)#-wfCP{?llFMoH!)ZomgsOYFvZ1DxrlYhkWRw#E-#Qf*z@Y-EQ z1~?_=c@M4DO@8AzZ2hKvw8CgitzI9yFd&N1-{|vP#4IqYb*#S0e3hrjsEGlnc4xwk z4o!0rxpUt8j&`mJ8?+P8G{m^jbk)bo_UPM+ifW*y-A*et`#_Ja_3nYyRa9fAG1Xr5 z>#AM_@PY|*u)DGRWJihZvgEh#{*joJN28uN7;i5{kJ*Gb-TERfN{ERe_~$Es~NJCpdKLRvdj4658uYYx{ng7I<6j~w@p%F<7a(Ssib|j z51;=Py(Nu*#hnLx@w&8X%=jrADn3TW>kplnb zYbFIWWVQXN7%Cwn6KnR)kYePEBmvM45I)UJb$)ninpdYg3a5N6pm_7Q+9>!_^xy?k za8@tJ@OOs-pRAAfT>Nc2x=>sZUs2!9Dwa%TTmDggH4fq(x^MW>mcRyJINlAqK$YQCMgR8`>6=Sg$ zFnJZsA8xUBXIN3i70Q%8px@yQPMgVP=>xcPI38jNJK<=6hC={a07+n@R|$bnhB)X$ z(Zc%tadp70vBTnW{OUIjTMe38F}JIH$#A}PB&RosPyFZMD}q}5W%$rh>5#U;m`z2K zc(&WRxx7DQLM-+--^w*EWAIS%bi>h587qkwu|H=hma3T^bGD&Z!`u(RKLeNZ&pI=q$|HOcji(0P1QC!YkAp*u z3%S$kumxR}jU<@6`;*-9=5-&LYRA<~uFrwO3U0k*4|xUTp4ZY7;Zbjx|uw&BWU$zK(w55pWa~#=f$c zNDW0O68N!xCy>G}(CX=;8hJLxAKn@Aj(dbZxO8a$+L$jK8$N-h@4$i8)WqD_%Snh4 zR?{O%k}>lr>w$b$g=VP8mckcCrjnp>uQl5F_6dPM8FWRqs}h`DpfCv20uZhyY~tr8 zkAYW4#yM;*je)n=EAb(q@5BWD8b1_--m$Q-3wbh1hM{8ihq7UUQfg@)l06}y+#=$( z$x>oVYJ47zAC^>HLRE-!HitjUixP6!R98WU+h>zct7g4eD;Mj#FL*a!VW!v-@b(Jv zj@@xM5noCp5%Vk3vY{tyI#oyDV7<$`KG`tktVyC&0DqxA#>V;-3oH%NW|Q&=UQ&zU zXNIT67J4D%5R1k#bW0F}TD`hlW7b)-=-%X4;UxQ*u4bK$mTAp%y&-(?{sXF%e_VH6 zTkt(X)SSN|;8q@8XX6qfR;*$r#HbIrvOj*-5ND8RCrcw4u8D$LXm5zlj@E5<3S0R# z??=E$p{tOk96$SloZ~ARe5`J=dB|Nj?u|zy2r(-*(q^@YwZiTF@QzQyPx_l=IDKa) zqD@0?IHJqSqZ_5`)81?4^~`yiGh6>7?|dKa8!e|}5@&qV!Iu9<@G?E}Vx9EzomB3t zEbMEm$TKGwkHDpirp;FZD#6P5qIlQJ8}rf;lHoz#h4TFFPYmS3+8(13_Mx2`?^=8S z|0)0&dQLJTU6{b%*yrpQe#OKKCrL8}YKw+<#|m`SkgeoN69TzIBQOl_Yg)W*w?NW) z*WxhEp$zQBBazJSE6ygu@O^!@Fr46j=|K`Mmb~xbggw7<)BuC@cT@Bwb^k?o-A zKX^9AyqR?zBtW5UA#siILztgOp?r4qgC`9jYJG_fxlsVSugGprremg-W(K0{O!Nw-DN%=FYCyfYA3&p*K>+|Q}s4rx#CQK zNj^U;sLM#q8}#|PeC$p&jAjqMu(lkp-_50Y&n=qF9`a3`Pr9f;b`-~YZ+Bb0r~c+V z*JJ&|^T{}IHkwjNAaM^V*IQ;rk^hnnA@~?YL}7~^St}XfHf6OMMCd9!vhk#gRA*{L zp?&63axj|Si%^NW05#87zpU_>QpFNb+I00v@cHwvdBn+Un)n2Egdt~LcWOeBW4Okm zD$-e~RD+W|UB;KQ;a7GOU&%p*efGu2$@wR74+&iP8|6#_fmnh^WcJLs)rtz{46);F z4v0OL{ZP9550>2%FE(;SbM*#sqMl*UXOb>ch`fJ|(*bOZ9=EB1+V4fkQ)hjsm3-u^Pk-4ji_uDDHdD>84tER!MvbH`*tG zzvbhBR@}Yd`azQGavooV=<WbvWLlO#x`hyO34mKcxrGv=`{ssnP=0Be5#1B;Co9 zh{TR>tjW2Ny$ZxJpYeg57#0`GP#jxDCU0!H15nL@@G*HLQcRdcsUO3sO9xvtmUcc{F*>FQZcZ5bgwaS^k-j5mmt zI7Z{Xnoml|A(&_{imAjK!kf5>g(oDqDI4C{;Bv162k8sFNr;!qPa2LPh>=1n z=^_9)TsLDvTqK7&*Vfm5k;VXjBW^qN3Tl&}K=X5)oXJs$z3gk0_+7`mJvz{pK|FVs zHw!k&7xVjvY;|(Py<;J{)b#Yjj*LZO7x|~pO4^MJ2LqK3X;Irb%nf}L|gck zE#55_BNsy6m+W{e zo!P59DDo*s@VIi+S|v93PwY6d?CE=S&!JLXwE9{i)DMO*_X90;n2*mPDrL%{iqN!?%-_95J^L z=l<*{em(6|h7DR4+4G3Wr;4*}yrBkbe3}=p7sOW1xj!EZVKSMSd;QPw>uhKK z#>MlS@RB@-`ULv|#zI5GytO{=zp*R__uK~R6&p$q{Y{iNkg61yAgB8C^oy&``{~FK z8hE}H&nIihSozKrOONe5Hu?0Zy04U#0$fB7C6y~?8{or}KNvP)an=QP&W80mj&8WL zEZQF&*FhoMMG6tOjeiCIV;T{I>jhi9hiUwz?bkX3NS-k5eWKy)Mo_orMEg4sV6R6X&i-Q%JG;Esl+kLpn@Bsls9O|i9z`tKB^~1D5)RIBB&J<6T@a4$pUvh$IR$%ubH)joi z!7>ON0DPwx=>0DA>Bb^c?L8N0BBrMl#oDB+GOXJh;Y&6I)#GRy$W5xK%a;KS8BrER zX)M>Rdoc*bqP*L9DDA3lF%U8Yzb6RyIsW@}IKq^i7v&{LeIc=*ZHIbO68x=d=+0T( zev=DT9f|x!IWZNTB#N7}V4;9#V$%Wo0%g>*!MdLOEU>My0^gni9ocID{$g9ytD!gy zKRWT`DVN(lcYjR|(}f0?zgBa3SwunLfAhx><%u0uFkrdyqlh8_g zDKt#R6rA2(Vm2LW_>3lBNYKG_F{TEnnKWGGC15y&OebIRhFL4TeMR*v9i0wPoK#H< zu4){s4K&K)K(9~jgGm;H7lS7y_RYfS;&!Oj5*eqbvEcW^a*i67nevzOZxN6F+K~A%TYEtsAVsR z@J=1hc#Dgs7J2^FL|qV&#WBFQyDtEQ2kPO7m2`)WFhqAob)Y>@{crkil6w9VoA?M6 zADGq*#-hyEVhDG5MQj677XmcWY1_-UO40QEP&+D)rZoYv^1B_^w7zAvWGw&pQyCyx zD|ga$w!ODOxxGf_Qq%V9Z7Q2pFiUOIK818AGeZ-~*R zI1O|SSc=3Z?#61Rd|AXx2)K|F@Z1@x!hBBMhAqiU)J=U|Y)T$h3D?ZPPQgkSosnN! zIqw-t$0fqsOlgw3TlHJF*t$Q@bg$9}A3X=cS@-yU3_vNG_!#9}7=q7!LZ?-%U26W4 z$d>_}*s1>Ac%3uFR;tnl*fNlylJ)}r2^Q3&@+is3BIv<}x>-^_ng;jhdaM}6Sg3?p z0jS|b%QyScy3OQ(V*~l~bK>VC{9@FMuW_JUZO?y(V?LKWD6(MXzh}M3r3{7b4eB(#`(q1m{>Be%_<9jw8HO!x#yF6vez$c#kR+}s zZO-_;25Sxngd(}){zv?ccbLqRAlo;yog>4LH&uZUK1n>x?u49C)Y&2evH5Zgt~666 z_2_z|H5AO5Iqxv_Bn~*y1qzRPcob<+Otod5Xd2&z=C;u+F}zBB@b^UdGdUz|s!H}M zXG%KiLzn3G?FZgdY&3pV$nSeY?ZbU^jhLz9!t0K?ep}EFNqR1@E!f*n>x*!uO*~JF zW9UXWrVgbX1n#76_;&0S7z}(5n-bqnII}_iDsNqfmye@)kRk`w~1 z6j4h4BxcPe6}v)xGm%=z2#tB#^KwbgMTl2I*$9eY|EWAHFc3tO48Xo5rW z5oHD!G4kb?MdrOHV=A+8ThlIqL8Uu+7{G@ zb)cGBm|S^Eh5= z^E^SZ=yeC;6nNCdztw&TdnIz}^Of@Ke*@vjt)0g>Y!4AJvWiL~e7+9#Ibhe)> ziNwh>gWZL@FlWc)wzihocz+%+@*euwXhW%Hb>l7tf8aJe5_ZSH1w-uG|B;9qpcBP0 zM`r1Hu#htOl)4Cl1c7oY^t0e4Jh$-I(}M5kzWqh{F=g&IM#JiC`NDSd@BCKX#y<P@Gwl$3a3w z6<(b|K(X5FIR22M)sy$4jY*F4tT{?wZRI+KkZFb<@j@_C316lu1hq2hA|1wCmR+S@ zRN)YNNE{}i_H`_h&VUT5=Y(lN%m?%QX;6$*1P}K-PcPx>*S55v)qZ@r&Vcic-sjkm z! z=nfW&X`}iAqa_H$H%z3Tyz5&P3%+;93_0b;zxLs)t#B|up}JyV$W4~`8E@+BHQ+!y zuIo-jW!~)MN$2eHwyx-{fyGjAWJ(l8TZtUp?wZWBZ%}krT{f*^fqUh+ywHifw)_F> zp76_kj_B&zFmv$FsPm|L7%x-j!WP>_P6dHnUTv!9ZWrrmAUteBa`rT7$2ixO;ga8U z3!91micm}{!Btk+I%pMgcKs?H4`i+=w0@Ws-CS&n^=2hFTQ#QeOmSz6ttIkzmh^`A zYPq)G1l3h(E$mkyr{mvz*MP`x+PULBn%CDhltKkNo6Uqg!vJ#DA@BIYr9TQ`18Un2 zv$}BYzOQuay9}w(?JV63F$H6WmlYPPpH=R|CPb%C@BCv|&Q|&IcW7*LX?Q%epS z`=CPx{1HnJ9_46^=0VmNb>8JvMw-@&+V8SDLRYsa>hZXEeRbtf5eJ>0@Ds47zIY{N z42EOP9J8G@MXXdeiPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91AfN*P1ONa40RR91AOHXW0IY^$^8f$?lu1NER9Fe^SItioK@|V(ZWmgL zZT;XwPgVuWM>O%^|Dc$VK;n&?9!&g5)aVsG8cjs5UbtxVVnQNOV~7Mrg3+jnU;rhE z6fhW6P)R>_eXrXo-RW*y6RQ_qcb^s1wTu$TwriZ`=JUws>vRi}5x}MW1MR#7p|gIWJlaLK;~xaN}b< z<-@=RX-%1mt`^O0o^~2=CD7pJ<<$Rp-oUL-7PuG>do^5W_Mk#unlP}6I@6NPxY`Q} zuXJF}!0l)vwPNAW;@5DjPRj?*rZxl zwn;A(cFV!xe^CUu+6SrN?xe#mz?&%N9QHf~=KyK%DoB8HKC)=w=3E?1Bqj9RMJs3U z5am3Uv`@+{jgqO^f}Lx_Jp~CoP3N4AMZr~4&d)T`R?`(M{W5WWJV^z~2B|-oih@h^ zD#DuzGbl(P5>()u*YGo*Och=oRr~3P1wOlKqI)udc$|)(bacG5>~p(y>?{JD7nQf_ z*`T^YL06-O>T(s$bi5v~_fWMfnE7Vn%2*tqV|?~m;wSJEVGkNMD>+xCu#um(7}0so zSEu7?_=Q64Q5D+fz~T=Rr=G_!L*P|(-iOK*@X8r{-?oBlnxMNNgCVCN9Y~ocu+?XA zjjovJ9F1W$Nf!{AEv%W~8oahwM}4Ruc+SLs>_I_*uBxdcn1gQ^2F8a*vGjgAXYyh? zWCE@c5R=tbD(F4nL9NS?$PN1V_2*WR?gjv3)4MQeizuH`;sqrhgykEzj z593&TGlm3h`sIXy_U<7(dpRXGgp0TB{>s?}D{fwLe>IV~exweOfH!qM@CV5kib!YA z6O0gvJi_0J8IdEvyP#;PtqP*=;$iI2t(xG2YI-e!)~kaUn~b{6(&n zp)?iJ`z2)Xh%sCV@BkU`XL%_|FnCA?cVv@h*-FOZhY5erbGh)%Q!Av#fJM3Csc_g zC2I6x%$)80`Tkz#KRA!h1FzY`?0es3t!rKDT5EjPe6B=BLPr7s0GW!if;Ip^!AmGW zL;$`Vdre+|FA!I4r6)keFvAx3M#1`}ijBHDzy)3t0gwjl|qC2YB`SSxFKHr(oY#H$)x{L$LL zBdLKTlsOrmb>T0wd=&6l3+_Te>1!j0OU8%b%N342^opKmT)gni(wV($s(>V-fUv@0p8!f`=>PxC|9=nu ze{ToBBj8b<{PLfXV$h8YPgA~E!_sF9bl;QOF{o6t&JdsX?}rW!_&d`#wlB6T_h;Xf zl{4Tz5>qjF4kZgjO7ZiLPRz_~U@k5%?=30+nxEh9?s78gZ07YHB`FV`4%hlQlMJe@J`+e(qzy+h(9yY^ckv_* zb_E6o4p)ZaWfraIoB2)U7_@l(J0O%jm+Or>8}zSSTkM$ASG^w3F|I? z$+eHt7T~04(_WfKh27zqS$6* zzyy-ZyqvSIZ0!kkSvHknm_P*{5TKLQs8S6M=ONuKAUJWtpxbL#2(_huvY(v~Y%%#~ zYgsq$JbLLprKkV)32`liIT$KKEqs$iYxjFlHiRNvBhxbDg*3@Qefw4UM$>i${R5uB zhvTgmqQsKA{vrKN;TSJU2$f9q=y{$oH{<)woSeV>fkIz6D8@KB zf4M%v%f5U2?<8B(xn}xV+gWP?t&oiapJhJbfa;agtz-YM7=hrSuxl8lAc3GgFna#7 zNjX7;`d?oD`#AK+fQ=ZXqfIZFEk{ApzjJF0=yO~Yj{7oQfXl+6v!wNnoqwEvrs81a zGC?yXeSD2NV!ejp{LdZGEtd1TJ)3g{P6j#2jLR`cpo;YX}~_gU&Gd<+~SUJVh+$7S%`zLy^QqndN<_9 zrLwnXrLvW+ew9zX2)5qw7)zIYawgMrh`{_|(nx%u-ur1B7YcLp&WFa24gAuw~& zKJD3~^`Vp_SR$WGGBaMnttT)#fCc^+P$@UHIyBu+TRJWbcw4`CYL@SVGh!X&y%!x~ zaO*m-bTadEcEL6V6*{>irB8qT5Tqd54TC4`h`PVcd^AM6^Qf=GS->x%N70SY-u?qr>o2*OV7LQ=j)pQGv%4~z zz?X;qv*l$QSNjOuQZ>&WZs2^@G^Qas`T8iM{b19dS>DaXX~=jd4B2u`P;B}JjRBi# z_a@&Z5ev1-VphmKlZEZZd2-Lsw!+1S60YwW6@>+NQ=E5PZ+OUEXjgUaXL-E0fo(E* zsjQ{s>n33o#VZm0e%H{`KJi@2ghl8g>a~`?mFjw+$zlt|VJhSU@Y%0TWs>cnD&61fW4e0vFSaXZa4-c}U{4QR8U z;GV3^@(?Dk5uc@RT|+5C8-24->1snH6-?(nwXSnPcLn#X_}y3XS)MI_?zQ$ZAuyg+ z-pjqsw}|hg{$~f0FzmmbZzFC0He_*Vx|_uLc!Ffeb8#+@m#Z^AYcWcZF(^Os8&Z4g zG)y{$_pgrv#=_rV^D|Y<_b@ICleUv>c<0HzJDOsgJb#Rd-Vt@+EBDPyq7dUM9O{Yp zuGUrO?ma2wpuJuwl1M=*+tb|qx7Doj?!F-3Z>Dq_ihFP=d@_JO;vF{iu-6MWYn#=2 zRX6W=`Q`q-+q@Db|6_a1#8B|#%hskH82lS|9`im0UOJn?N#S;Y0$%xZw3*jR(1h5s z?-7D1tnIafviko>q6$UyqVDq1o@cwyCb*})l~x<@s$5D6N=-Uo1yc49p)xMzxwnuZ zHt!(hu-Ek;Fv4MyNTgbW%rPF*dB=;@r3YnrlFV{#-*gKS_qA(G-~TAlZ@Ti~Yxw;k za1EYyX_Up|`rpbZ0&Iv#$;eC|c0r4XGaQ-1mw@M_4p3vKIIpKs49a8Ns#ni)G314Z z8$Ei?AhiT5dQGWUYdCS|IC7r z=-8ol>V?u!n%F*J^^PZ(ONT&$Ph;r6X;pj|03HlDY6r~0g~X#zuzVU%a&!fs_f|m?qYvg^Z{y?9Qh7Rn?T*F%7lUtA6U&={HzhYEzA`knx1VH> z{tqv?p@I(&ObD5L4|YJV$QM>Nh-X3cx{I&!$FoPC_2iIEJfPk-$;4wz>adRu@n`_y z_R6aN|MDHdK;+IJmyw(hMoDCFCQ(6?hCAG5&7p{y->0Uckv# zvooVuu04$+pqof777ftk<#42@KQ((5DPcSMQyzGOJ{e9H$a9<2Qi_oHjl{#=FUL9d z+~0^2`tcvmp0hENwfHR`Ce|<1S@p;MNGInXCtHnrDPXCKmMTZQ{HVm_cZ>@?Wa6}O zHsJc7wE)mc@1OR2DWY%ZIPK1J2p6XDO$ar`$RXkbW}=@rFZ(t85AS>>U0!yt9f49^ zA9@pc0P#k;>+o5bJfx0t)Lq#v4`OcQn~av__dZ-RYOYu}F#pdsl31C^+Qgro}$q~5A<*c|kypzd} ziYGZ~?}5o`S5lw^B{O@laad9M_DuJle- z*9C7o=CJh#QL=V^sFlJ0c?BaB#4bV^T(DS6&Ne&DBM_3E$S^S13qC$7_Z?GYXTpR@wqr70wu$7+qvf-SEUa5mdHvFbu^7ew!Z1a^ zo}xKOuT*gtGws-a{Tx}{#(>G~Y_h&5P@Q8&p!{*s37^QX_Ibx<6XU*AtDOIvk|^{~ zPlS}&DM5$Ffyu-T&0|KS;Wnaqw{9DB&B3}vcO14wn;)O_e@2*9B&0I_ zZz{}CMxx`hv-XouY>^$Y@J(_INeM>lIQI@I>dBAqq1)}?Xmx(qRuX^i4IV%=MF306 z9g)i*79pP%_7Ex?m6ag-4Tlm=Z;?DQDyC-NpUIb#_^~V_tsL<~5<&;Gf2N+p?(msn zzUD~g>OoW@O}y0@Z;RN)wjam`CipmT&O7a|YljZqU=U86 zedayEdY)2F#BJ6xvmW8K&ffdS*0!%N<%RB!2~PAT4AD*$W7yzHbX#Eja9%3aD+Ah2 zf#T;XJW-GMxpE=d4Y>}jE=#U`IqgSoWcuvgaWQ9j1CKzG zDkoMDDT)B;Byl3R2PtC`ip=yGybfzmVNEx{xi_1|Cbqj>=FxQc{g`xj6fIfy`D8fA z##!-H_e6o0>6Su&$H2kQTujtbtyNFeKc}2=|4IfLTnye#@$Au7Kv4)dnA;-fz@D_8 z)>irG$)dkBY~zX zC!ZXLy*L3xr6cb70QqfN#Q>lFIc<>}>la4@3%7#>a1$PU&O^&VszpxLC%*!m-cO{B z-Y}rQr4$84(hvy#R69H{H zJ*O#uJh)TF6fbXy;fZkk%X=CjsTK}o5N1a`d7kgYYZLPxsHx%9*_XN8VWXEkVJZ%A z1A+5(B;0^{T4aPYr8%i@i32h)_)|q?9vws)r+=5u)1YNftF5mknwfd*%jXA2TeP}Z zQ!m?xJ3?9LpPM?_A3$hQ1QxNbR&}^m z!F999s?p^ak#C4NM_x2p9FoXWJ$>r?lJ)2bG)sX{gExgLA2s5RwHV!h6!C~d_H||J z>9{E{mEv{Z1z~65Vix@dqM4ZqiU|!)eWX$mwS5mLSufxbpBqqS!jShq1bmwCR6 z4uBri7ezMeS6ycaXPVu(i2up$L; zjpMtB`k~WaNrdgM_R=e#SN?Oa*u%nQy01?()h4A(jyfeNfx;5o+kX?maO4#1A^L}0 zYNyIh@QVXIFiS0*tE}2SWTrWNP3pH}1Vz1;E{@JbbgDFM-_Mky^7gH}LEhl~Ve5PexgbIyZ(IN%PqcaV@*_`ZFb=`EjspSz%5m2E34BVT)d=LGyHVz@-e%9Ova*{5@RD;7=Ebkc2GP%pIP^P7KzKapnh`UpH?@h z$RBpD*{b?vhohOKf-JG3?A|AX|2pQ?(>dwIbWhZ38GbTm4AImRNdv_&<99ySX;kJ| zo|5YgbHZC#HYgjBZrvGAT4NZYbp}qkVSa;C-LGsR26Co+i_HM&{awuO9l)Ml{G8zD zs$M8R`r+>PT#Rg!J(K6T4xHq7+tscU(}N$HY;Yz*cUObX7J7h0#u)S7b~t^Oj}TBF zuzsugnst;F#^1jm>22*AC$heublWtaQyM6RuaquFd8V#hJ60Z3j7@bAs&?dD#*>H0SJaDwp%U~27>zdtn+ z|8sZzklZy$%S|+^ie&P6++>zbrq&?+{Yy11Y>@_ce@vU4ZulS@6yziG6;iu3Iu`M= zf3rcWG<+3F`K|*(`0mE<$89F@jSq;j=W#E>(R}2drCB7D*0-|D;S;(;TwzIJkGs|q z2qH{m_zZ+el`b;Bv-#bQ>}*VPYC|7`rgBFf2oivXS^>v<&HHTypvd4|-zn|=h=TG{ z05TH2+{T%EnADO>3i|CB zCu60#qk`}GW{n4l-E$VrqgZGbI zbQW690KgZt4U3F^5@bdO1!xu~p@7Y~*_FfWg2CdvED5P5#w#V46LH`<&V0{t&Ml~4 zHNi7lIa+#i+^Z6EnxO7KJQw)wD)4~&S-Ki8)3=jpqxmx6c&zU&<&h%*c$I(5{1HZT zc9WE}ijcWJiVa^Q^xC|WX0habl89qycOyeViIbi(LFsEY_8a|+X^+%Qv+W4vzj>`y zpuRnjc-eHNkvXvI_f{=*FX=OKQzT?bck#2*qoKTHmDe>CDb&3AngA1O)1b}QJ1Tun z_<@yVEM>qG7664Pa@dzL@;DEh`#?yM+M|_fQS<7yv|i*pw)|Z8)9IR+QB7N3v3K(wv4OY*TXnH&X0nQB}?|h2XQeGL^q~N7N zDFa@x0E(UyN7k9g%IFq7Sf+EAfE#K%%#`)!90_)Dmy3Bll&e1vHQyPA87TaF(xbqMpDntVp?;8*$87STop$!EAnGhZ?>mqPJ(X zFsr336p3P{PpZCGn&^LP(JjnBbl_3P3Kcq+m}xVFMVr1zdCPJMDIV_ki#c=vvTwbU z*gKtfic&{<5ozL6Vfpx>o2Tts?3fkhWnJD&^$&+Mh5WGGyO7fG@6WDE`tEe(8<;+q z@Ld~g08XDzF8xtmpIj`#q^(Ty{Hq>t*v`pedHnuj(0%L(%sjkwp%s}wMd!a<*L~9T z9MM@s)Km~ogxlqEhIw5(lc46gCPsSosUFsgGDr8H{mj%OzJz{N#;bQ;KkV+ZWA1(9 zu0PXzyh+C<4OBYQ0v3z~Lr;=C@qmt8===Ov2lJ1=DeLfq*#jgT{YQCuwz?j{&3o_6 zsqp2Z_q-YWJg?C6=!Or|b@(zxTlg$ng2eUQzuC<+o)k<6^9ju_Z*#x+oioZ5T8Z_L zz9^A1h2eFS0O5muq8;LuDKwOv4A9pxmOjgb6L*i!-(0`Ie^d5Fsgspon%X|7 zC{RRXEmYn!5zP9XjG*{pLa)!2;PJB2<-tH@R7+E1cRo=Wz_5Ko8h8bB$QU%t9#vol zAoq?C$~~AsYC|AQQ)>>7BJ@{Cal)ZpqE=gjT+Juf!RD-;U0mbV1ED5PbvFD6M=qj1 zZ{QERT5@(&LQ~1X9xSf&@%r|3`S#ZCE=sWD`D4YQZ`MR`G&s>lN{y2+HqCfvgcw3E z-}Kp(dfGG?V|97kAHQX+OcKCZS`Q%}HD6u*e$~Ki&Vx53&FC!x94xJd4F2l^qQeFO z?&JdmgrdVjroKNJx64C!H&Vncr^w zzR#XI}Dn&o8jB~_YlVM^+#0W(G1LZH5K^|uYT@KSR z^Y5>^*Bc45E1({~EJB(t@4n9gb-eT#s@@7)J^^<_VV`Pm!h7av8XH6^5zO zOcQBhTGr;|MbRsgxCW69w{bl4EW#A~);L?d4*y#j8Ne=Z@fmJP0k4{_cQ~KA|Y#_#BuUiYx8y*za3_6Y}c=GSe7(2|KAfhdzud!Zq&}j)=o4 z7R|&&oX7~e@~HmyOOsCCwy`AR+deNjZ3bf6ijI_*tKP*_5JP3;0d;L_p(c>W1b%sG zJ*$wcO$ng^aW0E(5ldckV9unU7}OB7s?Wx(761?1^&8tA5y0_(ieV>(x-e@}1`lWC z-YH~G$D>#ud!SxK2_Iw{K%92=+{4yb-_XC>ji&j7)1ofp(OGa4jjF;Hd*`6YQL+Jf zffg+6CPc8F@EDPN{Kn96yip;?g@)qgkPo^nVKFqY?8!=h$G$V=<>%5J&iVjwR!7H0 z$@QL|_Q81I;Bnq8-5JyNRv$Y>`sWl{qhq>u+X|)@cMlsG!{*lu?*H`Tp|!uv z9oEPU1jUEj@ueBr}%Y)7Luyi)REaJV>eQ{+uy4uh0ep0){t;OU8D*RZ& zE-Z-&=BrWQLAD^A&qut&4{ZfhqK1ZQB0fACP)=zgx(0(o-`U62EzTkBkG@mXqbjXm z>w`HNeQM?Is&4xq@BB(K;wv5nI6EXas)XXAkUuf}5uSrZLYxRCQPefn-1^#OCd4aO zzF=dQ*CREEyWf@n6h7(uXLNgJIwGp#Xrsj6S<^bzQ7N0B0N{XlT;`=m9Olg<>KL}9 zlp>EKTx-h|%d1Ncqa=wnQEuE;sIO-f#%Bs?g4}&xS?$9MG?n$isHky0caj za8W+B^ERK#&h?(x)7LLpOqApV5F>sqB`sntV%SV>Q1;ax67qs+WcssfFeF3Xk=e4^ zjR2^(%K1oBq%0%Rf!y&WT;lu2Co(rHi|r1_uW)n{<7fGc-c=ft7Z0Q}r4W$o$@tQF#i?jDBwZ8h+=SC}3?anUp3mtRVv9l#H?-UD;HjTF zQ*>|}e=6gDrgI9p%c&4iMUkQa4zziS$bO&i#DI$Wu$7dz7-}XLk%!US^XUIFf2obO zFCTjVEtkvYSKWB;<0C;_B{HHs~ax_48^Cml*mjfBC5*7^HJZiLDir(3k&BerVIZF8zF;0q80eX8c zPN4tc+Dc5DqEAq$Y3B3R&XPZ=AQfFMXv#!RQnGecJONe0H;+!f^h5x0wS<+%;D}MpUbTNUBA}S2n&U59-_5HKr{L^jPsV8B^%NaH|tUr)mq=qCBv_- ziZ1xUp(ZzxUYTCF@C}To;u60?RIfTGS?#JnB8S8@j`TKPkAa)$My+6ziGaBcA@){d z91)%+v2_ba7gNecdj^8*I4#<11l!{XKl6s0zkXfJPxhP+@b+5ev{a>p*W-3*25c&} zmCf{g9mPWVQ$?Sp*4V|lT@~>RR)9iNdN^7KT@>*MU3&v^3e?=NTbG9!h6C|9zO097 zN{Qs6YwR-5$)~ z`b~qs`a1Dbx8P>%V=1XGjBptMf%P~sl1qbHVm1HYpY|-Z^Dar8^HqjIw}xaeRlsYa zJ_@Apy-??`gxPmb`m`0`z`#G7*_C}qiSZe~l2z65tE~IwMw$1|-u&t|z-8SxliH00 zlh1#kuqB56s+E&PWQ7Nz17?c}pN+A@-c^xLqh(j;mS|?>(Pf7(?qd z5q@jkc^nA&!K-}-1P=Ry0yyze0W!+h^iW}7jzC1{?|rEFFWbE^Yu7Y}t?jmP-D$f+ zmqFT7nTl0HL|4jwGm7w@a>9 zKD)V~+g~ysmei$OT5}%$&LK8?ib|8aY|>W3;P+0B;=oD=?1rg+PxKcP(d;OEzq1CKA&y#boc51P^ZJPPS)z5 zAZ)dd2$glGQXFj$`XBBJyl2y-aoBA8121JC9&~|_nY>nkmW>TLi%mWdn-^Jks-Jv| zSR*wij;A3Fcy8KsDjQ15?Z9oOj|Qw2;jgJiq>dxG(2I2RE- z$As!#zSFIskebqU2bnoM^N<4VWD2#>!;saPSsY8OaCCQqkCMdje$C?Sp%V}f2~tG5 z0whMYk6tcaABwu*x)ak@n4sMElGPX1_lmv@bgdI2jPdD|2-<~Jf`L`@>Lj7{<-uLQ zE3S_#3e10q-ra=vaDQ42QUY^@edh>tnTtpBiiDVUk5+Po@%RmuTntOlE29I4MeJI?;`7;{3e4Qst#i-RH6s;>e(Sc+ubF2_gwf5Qi%P!aa89fx6^{~A*&B4Q zKTF|Kx^NkiWx=RDhe<{PWXMQ;2)=SC=yZC&mh?T&CvFVz?5cW~ritRjG2?I0Av_cI z)=s!@MXpXbarYm>Kj0wOxl=eFMgSMc?62U#2gM^li@wKPK9^;;0_h7B>F>0>I3P`{ zr^ygPYp~WVm?Qbp6O3*O2)(`y)x>%ZXtztz zMAcwKDr=TCMY!S-MJ8|2MJCVNUBI0BkJV6?(!~W!_dC{TS=eh}t#X+2D>Kp&)ZN~q zvg!ogxUXu^y(P*;Q+y_rDoGeSCYxkaGPldDDx)k;ocJvvGO#1YKoQLHUf2h_pjm&1 zqh&!_KFH03FcJvSdfgUYMp=5EpigZ*8}7N_W%Ms^WSQ4hH`9>3061OEcxmf~TcYn5_oHtscWn zo5!ayj<_fZ)vHu3!A!7M;4y1QIr8YGy$P2qDD_4+T8^=^dB6uNsz|D>p~4pF3Nrb6 zcpRK*($<~JUqOya#M1=#IhOZ zG)W+rJS-x(6EoVz)P zsSo>JtnChdj9^);su%SkFG~_7JPM zEDz3gk2T7Y%x>1tWyia|op(ilEzvAujW?Xwlw>J6d7yEi8E zv30riR|a_MM%ZZX&n!qm0{2agq(s?x9E@=*tyT$nND+{Djpm7Rsy!+c$j+wqMwTOF zZL8BQ|I`<^bGW)5apO{lh(Asqen?_U`$_n0-Ob~Yd%^89oEe%9yGumQ_8Be+l2k+n zCxT%s?bMpv|AdWP7M1LQwLm|x+igA~;+iK-*+tClF&ueX_V}>=4gvZ01xpubQWXD_ zi?Un>&3=$fu)dgk-Z;0Ll}HK5_YM->l^Czrd0^cJ))(DwL2g3aZuza7ga9^|mT_70 z))}A}r1#-(9cxtn<9jGRwOB4hb9kK@YCgjfOM-90I$8@l=H^`K$cyhe2mTM|FY9vW znH~h)I<_aa#V1xmhk?Ng@$Jw-s%a!$BI4Us+Df+?J&gKAF-M`v}j`OWKP3>6`X`tEmhe#y*(Xm$_^Ybbs=%;L7h zp7q^C*qM}Krqsinq|WolR99>_!GL#Z71Hhz|IwQQv<>Ds09B?Je(lhI1(FInO8mc} zl$RyKCUmfku+Cd^8s0|t+e}5g7M{ZPJQH=UB3(~U&(w#Bz#@DTDHy>_UaS~AtN>4O zJ-I#U@R($fgupHebcpuEBX`SZ>kN!rW$#9>s{^3`86ZRQRtYTY)hiFm_9wU3c`SC8 z-5M%g)h}3Pt|wyj#F%}pGC@VL`9&>9P+_UbudCkS%y2w&*o})hBplrB*@Z?gel5q+ z%|*59(sR9GMk3xME}wd%&k?7~J)OL`rK#4d-haC7uaU8-L@?$K6(r<0e<;y83rK&` z3Q!1rD9WkcB8WBQ|WT|$u^lkr0UL4WH4EQTJyk@5gzHb18cOte4w zS`fLv8q;PvAZyY;*Go3Qw1~5#gP0D0ERla6M6#{; zr1l?bR}Nh+OC7)4bfAs(0ZD(axaw6j9v`^jh5>*Eo&$dAnt?c|Y*ckEORIiJXfGcM zEo`bmIq6rJm`XhkXR-^3d8^RTK2;nmVetHfUNugJG(4XLOu>HJA;0EWb~?&|0abr6 zxqVp@p=b3MN^|~?djPe!=eex(u!x>RYFAj|*T$cTi*Sd3Bme7Pri1tkK9N`KtRmXf zZYNBNtik97ct1R^vamQBfo9ZUR@k*LhIg8OR9d_{iv#t)LQV91^5}K5u{eyxwOFoU zHMVq$C>tfa@uNDW^_>EmO~WYQd(@!nKmAvSSIb&hPO|}g-3985t?|R&WZXvxS}Kt2i^eRe>WHb_;-K5cM4=@AN1>E&1c$k!w4O*oscx(f=<1K6l#8Exi)U(ZiZ zdr#YTP6?m1e1dOKysUjQ^>-MR={OuD00g6+(a^cvcmn#A_%Fh3Of%(qP5nvjS1=(> z|Ld8{u%(J}%2SY~+$4pjy{()5HN2MYUjg1X9umxOMFFPdM+IwOVEs4Z(olynvT%G) zt9|#VR}%O2@f6=+6uvbZv{3U)l;C{tuc zZ{K$rut=eS%3_~fQv^@$HV6#9)K9>|0qD$EV2$G^XUNBLM|5-ZmFF!KV)$4l^KVj@ zZ4fI}Knv*K%zPqK77}B-h_V{66VrmoZP2>@^euu8Rc}#qwRwt5uEBWcJJE5*5rT2t zA4Jpx`QQ~1Sh_n_a9x%Il!t1&B~J6p54zxAJx`REov${jeuL8h8x-z=?qwMAmPK5i z_*ES)BW(NZluu#Bmn1-NUKQip_X&_WzJy~J`WYxEJQ&Gu7DD< z&F9urE;}8S{x4{yB zaq~1Zrz%8)<`prSQv$eu5@1RY2WLu=waPTrn`WK%;G5(jt^FeM;gOdvXQjYhax~_> z{bS_`;t#$RYMu-;_Dd&o+LD<5Afg6v{NK?0d8dD5ohAN?QoocETBj?y{MB)jQ%UQ}#t3j&iL!qr@#6JEajR3@^k5wgLfI9S9dT2^f`2wd z%I#Q*@Ctk@w=(u)@QC}yBvUP&fFRR-uYKJ){Wp3&$s(o~W7OzgsUIPx0|ph2L1(r*_Pa@T@mcH^JxBjh09#fgo|W#gG7}|)k&uD1iZxb0 z@|Y)W79SKj9sS&EhmTD;uI#)FE6VwQ*YAr&foK$RI5H8_ripb$^=;U%gWbrrk4!5P zXDcyscEZoSH~n6VJu8$^6LE6)>+=o#Q-~*jmob^@191+Ot1w454e3)WMliLtY6~^w zW|n#R@~{5K#P+(w+XC%(+UcOrk|yzkEes=!qW%imu6>zjdb!B#`efaliKtN}_c!Jp zfyZa`n+Nx8;*AquvMT2;c8fnYszdDA*0(R`bsof1W<#O{v%O!1IO4WZe=>XBu_D%d zOwWDaEtX%@B>4V%f1+dKqcXT>m2!|&?}(GK8e&R=&w?V`*Vj)sCetWp9lr@@{xe6a zE)JL&;p}OnOO}Nw?vFyoccXT*z*?r}E8{uPtd;4<(hmX;d$rqJhEF}I+kD+m(ke;J z7Cm$W*CSdcD=RYEBhedg>tuT{PHqwCdDP*NkHv4rvQTXkzEn*Mb0oJz&+WfWIOS4@ zzpPJ|e%a-PIwOaOC7uQcHQ-q(SE(e@fj+7oC@34wzaBNaP;cw&gm{Z8yYX?V(lIv5 zKbg*zo1m5aGA4^lwJ|bAU=j3*d8S{vp!~fLFcK8s6%Ng55_qW_d*3R%e=34aDZPfD z&Le39j|ahp6E7B0*9OVdeMNrTErFatiE+=Z!XZ^tv0y%zZKXRTBuPyP&C{5(H?t)S zKV24_-TKpOmCPzU&by8R1Q5HY^@IDoeDA9MbgizgQ*F1Er~HVmvSU>vx}pZVQ&tr| zOtZl8vfY2#L<)gZ=ba&wG~EI*Vd?}lRMCf+!b5CDz$8~be-HKMo5omk$w7p4`Mym*IR8WiTz4^kKcUo^8Hkcsu14u z`Pkg`#-Y^A%CqJ0O@UF|caAulf68@(zhqp~YjzInh7qSN7Ov%Aj(Qz%{3zW|xubJ- ztNE_u_MO7Q_585r;xD?e=Er}@U1G@BKW5v$UM((eByhH2p!^g9W}99OD8VV@7d{#H zv)Eam+^K(5>-Ot~U!R$Um3prQmM)7DyK=iM%vy>BRX4#aH7*oCMmz07YB(EL!^%F7?CA#>zXqiYDhS;e?LYPTf(bte6B ztrfvDXYG*T;ExK-w?Knt{jNv)>KMk*sM^ngZ-WiUN;=0Ev^GIDMs=AyLg2V@3R z7ugNc45;4!RPxvzoT}3NCMeK$7j#q3r_xV(@t@OPRyoKBzHJ#IepkDsm$EJRxL)A* zf{_GQYttu^OXr$jHQn}zs$Eh|s|Z!r?Yi+bS-bi+PE*lH zo|6ztu6$r_?|B~S#m>imI!kQP9`6X426uHRri!wGcK;J;`%sFM(D#*Le~W*t2uH`Q z(HEO9-c_`mhA@4QhbW+tgtt9Pzx=_*3Kh~TB$SKmU4yx-Ay&)n%PZPKg#rD4H{%Ke zdMY@rf5EAFfqtrf?Vmk&N(_d-<=bvfOdPrYwY*;5%j@O6@O#Qj7LJTk-x3LN+dEKy+X z>~U8j3Ql`exr1jR>+S4nEy+4c2f{-Q!3_9)yY758tLGg7k^=nt<6h$YE$ltA+13S<}uOg#XHe6 zZHKdNsAnMQ_RIuB;mdoZ%RWpandzLR-BnjN2j@lkBbBd+?i ze*!5mC}!Qj(Q!rTu`KrRRqp22c=hF6<^v&iCDB`n7mHl;vdclcer%;{;=kA(PwdGG zdX#BWoC!leBC4);^J^tPkPbIe<)~nYb6R3u{HvC!NOQa?DC^Q`|_@ zcz;rk`a!4rSLAS>_=b@g?Yab4%=J3Cc7pRv8?_rHMl_aK*HSPU%0pG2Fyhef_biA!aW|-(( z*RIdG&Lmk(=(nk28Q1k1Oa$8Oa-phG%Mc6dT3>JIylcMMIc{&FsBYBD^n@#~>C?HG z*1&FpYVvXOU@~r2(BUa+KZv;tZ15#RewooEM0LFb>guQN;Z0EBFMFMZ=-m$a3;gVD z)2EBD4+*=6ZF?+)P`z@DOT;azK0Q4p4>NfwDR#Pd;no|{q_qB!zk1O8QojE;>zhPu z1Q=1z^0MYHo1*``H3ex|bW-Zy==5J4fE2;g6sq6YcXMYK5i|S^9(OSw#v!3^!EB<% zZF~J~CleS`V-peStyf*I%1^R88D;+8{{qN6-t!@gTARDg^w2`uSzFZbPQ!)q^oC}m zPo8VOQxq2BaIN`pAVFGu8!{p3}(+iZ`f4ck2ygVpEZMQW38nLpj3NQx+&sAkb8`}P3- zc>N*k6AG?r}bfO6_vccTuKX+*- z7W4Q#2``P0jIHYs)F>uG#AM#I6W2)!Nu2nD5{CRV_PmkDS2ditmbd#pggqEgAo%5oC?|CP zGa0CV)wA*ko!xC7pZYkqo{10CN_e00FX5SjWkI3?@XG}}bze!(&+k2$C-C`6temSk z_YyYpB^wh3woo`B zrMSTd4T?(X-jh`FeO76C(3xsOm9s2BP_b%ospg^!#*2*o9N;tf4(X9$qc_d(()yz5 zDk@1}u_Xd+86vy5RBs?LQCuYKCGPS;E4uFOi@V%1JTK&|eRf~lp$AV#;*#O}iRI2=i3rFL8{ zA^ptDZ0l6k-mq=hUJ0x$Y@J>UNfz~I5l63H(`~*v;qX`Z{zwsQQD-!wp0D&hyB8&Z z7$R07gIKGJ^%AvQ{4KM0edM39iFRx=P^6`!<1(s0t|JbB2tXs_B_IH9#ajH0C=-n+ z`nz`fKMBKLlf?2AC+|83M+0rqR%uhNGD;uKA6jOjp7YDe^4%0fRB<^bcjlS2KF~F; zu09wh1x0&4pG&76M;x8$u`b134t=dEPBn6PV|X29<#T4F1mxGF*HOgiWU8tN@cguI z_F@o+XL7FJztR63wC|j4x_DANzcX94r7Iz-O2x$({&qd*mdLG=-Rv)uZ}UlMR+F&q zU}=lkfb0p1>1Ho){o$@}mSKIV;h*$AND7~Dl)QzpFBlSM99Kx+F7GsVK5xcR? z_4Q(Z%cgk8ST}U;;=!LwyZVu^S$>B-Waeik%wzcKTIqeX=0FP(TGQ=nxi=dsS5BYF zl@?}NT!Y!Iyos^@v7XWXA{_bV~1lxz7gC?xuXxy0_?GaN!AhRRM5>)^t%&ODd;@HN5L{MD3 zc>i2keQZVm#?NrDwbfd}_<*5^U&w0zv~n-y8=GGN-!=_`FU^cM8oVCWRFxw?BM^YD zi=Vxz4q|jwPTg+?q7_XI)-S@gQkh>w0ZUB}a{^ z_i;`Y(~fvpI!vmW*A^|P7(6+@C4UeL2WATf{P1?H5rk`5{TL zcf!CgP6Mi{MvjZS)rfo7JLDZK7M7ANd$3`{j9baD*7{#Zu-33fOYUzjvtKzR2)_T1I1s7fe&z|=)QkX;=`zX8!Byw-veM#yr;|wjO^II>!B*B z0+w%;0(=*G3V@88t!}~zx)&do(uF=073Yeh*fEhZb3Vn>t!m(9p~Y_FdV3IgR)9eT z)~e9xpI%2deTWyHlXA(7srrfc_`7ACm!R>SoIgkuF8 z!wkOhrixFy9y@)GdxAntd!!7@=L_tFD2T5OdSUO)I%yj02le`qeQ=yKq$g^h)NG;# za(0J@#VBi^5YI|QI=rq{KlxwGabZJ0dKmfWDROkcM}lUN$@DV`K7fU?8CP2H23QPi zG?YF*=Vn=kTK*#Y_{AQN&oLju|0#E=fx%YVh>S{puu&K$b;BN*jIo@VYhqPiJPzzM>#kxoy0vW9i;ne2_BIG0zyRFp<3M(iY(%*M_>q0ulV2K}Tg zkG{EWKS{i%4DUuHi%DVKy%e+Q!~Uf`>>F6NgD{{I8~nO4!VgOvtFOc7(O)X`|7n*f zxBa4CJ-v9fUUH+`7sPVvpM_C*udZ@OTGTzx56QM5y~OlrZc&w9=)B?nmd@keRn+^= zvm~4sa5987LFDnU{(N|N zJAR8H@}p1fC+H(yTI4n#%~TbImMpuqYn9cQ<0QQ%=PzZItLkC*ef9WJUvfITKWh#D zc#__8`4am9%#NslIUw+<82#SR8AYG|woLfBg#!-&dqq}@P>|I0%lbdy0lSMmNe+}o zj0zZuFr6Wb?Y{Qy-S=|r`bdrDmhnmvkRnkdn`YCleU>Q$=je}LGhh>_QAj6aa_0Oc z%Swsmui;IRx7bN*=AAS@5yW&Y2hy;3&|HAiA8}!HT6!Z!RVn~MZg`RmI6&%#tBZDx zfD+y@Z~NWlk*4l13vmt3AK2wP!fQlnBbECL>?p)F?T)<`w&QN>cP_V>r7UTcsTaaP zTOb$f!P@zf$6>890NVKbIkG8rE?9!Y97sMSZjfF?A zYR8lp`LMoz~O?iaZN;gcX;LC-%Ia*R%A&SLx!YIf29?P+=XAAojK8!^OU*@?R&DK!#G_lsn!#;S375uZ&B0HH1|BO0R90$U>qs zSvHv>H~mAgNCcjo-e+;RjY6B9NCbQrZ|BHjTkehaU<9CSkdd>Vl*ifA2LNOP&R2Qdy3k3-TQ+ zbq=#vI43x`s=%~cGyN&y4Y!FxhwgDe@i6uv8^BLL&3z*SO=D0aLjih?gY4-9uWp5or)H+v~w6n5X#F-I52z=Z_p4JB(;M| zeaVFhuR2|3UD2MzVc~^nSoD2(dD#uL_1PdnIxeA{V5n`#3xf1Zx@4lw(DsQ&H$h zw#%3O<1173hjg2_nhKi!d1ej=h7y`hVjCNB6|HTnx>SWuCE-kgTnfT+YGX4_Lun({ zDv2`>d3vrS)tTf7ps_vvh!Cx^e1BFuWnEAh0(7fkNk|-3oU|iRWdsC6U)?Raft~HN z;^$U}vZK5O8|LV$>6X5T(uYkblv{zwPxnQBh(BQ5tA~J!vGiAMYP^_ki~pkIxDfOZ zUJDwq%O~WueeV6%uN<54&u*c&E4y431cklBNrb06zGOOy4XNT~JS-q(s6@)F@ovbe ze`fial(O4(-su%6@@1+V0MsdLLMyE8;)nou(7}czU(5ASaZYDT(kUZ0L(&g$nF^n9 z9-Pi`ZZLX&)^*M6As4_2Mmc9S7OT)F8KkL2NJ)KJcnCuWU=Wy402A&45#Q9Id~BBH z0cY*xlv!uXzKrXLH!xQu(OtJvEj|0-DmRj1vjFz{c*I4$Pe(+_V|^b~S!0xm{8lq= zZv)@NlcyL3Xdz+*|L137F7y6L-2VsrKw=q^S>F6i%<{Fr8zk06$Ay-(!L$fY@7mcng!2}L0t zgi|KxfB63Xtk_Q8#ZPipQ@!zgjdpEIbK_?q17Hoi4Eiyun$hrc>T(7pOLVLQE=lgGwA+A308p& z7@=09(|$>eLy5gLe{*|3b(M;1n;C^~v?o88jYib48eR4$QGsBFzd}3QuwO^_XE(=B zq+hMi0UFC|dB{LCwch7;zYT=NK})O%sgi0k#yV;My@24^B1+CuZmYOh0^b)5Ba_)) zC%i#_Iev&nsu%I|1N5=MVc#PrlunKAs&hY|3s5;@}`>sB>}gzxuB zB=2vrRyB3uiyW(hkDUNe1@&(b`;>ZvGgw|@s{zVC#_`HXIN_^J@Etb zA7A+F?ot37T{<-vTy8h&b3e+WKHE1oh;pUQrN4yRRrx?mT_9jRa2i4l1fUnLW^Cbl z!I1>VzyFe?VELWWhM?@?t-YPZkD-Qjo@bC2(o#ZtZmr{KZsdFWItV`rs$gp{724@C zL8K5}E0+DHcWcL^{BGei4>@J-3%a#$y6;I}=upc};-NDv-z#kPX26ylOpH)Ov1uU{ zkLj6oiH6l_s+B~_z;|Jc2oi?naS7#3H63~~lWj4rUnd=fCnKdkik<@R&kch9q##G{ z4u!%=rlM~Yp3jk*t8}1B`Sv6<%Z^}~1e@aq zg|JQ`QO2pSjAm-g*?IrNc$^~sIrNBo2$m|Sxanr?Mfs>2@Auu49 zGXlsS<9XS1&8h(dD*Hl&5HBDG!^pJ*lkau_Ur+7`7z;rcs$hT4we?3bT=7Fe<>{5( z2m2(c+hUz2BTHM8dCe*Z3XX&Av;b~a=$6EF>&^E8%nyxO@m_n!q&XD^A{SRjRZQ0L~qDeC=j&0$j6=LNIz@`ni^>ch|sv}^6 zlm>?28yPl@WmDPR?Y-A9X{U9Dv_IsbXJnzKCjkRksLOg#42uG2mE_acbTQ4)J|1V>%U@K(FP3AYhL0U zdeOCPN1qLv!|#c=p!_+%VNV(GHt`RuLRV^vz<5tt-r)yOK**kUWPspVAf|}ZL{LS= z@k(@@!P&W!>wwe`x{+GrFSWhHov7hu?{KuuT%kl#WO@*WX$i_@retlhQBj++SVNCx z5$78LxP>Z=^aJ)D280r_jj=zFfMJFXCIe^B{~V@d1rl_F(qo&AB4bC-vYL>x2jSKX zpuTG-6kgp3e^T&+dtV*i6a~)v@n?n*MffN59y}<0djUX zt27R+SE#hp8bzc#;rk$jw3r4)Q@eI$*`_)=Pvge8@8|8>H3X)<9YX6cXa=ii#Le;(qKm@%0-7$>2ShnYc`j#zJ7gu_FE^?uAkL|H)UIH#gPu^40!6^J=^ zr`}iwa^!4tzW~vOMZAaKF>*8A{^8m$i(VK)>?=#l`xrVe>wseSvM_aF zATNkY>kM_P3?1kE`uIq#mvr-wuTgUH0N<&JhF=(E9%^NS*HLm!4GZ4_XI zL=R5tlG5Mk_1rPfg)sk^llFuKPMPBhuU|L5q#yP_mzxp1o&pAzi-X31sgFpIHn@($ z_>=`AB5(8tP6p2zS5VEvH5J$M` z_much3>S7t3Yo`Yx!>83-hW9LYzDKP?mKdkD#QAK8*M((sx{eBQdrR<^3ZhFP81+& zBnJMUefQyNBji~$5d88Wfw1Lv59aJN9t2!pABLg;ewJ#LXL-10;QcJl+Y4Mtngb)k6JZlCf)3uD_u)J3sYyN;NN5hNbg$%W!i-GK%e&!Us)2IExWSss$YG(hm3kJ-h%yD z>8q^n$+4I(_y_mbT{du4P%h1j3oSpjhY97{+IZ`aA4ug!vNJ6*p?<2H(2w+GD3j$I z1TUXGyNzdf>_yB3grP~FZUs<2Quw;eEi*7s(-MiIkQ%@J^+WGdQvYSUN+TRiD-xto zJ=OUU+kxGYc!HCLNbCvR4lGTp~#L;DFzGd-#gJe*xf(P3hDQz|y)?b9mwU3WUVnpcqXM<@w%r-k*Wr^gzAv)8T^sqA=Ye z!7qy&exJmAcAt~CwS#@yNmjr8*T*!A6w4~E*ibaLRs0CFo(;R3=ODhDt6zWNodmo0 zXx&bT$6&+5c>a|WJ)F4G-^GjY0H#*tY=UNyYr_q5fsrcjk(c^~e*7Lf`!Jd`)p412 zn|^*hV= zFI4UbwA%X@smDd$cQOiMC%jfitTxTb+#`9`G=2rJDfK!E=5ra|So>lc{X1$~w28i+ z4p&cTGwZ#5VueiXS9O8#;RR$yg7tL9!^)Sz&pZYIzlSh}0}V{LxL$Cu%B4U5_}k}- zm~|CsD<076x@<>m=6w6N?WaThIBP`!u{-;WF)xc=2otx*lwf|5+MkdJePjh(B z9SH+%cHGCMAXNxB{_3^otDWdsV7Ob6n{0 z+&!(;iaHOX__5z_$Qk{%xYV%Ig@7iokGBwR`3642ZP#H#v9QGbWl8<|MS*=@qO@Uj z6+SZ_v9`1paUe5tFN~v(b#J3a_Lx0+;r9giZIx-A5TxdbG>xi#AZ5_z1V}B^n)sxT zz49}eK7EWb6wR!6-qQOrHQHkUvshvq%=G2d&@(#XM*Am1;WbnJ{X_!a{ZkphD$^TQ z=Iskb&}=lBm(RHiwJoGg`*NiQ6#RB$T#LF+>#ef;Jne&MxKPX!#r`&TVEFsp2jnNx>dClzpcPy&G&13a_<0qaR3i+k212~hoQ z8nMk{JP-t04I{GW5gUBqcJW-jSMrlw}>p)ptx?WKuCUV77taMiV zHok9V=6yv+Uts@fMY&A}amC=!Yj}eL@=e%XJ#%?agkt1jWF+10{(E9mHLDa>Ll7Vj zG=3cp%ljIB-6pC}6&`xJ*6WCP|IlglLWJ^?yviI8Ve)?V_i4%n;olzny62_`-|IGi z^=}p_O>Z8M;c4|RExu70E7ePW(HWVS&E$+LL6xSQgB`QfMQJ|4pCTFowA39p5P-|$ zUtM_H2HnP8_RoS~Vwk(FhbG zH41licj%=0a;Ln2STFBvU}Ne&O&%8bYKj!h1FA#sNM`232fX|U3QPp#3C?mN2;hE9 z;)!@5ixSPl<89^7gwhHc2YAX1KJK$#*3`KOMIQ253q7-*RJ5k)zp9GBO|Ga~X*^}US5oN@aG&waHV%vi~r{t^`ptTxb zL}q1W8S7*>7oWwvgV4uFLZ(@k`R*=LO_|Gu`prs~!WQXj-NLIa^2(7IHg>BG^N zc|i{-^=&Cek9dkJFQys|sjG9i>LLz|;yCv{^1i%c*h>8zF91kLvS9HBQi~ZU!JL`B zK8N+U0fr1*6??Ium)AF!6tc1eGhXIYL6IRT7rmKp7+>?%5Pa6zC5)KY$ycF0ZJ`G5nEQDG100U-jLkH8^UE4g6wq?sg%pP=-$&G#bcN`^?w3a6 z((s$6eRKcSEIslW-kk5Qi|5Mg-(xdLF}PxxVh$PuO}#aR6pW1kV4Af!Bqh*btXNNZ z>-4(IUl+L4dw+3LcpGut=qB45O+W)Q5?*zZ2A6rJcg`qkSvWA!j^r2mqKuCm6`Py? z@^T#Ux04HemPGd!Hs7NkZdVn1}8_j`o?)*OKZGS!`ff)gF zG?v-lj$wWNWCcw2Mg2o18D~1?3_b0XzdiKBNkYSDpcv@&kp0POmweJE2ZkIQ3B!a! zIgIoE+Xv?;34kyo^QYjZk+tEqZvq^#QG(OzX4~X+KtsoQoddTWUR(yo8R+ObEF1j<-syWOb>)JQ&Zbdu(sctU%Mt zW&YR0{ttY2TTXYZ?~WNU&cES1Z2q(7SrWDh``!J(JM+Nk$!hu&Y;(7E`ZNKTe0w+% zJc?Qnw2B+%UR}0;cB0Rufa(7-3FF}?629@LgTiEC&2uyL6NxexOp?AKT^aAx3gi(W zao>r>MPw0eQ3>IV02uLsC@>yK_epX6GRg4{NEL2wPPF9=*L2RV3yyK8DhuEK>rmmV z`&Q~#c`lgR&93TdOCja|ewOXmPNRh7!&dMT(1ett#iDr8HZW~VqWW@7fe9B6;7S+? zbC`d4@MEau&mKlOPKd>*10q0c{~^baw6!a*w^sY#0Xim{oOsiXiDOhbG&kl3c$$n1 zMRrD83&QucDSEcV*7LIp8VTA@F<%qe+_c`L;6on(>SjAU^}5c9!BCffT>$VQhe=)z z8(=Ej{5>jhmjB3{xDfj2R@VmHQ!CqjlO4KnuOmvHy3K#po$yp_V;p_MKjh1`(rzj6 zHW956k1yvntz{_g?Xbs`avK(IjlTnsu%htO;D7 z?J#x^EzuvVn&NA=!MEj7cwe5A-Z$Zk2LBZH$~%E* zf`((xH0?`}hs|HA%mtwfOEsZJxxrennkTYcwP#FKO5%Lpc^JXhSpV|ZH$Wr;`}`_( zIP==gd3LYyVtwD|*ZJGi{7~x8{=^bGVqu0RJ`n_BZH9+}kz%-4ZRsImi@rx%=ZEKs zcPnUXo6hbJV>fH;@1|bAHIe0ijYI*&kdT|HkDS$9No9 zCHo=*HWb~U+Dtzxr+Esao}6@|;Pf+E$ay0$kQp#s{wlw+7aIKbMdf`OqhoG*;Tco0 zjrP}VQG#Y2cJuqoJg&5({)S(BA}q9T1lGeWRyu=Je|)I!6a+aj!IP^1({)ZYe&x6w zt3a)Dq^TB+A7CdB0-}#z2Ur$W&h3YVw8==!xONy$uQmDWh-@15iEOt!q2m&?ZLA|w z8loSb(0}7y6Xu0?M5Uf4>VZGluB`wMf2oh;m)ghxVda>3m}4%V)r^0nVQ5V6f3>*) z0&VN!N0~GC^P}vj$`EDMZEmVV;N&RISY2C;$0;2(<{Lt&PKzqRByQdiEHGAbwtbS zPj`Da5%U6k1oEtVzI}QNw;!hT6F+~|@=c@$C4NtO@=xgP?|5MyZAyuCzcvq4rdAv@C06%gZ`9%I);R6UGiGJobfux+<0DLS&|MSG4UH z_~o{^^9>ixMg~mY!-@Fai{xaE4^;qy9iZN15Gbn5ZqHWf>Jc5Rv6(#n8`1NcCsdmG zab*dSXVPaE?)wCalD;$ivF%@nB#7D`@YG04p6ed9m}4iJW|pfVMLE<-c{=-8$e?cH zUdU#mCj4gb zZKA^b9p*9S(}8@tw~1RNPHr7tQr;P+-)D8|sq=*o)G%RGqt> zzP5yf`pVxb)I51D_G~Xp^GNK zVI6sAX)a9s)e{8N3?35YA6aQTXuyszK3ah~CemzA&CII#8F&F#KN41~8I^&_%}6MCNb{W87qAF`zj_Y^szhb> z3p3}KbOxotY|(lD=;)`fYE_*{S}x;f^SW#)SU&5X#o|-R|trpa|L5PS5aa0 zTHw8%SDSVtU4?vyrhnq+^@dgFS)|(y{~(4j%3UEiO-rBM9%`)8(dh33pMLiuurNY# z#10AsQ7%*0Cu_DSAU}P;X(JwA64~Q_^R%d_zSm^6Aux?Pn70PM>9EvLeOX z&w9c)pGmcL22;MO3C_B>=NC0RJpMp8?#ZUf=GWRvy z6RHq3B}=MGVg?9@iKFBpsvnkVh3{Vpp=`CcD=u~@ql{my|6?3ssi3mCOPnjI&E}VC zc@X+Yl>;;DNo0W0`0th!X{?luDhOC{E8N=?!w}K1{V=)+1={m(f`Oc|N=07>}3;z{-(A zm{JL=j?Sro5iecmE2-pWlRf(r%|HEQ7kgwQ9+kt=NBhtQI7OwcZ#3%$Uf%^r2nhjY zoQ08MfC%_X{O9~WcirMZMhn#z^ux4Erx-tf-6bHD)9eH&^L>^jvAd^9A^DCDs?0;k zkm7LE*KjP6`2d17MrQaaLqd_Rka}J$csvUec#hw78<=s(hyR>065~YCVCA9+#Q+; za(*L0IEw!r5P|@-;x33L$Lv9 zcuN8YG&g{<(SeJG18~(b!5yywSqQiLAX0;---;}mF5&b4lg|T?LwKREa{9YX_-zL@ZE?Zqi@HxK^2KO1>0LATu{te=T zprmHtY)bDVfxI1S}KBE7V zznP7KQ8HekWU#W6mw`dr-boV}pMQR==&5=Q5T=_q091jfc;R*jX#&=MQ%~@E@9^?`$v48ks<>(fI(F6L(5ppKy|$HWng*bKOb(4|cMUB&z$#ob#XV z5-mg)gmFIybZf=znm3ZPyUO^GJfxt0kmHjaTZ|sthsxXw&}Y)fOUSg=JhRSR^UjZ- zhqqb}Wsyw4zdnj6@#BAJa#-PdI4_dgafFXh85DsEQ_cT+5)XpZq$fZlBA_9UsE9r6 zEFec5?uqN@QhJ^IzwZrwl-5J`CmVPv{(YDTqEqWR^dI;5hXc~cxP%B3v&~s0`Ct89 z@S`i~a^c%V^N81dDT*ItFS*&IN;@O$EgzX0e7x&}TD=!zS}hTpezBLS>mdX(5< z)8DEI(-o_D)c-UX@dA1MuJ*yc>Hf4|`*B2S_O>w*-tbUwtiu`;W(Ud{HTty@(&x(T(F&;M zJ=?H>6`B7nf-90e8V`WSVp|0oEKB-P2M{}4ZDawzvM&a!y>`Y#jCsD%T_l``@ah(I2nJs~Q|%uSKu@k!m~*8B*IoA{*TgtF<(5sHCGG;n@NE%~Xt(G$^&<87u;}Na zx-8cq0g`uA(&RBFo=-4Y1GUZ<``Zw{xL4jfHkZw~%~wvtGueszcXt)_QwH8g!; z%s&3kSa~R$dO$-%L-)c@_hi7&>{6L_M>OZFkUQu;{sL_bUMStNrt{{&O(Wn~*zPOk zB>dnfszb29NSTf2pqIs68k|p-UrSrxgLHqi?3N-UFa!LHy9n1)=s>`yS+J{MEzS@ zNlfGtpma7kG&LR3JE@wB%rFA*h~~KitlO=IP)ZjN6dQLM6qsry zHkB#cyNh#n`)}bCrN1My*;k)^@>e4gJ`LJK?2)Pwp?4Tl4)4FA0(tvY+#1jOUM)xw zlMz4x-f@g^+yKUN`?Vu)|AwujArnM~Pa@y*Q9S8eS(u{-S%(Z5=R~pRl5ZGDjdqH% zC8rW&{##wOpU_oTIG4WXMk4&%2t1;lWcW5&!yxmOT*!hBcKyTqEcNoO+R2;Q?Yj+W z1-Y4?59fijz4(MIDwGe4-baYf08UCs;r|YefD-Md2ST;=cxwpgW=tR76-dQVAhn^= zG9Wk5lQk%jIR@KNU!UMp6@BfU;r+;y4VQ)D2!Il9HX%yW-9nOzV+m$YKzVaO`B8S7t z$!S2Mz`xw>V(RjE`0>bQp<0y&h~Y=M#jpy!#=dE>`=e_AjSZq6u!Dy1xJf~-7|0F! zPR9|n`e_7D2DIV2H(CESQ}hA>U>n|6`%z?YKEA~)BOVY%y=jPV zT=44R!L?J)736X#csn|lfBJ)o8ixaZclguWgrGO<`TN2FMfO}7;5}d+BlK0yTSH3* z4!=;5rOh85&2|x=46hkNaz?)U8&=bcfh=N_#8BNpZ2v$aVBo;sk^*X`v;4-LU;D>! zM*h12MxXIQy)SfAqE4;jY)wgnppazZkdNNVVF;(PLf^qK$FgY9+VFyBKE7UC|f z`R|?&egV11K3s$rJ6!GvoeW=jV*!-e(wA;x(2=d0E_e_%0x--0o8#~m^H1%AH5Z^B zn!TNPn927*bvaf0pt}zhK0o^V@WlGwwKo(*nQ|Q~4_;>~-8y20`HP>@UJa)3nEnGG z5Hwhs|FcmFG16ZVNb5hL`2Gc1{zWIMM{_OiKewV!hCi}U!VuE?s9wU-QbZ!)+Y^tS zGzp5OSi5iq6hmEr$w}&9DFgoB+i*`q`8TBi^MVS{SKEb8Aw%@K7@XCo(De2A`6%mf&a2#~y1N)+kJLD$1HCP!22)(U}xo2|j?WRzt(11j8Z_*v;P$R+Ug*Gy3VxV4K; zGGUGabnW*`Z}~`ydXL-l9e=GC$pY#z|63vy>E*m=$=j}iWP{sRTh0%H54`t>2xYH% zsk+M&u&pNgMCM@3e)Xc?jBWX-TIR_cQ1Z!RW7!B zBjZX=+^3}?SE)B+$EP+0oi1Fp5blDT?*}nsP>filqXH{ms zxU<$hetC`u)Wi+x|EKL-`y^#aQX+sDYIa{M;V%LqLrOk~lR>u0Q!+pyQSU4zY`?E^ z|5@)C)w6G_=i5YYC5SE_u(7hDNYr}uKT|@DSqF%S++lTIbIk^$a>{~0IH8KNFEy%+ zW#$&!ynpgNJh>6uR~?2c)ZMW+h0OKu231(7L_vETPaR+(P)Zy%0~yGm>E9?@@x!Jy z3PYgS}Q@b}x}E#F27@F+j}0=&Ql4gES&f8acMrPAVlVs9$97`FR))R5wI zc&}KFI1UIewh>3PkhnB7u zS3AT8_*|nexznG|Z*DU0c!K@jsI4J)5#DyNi#|e#`l1Vv1`1)*NVcy0LZ``aL0n8B zecupJ(rhq3u8bW0NIRhKYq$v1li+jp*4hfAd&wxYDE8vn1TQ7S@bTM|I2Ob z8vMOIxA7&_j{AKmD+O@EyXT`|dElt0pED^@IV0m)RPBUs*5jW60>>w1!@_G3aBKzG z_f(KfAPBk}-jQtR*Sroq!*3rbQ_m27e+YdzQjUb<_*k8vc_C)y!@cj5E>NxUhPu&g z@Z2<~esU`)ih+4opWe+K7sbN9n*9@n>#@n3*o z?xoROgDuvhq>jJ;Ve{6i<3roQNfgo5^4Q4(|GNExO2Dr7GjgA2zWuKp_K)K0R(6lv z!l$!zW-+T6mb3gQaAFviTQi{|*t%>{(mhTdy+y;Re4qT@kccy#{b z&zWy~kLO@>*WPj2k#H)|7L&gAJ37DmHQAme#@m;(Y8Nu^`D5vf8sZFW#+lA2!HK=( zJ)#hO6JD*`o~&c*&46d}g=Qj@SsoB5ikC z^1V8E+&<-OzuS_C`p5<<(A6fB`LXT(!kV^0_~hL6PpW4={l%|#xgdh?5EIk~lu8{D z2hiyhv3Yxij_#$Wu>P@7SYsl`-~3;}Ktx{34_NL^Kwin&=?!HDv3elQDbcU*qyYpN z(#yw~f1vFGK-t%CC-qa-4FYHbA^h>bag-I&*qaxwn?Qv|idE$<>1H|Gr6JtUu(he2$eg!N z@HTF@dG1)*y;4fxe)4_ZkpaBHH9hXp9p4|gLrRQyuevRd@gSS}JhRnWqrvm|U@>qM z=yl7RQROTKwQtzP3!zUF)_6Ld#NGA6v~2{J9Dd`h6{%+XsU#qGLh%`fB1Hc?wfayK zN`H4BpDp)npVQuu$DVW1qsBS&AJ2eP%6Qw>;k{)Z$8%HL=Q4(a$Ng2_vHw&vA!1L+9zc8vaX2GtqJ{L-;gvF0IR$em zMQ8@{Qp3+3Quk)TJ$?I<8KmwzD*7#(q<@Mc`dchngW}cRG14(Z6K7{T|LhFXwhqUQ;BET;cYqPcAcMgt6M$V9$(?jHo@Sud$an$U&5F zZ1QNh^ztt)E*d#Ij;<43oSKKnd+WNr$_r}+s_O_x6DZSB10*5Q{ourqq>mTl| zx4y^(cy+9;t@R=*j>3_dmm_m)$k$#937V(sllby&5)Xex^UD-|m|q<(jEd#@DV(of zAd7sSdmS*zUDqJ9|K%O2J2OfdUiK{{b{PCy)pi<;hp~7v1CQj&4-10 zgO<3dqhYH1#-Fa}Q{pjql5>>P6gZH21zLfxZ4$SK4T@7b!|`nWF9b*84Bq8&Eht;9 z*P72x&NUCZ7*@B$`FtE=hz5b}S`|c6Ey+j@D1ZibjJaRlR;{cxAWv z?Nqa>QqV*H-*zzaPvpLMHt~nl(x6?vrPpR?zn7~wow?oj*1TKmx4j71>$hvtC$DLD zUrz0^tiP0792U&dxJxNv@r}Elsjn^aSLUu=9#mD{&9n8|ayIL$!H3s>%KEvbchBFW z%cd?VU83mGF#Dar9*s~w&AnmQRQIOvR+uWsuZ?+|a=TzApXO@q^(r%8=}iv#wCnFq z=K9}JbqU@k99Q%j-}NNk+qLCP)jXfmOO|)@?mHcnynd6({mJisP1_}u7k)|eYHXWK z63eQ)E$ufFi!3CWUY2gw%e>omCv}qEX66aH-k&35f9`Q@Us|NPetVqe8=dX*VxJdn ze`q7b=Dn(UA(2sf&g)cOmQFhNJ#<-aMELJZbA#@to>25@kbW<)&!X01 z%NMJt>1ST)tyX)h@?`DxhbgCHr>S4wv}WC&Nw-!{+Z7$2D}74QAcXTvip=M0%Tp_N zor=k`)t|ra^ySr-+(|R9mB(E=`MX#y(wSw)$!iymzB;^c*>%&^*7HxTnRga=soSZT zdDl+9s;r!v8hk6POtzBaig4pRp7eWF(<8gufvNHPu6xs-=e{;mnHzJyGKE+8L0j}; z@%8-e^UCL5HhMiR>sD3Rve&yVZ#{Q1*CO8c+qSr^Z#CN;)(X5>tGG5yUw3<+CfhaL z%bP;hZ?jvgJU67BWyiy74_)6r)_nSxttxn0`0?HE^5(uydHVgP+HE$V?Lv)Leti43 zWA|;f-RqX``95>)^P-fw!Vi{3KNsII-*5f){gdxqd%gVdB1sOBNe=nEW%;i~g_P8J w!5uhoe-Jcg1nPN%MiEAtgE$;km@@t6ukO)1^!cY^83Pb_y85}Sb4q9e0FIsP9{>OV literal 0 HcmV?d00001 diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..47821fa6d8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = appflowy_ui_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml new file mode 100644 index 0000000000..af361ecfab --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: appflowy_ui_example +description: "Example app showcasing AppFlowy UI components and widgets" +publish_to: "none" + +version: 1.0.0+1 + +environment: + flutter: ">=3.27.4" + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + appflowy_ui: + path: ../ + cupertino_icons: ^1.0.6 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart new file mode 100644 index 0000000000..423052a342 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:appflowy_ui_example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart new file mode 100644 index 0000000000..92d4484c0c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart @@ -0,0 +1,4 @@ +library; + +export 'src/component/component.dart'; +export 'src/theme/theme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart new file mode 100644 index 0000000000..dc85a3ee55 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart @@ -0,0 +1,54 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/widgets.dart'; + +enum AFButtonSize { + s, + m, + l, + xl; + + TextStyle buildTextStyle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => theme.textStyle.body.enhanced(), + AFButtonSize.m => theme.textStyle.body.enhanced(), + AFButtonSize.l => theme.textStyle.body.enhanced(), + AFButtonSize.xl => theme.textStyle.title.enhanced(), + }; + } + + EdgeInsetsGeometry buildPadding(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => EdgeInsets.symmetric( + horizontal: theme.spacing.l, + vertical: theme.spacing.xs, + ), + AFButtonSize.m => EdgeInsets.symmetric( + horizontal: theme.spacing.l, + vertical: theme.spacing.s, + ), + AFButtonSize.l => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: 10, // why? + ), + AFButtonSize.xl => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: 14, // why? + ), + }; + } + + double buildBorderRadius(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => theme.borderRadius.m, + AFButtonSize.m => theme.borderRadius.m, + AFButtonSize.l => 10, // why? + AFButtonSize.xl => theme.borderRadius.xl, + }; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart new file mode 100644 index 0000000000..22c5325681 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart @@ -0,0 +1,79 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFBaseButtonColorBuilder = Color Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFBaseButton extends StatefulWidget { + const AFBaseButton({ + super.key, + required this.onTap, + required this.builder, + required this.padding, + required this.borderRadius, + this.borderColor, + this.backgroundColor, + this.disabled = false, + }); + + final VoidCallback? onTap; + + final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + final EdgeInsetsGeometry padding; + final double borderRadius; + final bool disabled; + + final Widget Function(BuildContext context, bool isHovering, bool disabled) + builder; + + @override + State createState() => _AFBaseButtonState(); +} + +class _AFBaseButtonState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + final Color borderColor = _buildBorderColor(context); + final Color backgroundColor = _buildBackgroundColor(context); + + return MouseRegion( + cursor: + widget.disabled ? SystemMouseCursors.basic : SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + onTap: widget.disabled ? null : widget.onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Padding( + padding: widget.padding, + child: widget.builder(context, isHovering, widget.disabled), + ), + ), + ), + ); + } + + Color _buildBorderColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return widget.borderColor?.call(context, isHovering, widget.disabled) ?? + theme.borderColorScheme.greyTertiary; + } + + Color _buildBackgroundColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return widget.backgroundColor?.call(context, isHovering, widget.disabled) ?? + theme.fillColorScheme.transparent; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart new file mode 100644 index 0000000000..ced936cf0a --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart @@ -0,0 +1,51 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AFBaseTextButton extends StatelessWidget { + const AFBaseTextButton({ + super.key, + required this.text, + required this.onTap, + this.disabled = false, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.textColor, + this.backgroundColor, + this.alignment, + }); + + /// The text of the button. + final String text; + + /// Whether the button is disabled. + final bool disabled; + + /// The callback when the button is tapped. + final VoidCallback onTap; + + /// The size of the button. + final AFButtonSize size; + + /// The padding of the button. + final EdgeInsetsGeometry? padding; + + /// The border radius of the button. + final double? borderRadius; + + /// The text color of the button. + final AFBaseButtonColorBuilder? textColor; + + /// The background color of the button. + final AFBaseButtonColorBuilder? backgroundColor; + + /// The alignment of the button. + /// + /// If it's null, the button size will be the size of the text with padding. + final Alignment? alignment; + + @override + Widget build(BuildContext context) { + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart new file mode 100644 index 0000000000..31a3a20b5f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart @@ -0,0 +1,16 @@ +// Base button +export 'base_button/base.dart'; +export 'base_button/base_button.dart'; +export 'base_button/base_text_button.dart'; +// Filled buttons +export 'filled_button/filled_button.dart'; +export 'filled_button/filled_icon_text_button.dart'; +export 'filled_button/filled_text_button.dart'; +// Ghost buttons +export 'ghost_button/ghost_button.dart'; +export 'ghost_button/ghost_icon_text_button.dart'; +export 'ghost_button/ghost_text_button.dart'; +// Outlined buttons +export 'outlined_button/outlined_button.dart'; +export 'outlined_button/outlined_icon_text_button.dart'; +export 'outlined_button/outlined_text_button.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart new file mode 100644 index 0000000000..68fb341827 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFFilledButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFFilledButton extends StatelessWidget { + const AFFilledButton._({ + super.key, + required this.builder, + required this.onTap, + required this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Primary text button. + factory AFFilledButton.primary({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.themeThick; + }, + ); + } + + /// Destructive text button. + factory AFFilledButton.destructive({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.errorThick; + }, + ); + } + + /// Disabled text button. + factory AFFilledButton.disabled({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: () {}, + size: size, + disabled: true, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? backgroundColor; + final AFFilledButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart new file mode 100644 index 0000000000..04c49d0b01 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart @@ -0,0 +1,199 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFFilledIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFFilledIconTextButton extends StatelessWidget { + const AFFilledIconTextButton._({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + }); + + /// Primary filled text button. + factory AFFilledIconTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.tertiary; + } + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.fillColorScheme.themeThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Destructive filled text button. + factory AFFilledIconTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.tertiary; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Disabled filled text button. + factory AFFilledIconTextButton.disabled({ + Key? key, + required String text, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.fillColorScheme.tertiary; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Ghost filled text button with transparent background that shows color on hover. + factory AFFilledIconTextButton.ghost({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return Colors.transparent; + } + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + return theme.textColorScheme.primary; + }, + ); + } + + final String text; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFFilledIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + backgroundColor: backgroundColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.onFill; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + iconBuilder(context, isHovering, disabled), + SizedBox(width: theme.spacing.s), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart new file mode 100644 index 0000000000..353d5ac785 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart @@ -0,0 +1,141 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFFilledTextButton extends AFBaseTextButton { + const AFFilledTextButton({ + super.key, + required super.text, + required super.onTap, + required super.backgroundColor, + required super.textColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Primary text button. + factory AFFilledTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.onFill, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.themeThick; + }, + ); + } + + /// Destructive text button. + factory AFFilledTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.onFill, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.errorThick; + }, + ); + } + + /// Disabled text button. + factory AFFilledTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.tertiary, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5, + ); + } + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + AppFlowyTheme.of(context).textColorScheme.onFill; + Widget child = Text( + text, + style: size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart new file mode 100644 index 0000000000..47ff96e878 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart @@ -0,0 +1,96 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFGhostButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFGhostButton extends StatelessWidget { + const AFGhostButton._({ + super.key, + required this.onTap, + required this.backgroundColor, + required this.builder, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Normal ghost button. + factory AFGhostButton.normal({ + Key? key, + required VoidCallback onTap, + required AFGhostButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFGhostButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + /// Disabled ghost button. + factory AFGhostButton.disabled({ + Key? key, + required AFGhostButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFGhostButton._( + key: key, + builder: builder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.transparent, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? backgroundColor; + final AFGhostButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart new file mode 100644 index 0000000000..e65eb2dd7e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart @@ -0,0 +1,141 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFGhostIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFGhostIconTextButton extends StatelessWidget { + const AFGhostIconTextButton({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Primary ghost text button. + factory AFGhostIconTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + required AFGhostIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFGhostIconTextButton( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return Colors.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Disabled ghost text button. + factory AFGhostIconTextButton.disabled({ + Key? key, + required String text, + required AFGhostIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFGhostIconTextButton( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + backgroundColor: (context, isHovering, disabled) { + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.tertiary; + }, + ); + } + + final String text; + final bool disabled; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFGhostIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (context, isHovering, disabled) { + return Colors.transparent; + }, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + iconBuilder( + context, + isHovering, + disabled, + ), + SizedBox(width: theme.spacing.m), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart new file mode 100644 index 0000000000..441b544f8a --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFGhostTextButton extends AFBaseTextButton { + const AFGhostTextButton({ + super.key, + required super.text, + required super.onTap, + super.textColor, + super.backgroundColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Normal ghost text button. + factory AFGhostTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFGhostTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Disabled ghost text button. + factory AFGhostTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + }) { + return AFGhostTextButton( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.tertiary, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.transparent, + ); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + + Widget child = Text( + text, + style: size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart new file mode 100644 index 0000000000..3b0ea7a06d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart @@ -0,0 +1,168 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFOutlinedButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFOutlinedButton extends StatelessWidget { + const AFOutlinedButton._({ + super.key, + required this.onTap, + required this.builder, + this.borderColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Normal outlined button. + factory AFOutlinedButton.normal({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFOutlinedButton._( + key: key, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + /// Destructive outlined button. + factory AFOutlinedButton.destructive({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFOutlinedButton._( + key: key, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorSelect; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedButton.disabled({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFOutlinedButton._( + key: key, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + final AFOutlinedButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart new file mode 100644 index 0000000000..710a4ccca5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart @@ -0,0 +1,226 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFOutlinedIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFOutlinedIconTextButton extends StatelessWidget { + const AFOutlinedIconTextButton._({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.borderColor, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + this.alignment = MainAxisAlignment.center, + }); + + /// Normal outlined text button. + factory AFOutlinedIconTextButton.normal({ + Key? key, + required String text, + required VoidCallback onTap, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Destructive outlined text button. + factory AFOutlinedIconTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.error + : theme.textColorScheme.error; + }, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedIconTextButton.disabled({ + Key? key, + required String text, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary; + }, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + final String text; + final bool disabled; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + final MainAxisAlignment alignment; + + final AFOutlinedIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + disabled: disabled, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + return Row( + mainAxisAlignment: alignment, + children: [ + iconBuilder(context, isHovering, disabled), + SizedBox(width: theme.spacing.s), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart new file mode 100644 index 0000000000..7cb5f2d609 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart @@ -0,0 +1,204 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFOutlinedTextButton extends AFBaseTextButton { + const AFOutlinedTextButton._({ + super.key, + required super.text, + required super.onTap, + this.borderColor, + super.textColor, + super.backgroundColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Normal outlined text button. + factory AFOutlinedTextButton.normal({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Destructive outlined text button. + factory AFOutlinedTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorSelect; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.error + : theme.textColorScheme.error; + }, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary; + }, + borderColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + final AFBaseButtonColorBuilder? borderColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + + Widget child = Text( + text, + style: size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart new file mode 100644 index 0000000000..d01d64109c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart @@ -0,0 +1,2 @@ +export 'button/button.dart'; +export 'textfield/textfield.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart new file mode 100644 index 0000000000..9fed31027f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -0,0 +1,177 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFTextFieldValidator = (bool result, String errorText) Function( + TextEditingController controller, +); + +class AFTextField extends StatefulWidget { + const AFTextField({ + super.key, + this.hintText, + this.initialText, + this.keyboardType, + this.radius, + this.validator, + this.controller, + this.onChanged, + this.onSubmitted, + this.autoFocus, + }); + + /// The hint text to display when the text field is empty. + final String? hintText; + + /// The initial text to display in the text field. + final String? initialText; + + /// The type of keyboard to display. + final TextInputType? keyboardType; + + /// The radius of the text field. + final double? radius; + + /// The validator to use for the text field. + final AFTextFieldValidator? validator; + + /// The controller to use for the text field. + /// + /// If it's not provided, the text field will use a new controller. + final TextEditingController? controller; + + /// The callback to call when the text field changes. + final void Function(String)? onChanged; + + /// The callback to call when the text field is submitted. + final void Function(String)? onSubmitted; + + /// Enable auto focus. + final bool? autoFocus; + + @override + State createState() => _AFTextFieldState(); +} + +class _AFTextFieldState extends State { + late final TextEditingController effectiveController; + + bool hasError = false; + String errorText = ''; + + @override + void initState() { + super.initState(); + + effectiveController = widget.controller ?? TextEditingController(); + + final initialText = widget.initialText; + if (initialText != null) { + effectiveController.text = initialText; + } + + effectiveController.addListener(_validate); + } + + @override + void dispose() { + effectiveController.removeListener(_validate); + if (widget.controller == null) { + effectiveController.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final borderRadius = BorderRadius.circular( + widget.radius ?? theme.borderRadius.l, + ); + + final errorBorderColor = theme.borderColorScheme.errorThick; + final defaultBorderColor = theme.borderColorScheme.greyTertiary; + + Widget child = TextField( + controller: effectiveController, + keyboardType: widget.keyboardType, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + autofocus: widget.autoFocus ?? false, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.tertiary, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: 10, + ), + border: OutlineInputBorder( + borderSide: BorderSide( + color: hasError ? errorBorderColor : defaultBorderColor, + ), + borderRadius: borderRadius, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: hasError ? errorBorderColor : defaultBorderColor, + ), + borderRadius: borderRadius, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: hasError + ? errorBorderColor + : theme.borderColorScheme.themeThick, + ), + borderRadius: borderRadius, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorBorderColor, + ), + borderRadius: borderRadius, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorBorderColor, + ), + borderRadius: borderRadius, + ), + hoverColor: theme.borderColorScheme.greyTertiaryHover, + ), + ); + + if (hasError && errorText.isNotEmpty) { + child = Column( + children: [ + child, + SizedBox(height: theme.spacing.xs), + Text( + errorText, + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.error, + ), + ), + ], + ); + } + + return child; + } + + void _validate() { + final validator = widget.validator; + if (validator != null) { + final result = validator(effectiveController); + setState(() { + hasError = result.$1; + errorText = result.$2; + }); + } + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart new file mode 100644 index 0000000000..49deecc178 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart @@ -0,0 +1,72 @@ +import 'package:appflowy_ui/src/theme/data/data.dart'; +import 'package:flutter/widgets.dart'; + +class AppFlowyTheme extends StatelessWidget { + const AppFlowyTheme({ + super.key, + required this.data, + required this.child, + }); + + final AppFlowyThemeData data; + final Widget child; + + static AppFlowyThemeData of(BuildContext context, {bool listen = true}) { + final provider = maybeOf(context, listen: listen); + if (provider == null) { + throw FlutterError( + ''' + AppFlowyTheme.of() called with a context that does not contain a AppFlowyTheme.\n + No AppFlowyTheme ancestor could be found starting from the context that was passed to AppFlowyTheme.of(). + This can happen because you do not have a AppFlowyTheme widget (which introduces a AppFlowyTheme), + or it can happen if the context you use comes from a widget above this widget.\n + The context used was: $context''', + ); + } + return provider; + } + + static AppFlowyThemeData? maybeOf( + BuildContext context, { + bool listen = true, + }) { + if (listen) { + return context + .dependOnInheritedWidgetOfExactType() + ?.theme + .data; + } + final provider = context + .getElementForInheritedWidgetOfExactType() + ?.widget; + + return (provider as AppFlowyInheritedTheme?)?.theme.data; + } + + @override + Widget build(BuildContext context) { + return AppFlowyInheritedTheme( + theme: this, + child: child, + ); + } +} + +class AppFlowyInheritedTheme extends InheritedTheme { + const AppFlowyInheritedTheme({ + super.key, + required this.theme, + required super.child, + }); + + final AppFlowyTheme theme; + + @override + Widget wrap(BuildContext context, Widget child) { + return AppFlowyTheme(data: theme.data, child: child); + } + + @override + bool updateShouldNotify(AppFlowyInheritedTheme oldWidget) => + theme.data != oldWidget.theme.data; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/border_radius/border_radius.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/border_radius/border_radius.dart new file mode 100644 index 0000000000..fb07a5fe64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/border_radius/border_radius.dart @@ -0,0 +1,17 @@ +class AppFlowyBorderRadius { + const AppFlowyBorderRadius({ + required this.xs, + required this.s, + required this.m, + required this.l, + required this.xl, + required this.xxl, + }); + + final double xs; + final double s; + final double m; + final double l; + final double xl; + final double xxl; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/background/background_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/background/background_color_scheme.dart new file mode 100644 index 0000000000..547c7f1635 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/background/background_color_scheme.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBackgroundColorScheme { + const AppFlowyBackgroundColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart new file mode 100644 index 0000000000..5b843d97e2 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart @@ -0,0 +1,33 @@ +import 'package:appflowy_ui/src/theme/color_scheme/base/blue.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/green.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/magenta.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/neutral.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/orange.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/purple.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/red.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/subtle.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/yellow.dart'; + +class AppFlowyBaseColorScheme { + const AppFlowyBaseColorScheme({ + this.blue = const BlueColors(), + this.green = const GreenColors(), + this.yellow = const YellowColors(), + this.red = const RedColors(), + this.orange = const OrangeColors(), + this.magenta = const MagentaColors(), + this.purple = const PurpleColors(), + this.neutral = const NeutralColors(), + this.subtle = const SubtleColors(), + }); + + final BlueColors blue; + final GreenColors green; + final YellowColors yellow; + final RedColors red; + final OrangeColors orange; + final MagentaColors magenta; + final PurpleColors purple; + final NeutralColors neutral; + final SubtleColors subtle; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart new file mode 100644 index 0000000000..fb73b42f60 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class BlueColors { + const BlueColors(); + + Color get blue100 => const Color(0xFFE3F6FF); + + Color get blue200 => const Color(0xFFA9E2FF); + + Color get blue300 => const Color(0xFF80D2FF); + + Color get blue400 => const Color(0xFF4EC1FF); + + Color get blue500 => const Color(0xFF00B5FF); + + Color get blue600 => const Color(0xFF0092D6); + + Color get blue700 => const Color(0xFF0078C0); + + Color get blue800 => const Color(0xFF0065A9); + + Color get blue900 => const Color(0xFF00508F); + + Color get blue1000 => const Color(0xFF003C77); + + Color get alphaBlue50015 => const Color(0x2600B5FF); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart new file mode 100644 index 0000000000..652f5f5932 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class GreenColors { + const GreenColors(); + Color get green100 => const Color(0xFFECF9F5); + + Color get green200 => const Color(0xFFC3E5D8); + + Color get green300 => const Color(0xFF9AD1BC); + + Color get green400 => const Color(0xFF71BD9F); + + Color get green500 => const Color(0xFF48A982); + + Color get green600 => const Color(0xFF248569); + + Color get green700 => const Color(0xFF29725D); + + Color get green800 => const Color(0xFF2E6050); + + Color get green900 => const Color(0xFF305548); + + Color get green1000 => const Color(0xFF305244); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart new file mode 100644 index 0000000000..dec5617c67 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class MagentaColors { + const MagentaColors(); + + Color get magenta100 => const Color(0xFFFFE5EF); + + Color get magenta200 => const Color(0xFFFFB8D1); + + Color get magenta300 => const Color(0xFFFF8AB2); + + Color get magenta400 => const Color(0xFFFF5C93); + + Color get magenta500 => const Color(0xFFFB006D); + + Color get magenta600 => const Color(0xFFD2005F); + + Color get magenta700 => const Color(0xFFD2005F); + + Color get magenta800 => const Color(0xFF850040); + + Color get magenta900 => const Color(0xFF610031); + + Color get magenta1000 => const Color(0xFF400022); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart new file mode 100644 index 0000000000..4b6c08b595 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class NeutralColors { + const NeutralColors(); + + Color get neutral100 => const Color(0xFFF8FAFF); + + Color get neutral200 => const Color(0xFFE4E8F5); + + Color get neutral300 => const Color(0xFFCED3E6); + + Color get neutral400 => const Color(0xFFB5BBD3); + + Color get neutral500 => const Color(0xFF989EB7); + + Color get neutral600 => const Color(0xFF6F748C); + + Color get neutral700 => const Color(0xFF54596E); + + Color get neutral800 => const Color(0xFF3C3F4E); + + Color get neutral900 => const Color(0xFF272930); + + Color get neutral1000 => const Color(0xFF21232A); + + Color get black => const Color(0xFF000000); + + Color get alphaBlack60 => const Color(0x99000000); + + Color get white => const Color(0xFFFFFFFF); + + Color get alphaWhite0 => const Color(0x00FFFFFF); + + Color get alphaWhite20 => const Color(0x33FFFFFF); + + Color get alphaWhite30 => const Color(0x4DFFFFFF); + + Color get alphaGrey10005 => const Color(0x0DF9FAFD); + + Color get alphaGrey10010 => const Color(0x1AF9FAFD); + + Color get alphaGrey100005 => const Color(0x0D1F2329); + + Color get alphaGrey100010 => const Color(0x1A1F2329); + + Color get alphaGrey100070 => const Color(0xB21F2329); + + Color get alphaGrey100080 => const Color(0xCC1F2329); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart new file mode 100644 index 0000000000..c9424bd960 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class OrangeColors { + const OrangeColors(); + + Color get orange100 => const Color(0xFFFFF3D5); + + Color get orange200 => const Color(0xFFFFE4AB); + + Color get orange300 => const Color(0xFFFFD181); + + Color get orange400 => const Color(0xFFFFBE62); + + Color get orange500 => const Color(0xFFFFA02E); + + Color get orange600 => const Color(0xFFDB7E21); + + Color get orange700 => const Color(0xFFB75F17); + + Color get orange800 => const Color(0xFF93450E); + + Color get orange900 => const Color(0xFF7A3108); + + Color get orange1000 => const Color(0xFF602706); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart new file mode 100644 index 0000000000..fa3b9e54cf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class PurpleColors { + const PurpleColors(); + + Color get purple100 => const Color(0xFFF1E0FF); + + Color get purple200 => const Color(0xFFE1B3FF); + + Color get purple300 => const Color(0xFFD185FF); + + Color get purple400 => const Color(0xFFBC58FF); + + Color get purple500 => const Color(0xFF9327FF); + + Color get purple600 => const Color(0xFF7A1DCC); + + Color get purple700 => const Color(0xFF6617B3); + + Color get purple800 => const Color(0xFF55138F); + + Color get purple900 => const Color(0xFF470C72); + + Color get purple1000 => const Color(0xFF380758); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart new file mode 100644 index 0000000000..030f2988bc --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class RedColors { + const RedColors(); + + Color get red100 => const Color(0xFFFFD2DD); + + Color get red200 => const Color(0xFFFFA5B4); + + Color get red300 => const Color(0xFFFF7D87); + + Color get red400 => const Color(0xFFFF5050); + + Color get red500 => const Color(0xFFF33641); + + Color get red600 => const Color(0xFFE71D32); + + Color get red700 => const Color(0xFFAD1625); + + Color get red800 => const Color(0xFF8C101C); + + Color get red900 => const Color(0xFF6E0A1E); + + Color get red1000 => const Color(0xFF4C0A17); + + Color get alphaRed50010 => const Color(0x1AF33641); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart new file mode 100644 index 0000000000..2fddd14f1a --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; + +class SubtleColors { + const SubtleColors(); + + // Rose colors + Color get rose100 => const Color(0xFFFCF2F2); + + Color get rose200 => const Color(0xFFFAE3E3); + + Color get rose300 => const Color(0xFFFAD9D9); + + Color get rose400 => const Color(0xFFEDADAD); + + Color get rose500 => const Color(0xFFCC4E4E); + + Color get rose600 => const Color(0xFF702828); + + // Papaya colors + Color get papaya100 => const Color(0xFFFCF4F0); + + Color get papaya200 => const Color(0xFFFAE8DE); + + Color get papaya300 => const Color(0xFFFADFD2); + + Color get papaya400 => const Color(0xFFF0BDA3); + + Color get papaya500 => const Color(0xFFD67240); + + Color get papaya600 => const Color(0xFF6B3215); + + // Tangerine colors + Color get tangerine100 => const Color(0xFFFFF7ED); + + Color get tangerine200 => const Color(0xFFFCEDD9); + + Color get tangerine300 => const Color(0xFFFAE5CA); + + Color get tangerine400 => const Color(0xFFF2CB99); + + Color get tangerine500 => const Color(0xFFDB8F2C); + + Color get tangerine600 => const Color(0xFF613B0A); + + // Mango colors + Color get mango100 => const Color(0xFFFFF9EC); + + Color get mango200 => const Color(0xFFFCF1D7); + + Color get mango300 => const Color(0xFFFAE9C3); + + Color get mango400 => const Color(0xFFF5D68E); + + Color get mango500 => const Color(0xFFE0A416); + + Color get mango600 => const Color(0xFF5C4102); + + // Lemon colors + Color get lemon100 => const Color(0xFFFFFBE8); + + Color get lemon200 => const Color(0xFFFCF5CF); + + Color get lemon300 => const Color(0xFFFAEFB9); + + Color get lemon400 => const Color(0xFFF5E282); + + Color get lemon500 => const Color(0xFFE0BB00); + + Color get lemon600 => const Color(0xFF574800); + + // Olive colors + Color get olive100 => const Color(0xFFF9FAE6); + + Color get olive200 => const Color(0xFFF6F7D0); + + Color get olive300 => const Color(0xFFF0F2B3); + + Color get olive400 => const Color(0xFFDBDE83); + + Color get olive500 => const Color(0xFFADB204); + + Color get olive600 => const Color(0xFF4A4C03); + + // Lime colors + Color get lime100 => const Color(0xFFF6F9E6); + + Color get lime200 => const Color(0xFFEEF5CE); + + Color get lime300 => const Color(0xFFE7F0BB); + + Color get lime400 => const Color(0xFFCFDB91); + + Color get lime500 => const Color(0xFF92A822); + + Color get lime600 => const Color(0xFF414D05); + + // Grass colors + Color get grass100 => const Color(0xFFF4FAEB); + + Color get grass200 => const Color(0xFFE9F5D7); + + Color get grass300 => const Color(0xFFDEF0C5); + + Color get grass400 => const Color(0xFFBFD998); + + Color get grass500 => const Color(0xFF75A828); + + Color get grass600 => const Color(0xFF334D0C); + + // Forest colors + Color get forest100 => const Color(0xFFF1FAF0); + + Color get forest200 => const Color(0xFFE2F5DF); + + Color get forest300 => const Color(0xFFD7F0D3); + + Color get forest400 => const Color(0xFFA8D6A1); + + Color get forest500 => const Color(0xFF49A33B); + + Color get forest600 => const Color(0xFF1E4F16); + + // Jade colors + Color get jade100 => const Color(0xFFF0FAF6); + + Color get jade200 => const Color(0xFFDFF5EB); + + Color get jade300 => const Color(0xFFCEF0E1); + + Color get jade400 => const Color(0xFF90D1B5); + + Color get jade500 => const Color(0xFF1C9963); + + Color get jade600 => const Color(0xFF075231); + + // Aqua colors + Color get aqua100 => const Color(0xFFF0F9FA); + + Color get aqua200 => const Color(0xFFDFF3F5); + + Color get aqua300 => const Color(0xFFCCECF0); + + Color get aqua400 => const Color(0xFF83CCD4); + + Color get aqua500 => const Color(0xFF008E9E); + + Color get aqua600 => const Color(0xFF004E57); + + // Azure colors + Color get azure100 => const Color(0xFFF0F6FA); + + Color get azure200 => const Color(0xFFE1EEF7); + + Color get azure300 => const Color(0xFFD3E6F5); + + Color get azure400 => const Color(0xFF88C0EB); + + Color get azure500 => const Color(0xFF0877CC); + + Color get azure600 => const Color(0xFF154469); + + // Denim colors + Color get denim100 => const Color(0xFFF0F3FA); + + Color get denim200 => const Color(0xFFE3EBFA); + + Color get denim300 => const Color(0xFFD7E2F7); + + Color get denim400 => const Color(0xFF9AB6ED); + + Color get denim500 => const Color(0xFF3267D1); + + Color get denim600 => const Color(0xFF223C70); + + // Mauve colors + Color get mauve100 => const Color(0xFFF2F2FC); + + Color get mauve200 => const Color(0xFFE6E6FA); + + Color get mauve300 => const Color(0xFFDCDCF7); + + Color get mauve400 => const Color(0xFFAEAEF5); + + Color get mauve500 => const Color(0xFF5555E0); + + Color get mauve600 => const Color(0xFF36366B); + + // Lavender colors + Color get lavender100 => const Color(0xFFF6F3FC); + + Color get lavender200 => const Color(0xFFEBE3FA); + + Color get lavender300 => const Color(0xFFE4DAF7); + + Color get lavender400 => const Color(0xFFC1AAF0); + + Color get lavender500 => const Color(0xFF8153DB); + + Color get lavender600 => const Color(0xFF462F75); + + // Lilac colors + Color get lilac100 => const Color(0xFFF7F0FA); + + Color get lilac200 => const Color(0xFFF0E1F7); + + Color get lilac300 => const Color(0xFFEDD7F7); + + Color get lilac400 => const Color(0xFFD3A9E8); + + Color get lilac500 => const Color(0xFF9E4CC7); + + Color get lilac600 => const Color(0xFF562D6B); + + // Mallow colors + Color get mallow100 => const Color(0xFFFAF0FA); + + Color get mallow200 => const Color(0xFFF5E1F4); + + Color get mallow300 => const Color(0xFFF5D7F4); + + Color get mallow400 => const Color(0xFFDEA4DC); + + Color get mallow500 => const Color(0xFFB240AF); + + Color get mallow600 => const Color(0xFF632861); + + // Camellia colors + Color get camellia100 => const Color(0xFFF9EFF3); + + Color get camellia200 => const Color(0xFFF7E1EB); + + Color get camellia300 => const Color(0xFFF7D7E5); + + Color get camellia400 => const Color(0xFFE5A3C0); + + Color get camellia500 => const Color(0xFFC24279); + + Color get camellia600 => const Color(0xFF6E2343); + + // Smoke colors + Color get smoke100 => const Color(0xFFF5F5F5); + + Color get smoke200 => const Color(0xFFE8E8E8); + + Color get smoke300 => const Color(0xFFDEDEDE); + + Color get smoke400 => const Color(0xFFB8B8B8); + + Color get smoke500 => const Color(0xFF6E6E6E); + + Color get smoke600 => const Color(0xFF404040); + + // Iron colors + Color get iron100 => const Color(0xFFF2F4F7); + + Color get iron200 => const Color(0xFFE6E9F0); + + Color get iron300 => const Color(0xFFDADEE5); + + Color get iron400 => const Color(0xFFB0B5BF); + + Color get iron500 => const Color(0xFF666F80); + + Color get iron600 => const Color(0xFF394152); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart new file mode 100644 index 0000000000..a38cf2bd78 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class YellowColors { + const YellowColors(); + Color get yellow100 => const Color(0xFFFFF9B2); + + Color get yellow200 => const Color(0xFFFFEC66); + + Color get yellow300 => const Color(0xFFFFDF1A); + + Color get yellow400 => const Color(0xFFFFCC00); + + Color get yellow500 => const Color(0xFFFFCE00); + + Color get yellow600 => const Color(0xFFE6B800); + + Color get yellow700 => const Color(0xFFCC9F00); + + Color get yellow800 => const Color(0xFFB38A00); + + Color get yellow900 => const Color(0xFF9A7500); + + Color get yellow1000 => const Color(0xFF7F6200); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart new file mode 100644 index 0000000000..d1618b6cff --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBorderColorScheme { + const AppFlowyBorderColorScheme({ + required this.greyPrimary, + required this.greyPrimaryHover, + required this.greySecondary, + required this.greySecondaryHover, + required this.greyTertiary, + required this.greyTertiaryHover, + required this.greyQuaternary, + required this.greyQuaternaryHover, + required this.transparent, + required this.themeThick, + required this.themeThickHover, + required this.infoThick, + required this.infoThickHover, + required this.successThick, + required this.successThickHover, + required this.warningThick, + required this.warningThickHover, + required this.errorThick, + required this.errorThickHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color greyPrimary; + final Color greyPrimaryHover; + final Color greySecondary; + final Color greySecondaryHover; + final Color greyTertiary; + final Color greyTertiaryHover; + final Color greyQuaternary; + final Color greyQuaternaryHover; + final Color transparent; + final Color themeThick; + final Color themeThickHover; + final Color infoThick; + final Color infoThickHover; + final Color successThick; + final Color successThickHover; + final Color warningThick; + final Color warningThickHover; + final Color errorThick; + final Color errorThickHover; + final Color purpleThick; + final Color purpleThickHover; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border_color_scheme.dart new file mode 100644 index 0000000000..d1618b6cff --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border_color_scheme.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBorderColorScheme { + const AppFlowyBorderColorScheme({ + required this.greyPrimary, + required this.greyPrimaryHover, + required this.greySecondary, + required this.greySecondaryHover, + required this.greyTertiary, + required this.greyTertiaryHover, + required this.greyQuaternary, + required this.greyQuaternaryHover, + required this.transparent, + required this.themeThick, + required this.themeThickHover, + required this.infoThick, + required this.infoThickHover, + required this.successThick, + required this.successThickHover, + required this.warningThick, + required this.warningThickHover, + required this.errorThick, + required this.errorThickHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color greyPrimary; + final Color greyPrimaryHover; + final Color greySecondary; + final Color greySecondaryHover; + final Color greyTertiary; + final Color greyTertiaryHover; + final Color greyQuaternary; + final Color greyQuaternaryHover; + final Color transparent; + final Color themeThick; + final Color themeThickHover; + final Color infoThick; + final Color infoThickHover; + final Color successThick; + final Color successThickHover; + final Color warningThick; + final Color warningThickHover; + final Color errorThick; + final Color errorThickHover; + final Color purpleThick; + final Color purpleThickHover; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/brand/brand_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/brand/brand_color_scheme.dart new file mode 100644 index 0000000000..8374d2bbfd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/brand/brand_color_scheme.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBrandColorScheme { + const AppFlowyBrandColorScheme({ + required this.skyline, + required this.aqua, + required this.violet, + required this.amethyst, + required this.berry, + required this.coral, + required this.golden, + required this.amber, + required this.lemon, + }); + + final Color skyline; + final Color aqua; + final Color violet; + final Color amethyst; + final Color berry; + final Color coral; + final Color golden; + final Color amber; + final Color lemon; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart new file mode 100644 index 0000000000..5a1e7debeb --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart @@ -0,0 +1,8 @@ +export 'background/background_color_scheme.dart'; +export 'base/base_scheme.dart'; +export 'border/border_color_scheme.dart'; +export 'brand/brand_color_scheme.dart'; +export 'fill/fill_color_scheme.dart'; +export 'icon/icon_color_theme.dart'; +export 'surface/surface_color_scheme.dart'; +export 'text/text_color_scheme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/fill/fill_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/fill/fill_color_scheme.dart new file mode 100644 index 0000000000..8616bde4eb --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/fill/fill_color_scheme.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +class AppFlowyFillColorScheme { + const AppFlowyFillColorScheme({ + required this.primary, + required this.primaryHover, + required this.secondary, + required this.secondaryHover, + required this.tertiary, + required this.tertiaryHover, + required this.quaternary, + required this.quaternaryHover, + required this.transparent, + required this.primaryAlpha5, + required this.primaryAlpha5Hover, + required this.primaryAlpha80, + required this.primaryAlpha80Hover, + required this.white, + required this.whiteAlpha, + required this.whiteAlphaHover, + required this.black, + required this.themeLight, + required this.themeLightHover, + required this.themeThick, + required this.themeThickHover, + required this.themeSelect, + required this.infoLight, + required this.infoLightHover, + required this.infoThick, + required this.infoThickHover, + required this.successLight, + required this.successLightHover, + required this.successThick, + required this.successThickHover, + required this.warningLight, + required this.warningLightHover, + required this.warningThick, + required this.warningThickHover, + required this.errorLight, + required this.errorLightHover, + required this.errorThick, + required this.errorThickHover, + required this.errorSelect, + required this.purpleLight, + required this.purpleLightHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color primary; + final Color primaryHover; + final Color secondary; + final Color secondaryHover; + final Color tertiary; + final Color tertiaryHover; + final Color quaternary; + final Color quaternaryHover; + final Color transparent; + final Color primaryAlpha5; + final Color primaryAlpha5Hover; + final Color primaryAlpha80; + final Color primaryAlpha80Hover; + final Color white; + final Color whiteAlpha; + final Color whiteAlphaHover; + final Color black; + final Color themeLight; + final Color themeLightHover; + final Color themeThick; + final Color themeThickHover; + final Color themeSelect; + final Color infoLight; + final Color infoLightHover; + final Color infoThick; + final Color infoThickHover; + final Color successLight; + final Color successLightHover; + final Color successThick; + final Color successThickHover; + final Color warningLight; + final Color warningLightHover; + final Color warningThick; + final Color warningThickHover; + final Color errorLight; + final Color errorLightHover; + final Color errorThick; + final Color errorThickHover; + final Color errorSelect; + final Color purpleLight; + final Color purpleLightHover; + final Color purpleThick; + final Color purpleThickHover; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart new file mode 100644 index 0000000000..f9ece5339c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class AppFlowyIconColorTheme { + const AppFlowyIconColorTheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + required this.white, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + final Color white; + final Color purpleThick; + final Color purpleThickHover; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/surface/surface_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/surface/surface_color_scheme.dart new file mode 100644 index 0000000000..8fdc21adef --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/surface/surface_color_scheme.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class AppFlowySurfaceColorScheme { + const AppFlowySurfaceColorScheme({ + required this.primary, + required this.overlay, + }); + + final Color primary; + final Color overlay; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/text/text_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/text/text_color_scheme.dart new file mode 100644 index 0000000000..486378643f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/text/text_color_scheme.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class AppFlowyTextColorScheme { + const AppFlowyTextColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + required this.inverse, + required this.onFill, + required this.theme, + required this.themeHover, + required this.action, + required this.actionHover, + required this.info, + required this.infoHover, + required this.success, + required this.successHover, + required this.warning, + required this.warningHover, + required this.error, + required this.errorHover, + required this.purple, + required this.purpleHover, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + final Color inverse; + final Color onFill; + final Color theme; + final Color themeHover; + final Color action; + final Color actionHover; + final Color info; + final Color infoHover; + final Color success; + final Color successHover; + final Color warning; + final Color warningHover; + final Color error; + final Color errorHover; + final Color purple; + final Color purpleHover; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart new file mode 100644 index 0000000000..668644f196 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -0,0 +1,324 @@ +import 'package:appflowy_ui/src/theme/border_radius/border_radius.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/background/background_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/base_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/border/border.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/brand/brand_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/fill/fill_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/dimensions.dart'; +import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; +import 'package:flutter/material.dart'; + +class AppFlowyThemeBuilder { + const AppFlowyThemeBuilder(); + + AppFlowyTextColorScheme buildTextColorScheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyTextColorScheme( + primary: colorScheme.neutral.neutral1000, + secondary: colorScheme.neutral.neutral600, + tertiary: colorScheme.neutral.neutral400, + quaternary: colorScheme.neutral.neutral200, + inverse: colorScheme.neutral.white, + onFill: colorScheme.neutral.white, + theme: colorScheme.blue.blue500, + themeHover: colorScheme.blue.blue600, + action: colorScheme.blue.blue500, + actionHover: colorScheme.blue.blue600, + info: colorScheme.blue.blue500, + infoHover: colorScheme.blue.blue600, + success: colorScheme.green.green600, + successHover: colorScheme.green.green700, + warning: colorScheme.orange.orange600, + warningHover: colorScheme.orange.orange700, + error: colorScheme.red.red600, + errorHover: colorScheme.red.red700, + purple: colorScheme.purple.purple500, + purpleHover: colorScheme.purple.purple600, + ), + Brightness.dark => AppFlowyTextColorScheme( + primary: colorScheme.neutral.neutral200, + secondary: colorScheme.neutral.neutral400, + tertiary: colorScheme.neutral.neutral600, + quaternary: colorScheme.neutral.neutral1000, + inverse: colorScheme.neutral.neutral1000, + onFill: colorScheme.neutral.white, + theme: colorScheme.blue.blue500, + themeHover: colorScheme.blue.blue600, + action: colorScheme.blue.blue500, + actionHover: colorScheme.blue.blue600, + info: colorScheme.blue.blue500, + infoHover: colorScheme.blue.blue600, + success: colorScheme.green.green600, + successHover: colorScheme.green.green700, + warning: colorScheme.orange.orange600, + warningHover: colorScheme.orange.orange700, + error: colorScheme.red.red500, + errorHover: colorScheme.red.red400, + purple: colorScheme.purple.purple500, + purpleHover: colorScheme.purple.purple600, + ), + }; + } + + AppFlowyIconColorTheme buildIconColorTheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyIconColorTheme( + primary: colorScheme.neutral.neutral1000, + secondary: colorScheme.neutral.neutral600, + tertiary: colorScheme.neutral.neutral400, + quaternary: colorScheme.neutral.neutral200, + white: colorScheme.neutral.white, + purpleThick: colorScheme.purple.purple500, + purpleThickHover: colorScheme.purple.purple600, + ), + Brightness.dark => AppFlowyIconColorTheme( + primary: colorScheme.neutral.neutral200, + secondary: colorScheme.neutral.neutral400, + tertiary: colorScheme.neutral.neutral600, + quaternary: colorScheme.neutral.neutral1000, + white: colorScheme.neutral.white, + purpleThick: const Color(0xFFFFFFFF), + purpleThickHover: const Color(0xFFFFFFFF), + ), + }; + } + + AppFlowyBorderColorScheme buildBorderColorScheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyBorderColorScheme( + greyPrimary: colorScheme.neutral.neutral1000, + greyPrimaryHover: colorScheme.neutral.neutral900, + greySecondary: colorScheme.neutral.neutral800, + greySecondaryHover: colorScheme.neutral.neutral700, + greyTertiary: colorScheme.neutral.neutral300, + greyTertiaryHover: colorScheme.neutral.neutral400, + greyQuaternary: colorScheme.neutral.neutral100, + greyQuaternaryHover: colorScheme.neutral.neutral200, + transparent: colorScheme.neutral.alphaWhite0, + themeThick: colorScheme.blue.blue500, + themeThickHover: colorScheme.blue.blue600, + infoThick: colorScheme.blue.blue500, + infoThickHover: colorScheme.blue.blue600, + successThick: colorScheme.green.green600, + successThickHover: colorScheme.green.green700, + warningThick: colorScheme.orange.orange600, + warningThickHover: colorScheme.orange.orange700, + errorThick: colorScheme.red.red600, + errorThickHover: colorScheme.red.red700, + purpleThick: colorScheme.purple.purple500, + purpleThickHover: colorScheme.purple.purple600, + ), + Brightness.dark => AppFlowyBorderColorScheme( + greyPrimary: colorScheme.neutral.neutral100, + greyPrimaryHover: colorScheme.neutral.neutral200, + greySecondary: colorScheme.neutral.neutral300, + greySecondaryHover: colorScheme.neutral.neutral400, + greyTertiary: colorScheme.neutral.neutral800, + greyTertiaryHover: colorScheme.neutral.neutral700, + greyQuaternary: colorScheme.neutral.neutral1000, + greyQuaternaryHover: colorScheme.neutral.neutral900, + transparent: colorScheme.neutral.alphaWhite0, + themeThick: colorScheme.blue.blue500, + themeThickHover: colorScheme.blue.blue600, + infoThick: colorScheme.blue.blue500, + infoThickHover: colorScheme.blue.blue600, + successThick: colorScheme.green.green600, + successThickHover: colorScheme.green.green700, + warningThick: colorScheme.orange.orange600, + warningThickHover: colorScheme.orange.orange700, + errorThick: colorScheme.red.red500, + errorThickHover: colorScheme.red.red400, + purpleThick: colorScheme.purple.purple500, + purpleThickHover: colorScheme.purple.purple600, + ), + }; + } + + AppFlowyFillColorScheme buildFillColorScheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyFillColorScheme( + primary: colorScheme.neutral.neutral100, + primaryHover: colorScheme.neutral.neutral200, + secondary: colorScheme.neutral.neutral300, + secondaryHover: colorScheme.neutral.neutral400, + tertiary: colorScheme.neutral.neutral600, + tertiaryHover: colorScheme.neutral.neutral500, + quaternary: colorScheme.neutral.neutral1000, + quaternaryHover: colorScheme.neutral.neutral900, + transparent: colorScheme.neutral.alphaWhite0, + primaryAlpha5: colorScheme.neutral.alphaGrey100005, + primaryAlpha5Hover: colorScheme.neutral.alphaGrey10010, + primaryAlpha80: colorScheme.neutral.alphaGrey100080, + primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, + white: colorScheme.neutral.white, + whiteAlpha: colorScheme.neutral.alphaWhite20, + whiteAlphaHover: colorScheme.neutral.alphaWhite30, + black: colorScheme.neutral.black, + themeLight: colorScheme.blue.blue100, + themeLightHover: colorScheme.blue.blue200, + themeThick: colorScheme.blue.blue500, + themeThickHover: colorScheme.blue.blue600, + themeSelect: colorScheme.blue.alphaBlue50015, + infoLight: colorScheme.blue.blue100, + infoLightHover: colorScheme.blue.blue200, + infoThick: colorScheme.blue.blue500, + infoThickHover: colorScheme.blue.blue600, + successLight: colorScheme.green.green100, + successLightHover: colorScheme.green.green200, + successThick: colorScheme.green.green600, + successThickHover: colorScheme.green.green700, + warningLight: colorScheme.orange.orange100, + warningLightHover: colorScheme.orange.orange200, + warningThick: colorScheme.orange.orange600, + warningThickHover: colorScheme.orange.orange700, + errorLight: colorScheme.red.red100, + errorLightHover: colorScheme.red.red200, + errorThick: colorScheme.red.red600, + errorThickHover: colorScheme.red.red700, + errorSelect: colorScheme.red.alphaRed50010, + purpleLight: colorScheme.purple.purple100, + purpleLightHover: colorScheme.purple.purple200, + purpleThick: colorScheme.purple.purple500, + purpleThickHover: colorScheme.purple.purple600, + ), + Brightness.dark => AppFlowyFillColorScheme( + primary: colorScheme.neutral.neutral1000, + primaryHover: colorScheme.neutral.neutral900, + secondary: colorScheme.neutral.neutral600, + secondaryHover: colorScheme.neutral.neutral500, + tertiary: colorScheme.neutral.neutral300, + tertiaryHover: colorScheme.neutral.neutral400, + quaternary: colorScheme.neutral.neutral100, + quaternaryHover: colorScheme.neutral.neutral200, + transparent: colorScheme.neutral.alphaWhite0, + primaryAlpha5: colorScheme.neutral.alphaGrey100005, + primaryAlpha5Hover: colorScheme.neutral.alphaGrey100010, + primaryAlpha80: colorScheme.neutral.alphaGrey100080, + primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, + white: colorScheme.neutral.white, + whiteAlpha: colorScheme.neutral.alphaWhite20, + whiteAlphaHover: colorScheme.neutral.alphaWhite30, + black: colorScheme.neutral.black, + themeLight: colorScheme.blue.blue100, + themeLightHover: colorScheme.blue.blue200, + themeThick: colorScheme.blue.blue500, + themeThickHover: colorScheme.blue.blue600, + themeSelect: colorScheme.blue.alphaBlue50015, + infoLight: colorScheme.blue.blue100, + infoLightHover: colorScheme.blue.blue200, + infoThick: colorScheme.blue.blue500, + infoThickHover: colorScheme.blue.blue600, + successLight: colorScheme.green.green100, + successLightHover: colorScheme.green.green200, + successThick: colorScheme.green.green600, + successThickHover: colorScheme.green.green700, + warningLight: colorScheme.orange.orange100, + warningLightHover: colorScheme.orange.orange200, + warningThick: colorScheme.orange.orange600, + warningThickHover: colorScheme.orange.orange700, + errorLight: colorScheme.red.red100, + errorLightHover: colorScheme.red.red200, + errorThick: colorScheme.red.red600, + errorThickHover: colorScheme.red.red700, + errorSelect: colorScheme.red.alphaRed50010, + purpleLight: colorScheme.purple.purple100, + purpleLightHover: colorScheme.purple.purple200, + purpleThick: colorScheme.purple.purple500, + purpleThickHover: colorScheme.purple.purple600, + ), + }; + } + + AppFlowySurfaceColorScheme buildSurfaceColorScheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowySurfaceColorScheme( + primary: colorScheme.neutral.white, + overlay: colorScheme.neutral.alphaBlack60, + ), + Brightness.dark => AppFlowySurfaceColorScheme( + primary: colorScheme.neutral.neutral900, + overlay: colorScheme.neutral.alphaBlack60, + ), + }; + } + + AppFlowyBackgroundColorScheme buildBackgroundColorScheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyBackgroundColorScheme( + primary: colorScheme.neutral.white, + secondary: colorScheme.neutral.neutral100, + tertiary: colorScheme.neutral.neutral200, + quaternary: colorScheme.neutral.neutral300, + ), + Brightness.dark => AppFlowyBackgroundColorScheme( + primary: colorScheme.neutral.neutral1000, + secondary: colorScheme.neutral.neutral900, + tertiary: colorScheme.neutral.neutral800, + quaternary: colorScheme.neutral.neutral700, + ), + }; + } + + AppFlowyBrandColorScheme buildBrandColorScheme( + AppFlowyBaseColorScheme colorScheme, + ) { + return AppFlowyBrandColorScheme( + skyline: const Color(0xFF00B5FF), + aqua: const Color(0xFF00C8FF), + violet: const Color(0xFF9327FF), + amethyst: const Color(0xFF8427E0), + berry: const Color(0xFFE3006D), + coral: const Color(0xFFFB006D), + golden: const Color(0xFFF7931E), + amber: const Color(0xFFFFBD00), + lemon: const Color(0xFFFFCE00), + ); + } + + AppFlowyBorderRadius buildBorderRadius( + AppFlowyBaseColorScheme colorScheme, + ) { + return AppFlowyBorderRadius( + xs: AppFlowyBorderRadiusConstant.radius100, + s: AppFlowyBorderRadiusConstant.radius200, + m: AppFlowyBorderRadiusConstant.radius300, + l: AppFlowyBorderRadiusConstant.radius400, + xl: AppFlowyBorderRadiusConstant.radius500, + xxl: AppFlowyBorderRadiusConstant.radius600, + ); + } + + AppFlowySpacing buildSpacing( + AppFlowyBaseColorScheme colorScheme, + ) { + return AppFlowySpacing( + xs: AppFlowySpacingConstant.spacing100, + s: AppFlowySpacingConstant.spacing200, + m: AppFlowySpacingConstant.spacing300, + l: AppFlowySpacingConstant.spacing400, + xl: AppFlowySpacingConstant.spacing500, + xxl: AppFlowySpacingConstant.spacing600, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart new file mode 100644 index 0000000000..68fb378c0f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart @@ -0,0 +1,238 @@ +import 'package:appflowy_ui/src/theme/border_radius/border_radius.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/background/background_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/base/base_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/border/border.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/brand/brand_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/fill/fill_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; +import 'package:appflowy_ui/src/theme/data/builder.dart'; +import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; +import 'package:appflowy_ui/src/theme/text_style/text_style.dart'; +import 'package:flutter/material.dart'; + +abstract class AppFlowyBaseTheme { + const AppFlowyBaseTheme(); + + AppFlowyBaseColorScheme get colorScheme; + + AppFlowyTextColorScheme get textColorScheme; + + AppFlowyBaseTextStyle get textStyle; + + AppFlowyIconColorTheme get iconColorTheme; + + AppFlowyBorderColorScheme get borderColorScheme; + + AppFlowyBackgroundColorScheme get backgroundColorScheme; + + AppFlowyFillColorScheme get fillColorScheme; + + AppFlowySurfaceColorScheme get surfaceColorScheme; + + AppFlowyBorderRadius get borderRadius; + + AppFlowySpacing get spacing; + + AppFlowyBrandColorScheme get brandColorScheme; +} + +class AppFlowyThemeData extends AppFlowyBaseTheme { + factory AppFlowyThemeData.light() { + final colorScheme = AppFlowyBaseColorScheme(); + + final textStyle = AppFlowyBaseTextStyle(); + final textColorScheme = themeBuilder.buildTextColorScheme( + colorScheme, + Brightness.light, + ); + final borderColorScheme = themeBuilder.buildBorderColorScheme( + colorScheme, + Brightness.light, + ); + final fillColorScheme = themeBuilder.buildFillColorScheme( + colorScheme, + Brightness.light, + ); + final surfaceColorScheme = themeBuilder.buildSurfaceColorScheme( + colorScheme, + Brightness.light, + ); + final backgroundColorScheme = themeBuilder.buildBackgroundColorScheme( + colorScheme, + Brightness.light, + ); + final iconColorTheme = themeBuilder.buildIconColorTheme( + colorScheme, + Brightness.light, + ); + final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); + final borderRadius = themeBuilder.buildBorderRadius(colorScheme); + final spacing = themeBuilder.buildSpacing(colorScheme); + + return AppFlowyThemeData( + colorScheme: colorScheme, + textColorScheme: textColorScheme, + textStyle: textStyle, + iconColorTheme: iconColorTheme, + backgroundColorScheme: backgroundColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + borderRadius: borderRadius, + spacing: spacing, + brandColorScheme: brandColorScheme, + ); + } + + factory AppFlowyThemeData.dark() { + final colorScheme = AppFlowyBaseColorScheme(); + final textStyle = AppFlowyBaseTextStyle(); + final textColorScheme = themeBuilder.buildTextColorScheme( + colorScheme, + Brightness.dark, + ); + final borderColorScheme = themeBuilder.buildBorderColorScheme( + colorScheme, + Brightness.dark, + ); + final fillColorScheme = themeBuilder.buildFillColorScheme( + colorScheme, + Brightness.dark, + ); + final surfaceColorScheme = themeBuilder.buildSurfaceColorScheme( + colorScheme, + Brightness.dark, + ); + final backgroundColorScheme = themeBuilder.buildBackgroundColorScheme( + colorScheme, + Brightness.dark, + ); + final iconColorTheme = themeBuilder.buildIconColorTheme( + colorScheme, + Brightness.dark, + ); + final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); + final borderRadius = themeBuilder.buildBorderRadius(colorScheme); + final spacing = themeBuilder.buildSpacing(colorScheme); + + return AppFlowyThemeData( + colorScheme: colorScheme, + textColorScheme: textColorScheme, + textStyle: textStyle, + iconColorTheme: iconColorTheme, + backgroundColorScheme: backgroundColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + borderRadius: borderRadius, + spacing: spacing, + brandColorScheme: brandColorScheme, + ); + } + + const AppFlowyThemeData({ + required this.colorScheme, + required this.textStyle, + required this.textColorScheme, + required this.borderColorScheme, + required this.fillColorScheme, + required this.surfaceColorScheme, + required this.borderRadius, + required this.spacing, + required this.brandColorScheme, + required this.iconColorTheme, + required this.backgroundColorScheme, + this.brightness = Brightness.light, + }); + + static const AppFlowyThemeBuilder themeBuilder = AppFlowyThemeBuilder(); + + final Brightness brightness; + + @override + final AppFlowyBaseColorScheme colorScheme; + + @override + final AppFlowyBaseTextStyle textStyle; + + @override + final AppFlowyTextColorScheme textColorScheme; + + @override + final AppFlowyBorderColorScheme borderColorScheme; + + @override + final AppFlowyFillColorScheme fillColorScheme; + + @override + final AppFlowySurfaceColorScheme surfaceColorScheme; + + @override + final AppFlowyBorderRadius borderRadius; + + @override + final AppFlowySpacing spacing; + + @override + final AppFlowyBrandColorScheme brandColorScheme; + + @override + final AppFlowyIconColorTheme iconColorTheme; + + @override + final AppFlowyBackgroundColorScheme backgroundColorScheme; + + static AppFlowyTextColorScheme buildTextColorScheme( + AppFlowyBaseColorScheme colorScheme, + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyTextColorScheme( + primary: colorScheme.neutral.neutral1000, + secondary: colorScheme.neutral.neutral600, + tertiary: colorScheme.neutral.neutral400, + quaternary: colorScheme.neutral.neutral200, + inverse: colorScheme.neutral.white, + onFill: colorScheme.neutral.white, + theme: colorScheme.blue.blue500, + themeHover: colorScheme.blue.blue600, + action: colorScheme.blue.blue500, + actionHover: colorScheme.blue.blue600, + info: colorScheme.blue.blue500, + infoHover: colorScheme.blue.blue600, + success: colorScheme.green.green600, + successHover: colorScheme.green.green700, + warning: colorScheme.orange.orange600, + warningHover: colorScheme.orange.orange700, + error: colorScheme.red.red600, + errorHover: colorScheme.red.red700, + purple: colorScheme.purple.purple500, + purpleHover: colorScheme.purple.purple600, + ), + Brightness.dark => AppFlowyTextColorScheme( + primary: colorScheme.neutral.neutral200, + secondary: colorScheme.neutral.neutral400, + tertiary: colorScheme.neutral.neutral600, + quaternary: colorScheme.neutral.neutral1000, + inverse: colorScheme.neutral.neutral1000, + onFill: colorScheme.neutral.white, + theme: colorScheme.blue.blue500, + themeHover: colorScheme.blue.blue600, + action: colorScheme.blue.blue500, + actionHover: colorScheme.blue.blue600, + info: colorScheme.blue.blue500, + infoHover: colorScheme.blue.blue600, + success: colorScheme.green.green600, + successHover: colorScheme.green.green700, + warning: colorScheme.orange.orange600, + warningHover: colorScheme.orange.orange700, + error: colorScheme.red.red500, + errorHover: colorScheme.red.red400, + purple: colorScheme.purple.purple500, + purpleHover: colorScheme.purple.purple600, + ), + }; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart new file mode 100644 index 0000000000..e502b3b875 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart @@ -0,0 +1,17 @@ +class AppFlowySpacingConstant { + static const double spacing100 = 4; + static const double spacing200 = 6; + static const double spacing300 = 8; + static const double spacing400 = 12; + static const double spacing500 = 16; + static const double spacing600 = 20; +} + +class AppFlowyBorderRadiusConstant { + static const double radius100 = 4; + static const double radius200 = 6; + static const double radius300 = 8; + static const double radius400 = 12; + static const double radius500 = 16; + static const double radius600 = 20; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/spacing/spacing.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/spacing/spacing.dart new file mode 100644 index 0000000000..ea90784db3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/spacing/spacing.dart @@ -0,0 +1,17 @@ +class AppFlowySpacing { + const AppFlowySpacing({ + required this.xs, + required this.s, + required this.m, + required this.l, + required this.xl, + required this.xxl, + }); + + final double xs; + final double s; + final double m; + final double l; + final double xl; + final double xxl; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart new file mode 100644 index 0000000000..a85ffbb6cb --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart @@ -0,0 +1,298 @@ +import 'package:flutter/widgets.dart'; + +abstract class TextThemeType { + const TextThemeType(); + + TextStyle standard({ + String family = '', + Color? color, + }); + TextStyle enhanced({ + String family = '', + Color? color, + }); + TextStyle prominent({ + String family = '', + Color? color, + }); + TextStyle underline({ + String family = '', + Color? color, + }); +} + +class TextThemeHeading { + const TextThemeHeading(); + + TextStyle h1({ + String family = '', + Color? color, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + ); + + TextStyle h2({ + String family = '', + Color? color, + }) => + _defaultTextStyle( + family: family, + fontSize: 24, + height: 32 / 24, + color: color, + ); + + TextStyle h3({ + String family = '', + Color? color, + }) => + _defaultTextStyle( + family: family, + fontSize: 20, + height: 28 / 20, + color: color, + ); + + TextStyle h4({ + String family = '', + Color? color, + }) => + _defaultTextStyle( + family: family, + fontSize: 16, + height: 22 / 16, + color: color, + ); + + static TextStyle _defaultTextStyle({ + required String family, + required double fontSize, + required double height, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: FontWeight.bold, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeadline extends TextThemeType { + const TextThemeHeadline(); + + @override + TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + ); + + @override + TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 24, + double height = 36 / 24, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeTitle extends TextThemeType { + const TextThemeTitle(); + + @override + TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + ); + + @override + TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 20, + double height = 28 / 20, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeBody extends TextThemeType { + const TextThemeBody(); + + @override + TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + ); + + @override + TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 14, + double height = 20 / 14, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeCaption extends TextThemeType { + const TextThemeCaption(); + + @override + TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + ); + + @override + TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + weight: FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + family: family, + color: color, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 12, + double height = 16 / 12, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart new file mode 100644 index 0000000000..64daae2370 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart @@ -0,0 +1,17 @@ +import 'package:appflowy_ui/src/theme/text_style/base/default_text_style.dart'; + +class AppFlowyBaseTextStyle { + const AppFlowyBaseTextStyle({ + this.heading = const TextThemeHeading(), + this.headline = const TextThemeHeadline(), + this.title = const TextThemeTitle(), + this.body = const TextThemeBody(), + this.caption = const TextThemeCaption(), + }); + + final TextThemeHeading heading; + final TextThemeType headline; + final TextThemeType title; + final TextThemeType body; + final TextThemeType caption; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart new file mode 100644 index 0000000000..b800b1bb6c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart @@ -0,0 +1,7 @@ +export 'appflowy_theme.dart'; +export 'border_radius/border_radius.dart'; +export 'color_scheme/color_scheme.dart'; +export 'data/data.dart'; +export 'dimensions.dart'; +export 'spacing/spacing.dart'; +export 'text_style/text_style.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml new file mode 100644 index 0000000000..2f5633bb1e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml @@ -0,0 +1,17 @@ +name: appflowy_ui +description: "A Flutter package for AppFlowy UI components and widgets" +version: 1.0.0 +homepage: https://github.com/appflowy-io/appflowy + +environment: + sdk: ^3.6.2 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_lints: ^5.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/frontend/resources/flowy_icons/20x/anonymous_mode.svg b/frontend/resources/flowy_icons/20x/anonymous_mode.svg new file mode 100644 index 0000000000..bee519e54a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/anonymous_mode.svg @@ -0,0 +1,3 @@ + + + From c561abd9f8aaf2036c4b5fbd6a34e020dc08b254 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:52:44 +0800 Subject: [PATCH 094/207] fix: clear text field upon selection (#7695) --- .../cell/bloc/select_option_cell_editor_bloc.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart index f8ed915b62..c6e4e6484b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart @@ -241,6 +241,11 @@ class SelectOptionCellEditorBloc } else if (!state.selectedOptions .any((option) => option.id == focusedOptionId)) { _selectOptionService.select(optionIds: [focusedOptionId]); + emit( + state.copyWith( + clearFilter: true, + ), + ); } } From 995b773c7404abe9687c619033123d367e56cf93 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 7 Apr 2025 19:24:58 +0800 Subject: [PATCH 095/207] chore: replace str with uuid --- frontend/rust-lib/Cargo.lock | 88 +++---- frontend/rust-lib/Cargo.toml | 20 +- frontend/rust-lib/collab-integrate/Cargo.toml | 3 +- .../collab-integrate/src/collab_builder.rs | 24 +- .../src/persistence/collab_metadata_sql.rs | 15 +- .../src/document/document_event.rs | 6 +- .../src/folder_event.rs | 9 +- .../event-integration-test/src/lib.rs | 13 +- .../tests/chat/chat_message_test.rs | 10 +- .../tests/document/local_test/edit_test.rs | 6 +- frontend/rust-lib/flowy-ai-pub/Cargo.toml | 4 +- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 57 +++-- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 102 ++++---- frontend/rust-lib/flowy-ai/src/chat.rs | 17 +- frontend/rust-lib/flowy-ai/src/completion.rs | 112 ++++---- frontend/rust-lib/flowy-ai/src/entities.rs | 8 +- .../rust-lib/flowy-ai/src/event_handler.rs | 35 ++- .../flowy-ai/src/local_ai/controller.rs | 25 +- .../src/middleware/chat_service_mw.rs | 80 +++--- .../rust-lib/flowy-ai/src/notification.rs | 12 +- frontend/rust-lib/flowy-core/Cargo.toml | 4 +- .../flowy-core/src/deps_resolve/chat_deps.rs | 21 +- .../src/deps_resolve/cloud_service_impl.rs | 241 +++++++++--------- .../src/deps_resolve/collab_deps.rs | 5 +- .../src/deps_resolve/database_deps.rs | 27 +- .../src/deps_resolve/document_deps.rs | 4 +- .../src/deps_resolve/file_storage_deps.rs | 3 +- .../folder_deps/folder_deps_chat_impl.rs | 27 +- .../folder_deps/folder_deps_database_impl.rs | 69 +++-- .../folder_deps/folder_deps_doc_impl.rs | 31 ++- .../src/deps_resolve/folder_deps/mod.rs | 42 +-- .../flowy-core/src/deps_resolve/user_deps.rs | 3 +- frontend/rust-lib/flowy-core/src/lib.rs | 18 +- .../flowy-core/src/user_state_callback.rs | 10 +- .../rust-lib/flowy-database-pub/Cargo.toml | 4 +- .../rust-lib/flowy-database-pub/src/cloud.rs | 23 +- frontend/rust-lib/flowy-database2/Cargo.toml | 1 + .../flowy-database2/src/event_handler.rs | 4 +- .../rust-lib/flowy-database2/src/manager.rs | 109 +++++--- .../src/services/database/database_editor.rs | 8 +- .../src/services/database/database_observe.rs | 5 +- .../rust-lib/flowy-document-pub/Cargo.toml | 4 +- .../rust-lib/flowy-document-pub/src/cloud.rs | 16 +- .../rust-lib/flowy-document/src/document.rs | 7 +- .../rust-lib/flowy-document/src/entities.rs | 43 ++-- .../flowy-document/src/event_handler.rs | 12 +- .../rust-lib/flowy-document/src/manager.rs | 69 ++--- .../src/parser/parser_entities.rs | 6 +- .../tests/document/document_redo_undo_test.rs | 4 +- .../tests/document/document_test.rs | 16 +- .../flowy-document/tests/document/util.rs | 51 ++-- frontend/rust-lib/flowy-error/Cargo.toml | 1 + frontend/rust-lib/flowy-error/src/errors.rs | 6 + .../rust-lib/flowy-folder-pub/src/cloud.rs | 40 +-- .../rust-lib/flowy-folder-pub/src/query.rs | 9 +- .../flowy-folder/src/entities/import.rs | 4 + .../flowy-folder/src/entities/view.rs | 50 ++-- .../flowy-folder/src/event_handler.rs | 16 +- frontend/rust-lib/flowy-folder/src/manager.rs | 166 ++++++------ .../rust-lib/flowy-folder/src/manager_init.rs | 26 +- .../flowy-folder/src/manager_observer.rs | 57 +++-- .../rust-lib/flowy-folder/src/notification.rs | 12 +- .../rust-lib/flowy-folder/src/share/import.rs | 3 +- frontend/rust-lib/flowy-folder/src/util.rs | 3 +- .../flowy-folder/src/view_operation.rs | 24 +- frontend/rust-lib/flowy-search-pub/Cargo.toml | 2 +- .../rust-lib/flowy-search-pub/src/cloud.rs | 3 +- .../rust-lib/flowy-search-pub/src/entities.rs | 15 +- frontend/rust-lib/flowy-search/Cargo.toml | 8 +- .../flowy-search/src/document/handler.rs | 16 +- .../flowy-search/src/folder/indexer.rs | 20 +- .../flowy-server/src/af_cloud/define.rs | 3 +- .../flowy-server/src/af_cloud/impls/chat.rs | 94 +++---- .../src/af_cloud/impls/database.rs | 70 +++-- .../src/af_cloud/impls/document.rs | 38 +-- .../src/af_cloud/impls/file_storage.rs | 15 +- .../flowy-server/src/af_cloud/impls/folder.rs | 81 +++--- .../flowy-server/src/af_cloud/impls/search.rs | 3 +- .../af_cloud/impls/user/cloud_service_impl.rs | 87 +++---- .../flowy-server/src/af_cloud/impls/util.rs | 7 +- .../rust-lib/flowy-server/src/default_impl.rs | 97 +++---- .../src/local_server/impls/database.rs | 32 +-- .../src/local_server/impls/document.rs | 21 +- .../src/local_server/impls/folder.rs | 58 ++--- .../src/local_server/impls/user.rs | 27 +- .../flowy-server/tests/af_cloud_test/util.rs | 2 +- .../rust-lib/flowy-storage-pub/Cargo.toml | 3 +- .../rust-lib/flowy-storage-pub/src/cloud.rs | 23 +- frontend/rust-lib/flowy-storage/Cargo.toml | 4 +- .../rust-lib/flowy-storage/src/manager.rs | 41 ++- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 28 +- .../rust-lib/flowy-user-pub/src/entities.rs | 6 + .../flowy-user-pub/src/workspace_service.rs | 3 +- .../rust-lib/flowy-user/src/event_handler.rs | 25 +- .../src/services/authenticate_user.rs | 12 +- .../flowy-user/src/services/billing_check.rs | 5 +- .../data_import/appflowy_data_import.rs | 6 +- .../flowy-user/src/user_manager/manager.rs | 6 +- .../user_manager/manager_user_awareness.rs | 52 ++-- .../user_manager/manager_user_workspace.rs | 46 ++-- 100 files changed, 1560 insertions(+), 1349 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 69d6220409..e10038c7d3 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -496,7 +496,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "bincode", @@ -516,16 +516,16 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "bytes", "futures", - "pin-project", "serde", "serde_json", "serde_repr", "thiserror 1.0.64", + "uuid", ] [[package]] @@ -1137,7 +1137,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "again", "anyhow", @@ -1192,7 +1192,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1205,7 +1205,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "futures-channel", "futures-util", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "arc-swap", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "async-trait", @@ -1313,7 +1313,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "arc-swap", @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "bytes", @@ -1354,7 +1354,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "arc-swap", @@ -1376,7 +1376,7 @@ dependencies = [ [[package]] name = "collab-importer" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "async-recursion", @@ -1418,7 +1418,6 @@ version = "0.1.0" dependencies = [ "anyhow", "arc-swap", - "async-trait", "collab", "collab-database", "collab-document", @@ -1429,18 +1428,18 @@ dependencies = [ "diesel", "flowy-error", "flowy-sqlite", - "futures", "lib-infra", "serde", "serde_json", "tokio", "tracing", + "uuid", ] [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "async-stream", @@ -1478,7 +1477,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "bincode", @@ -1500,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "async-trait", @@ -1511,13 +1510,14 @@ dependencies = [ "thiserror 1.0.64", "tokio", "tracing", + "uuid", "yrs", ] [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" dependencies = [ "anyhow", "collab", @@ -1764,7 +1764,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1947,7 +1947,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "bincode", "bytes", @@ -2513,13 +2513,13 @@ dependencies = [ name = "flowy-ai-pub" version = "0.1.0" dependencies = [ - "bytes", "client-api", "flowy-error", "futures", "lib-infra", "serde", "serde_json", + "uuid", ] [[package]] @@ -2592,7 +2592,6 @@ dependencies = [ "flowy-storage-pub", "flowy-user", "flowy-user-pub", - "futures", "futures-core", "lib-dispatch", "lib-infra", @@ -2606,19 +2605,18 @@ dependencies = [ "tokio-stream", "tracing", "uuid", - "walkdir", ] [[package]] name = "flowy-database-pub" version = "0.1.0" dependencies = [ - "anyhow", "client-api", "collab", "collab-entity", "flowy-error", "lib-infra", + "uuid", ] [[package]] @@ -2667,6 +2665,7 @@ dependencies = [ "tokio-util", "tracing", "url", + "uuid", "validator 0.18.1", ] @@ -2744,11 +2743,11 @@ dependencies = [ name = "flowy-document-pub" version = "0.1.0" dependencies = [ - "anyhow", "collab", "collab-document", "flowy-error", "lib-infra", + "uuid", ] [[package]] @@ -2778,6 +2777,7 @@ dependencies = [ "thiserror 1.0.64", "tokio", "url", + "uuid", "validator 0.18.1", ] @@ -2864,16 +2864,12 @@ dependencies = [ "bytes", "collab", "collab-folder", - "diesel", - "diesel_derives", - "diesel_migrations", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-folder", "flowy-notification", "flowy-search-pub", - "flowy-sqlite", "flowy-user", "futures", "lib-dispatch", @@ -2887,7 +2883,7 @@ dependencies = [ "tempfile", "tokio", "tracing", - "validator 0.18.1", + "uuid", ] [[package]] @@ -2898,8 +2894,8 @@ dependencies = [ "collab", "collab-folder", "flowy-error", - "futures", "lib-infra", + "uuid", ] [[package]] @@ -2991,7 +2987,6 @@ name = "flowy-storage" version = "0.1.0" dependencies = [ "allo-isolate", - "anyhow", "async-trait", "bytes", "chrono", @@ -3003,8 +2998,6 @@ dependencies = [ "flowy-notification", "flowy-sqlite", "flowy-storage-pub", - "futures-util", - "fxhash", "lib-dispatch", "lib-infra", "mime_guess", @@ -3032,9 +3025,8 @@ dependencies = [ "mime", "mime_guess", "serde", - "serde_json", "tokio", - "tracing", + "uuid", ] [[package]] @@ -3432,7 +3424,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3447,7 +3439,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "app-error", "jsonwebtoken", @@ -4068,7 +4060,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "bytes", @@ -5181,7 +5173,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5201,7 +5193,6 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5269,19 +5260,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", - "proc-macro2", - "quote", - "syn 2.0.94", -] - [[package]] name = "phf_shared" version = "0.8.0" @@ -6782,7 +6760,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 9245232f29..e3cb5a4178 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f7288f46c27dc8e3c7829cda1b70b61118e88336" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f7288f46c27dc8e3c7829cda1b70b61118e88336" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" } [profile.dev] opt-level = 0 @@ -139,14 +139,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } -collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/rust-lib/collab-integrate/Cargo.toml b/frontend/rust-lib/collab-integrate/Cargo.toml index b817d639f1..0cbdd41ccd 100644 --- a/frontend/rust-lib/collab-integrate/Cargo.toml +++ b/frontend/rust-lib/collab-integrate/Cargo.toml @@ -19,14 +19,13 @@ serde.workspace = true serde_json.workspace = true anyhow.workspace = true tracing.workspace = true -async-trait.workspace = true tokio = { workspace = true, features = ["sync"] } lib-infra = { workspace = true } -futures = "0.3.31" arc-swap = "1.7" flowy-sqlite = { workspace = true } diesel.workspace = true flowy-error.workspace = true +uuid.workspace = true [features] default = [] diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index c94660dbfd..226a5b679b 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -1,5 +1,6 @@ use std::borrow::BorrowMut; use std::fmt::{Debug, Display}; +use std::str::FromStr; use std::sync::{Arc, Weak}; use crate::CollabKVDB; @@ -33,8 +34,10 @@ use collab_plugins::local_storage::kv::KVTransactionDB; use collab_plugins::local_storage::CollabPersistenceConfig; use collab_user::core::{UserAwareness, UserAwarenessNotifier}; +use flowy_error::FlowyError; use lib_infra::{if_native, if_wasm}; use tracing::{error, instrument, trace, warn}; +use uuid::Uuid; #[derive(Clone, Debug)] pub enum CollabPluginProviderType { @@ -66,8 +69,8 @@ impl Display for CollabPluginProviderContext { } pub trait WorkspaceCollabIntegrate: Send + Sync { - fn workspace_id(&self) -> Result; - fn device_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn device_id(&self) -> Result; } pub struct AppFlowyCollabBuilder { @@ -119,15 +122,15 @@ impl AppFlowyCollabBuilder { pub fn collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, ) -> Result { // Compare the workspace_id with the currently opened workspace_id. Return an error if they do not match. // This check is crucial in asynchronous code contexts where the workspace_id might change during operation. let actual_workspace_id = self.workspace_integrate.workspace_id()?; - if workspace_id != actual_workspace_id { + if workspace_id != &actual_workspace_id { return Err(anyhow::anyhow!( "workspace_id not match when build collab. expect workspace_id: {}, actual workspace_id: {}", workspace_id, @@ -140,7 +143,7 @@ impl AppFlowyCollabBuilder { uid, object_id.to_string(), collab_type, - workspace_id, + workspace_id.to_string(), device_id, )) } @@ -399,11 +402,11 @@ impl CollabBuilderConfig { pub struct CollabPersistenceImpl { pub db: Weak, pub uid: i64, - pub workspace_id: String, + pub workspace_id: Uuid, } impl CollabPersistenceImpl { - pub fn new(db: Weak, uid: i64, workspace_id: String) -> Self { + pub fn new(db: Weak, uid: i64, workspace_id: Uuid) -> Self { Self { db, uid, @@ -423,7 +426,8 @@ impl CollabPersistence for CollabPersistenceImpl { .upgrade() .ok_or_else(|| CollabError::Internal(anyhow!("collab_db is dropped")))?; - let object_id = collab.object_id().to_string(); + let object_id = + Uuid::from_str(collab.object_id()).map_err(|v| CollabError::Internal(v.into()))?; let rocksdb_read = collab_db.read_txn(); if rocksdb_read.is_exist(self.uid, &self.workspace_id, &object_id) { @@ -461,7 +465,7 @@ impl CollabPersistence for CollabPersistenceImpl { write_txn .flush_doc( self.uid, - self.workspace_id.as_str(), + self.workspace_id.to_string().as_str(), object_id, encoded_collab.state_vector.to_vec(), encoded_collab.doc_state.to_vec(), diff --git a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs index 82e993fc49..bc5f28e642 100644 --- a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs +++ b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs @@ -7,6 +7,8 @@ use flowy_sqlite::{ DBConnection, ExpressionMethods, Identifiable, Insertable, Queryable, }; use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; #[derive(Queryable, Insertable, Identifiable)] #[diesel(table_name = af_collab_metadata)] @@ -43,13 +45,18 @@ pub fn batch_insert_collab_metadata( pub fn batch_select_collab_metadata( mut conn: DBConnection, - object_ids: &[String], -) -> FlowyResult> { + object_ids: &[Uuid], +) -> FlowyResult> { + let object_ids = object_ids + .iter() + .map(|id| id.to_string()) + .collect::>(); + let metadata = dsl::af_collab_metadata - .filter(af_collab_metadata::object_id.eq_any(object_ids)) + .filter(af_collab_metadata::object_id.eq_any(&object_ids)) .load::(&mut conn)? .into_iter() - .map(|m| (m.object_id.clone(), m)) + .flat_map(|m| Uuid::from_str(&m.object_id).and_then(|v| Ok((v, m)))) .collect(); Ok(metadata) } diff --git a/frontend/rust-lib/event-integration-test/src/document/document_event.rs b/frontend/rust-lib/event-integration-test/src/document/document_event.rs index 65b4943f80..28fb03e9ed 100644 --- a/frontend/rust-lib/event-integration-test/src/document/document_event.rs +++ b/frontend/rust-lib/event-integration-test/src/document/document_event.rs @@ -1,8 +1,6 @@ use collab::entity::EncodedCollab; use std::collections::HashMap; -use serde_json::Value; - use flowy_document::entities::*; use flowy_document::event_map::DocumentEvent; use flowy_document::parser::parser_entities::{ @@ -11,6 +9,8 @@ use flowy_document::parser::parser_entities::{ }; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder::event_map::FolderEvent; +use serde_json::Value; +use uuid::Uuid; use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data}; use crate::event_builder::EventBuilder; @@ -37,7 +37,7 @@ impl DocumentEventTest { Self { event_test: core } } - pub async fn get_encoded_v1(&self, doc_id: &str) -> EncodedCollab { + pub async fn get_encoded_v1(&self, doc_id: &Uuid) -> EncodedCollab { let doc = self .event_test .appflowy_core diff --git a/frontend/rust-lib/event-integration-test/src/folder_event.rs b/frontend/rust-lib/event-integration-test/src/folder_event.rs index fae34e175c..2e1b1cc417 100644 --- a/frontend/rust-lib/event-integration-test/src/folder_event.rs +++ b/frontend/rust-lib/event-integration-test/src/folder_event.rs @@ -1,4 +1,5 @@ use flowy_folder::view_operation::{GatherEncodedCollab, ViewData}; +use std::str::FromStr; use std::sync::Arc; use collab_folder::{FolderData, View}; @@ -16,6 +17,7 @@ use flowy_user::entities::{ use flowy_user::errors::FlowyError; use flowy_user::event_map::UserEvent; use flowy_user_pub::entities::Role; +use uuid::Uuid; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; @@ -123,10 +125,10 @@ impl EventIntegrationTest { let create_view_params = views .into_iter() .map(|view| CreateViewParams { - parent_view_id: view.parent_view_id, + parent_view_id: Uuid::from_str(&view.parent_view_id).unwrap(), name: view.name, layout: view.layout.into(), - view_id: view.id, + view_id: Uuid::from_str(&view.id).unwrap(), initial_data: ViewData::Empty, meta: Default::default(), set_as_current: false, @@ -195,9 +197,10 @@ impl EventIntegrationTest { view_id: &str, layout: ViewLayout, ) -> GatherEncodedCollab { + let view_id = Uuid::from_str(view_id).unwrap(); self .folder_manager - .gather_publish_encode_collab(view_id, &layout) + .gather_publish_encode_collab(&view_id, &layout) .await .unwrap() } diff --git a/frontend/rust-lib/event-integration-test/src/lib.rs b/frontend/rust-lib/event-integration-test/src/lib.rs index 573c8b692b..02efc0f75a 100644 --- a/frontend/rust-lib/event-integration-test/src/lib.rs +++ b/frontend/rust-lib/event-integration-test/src/lib.rs @@ -1,3 +1,4 @@ +use crate::user_event::TestNotificationSender; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; @@ -15,14 +16,14 @@ use nanoid::nanoid; use semver::Version; use std::env::temp_dir; use std::path::PathBuf; +use std::str::FromStr; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::select; use tokio::task::LocalSet; use tokio::time::sleep; - -use crate::user_event::TestNotificationSender; +use uuid::Uuid; mod chat_event; pub mod database_event; @@ -145,10 +146,16 @@ impl EventIntegrationTest { ) -> Result, FlowyError> { let server = self.server_provider.get_server().unwrap(); let workspace_id = self.get_current_workspace().await.id; + let oid = Uuid::from_str(oid).unwrap(); let uid = self.get_user_profile().await?.id; let doc_state = server .folder_service() - .get_folder_doc_state(&workspace_id, uid, collab_type, oid) + .get_folder_doc_state( + &Uuid::from_str(&workspace_id).unwrap(), + uid, + collab_type, + &oid, + ) .await?; Ok(doc_state) diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs index 1a5f9356c4..c5b30d68c3 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -3,10 +3,12 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_ai::entities::ChatMessageListPB; use flowy_ai::notification::ChatNotification; +use std::str::FromStr; use flowy_ai_pub::cloud::ChatMessageType; use std::time::Duration; +use uuid::Uuid; #[tokio::test] async fn af_cloud_create_chat_message_test() { @@ -21,8 +23,8 @@ async fn af_cloud_create_chat_message_test() { for i in 0..10 { let _ = chat_service .create_question( - ¤t_workspace.id, - &chat_id, + &Uuid::from_str(¤t_workspace.id).unwrap(), + &Uuid::from_str(&chat_id).unwrap(), &format!("hello world {}", i), ChatMessageType::System, &[], @@ -77,8 +79,8 @@ async fn af_cloud_load_remote_system_message_test() { for i in 0..10 { let _ = chat_service .create_question( - ¤t_workspace.id, - &chat_id, + &Uuid::from_str(¤t_workspace.id).unwrap(), + &Uuid::from_str(&chat_id).unwrap(), &format!("hello server {}", i), ChatMessageType::System, &[], diff --git a/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs b/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs index 199c1b43c2..d9273dbe8b 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs @@ -8,6 +8,8 @@ use flowy_document::parser::parser_entities::{ }; use serde_json::{json, Value}; use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; #[tokio::test] async fn get_document_event_test() { @@ -101,8 +103,8 @@ async fn document_size_test() { let s = generate_random_string(string_size); test.insert_index(&view.id, &s, 1, None).await; } - - let encoded_v1 = test.get_encoded_v1(&view.id).await; + let view_id = Uuid::from_str(&view.id).unwrap(); + let encoded_v1 = test.get_encoded_v1(&view_id).await; if encoded_v1.doc_state.len() > max_size { panic!( "The document size is too large. {}", diff --git a/frontend/rust-lib/flowy-ai-pub/Cargo.toml b/frontend/rust-lib/flowy-ai-pub/Cargo.toml index 09ca8dc2d4..dfb67490ac 100644 --- a/frontend/rust-lib/flowy-ai-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-ai-pub/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" lib-infra = { workspace = true } flowy-error = { workspace = true } client-api = { workspace = true } -bytes.workspace = true futures.workspace = true serde_json.workspace = true -serde.workspace = true \ No newline at end of file +serde.workspace = true +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 7eb8386de1..79478f64fc 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; +use uuid::Uuid; pub type ChatMessageStream = BoxStream<'static, Result>; pub type StreamAnswer = BoxStream<'static, Result>; @@ -81,15 +82,15 @@ pub trait ChatCloudService: Send + Sync + 'static { async fn create_chat( &self, uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, ) -> Result<(), FlowyError>; async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, metadata: &[ChatMessageMetadata], @@ -97,8 +98,8 @@ pub trait ChatCloudService: Send + Sync + 'static { async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -106,8 +107,8 @@ pub trait ChatCloudService: Send + Sync + 'static { async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, format: ResponseFormat, ai_model: Option, @@ -115,68 +116,68 @@ pub trait ChatCloudService: Send + Sync + 'static { async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, question_message_id: i64, ) -> Result; async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result; async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, answer_message_id: i64, ) -> Result; async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, ) -> Result; async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, ai_model: Option, ) -> Result; async fn embed_file( &self, - workspace_id: &str, + workspace_id: &Uuid, file_path: &Path, - chat_id: &str, + chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError>; - async fn get_local_ai_config(&self, workspace_id: &str) -> Result; + async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result; async fn get_workspace_plan( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result; async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError>; - async fn get_available_models(&self, workspace_id: &str) -> Result; - async fn get_workspace_default_model(&self, workspace_id: &str) -> Result; + async fn get_available_models(&self, workspace_id: &Uuid) -> Result; + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result; } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 5e50f768f1..e11f2328e4 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -27,14 +27,16 @@ use flowy_storage_pub::storage::StorageService; use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; use tokio::sync::RwLock; use tracing::{error, info, instrument, trace}; +use uuid::Uuid; pub trait AIUserService: Send + Sync + 'static { fn user_id(&self) -> Result; fn device_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn sqlite_connection(&self, uid: i64) -> Result; fn application_root_dir(&self) -> Result; } @@ -44,18 +46,18 @@ pub trait AIUserService: Send + Sync + 'static { pub trait AIExternalService: Send + Sync + 'static { async fn query_chat_rag_ids( &self, - parent_view_id: &str, - chat_id: &str, - ) -> Result, FlowyError>; + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError>; async fn sync_rag_documents( &self, - workspace_id: &str, - rag_ids: Vec, - rag_metadata_map: HashMap, + workspace_id: &Uuid, + rag_ids: Vec, + rag_metadata_map: HashMap, ) -> Result, FlowyError>; - async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError>; + async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError>; } #[derive(Debug, Default)] @@ -70,7 +72,7 @@ pub struct AIManager { pub cloud_service_wm: Arc, pub user_service: Arc, pub external_service: Arc, - chats: Arc>>, + chats: Arc>>, pub local_ai: Arc, pub store_preferences: Arc, server_models: Arc>, @@ -132,11 +134,11 @@ impl AIManager { Ok(()) } - pub async fn open_chat(&self, chat_id: &str) -> Result<(), FlowyError> { - self.chats.entry(chat_id.to_string()).or_insert_with(|| { + pub async fn open_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { + self.chats.entry(chat_id.clone()).or_insert_with(|| { Arc::new(Chat::new( self.user_service.user_id().unwrap(), - chat_id.to_string(), + chat_id.clone(), self.user_service.clone(), self.cloud_service_wm.clone(), )) @@ -150,7 +152,7 @@ impl AIManager { let cloud_service_wm = self.cloud_service_wm.clone(); let store_preferences = self.store_preferences.clone(); let external_service = self.external_service.clone(); - let chat_id = chat_id.to_string(); + let chat_id = chat_id.clone(); tokio::spawn(async move { match refresh_chat_setting( &user_service, @@ -161,7 +163,12 @@ impl AIManager { .await { Ok(settings) => { - let _ = sync_chat_documents(user_service, external_service, settings.rag_ids).await; + let rag_ids = settings + .rag_ids + .into_iter() + .flat_map(|r| Uuid::from_str(&r).ok()) + .collect(); + let _ = sync_chat_documents(user_service, external_service, rag_ids).await; }, Err(err) => { error!("failed to refresh chat settings: {}", err); @@ -172,13 +179,13 @@ impl AIManager { Ok(()) } - pub async fn close_chat(&self, chat_id: &str) -> Result<(), FlowyError> { + pub async fn close_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { trace!("close chat: {}", chat_id); self.local_ai.close_chat(chat_id); Ok(()) } - pub async fn delete_chat(&self, chat_id: &str) -> Result<(), FlowyError> { + pub async fn delete_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { if let Some((_, chat)) = self.chats.remove(chat_id) { chat.close(); @@ -212,8 +219,8 @@ impl AIManager { pub async fn create_chat( &self, uid: &i64, - parent_view_id: &str, - chat_id: &str, + parent_view_id: &Uuid, + chat_id: &Uuid, ) -> Result, FlowyError> { let workspace_id = self.user_service.workspace_id()?; let rag_ids = self @@ -231,11 +238,11 @@ impl AIManager { let chat = Arc::new(Chat::new( self.user_service.user_id()?, - chat_id.to_string(), + chat_id.clone(), self.user_service.clone(), self.cloud_service_wm.clone(), )); - self.chats.insert(chat_id.to_string(), chat.clone()); + self.chats.insert(chat_id.clone(), chat.clone()); Ok(chat) } @@ -244,7 +251,7 @@ impl AIManager { params: StreamMessageParams, ) -> Result { let chat = self.get_or_create_chat_instance(¶ms.chat_id).await?; - let ai_model = self.get_active_model(¶ms.chat_id).await; + let ai_model = self.get_active_model(¶ms.chat_id.to_string()).await; let question = chat.stream_chat_message(¶ms, ai_model).await?; let _ = self .external_service @@ -255,7 +262,7 @@ impl AIManager { pub async fn stream_regenerate_response( &self, - chat_id: &str, + chat_id: &Uuid, answer_message_id: i64, answer_stream_port: i64, format: Option, @@ -270,7 +277,7 @@ impl AIManager { || { self .store_preferences - .get_object::(&ai_available_models_key(chat_id)) + .get_object::(&ai_available_models_key(&chat_id.to_string())) }, |model| Some(model.into()), ); @@ -520,17 +527,17 @@ impl AIManager { }) } - pub async fn get_or_create_chat_instance(&self, chat_id: &str) -> Result, FlowyError> { + pub async fn get_or_create_chat_instance(&self, chat_id: &Uuid) -> Result, FlowyError> { let chat = self.chats.get(chat_id).as_deref().cloned(); match chat { None => { let chat = Arc::new(Chat::new( self.user_service.user_id()?, - chat_id.to_string(), + chat_id.clone(), self.user_service.clone(), self.cloud_service_wm.clone(), )); - self.chats.insert(chat_id.to_string(), chat.clone()); + self.chats.insert(chat_id.clone(), chat.clone()); Ok(chat) }, Some(chat) => Ok(chat), @@ -554,7 +561,7 @@ impl AIManager { pub async fn load_prev_chat_messages( &self, - chat_id: &str, + chat_id: &Uuid, limit: i64, before_message_id: Option, ) -> Result { @@ -567,7 +574,7 @@ impl AIManager { pub async fn load_latest_chat_messages( &self, - chat_id: &str, + chat_id: &Uuid, limit: i64, after_message_id: Option, ) -> Result { @@ -580,7 +587,7 @@ impl AIManager { pub async fn get_related_questions( &self, - chat_id: &str, + chat_id: &Uuid, message_id: i64, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; @@ -590,7 +597,7 @@ impl AIManager { pub async fn generate_answer( &self, - chat_id: &str, + chat_id: &Uuid, question_message_id: i64, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; @@ -598,19 +605,19 @@ impl AIManager { Ok(resp) } - pub async fn stop_stream(&self, chat_id: &str) -> Result<(), FlowyError> { + pub async fn stop_stream(&self, chat_id: &Uuid) -> Result<(), FlowyError> { let chat = self.get_or_create_chat_instance(chat_id).await?; chat.stop_stream_message().await; Ok(()) } - pub async fn chat_with_file(&self, chat_id: &str, file_path: PathBuf) -> FlowyResult<()> { + pub async fn chat_with_file(&self, chat_id: &Uuid, file_path: PathBuf) -> FlowyResult<()> { let chat = self.get_or_create_chat_instance(chat_id).await?; chat.index_file(file_path).await?; Ok(()) } - pub async fn get_rag_ids(&self, chat_id: &str) -> FlowyResult> { + pub async fn get_rag_ids(&self, chat_id: &Uuid) -> FlowyResult> { if let Some(settings) = self .store_preferences .get_object::(&setting_store_key(chat_id)) @@ -628,7 +635,7 @@ impl AIManager { Ok(settings.rag_ids) } - pub async fn update_rag_ids(&self, chat_id: &str, rag_ids: Vec) -> FlowyResult<()> { + pub async fn update_rag_ids(&self, chat_id: &Uuid, rag_ids: Vec) -> FlowyResult<()> { info!("[Chat] update chat:{} rag ids: {:?}", chat_id, rag_ids); let workspace_id = self.user_service.workspace_id()?; let update_setting = UpdateChatParams { @@ -659,6 +666,10 @@ impl AIManager { let user_service = self.user_service.clone(); let external_service = self.external_service.clone(); + let rag_ids = rag_ids + .into_iter() + .flat_map(|r| Uuid::from_str(&r).ok()) + .collect(); sync_chat_documents(user_service, external_service, rag_ids).await?; Ok(()) } @@ -667,7 +678,7 @@ impl AIManager { async fn sync_chat_documents( user_service: Arc, external_service: Arc, - rag_ids: Vec, + rag_ids: Vec, ) -> FlowyResult<()> { if rag_ids.is_empty() { return Ok(()); @@ -697,7 +708,7 @@ async fn sync_chat_documents( Ok(()) } -fn save_chat(conn: DBConnection, chat_id: &str) -> FlowyResult<()> { +fn save_chat(conn: DBConnection, chat_id: &Uuid) -> FlowyResult<()> { let row = ChatTable { chat_id: chat_id.to_string(), created_at: timestamp(), @@ -716,7 +727,7 @@ async fn refresh_chat_setting( user_service: &Arc, cloud_service: &Arc, store_preferences: &Arc, - chat_id: &str, + chat_id: &Uuid, ) -> FlowyResult { info!("[Chat] refresh chat:{} setting", chat_id); let workspace_id = user_service.workspace_id()?; @@ -728,15 +739,18 @@ async fn refresh_chat_setting( error!("failed to set chat settings: {}", err); } - chat_notification_builder(chat_id, ChatNotification::DidUpdateChatSettings) - .payload(ChatSettingsPB { - rag_ids: settings.rag_ids.clone(), - }) - .send(); + chat_notification_builder( + &chat_id.to_string(), + ChatNotification::DidUpdateChatSettings, + ) + .payload(ChatSettingsPB { + rag_ids: settings.rag_ids.clone(), + }) + .send(); Ok(settings) } -fn setting_store_key(chat_id: &str) -> String { - format!("chat_settings_{}", chat_id) +fn setting_store_key(chat_id: &Uuid) -> String { + format!("chat_settings_{}", chat_id.to_string()) } diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index e00f21a863..d893ff2b51 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -23,6 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicI64}; use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; use tracing::{error, instrument, trace}; +use uuid::Uuid; enum PrevMessageState { HasMore, @@ -31,7 +32,7 @@ enum PrevMessageState { } pub struct Chat { - chat_id: String, + chat_id: Uuid, uid: i64, user_service: Arc, chat_service: Arc, @@ -44,7 +45,7 @@ pub struct Chat { impl Chat { pub fn new( uid: i64, - chat_id: String, + chat_id: Uuid, user_service: Arc, chat_service: Arc, ) -> Chat { @@ -197,7 +198,7 @@ impl Chat { answer_stream_port: i64, answer_stream_buffer: Arc>, uid: i64, - workspace_id: String, + workspace_id: Uuid, question_id: i64, format: ResponseFormat, ai_model: Option, @@ -254,7 +255,7 @@ impl Chat { .send(StreamMessage::OnError(err.msg.clone()).to_string()) .await; let pb = ChatMessageErrorPB { - chat_id: chat_id.clone(), + chat_id: chat_id.to_string(), error_message: err.to_string(), }; chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError) @@ -293,7 +294,7 @@ impl Chat { } let pb = ChatMessageErrorPB { - chat_id: chat_id.clone(), + chat_id: chat_id.to_string(), error_message: err.to_string(), }; chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError) @@ -566,7 +567,7 @@ impl Chat { let conn = self.user_service.sqlite_connection(self.uid)?; let records = select_chat_messages( conn, - &self.chat_id, + &self.chat_id.to_string(), limit, after_message_id, before_message_id, @@ -628,7 +629,7 @@ impl Chat { fn save_chat_message_disk( conn: DBConnection, - chat_id: &str, + chat_id: &Uuid, messages: Vec, ) -> FlowyResult<()> { let records = messages @@ -683,7 +684,7 @@ impl StringBuffer { pub(crate) fn save_and_notify_message( uid: i64, - chat_id: &str, + chat_id: &Uuid, user_service: &Arc, message: ChatMessage, ) -> Result<(), FlowyError> { diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 27babfb28e..53d2f3915e 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -1,6 +1,7 @@ use crate::ai_manager::AIUserService; use crate::entities::{CompleteTextPB, CompleteTextTaskPB, CompletionTypePB}; use allo_isolate::Isolate; +use std::str::FromStr; use dashmap::DashMap; use flowy_ai_pub::cloud::{ @@ -15,7 +16,8 @@ use lib_infra::isolate_stream::IsolateSink; use crate::stream_message::StreamMessage; use std::sync::{Arc, Weak}; use tokio::select; -use tracing::info; +use tracing::{error, info}; +use uuid::Uuid; pub struct AICompletion { tasks: Arc>>, @@ -77,7 +79,7 @@ impl AICompletion { } pub struct CompletionTask { - workspace_id: String, + workspace_id: Uuid, task_id: String, stop_rx: tokio::sync::mpsc::Receiver<()>, context: CompleteTextPB, @@ -87,7 +89,7 @@ pub struct CompletionTask { impl CompletionTask { pub fn new( - workspace_id: String, + workspace_id: Uuid, context: CompleteTextPB, preferred_model: Option, cloud_service: Weak, @@ -122,59 +124,63 @@ impl CompletionTask { let _ = sink.send("start:".to_string()).await; let completion_history = Some(self.context.history.iter().map(Into::into).collect()); let format = self.context.format.map(Into::into).unwrap_or_default(); - let params = CompleteTextParams { - text: self.context.text, - completion_type: Some(complete_type), - metadata: Some(CompletionMetadata { - object_id: self.context.object_id, - workspace_id: Some(self.workspace_id.clone()), - rag_ids: Some(self.context.rag_ids), - completion_history, - custom_prompt: self - .context - .custom_prompt - .map(|v| CustomPrompt { system: v }), - }), - format, - }; + if let Ok(object_id) = Uuid::from_str(&self.context.object_id) { + let params = CompleteTextParams { + text: self.context.text, + completion_type: Some(complete_type), + metadata: Some(CompletionMetadata { + object_id, + workspace_id: Some(self.workspace_id.clone()), + rag_ids: Some(self.context.rag_ids), + completion_history, + custom_prompt: self + .context + .custom_prompt + .map(|v| CustomPrompt { system: v }), + }), + format, + }; - info!("start completion: {:?}", params); - match cloud_service - .stream_complete(&self.workspace_id, params, self.preferred_model) - .await - { - Ok(mut stream) => loop { - select! { - _ = self.stop_rx.recv() => { - return; - }, - result = stream.next() => { - match result { - Some(Ok(data)) => { - match data { - CompletionStreamValue::Answer{ value } => { - let _ = sink.send(format!("data:{}", value)).await; + info!("start completion: {:?}", params); + match cloud_service + .stream_complete(&self.workspace_id, params, self.preferred_model) + .await + { + Ok(mut stream) => loop { + select! { + _ = self.stop_rx.recv() => { + return; + }, + result = stream.next() => { + match result { + Some(Ok(data)) => { + match data { + CompletionStreamValue::Answer{ value } => { + let _ = sink.send(format!("data:{}", value)).await; + } + CompletionStreamValue::Comment{ value } => { + let _ = sink.send(format!("comment:{}", value)).await; + } } - CompletionStreamValue::Comment{ value } => { - let _ = sink.send(format!("comment:{}", value)).await; - } - } - }, - Some(Err(error)) => { - handle_error(&mut sink, error).await; - return; - }, - None => { - let _ = sink.send(format!("finish:{}", self.task_id)).await; - return; - }, + }, + Some(Err(error)) => { + handle_error(&mut sink, error).await; + return; + }, + None => { + let _ = sink.send(format!("finish:{}", self.task_id)).await; + return; + }, + } } - } - } - }, - Err(error) => { - handle_error(&mut sink, error).await; - }, + } + }, + Err(error) => { + handle_error(&mut sink, error).await; + }, + } + } else { + error!("Invalid uuid: {}", self.context.object_id); } } }); diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index 0075e35c04..c48fbd9646 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -1,8 +1,6 @@ -use af_plugin::core::plugin::RunningState; -use std::collections::HashMap; - use crate::local_ai::controller::LocalAISetting; use crate::local_ai::resource::PendingResource; +use af_plugin::core::plugin::RunningState; use flowy_ai_pub::cloud::{ AIModel, ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, @@ -10,6 +8,8 @@ use flowy_ai_pub::cloud::{ }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use lib_infra::validator_fn::required_not_empty_str; +use std::collections::HashMap; +use uuid::Uuid; use validator::Validate; #[derive(Default, ProtoBuf, Validate, Clone, Debug)] @@ -78,7 +78,7 @@ pub struct StreamChatPayloadPB { #[derive(Default, Debug)] pub struct StreamMessageParams { - pub chat_id: String, + pub chat_id: Uuid, pub message: String, pub message_type: ChatMessageType, pub answer_stream_port: i64, diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 72a271f5a1..b8334ffe8d 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -1,6 +1,3 @@ -use std::fs; -use std::path::PathBuf; - use crate::ai_manager::{AIManager, GLOBAL_ACTIVE_MODEL_KEY}; use crate::completion::AICompletion; use crate::entities::*; @@ -10,8 +7,12 @@ use flowy_ai_pub::cloud::{ }; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; use tracing::trace; +use uuid::Uuid; use validator::Validate; fn upgrade_ai_manager(ai_manager: AFPluginState>) -> FlowyResult> { @@ -70,6 +71,7 @@ pub(crate) async fn stream_chat_message_handler( trace!("Stream chat message with metadata: {:?}", metadata); + let chat_id = Uuid::from_str(&chat_id)?; let params = StreamMessageParams { chat_id, message, @@ -91,11 +93,12 @@ pub(crate) async fn regenerate_response_handler( ai_manager: AFPluginState>, ) -> FlowyResult<()> { let data = data.try_into_inner()?; + let chat_id = Uuid::from_str(&data.chat_id)?; let ai_manager = upgrade_ai_manager(ai_manager)?; ai_manager .stream_regenerate_response( - &data.chat_id, + &chat_id, data.answer_message_id, data.answer_stream_port, data.format, @@ -147,8 +150,9 @@ pub(crate) async fn load_prev_message_handler( let data = data.into_inner(); data.validate()?; + let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .load_prev_chat_messages(&data.chat_id, data.limit, data.before_message_id) + .load_prev_chat_messages(&chat_id, data.limit, data.before_message_id) .await?; data_result_ok(messages) } @@ -162,8 +166,9 @@ pub(crate) async fn load_next_message_handler( let data = data.into_inner(); data.validate()?; + let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .load_latest_chat_messages(&data.chat_id, data.limit, data.after_message_id) + .load_latest_chat_messages(&chat_id, data.limit, data.after_message_id) .await?; data_result_ok(messages) } @@ -175,8 +180,9 @@ pub(crate) async fn get_related_question_handler( ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; let data = data.into_inner(); + let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .get_related_questions(&data.chat_id, data.message_id) + .get_related_questions(&chat_id, data.message_id) .await?; data_result_ok(messages) } @@ -188,8 +194,9 @@ pub(crate) async fn get_answer_handler( ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; let data = data.into_inner(); + let chat_id = Uuid::from_str(&data.chat_id)?; let message = ai_manager - .generate_answer(&data.chat_id, data.message_id) + .generate_answer(&chat_id, data.message_id) .await?; data_result_ok(message) } @@ -203,7 +210,8 @@ pub(crate) async fn stop_stream_handler( data.validate()?; let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager.stop_stream(&data.chat_id).await?; + let chat_id = Uuid::from_str(&data.chat_id)?; + ai_manager.stop_stream(&chat_id).await?; Ok(()) } @@ -273,7 +281,8 @@ pub(crate) async fn chat_file_handler( tracing::debug!("File size: {} bytes", file_size); let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager.chat_with_file(&data.chat_id, file_path).await?; + let chat_id = Uuid::from_str(&data.chat_id)?; + ai_manager.chat_with_file(&chat_id, file_path).await?; Ok(()) } @@ -332,6 +341,7 @@ pub(crate) async fn get_chat_settings_handler( ai_manager: AFPluginState>, ) -> DataResult { let chat_id = data.try_into_inner()?.value; + let chat_id = Uuid::from_str(&chat_id)?; let ai_manager = upgrade_ai_manager(ai_manager)?; let rag_ids = ai_manager.get_rag_ids(&chat_id).await?; let pb = ChatSettingsPB { rag_ids }; @@ -345,9 +355,8 @@ pub(crate) async fn update_chat_settings_handler( ) -> FlowyResult<()> { let params = data.try_into_inner()?; let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager - .update_rag_ids(¶ms.chat_id.value, params.rag_ids) - .await?; + let chat_id = Uuid::from_str(¶ms.chat_id.value)?; + ai_manager.update_rag_ids(&chat_id, params.rag_ids).await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 05f6313655..8b3285507c 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -28,6 +28,7 @@ use std::sync::Arc; use tokio::select; use tokio_stream::StreamExt; use tracing::{debug, error, info, instrument}; +use uuid::Uuid; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LocalAISetting { @@ -51,7 +52,7 @@ const LOCAL_AI_SETTING_KEY: &str = "appflowy_local_ai_setting:v1"; pub struct LocalAIController { ai_plugin: Arc, resource: Arc, - current_chat_id: ArcSwapOption, + current_chat_id: ArcSwapOption, store_preferences: Arc, user_service: Arc, #[allow(dead_code)] @@ -240,7 +241,7 @@ impl LocalAIController { Some(self.resource.get_llm_setting().chat_model_name) } - pub fn open_chat(&self, chat_id: &str) { + pub fn open_chat(&self, chat_id: &Uuid) { if !self.is_enabled() { return; } @@ -252,9 +253,7 @@ impl LocalAIController { self.close_chat(current_chat_id); } - self - .current_chat_id - .store(Some(Arc::new(chat_id.to_string()))); + self.current_chat_id.store(Some(Arc::new(chat_id.clone()))); let chat_id = chat_id.to_string(); let weak_ctrl = Arc::downgrade(&self.ai_plugin); tokio::spawn(async move { @@ -266,7 +265,7 @@ impl LocalAIController { }); } - pub fn close_chat(&self, chat_id: &str) { + pub fn close_chat(&self, chat_id: &Uuid) { if !self.is_running() { return; } @@ -383,7 +382,7 @@ impl LocalAIController { #[instrument(level = "debug", skip_all)] pub async fn index_message_metadata( &self, - chat_id: &str, + chat_id: &Uuid, metadata_list: &[ChatMessageMetadata], index_process_sink: &mut (impl Sink + Unpin), ) -> FlowyResult<()> { @@ -434,7 +433,7 @@ impl LocalAIController { async fn process_index_file( &self, - chat_id: &str, + chat_id: &Uuid, file_path: PathBuf, index_metadata: &HashMap, index_process_sink: &mut (impl Sink + Unpin), @@ -456,7 +455,11 @@ impl LocalAIController { let result = self .ai_plugin - .embed_file(chat_id, file_path, Some(index_metadata.clone())) + .embed_file( + &chat_id.to_string(), + file_path, + Some(index_metadata.clone()), + ) .await; match result { Ok(_) => { @@ -616,6 +619,6 @@ impl LLMResourceService for LLMResourceServiceImpl { } const APPFLOWY_LOCAL_AI_ENABLED: &str = "appflowy_local_ai_enabled"; -fn local_ai_enabled_key(workspace_id: &str) -> String { - format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id) +fn local_ai_enabled_key(workspace_id: &Uuid) -> String { + format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id.to_string()) } diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index dd294e6f0e..8c27268139 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -26,6 +26,7 @@ use serde_json::{json, Value}; use std::path::Path; use std::sync::{Arc, Weak}; use tracing::{info, trace}; +use uuid::Uuid; pub struct AICloudServiceMiddleware { cloud_service: Arc, @@ -55,7 +56,7 @@ impl AICloudServiceMiddleware { pub async fn index_message_metadata( &self, - chat_id: &str, + chat_id: &Uuid, metadata_list: &[ChatMessageMetadata], index_process_sink: &mut (impl Sink + Unpin), ) -> Result<(), FlowyError> { @@ -114,9 +115,9 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn create_chat( &self, uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, ) -> Result<(), FlowyError> { self .cloud_service @@ -126,8 +127,8 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, metadata: &[ChatMessageMetadata], @@ -140,8 +141,8 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -154,8 +155,8 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, format: ResponseFormat, ai_model: Option, @@ -171,7 +172,12 @@ impl ChatCloudService for AICloudServiceMiddleware { let row = self.get_message_record(message_id)?; match self .local_ai - .stream_question(chat_id, &row.content, Some(json!(format)), json!({})) + .stream_question( + &chat_id.to_string(), + &row.content, + Some(json!(format)), + json!({}), + ) .await { Ok(stream) => Ok(QuestionStream::new(stream).boxed()), @@ -195,13 +201,17 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, question_message_id: i64, ) -> Result { if self.local_ai.is_running() { let content = self.get_message_record(question_message_id)?.content; - match self.local_ai.ask_question(chat_id, &content).await { + match self + .local_ai + .ask_question(&chat_id.to_string(), &content) + .await + { Ok(answer) => { let message = self .cloud_service @@ -224,8 +234,8 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result { @@ -237,26 +247,26 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, - answer_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, ) -> Result { self .cloud_service - .get_question_from_answer_id(workspace_id, chat_id, answer_id) + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) .await } async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, ) -> Result { if self.local_ai.is_running() { let questions = self .local_ai - .get_related_question(chat_id) + .get_related_question(&chat_id.to_string()) .await .map_err(|err| FlowyError::local_ai().with_context(err))?; trace!("LocalAI related questions: {:?}", questions); @@ -280,7 +290,7 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, ai_model: Option, ) -> Result { @@ -329,15 +339,15 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn embed_file( &self, - workspace_id: &str, + workspace_id: &Uuid, file_path: &Path, - chat_id: &str, + chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError> { if self.local_ai.is_running() { self .local_ai - .embed_file(chat_id, file_path.to_path_buf(), metadata) + .embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata) .await .map_err(|err| FlowyError::local_ai().with_context(err))?; Ok(()) @@ -349,21 +359,21 @@ impl ChatCloudService for AICloudServiceMiddleware { } } - async fn get_local_ai_config(&self, workspace_id: &str) -> Result { + async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { self.cloud_service.get_local_ai_config(workspace_id).await } async fn get_workspace_plan( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { self.cloud_service.get_workspace_plan(workspace_id).await } async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { self .cloud_service @@ -373,8 +383,8 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError> { self @@ -383,11 +393,11 @@ impl ChatCloudService for AICloudServiceMiddleware { .await } - async fn get_available_models(&self, workspace_id: &str) -> Result { + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { self.cloud_service.get_available_models(workspace_id).await } - async fn get_workspace_default_model(&self, workspace_id: &str) -> Result { + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { self .cloud_service .get_workspace_default_model(workspace_id) diff --git a/frontend/rust-lib/flowy-ai/src/notification.rs b/frontend/rust-lib/flowy-ai/src/notification.rs index 97cc0e9631..6fbf3a8e7a 100644 --- a/frontend/rust-lib/flowy-ai/src/notification.rs +++ b/frontend/rust-lib/flowy-ai/src/notification.rs @@ -1,5 +1,6 @@ use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; +use tracing::trace; const CHAT_OBSERVABLE_SOURCE: &str = "Chat"; pub const APPFLOWY_AI_NOTIFICATION_KEY: &str = "appflowy_ai_plugin"; @@ -39,7 +40,12 @@ impl std::convert::From for ChatNotification { } } -#[tracing::instrument(level = "trace")] -pub(crate) fn chat_notification_builder(id: &str, ty: ChatNotification) -> NotificationBuilder { - NotificationBuilder::new(id, ty, CHAT_OBSERVABLE_SOURCE) +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn chat_notification_builder( + id: T, + ty: ChatNotification, +) -> NotificationBuilder { + let id = id.to_string(); + trace!("chat_notification_builder: id = {id}, ty = {ty:?}"); + NotificationBuilder::new(&id, ty, CHAT_OBSERVABLE_SOURCE) } diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index f535dd757c..6225652078 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -32,7 +32,6 @@ collab = { workspace = true } #collab = { workspace = true, features = ["verbose_log"] } diesel.workspace = true -uuid.workspace = true flowy-storage = { workspace = true } flowy-storage-pub = { workspace = true } client-api.workspace = true @@ -56,8 +55,7 @@ lib-infra = { workspace = true } serde.workspace = true serde_json.workspace = true serde_repr.workspace = true -futures.workspace = true -walkdir = "2.4.0" +uuid.workspace = true sysinfo = "0.30.5" semver = { version = "1.0.22", features = ["serde"] } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs index f9e4befeb0..5a369eb9de 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -21,6 +21,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Weak}; use tracing::{error, info}; +use uuid::Uuid; pub struct ChatDepsResolver; @@ -56,9 +57,9 @@ struct ChatQueryServiceImpl { impl AIExternalService for ChatQueryServiceImpl { async fn query_chat_rag_ids( &self, - parent_view_id: &str, - chat_id: &str, - ) -> Result, FlowyError> { + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError> { let mut ids = self .folder_service .get_surrounding_view_ids_with_view_layout(parent_view_id, ViewLayout::Document) @@ -72,9 +73,9 @@ impl AIExternalService for ChatQueryServiceImpl { } async fn sync_rag_documents( &self, - workspace_id: &str, - rag_ids: Vec, - mut rag_metadata_map: HashMap, + workspace_id: &Uuid, + rag_ids: Vec, + mut rag_metadata_map: HashMap, ) -> Result, FlowyError> { let mut result = Vec::new(); @@ -96,7 +97,7 @@ impl AIExternalService for ChatQueryServiceImpl { if let Ok(prev_sv) = StateVector::decode_v1(&metadata.prev_sync_state_vector) { let collab = Collab::new_with_source( CollabOrigin::Empty, - &rag_id, + &rag_id.to_string(), DataSource::DocStateV1(query_collab.encoded_collab.doc_state.to_vec()), vec![], false, @@ -125,7 +126,7 @@ impl AIExternalService for ChatQueryServiceImpl { } else { info!("[Chat] full sync rag document: {}", rag_id); result.push(AFCollabMetadata { - object_id: rag_id, + object_id: rag_id.to_string(), updated_at: timestamp(), prev_sync_state_vector: query_collab.encoded_collab.state_vector.to_vec(), collab_type: CollabType::Document as i32, @@ -136,7 +137,7 @@ impl AIExternalService for ChatQueryServiceImpl { Ok(result) } - async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError> { + async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError> { info!( "notify_did_send_message: chat_id: {}, message: {}", chat_id, message @@ -169,7 +170,7 @@ impl AIUserService for ChatUserServiceImpl { self.upgrade_user()?.device_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 68adc45c0e..896a03cfd5 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -7,16 +7,6 @@ use collab::core::origin::{CollabClient, CollabOrigin}; use collab::entity::EncodedCollab; use collab::preclude::CollabPlugin; use collab_entity::CollabType; -use flowy_search_pub::cloud::SearchCloudService; -use serde_json::Value; -use std::collections::HashMap; -use std::path::Path; -use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::time::Duration; -use tokio_stream::wrappers::WatchStream; -use tracing::{debug, info}; - use collab_integrate::collab_builder::{ CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, }; @@ -37,12 +27,24 @@ use flowy_folder_pub::cloud::{ Workspace, WorkspaceRecord, }; use flowy_folder_pub::entities::PublishPayload; +use flowy_search_pub::cloud::SearchCloudService; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; use flowy_user_pub::entities::{Authenticator, UserTokenState}; use lib_infra::async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tokio_stream::wrappers::WatchStream; +use tracing::log::error; +use tracing::{debug, info}; +use uuid::Uuid; use crate::server_layer::{Server, ServerProvider}; @@ -82,7 +84,7 @@ impl StorageCloudService for ServerProvider { async fn get_object_url_v1( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, ) -> FlowyResult { @@ -93,7 +95,7 @@ impl StorageCloudService for ServerProvider { .await } - async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)> { + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { self .get_server() .ok()? @@ -104,7 +106,7 @@ impl StorageCloudService for ServerProvider { async fn create_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, content_type: &str, @@ -119,7 +121,7 @@ impl StorageCloudService for ServerProvider { async fn upload_part( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -142,7 +144,7 @@ impl StorageCloudService for ServerProvider { async fn complete_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -233,10 +235,9 @@ impl FolderCloudService for ServerProvider { server.folder_service().create_workspace(uid, &name).await } - async fn open_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let server = self.get_server()?; - server.folder_service().open_workspace(&workspace_id).await + server.folder_service().open_workspace(workspace_id).await } async fn get_all_workspace(&self) -> Result, FlowyError> { @@ -246,7 +247,7 @@ impl FolderCloudService for ServerProvider { async fn get_folder_data( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: &i64, ) -> Result, FlowyError> { let server = self.get_server()?; @@ -272,10 +273,10 @@ impl FolderCloudService for ServerProvider { async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, collab_type: CollabType, - object_id: &str, + object_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; @@ -287,7 +288,7 @@ impl FolderCloudService for ServerProvider { async fn batch_create_folder_collab_objects( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -307,7 +308,7 @@ impl FolderCloudService for ServerProvider { async fn publish_view( &self, - workspace_id: &str, + workspace_id: &Uuid, payload: Vec, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -320,8 +321,8 @@ impl FolderCloudService for ServerProvider { async fn unpublish_views( &self, - workspace_id: &str, - view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError> { let server = self.get_server()?; server @@ -330,15 +331,15 @@ impl FolderCloudService for ServerProvider { .await } - async fn get_publish_info(&self, view_id: &str) -> Result { + async fn get_publish_info(&self, view_id: &Uuid) -> Result { let server = self.get_server()?; server.folder_service().get_publish_info(view_id).await } async fn set_publish_name( &self, - workspace_id: &str, - view_id: String, + workspace_id: &Uuid, + view_id: Uuid, new_name: String, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -350,7 +351,7 @@ impl FolderCloudService for ServerProvider { async fn set_publish_namespace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_namespace: String, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -360,7 +361,7 @@ impl FolderCloudService for ServerProvider { .await } - async fn get_publish_namespace(&self, workspace_id: &str) -> Result { + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { let server = self.get_server()?; server .folder_service() @@ -371,7 +372,7 @@ impl FolderCloudService for ServerProvider { /// List all published views of the current workspace. async fn list_published_views( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; server @@ -382,7 +383,7 @@ impl FolderCloudService for ServerProvider { async fn get_default_published_view_info( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { let server = self.get_server()?; server @@ -393,7 +394,7 @@ impl FolderCloudService for ServerProvider { async fn set_default_published_view( &self, - workspace_id: &str, + workspace_id: &Uuid, view_id: uuid::Uuid, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -403,7 +404,7 @@ impl FolderCloudService for ServerProvider { .await } - async fn remove_default_published_view(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let server = self.get_server()?; server .folder_service() @@ -421,7 +422,7 @@ impl FolderCloudService for ServerProvider { async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, params: FullSyncCollabParams, ) -> Result<(), FlowyError> { self @@ -436,24 +437,22 @@ impl FolderCloudService for ServerProvider { impl DatabaseCloudService for ServerProvider { async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); let server = self.get_server()?; - let database_id = object_id.to_string(); server .database_service() - .get_database_encode_collab(&database_id, collab_type, &workspace_id) + .get_database_encode_collab(object_id, collab_type, &workspace_id) .await } async fn create_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -465,30 +464,28 @@ impl DatabaseCloudService for ServerProvider { async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { - let workspace_id = workspace_id.to_string(); let server = self.get_server()?; server .database_service() - .batch_get_database_encode_collab(object_ids, object_ty, &workspace_id) + .batch_get_database_encode_collab(object_ids, object_ty, workspace_id) .await } async fn get_database_collab_object_snapshots( &self, - object_id: &str, + object_id: &Uuid, limit: usize, ) -> Result, FlowyError> { let server = self.get_server()?; - let database_id = object_id.to_string(); server .database_service() - .get_database_collab_object_snapshots(&database_id, limit) + .get_database_collab_object_snapshots(&object_id, limit) .await } } @@ -497,29 +494,29 @@ impl DatabaseCloudService for ServerProvider { impl DatabaseAIService for ServerProvider { async fn summary_database_row( &self, - workspace_id: &str, - object_id: &str, - summary_row: SummaryRowContent, + _workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, ) -> Result { self .get_server()? .database_ai_service() .ok_or_else(FlowyError::not_support)? - .summary_database_row(workspace_id, object_id, summary_row) + .summary_database_row(_workspace_id, _object_id, _summary_row) .await } async fn translate_database_row( &self, - workspace_id: &str, - translate_row: TranslateRowContent, - language: &str, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, ) -> Result { self .get_server()? .database_ai_service() .ok_or_else(FlowyError::not_support)? - .translate_database_row(workspace_id, translate_row, language) + .translate_database_row(_workspace_id, _translate_row, _language) .await } } @@ -528,8 +525,8 @@ impl DatabaseAIService for ServerProvider { impl DocumentCloudService for ServerProvider { async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; server @@ -540,7 +537,7 @@ impl DocumentCloudService for ServerProvider { async fn get_document_snapshots( &self, - document_id: &str, + document_id: &Uuid, limit: usize, workspace_id: &str, ) -> Result, FlowyError> { @@ -554,8 +551,8 @@ impl DocumentCloudService for ServerProvider { async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; server @@ -566,8 +563,8 @@ impl DocumentCloudService for ServerProvider { async fn create_document_collab( &self, - workspace_id: &str, - document_id: &str, + workspace_id: &Uuid, + document_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -611,26 +608,37 @@ impl CollabCloudPluginProvider for ServerProvider { collab_object.uid, collab_object.device_id.clone(), )); - let sync_object = SyncObject::new( - &collab_object.object_id, - &collab_object.workspace_id, - collab_object.collab_type, - &collab_object.device_id, - ); - let (sink, stream) = (channel.sink(), channel.stream()); - let sink_config = SinkConfig::new().send_timeout(8); - let sync_plugin = SyncPlugin::new( - origin, - sync_object, - local_collab, - sink, - sink_config, - stream, - Some(channel), - ws_connect_state, - Some(Duration::from_secs(60)), - ); - plugins.push(Box::new(sync_plugin)); + + if let (Ok(object_id), Ok(workspace_id)) = ( + Uuid::from_str(&collab_object.object_id), + Uuid::from_str(&collab_object.workspace_id), + ) { + let sync_object = SyncObject::new( + object_id, + workspace_id, + collab_object.collab_type, + &collab_object.device_id, + ); + let (sink, stream) = (channel.sink(), channel.stream()); + let sink_config = SinkConfig::new().send_timeout(8); + let sync_plugin = SyncPlugin::new( + origin, + sync_object, + local_collab, + sink, + sink_config, + stream, + Some(channel), + ws_connect_state, + Some(Duration::from_secs(60)), + ); + plugins.push(Box::new(sync_plugin)); + } else { + error!( + "Failed to parse collab object id: {}", + collab_object.object_id + ); + } }, Ok(None) => { tracing::error!("🔴Failed to get collab ws channel: channel is none"); @@ -655,9 +663,9 @@ impl ChatCloudService for ServerProvider { async fn create_chat( &self, uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, ) -> Result<(), FlowyError> { let server = self.get_server(); server? @@ -668,14 +676,12 @@ impl ChatCloudService for ServerProvider { async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, metadata: &[ChatMessageMetadata], ) -> Result { - let workspace_id = workspace_id.to_string(); - let chat_id = chat_id.to_string(); let message = message.to_string(); self .get_server()? @@ -686,8 +692,8 @@ impl ChatCloudService for ServerProvider { async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -701,14 +707,12 @@ impl ChatCloudService for ServerProvider { async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, format: ResponseFormat, ai_model: Option, ) -> Result { - let workspace_id = workspace_id.to_string(); - let chat_id = chat_id.to_string(); let server = self.get_server()?; server .chat_service() @@ -718,8 +722,8 @@ impl ChatCloudService for ServerProvider { async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result { @@ -732,8 +736,8 @@ impl ChatCloudService for ServerProvider { async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, answer_message_id: i64, ) -> Result { self @@ -745,8 +749,8 @@ impl ChatCloudService for ServerProvider { async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, ) -> Result { self @@ -758,8 +762,8 @@ impl ChatCloudService for ServerProvider { async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, question_message_id: i64, ) -> Result { let server = self.get_server(); @@ -771,23 +775,22 @@ impl ChatCloudService for ServerProvider { async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, ai_model: Option, ) -> Result { - let workspace_id = workspace_id.to_string(); let server = self.get_server()?; server .chat_service() - .stream_complete(&workspace_id, params, ai_model) + .stream_complete(workspace_id, params, ai_model) .await } async fn embed_file( &self, - workspace_id: &str, + workspace_id: &Uuid, file_path: &Path, - chat_id: &str, + chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError> { self @@ -797,7 +800,7 @@ impl ChatCloudService for ServerProvider { .await } - async fn get_local_ai_config(&self, workspace_id: &str) -> Result { + async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { self .get_server()? .chat_service() @@ -807,7 +810,7 @@ impl ChatCloudService for ServerProvider { async fn get_workspace_plan( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { self .get_server()? @@ -818,8 +821,8 @@ impl ChatCloudService for ServerProvider { async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { self .get_server()? @@ -830,8 +833,8 @@ impl ChatCloudService for ServerProvider { async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError> { self @@ -841,7 +844,7 @@ impl ChatCloudService for ServerProvider { .await } - async fn get_available_models(&self, workspace_id: &str) -> Result { + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { self .get_server()? .chat_service() @@ -849,7 +852,7 @@ impl ChatCloudService for ServerProvider { .await } - async fn get_workspace_default_model(&self, workspace_id: &str) -> Result { + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { self .get_server()? .chat_service() @@ -862,7 +865,7 @@ impl ChatCloudService for ServerProvider { impl SearchCloudService for ServerProvider { async fn document_search( &self, - workspace_id: &str, + workspace_id: &Uuid, query: String, ) -> Result, FlowyError> { let server = self.get_server()?; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs index c3d0358fcb..078ee7359b 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs @@ -13,6 +13,7 @@ use collab_integrate::collab_builder::WorkspaceCollabIntegrate; use lib_infra::util::timestamp; use std::sync::{Arc, Weak}; use tracing::debug; +use uuid::Uuid; pub struct SnapshotDBImpl(pub Weak); @@ -222,12 +223,12 @@ impl WorkspaceCollabIntegrateImpl { } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { let workspace_id = self.upgrade_user()?.workspace_id()?; Ok(workspace_id) } - fn device_id(&self) -> Result { + fn device_id(&self) -> Result { Ok(self.upgrade_user()?.user_config.device_id.clone()) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs index 715b8207b8..1bd3223946 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs @@ -13,6 +13,7 @@ use lib_infra::async_trait::async_trait; use lib_infra::priority_task::TaskDispatcher; use std::sync::{Arc, Weak}; use tokio::sync::RwLock; +use uuid::Uuid; pub struct DatabaseDepsResolver(); @@ -47,41 +48,41 @@ struct DatabaseAIServiceMiddleware { impl DatabaseAIService for DatabaseAIServiceMiddleware { async fn summary_database_row( &self, - workspace_id: &str, - object_id: &str, - summary_row: SummaryRowContent, + workspace_id: &Uuid, + object_id: &Uuid, + _summary_row: SummaryRowContent, ) -> Result { if self.ai_manager.local_ai.is_running() { self .ai_manager .local_ai - .summary_database_row(summary_row) + .summary_database_row(_summary_row) .await .map_err(|err| FlowyError::local_ai().with_context(err)) } else { self .ai_service - .summary_database_row(workspace_id, object_id, summary_row) + .summary_database_row(workspace_id, object_id, _summary_row) .await } } async fn translate_database_row( &self, - workspace_id: &str, - translate_row: TranslateRowContent, - language: &str, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, ) -> Result { if self.ai_manager.local_ai.is_running() { let data = LocalAITranslateRowData { - cells: translate_row + cells: _translate_row .into_iter() .map(|row| LocalAITranslateItem { title: row.title, content: row.content, }) .collect(), - language: language.to_string(), + language: _language.to_string(), include_header: false, }; let resp = self @@ -95,7 +96,7 @@ impl DatabaseAIService for DatabaseAIServiceMiddleware { } else { self .ai_service - .translate_database_row(workspace_id, translate_row, language) + .translate_database_row(_workspace_id, _translate_row, _language) .await } } @@ -121,11 +122,11 @@ impl DatabaseUser for DatabaseUserImpl { self.upgrade_user()?.get_collab_db(uid) } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } - fn workspace_database_object_id(&self) -> Result { + fn workspace_database_object_id(&self) -> Result { self.upgrade_user()?.workspace_database_object_id() } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs index a4203d8268..533ac6f931 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs @@ -1,5 +1,5 @@ use std::sync::{Arc, Weak}; - +use uuid::Uuid; use crate::deps_resolve::CollabSnapshotSql; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; @@ -97,7 +97,7 @@ impl DocumentUserService for DocumentUserImpl { .device_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self .0 .upgrade() diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs index f0e6985a78..bee5f19ced 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs @@ -4,6 +4,7 @@ use flowy_storage::manager::{StorageManager, StorageUserService}; use flowy_storage_pub::cloud::StorageCloudService; use flowy_user::services::authenticate_user::AuthenticateUser; use std::sync::{Arc, Weak}; +use uuid::Uuid; pub struct FileStorageResolver; @@ -40,7 +41,7 @@ impl StorageUserService for FileStorageServiceImpl { self.upgrade_user()?.user_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs index 40a7657967..585cba84c5 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs @@ -8,6 +8,7 @@ use flowy_folder::share::ImportType; use flowy_folder::view_operation::{FolderOperationHandler, ImportedData}; use lib_infra::async_trait::async_trait; use std::sync::Arc; +use uuid::Uuid; pub struct ChatFolderOperation(pub Arc); @@ -17,19 +18,19 @@ impl FolderOperationHandler for ChatFolderOperation { "ChatFolderOperationHandler" } - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.open_chat(view_id).await } - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.close_chat(view_id).await } - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.delete_chat(view_id).await } - async fn duplicate_view(&self, _view_id: &str) -> Result { + async fn duplicate_view(&self, view_id: &Uuid) -> Result { Err(FlowyError::not_support()) } @@ -44,10 +45,10 @@ impl FolderOperationHandler for ChatFolderOperation { async fn create_default_view( &self, user_id: i64, - parent_view_id: &str, - view_id: &str, - _name: &str, - _layout: ViewLayout, + parent_view_id: &Uuid, + view_id: &Uuid, + name: &str, + layout: ViewLayout, ) -> Result<(), FlowyError> { self .0 @@ -58,11 +59,11 @@ impl FolderOperationHandler for ChatFolderOperation { async fn import_from_bytes( &self, - _uid: i64, - _view_id: &str, - _name: &str, - _import_type: ImportType, - _bytes: Vec, + uid: i64, + view_id: &Uuid, + name: &str, + import_type: ImportType, + bytes: Vec, ) -> Result, FlowyError> { Err(FlowyError::not_support()) } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs index d7d0c4d0cc..d57eea2ae4 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs @@ -19,23 +19,31 @@ use lib_infra::async_trait::async_trait; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use uuid::Uuid; pub struct DatabaseFolderOperation(pub Arc); #[async_trait] impl FolderOperationHandler for DatabaseFolderOperation { - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.open_database_view(view_id).await?; Ok(()) } - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { - self.0.close_database_view(view_id).await?; + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self + .0 + .close_database_view(view_id.to_string().as_str()) + .await?; Ok(()) } - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError> { - match self.0.delete_database_view(view_id).await { + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + match self + .0 + .delete_database_view(view_id.to_string().as_str()) + .await + { Ok(_) => tracing::trace!("Delete database view: {}", view_id), Err(e) => tracing::error!("🔴delete database failed: {}", e), } @@ -44,19 +52,23 @@ impl FolderOperationHandler for DatabaseFolderOperation { async fn gather_publish_encode_collab( &self, - user: &Arc, - view_id: &str, + _user: &Arc, + view_id: &Uuid, ) -> Result { - let workspace_id = user.workspace_id()?; + let workspace_id = _user.workspace_id()?; + let view_id_str = view_id.to_string(); // get the collab_object_id for the database. // // the collab object_id for the database is not the view_id, // we should use the view_id to get the database_id - let oid = self.0.get_database_id_with_view_id(view_id).await?; - let row_oids = self.0.get_database_row_ids_with_view_id(view_id).await?; + let oid = self.0.get_database_id_with_view_id(&view_id_str).await?; + let row_oids = self + .0 + .get_database_row_ids_with_view_id(&view_id_str) + .await?; let row_metas = self .0 - .get_database_row_metas_with_view_id(view_id, row_oids.clone()) + .get_database_row_metas_with_view_id(&view_id, row_oids.clone()) .await?; let row_document_ids = row_metas .iter() @@ -68,12 +80,12 @@ impl FolderOperationHandler for DatabaseFolderOperation { .collect::>(); let database_metas = self.0.get_all_databases_meta().await; - let uid = user + let uid = _user .user_id() .map_err(|e| e.with_context("unable to get the uid: {}"))?; // get the collab db - let collab_db = user + let collab_db = _user .collab_db(uid) .map_err(|e| e.with_context("unable to get the collab"))?; let collab_db = collab_db.upgrade().ok_or_else(|| { @@ -84,7 +96,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { tokio::task::spawn_blocking(move || { let collab_read_txn = collab_db.read_txn(); - let database_collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id, &oid) + let database_collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id.to_string(), &oid) .map_err(|e| { FlowyError::internal().with_context(format!("load database collab failed: {}", e)) })?; @@ -97,7 +109,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { })?; let database_row_encoded_collabs = - load_collab_by_object_ids(uid, &workspace_id, &collab_read_txn, &row_oids) + load_collab_by_object_ids(uid, &workspace_id.to_string(), &collab_read_txn, &row_oids) .0 .into_iter() .map(|(oid, collab)| { @@ -123,7 +135,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { .collect::>(); let database_row_document_encoded_collabs = - load_collab_by_object_ids(uid, &workspace_id, &collab_read_txn, &row_document_ids) + load_collab_by_object_ids(uid, &workspace_id.to_string(), &collab_read_txn, &row_document_ids) .0 .into_iter() .map(|(oid, collab)| { @@ -147,7 +159,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { .await? } - async fn duplicate_view(&self, view_id: &str) -> Result { + async fn duplicate_view(&self, view_id: &Uuid) -> Result { Ok(Bytes::from(view_id.to_string())) } @@ -166,14 +178,14 @@ impl FolderOperationHandler for DatabaseFolderOperation { String::from_utf8(data.to_vec()).map_err(|_| FlowyError::invalid_data())?; let encoded_collab = self .0 - .duplicate_database(&duplicated_view_id, ¶ms.view_id) + .duplicate_database(&duplicated_view_id, ¶ms.view_id.to_string()) .await?; Ok(Some(encoded_collab)) }, ViewData::Data(data) => { let encoded_collab = self .0 - .create_database_with_data(¶ms.view_id, data.to_vec()) + .create_database_with_data(¶ms.view_id.to_string(), data.to_vec()) .await?; Ok(Some(encoded_collab)) }, @@ -212,17 +224,18 @@ impl FolderOperationHandler for DatabaseFolderOperation { /// these references views. async fn create_default_view( &self, - _user_id: i64, - _parent_view_id: &str, - view_id: &str, + user_id: i64, + parent_view_id: &Uuid, + view_id: &Uuid, name: &str, layout: ViewLayout, ) -> Result<(), FlowyError> { let name = name.to_string(); + let view_id = view_id.to_string(); let data = match layout { - ViewLayout::Grid => make_default_grid(view_id, &name), - ViewLayout::Board => make_default_board(view_id, &name), - ViewLayout::Calendar => make_default_calendar(view_id, &name), + ViewLayout::Grid => make_default_grid(&view_id, &name), + ViewLayout::Board => make_default_board(&view_id, &name), + ViewLayout::Calendar => make_default_calendar(&view_id, &name), ViewLayout::Document | ViewLayout::Chat => { return Err( FlowyError::internal().with_context(format!("Can't handle {:?} layout type", layout)), @@ -244,9 +257,9 @@ impl FolderOperationHandler for DatabaseFolderOperation { async fn import_from_bytes( &self, - _uid: i64, - view_id: &str, - _name: &str, + uid: i64, + view_id: &Uuid, + name: &str, import_type: ImportType, bytes: Vec, ) -> Result, FlowyError> { diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs index af95b8987d..bb00d8c6bd 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs @@ -17,8 +17,10 @@ use flowy_folder::view_operation::{ use lib_dispatch::prelude::ToBytes; use lib_infra::async_trait::async_trait; use std::convert::TryFrom; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; +use uuid::Uuid; pub struct DocumentFolderOperation(pub Arc); #[async_trait] @@ -33,6 +35,7 @@ impl FolderOperationHandler for DocumentFolderOperation { workspace_view_builder: Arc>, ) -> Result<(), FlowyError> { let manager = self.0.clone(); + let mut write_guard = workspace_view_builder.write().await; // Create a view named "Getting started" with an icon ⭐️ and the built-in README data. // Don't modify this code unless you know what you are doing. @@ -45,8 +48,9 @@ impl FolderOperationHandler for DocumentFolderOperation { // create a empty document let json_str = include_str!("../../../assets/read_me.json"); let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap(); + let view_id = Uuid::from_str(&view.view.id).unwrap(); manager - .create_document(uid, &view.view.id, Some(document_pb.into())) + .create_document(uid, &view_id, Some(document_pb.into())) .await .unwrap(); view @@ -55,18 +59,18 @@ impl FolderOperationHandler for DocumentFolderOperation { Ok(()) } - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.open_document(view_id).await?; Ok(()) } /// Close the document view. - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.close_document(view_id).await?; Ok(()) } - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { match self.0.delete_document(view_id).await { Ok(_) => tracing::trace!("Delete document: {}", view_id), Err(e) => tracing::error!("🔴delete document failed: {}", e), @@ -74,7 +78,7 @@ impl FolderOperationHandler for DocumentFolderOperation { Ok(()) } - async fn duplicate_view(&self, view_id: &str) -> Result { + async fn duplicate_view(&self, view_id: &Uuid) -> Result { let data: DocumentDataPB = self.0.get_document_data(view_id).await?.into(); let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?; Ok(data_bytes) @@ -83,10 +87,11 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn gather_publish_encode_collab( &self, user: &Arc, - view_id: &str, + view_id: &Uuid, ) -> Result { let encoded_collab = - get_encoded_collab_v1_from_disk(user, view_id, CollabType::Document).await?; + get_encoded_collab_v1_from_disk(user, view_id.to_string().as_str(), CollabType::Document) + .await?; Ok(GatherEncodedCollab::Document(encoded_collab)) } @@ -112,9 +117,9 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn create_default_view( &self, user_id: i64, - _parent_view_id: &str, - view_id: &str, - _name: &str, + parent_view_id: &Uuid, + view_id: &Uuid, + name: &str, layout: ViewLayout, ) -> Result<(), FlowyError> { debug_assert_eq!(layout, ViewLayout::Document); @@ -133,9 +138,9 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn import_from_bytes( &self, uid: i64, - view_id: &str, - _name: &str, - _import_type: ImportType, + view_id: &Uuid, + name: &str, + import_type: ImportType, bytes: Vec, ) -> Result, FlowyError> { let data = DocumentDataPB::try_from(Bytes::from(bytes))?; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs index ef69fe7d82..9c064acbe3 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs @@ -17,6 +17,7 @@ use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_sqlite::kv::KVStorePreferences; use flowy_user::services::authenticate_user::AuthenticateUser; use flowy_user::services::data_import::load_collab_by_object_id; +use std::str::FromStr; use std::sync::{Arc, Weak}; use crate::deps_resolve::folder_deps::folder_deps_chat_impl::ChatFolderOperation; @@ -25,6 +26,7 @@ use crate::deps_resolve::folder_deps::folder_deps_doc_impl::DocumentFolderOperat use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_folder_pub::query::{FolderQueryService, FolderService, FolderViewEdit, QueryCollab}; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct FolderDepsResolver(); #[allow(clippy::too_many_arguments)] @@ -89,7 +91,7 @@ impl FolderUser for FolderUserImpl { self.upgrade_user()?.user_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } @@ -97,8 +99,10 @@ impl FolderUser for FolderUserImpl { self.upgrade_user()?.get_collab_db(uid) } - fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &str) -> FlowyResult { - self.upgrade_user()?.is_collab_on_disk(uid, workspace_id) + fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &Uuid) -> FlowyResult { + self + .upgrade_user()? + .is_collab_on_disk(uid, workspace_id.to_string().as_str()) } } @@ -124,13 +128,13 @@ impl FolderServiceImpl { #[async_trait] impl FolderViewEdit for FolderServiceImpl { - async fn set_view_title_if_empty(&self, view_id: &str, title: &str) -> FlowyResult<()> { + async fn set_view_title_if_empty(&self, view_id: &Uuid, title: &str) -> FlowyResult<()> { if title.is_empty() { return Ok(()); } if let Some(folder_manager) = self.folder_manager.upgrade() { - if let Ok(view) = folder_manager.get_view(view_id).await { + if let Ok(view) = folder_manager.get_view(view_id.to_string().as_str()).await { if view.name.is_empty() { let title = if title.len() > 50 { title.chars().take(50).collect() @@ -160,22 +164,25 @@ impl FolderViewEdit for FolderServiceImpl { impl FolderQueryService for FolderServiceImpl { async fn get_surrounding_view_ids_with_view_layout( &self, - parent_view_id: &str, + parent_view_id: &Uuid, view_layout: ViewLayout, - ) -> Vec { + ) -> Vec { let folder_manager = match self.folder_manager.upgrade() { Some(folder_manager) => folder_manager, None => return vec![], }; - if let Ok(view) = folder_manager.get_view(parent_view_id).await { + if let Ok(view) = folder_manager + .get_view(parent_view_id.to_string().as_str()) + .await + { if view.space_info().is_some() { return vec![]; } } match folder_manager - .get_untrashed_views_belong_to(parent_view_id) + .get_untrashed_views_belong_to(parent_view_id.to_string().as_str()) .await { Ok(views) => { @@ -183,23 +190,24 @@ impl FolderQueryService for FolderServiceImpl { .into_iter() .filter_map(|child| { if child.layout == view_layout { - Some(child.id.clone()) + Uuid::from_str(&child.id).ok() } else { None } }) .collect::>(); - children.push(parent_view_id.to_string()); + children.push(parent_view_id.clone()); children }, _ => vec![], } } - async fn get_collab(&self, object_id: &str, collab_type: CollabType) -> Option { - let encode_collab = get_encoded_collab_v1_from_disk(&self.user, object_id, collab_type) - .await - .ok(); + async fn get_collab(&self, object_id: &Uuid, collab_type: CollabType) -> Option { + let encode_collab = + get_encoded_collab_v1_from_disk(&self.user, object_id.to_string().as_str(), collab_type) + .await + .ok(); encode_collab.map(|encoded_collab| QueryCollab { collab_type, @@ -229,8 +237,8 @@ async fn get_encoded_collab_v1_from_disk( ) })?; let collab_read_txn = collab_db.read_txn(); - let collab = - load_collab_by_object_id(uid, &collab_read_txn, &workspace_id, view_id).map_err(|e| { + let collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id.to_string(), view_id) + .map_err(|e| { FlowyError::internal().with_context(format!("load document collab failed: {}", e)) })?; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs index b6179a1ad8..1fd1d5211c 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs @@ -13,6 +13,7 @@ use lib_infra::async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; use tracing::info; +use uuid::Uuid; pub struct UserDepsResolver(); @@ -81,7 +82,7 @@ impl UserWorkspaceService for UserWorkspaceServiceImpl { Ok(()) } - fn did_delete_workspace(&self, workspace_id: String) -> FlowyResult<()> { + fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { // The remove_indices_for_workspace should not block the deletion of the workspace // Log the error and continue if let Err(err) = self diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 2c41c1f205..1562c44a22 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -1,20 +1,20 @@ #![allow(unused_doc_comments)] -use flowy_search::folder::indexer::FolderIndexManagerImpl; -use flowy_search::services::manager::SearchManager; -use std::sync::{Arc, Weak}; -use std::time::Duration; -use sysinfo::System; -use tokio::sync::RwLock; -use tracing::{debug, error, event, info, instrument}; - use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabPluginProviderType}; use flowy_ai::ai_manager::AIManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::manager::FolderManager; +use flowy_search::folder::indexer::FolderIndexManagerImpl; +use flowy_search::services::manager::SearchManager; use flowy_server::af_cloud::define::ServerUser; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use sysinfo::System; +use tokio::sync::RwLock; +use tracing::{debug, error, event, info, instrument}; +use uuid::Uuid; use flowy_sqlite::kv::KVStorePreferences; use flowy_storage::manager::StorageManager; @@ -329,7 +329,7 @@ impl ServerUserImpl { } } impl ServerUser for ServerUserImpl { - fn workspace_id(&self) -> FlowyResult { + fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() } } diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index 5a6a2e7b2a..2882b9b050 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -55,6 +55,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { _device_id: &str, authenticator: &Authenticator, ) -> FlowyResult<()> { + let workspace_id = user_workspace.workspace_id()?; self .server_provider .set_user_authenticator(user_authenticator); @@ -74,7 +75,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { .folder_manager .initialize( user_id, - &user_workspace.id, + &workspace_id, FolderInitDataSource::LocalDisk { create_if_not_exist: false, }, @@ -140,6 +141,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace, device_id ); + let workspace_id = user_workspace.workspace_id()?; // In the current implementation, when a user signs up for AppFlowy Cloud, a default workspace // is automatically created for them. However, for users who sign up through Supabase, the creation @@ -149,10 +151,10 @@ impl UserStatusCallback for UserStatusCallbackImpl { .folder_manager .cloud_service .get_folder_doc_state( - &user_workspace.id, + &workspace_id, user_profile.uid, CollabType::Folder, - &user_workspace.id, + &workspace_id, ) .await { @@ -179,7 +181,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { &user_profile.token, is_new_user, data_source, - &user_workspace.id, + &workspace_id, ) .await .context("FolderManager error")?; diff --git a/frontend/rust-lib/flowy-database-pub/Cargo.toml b/frontend/rust-lib/flowy-database-pub/Cargo.toml index 91426a5c87..088c7b6465 100644 --- a/frontend/rust-lib/flowy-database-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-database-pub/Cargo.toml @@ -9,6 +9,6 @@ edition = "2021" lib-infra = { workspace = true } collab-entity = { workspace = true } collab = { workspace = true } -anyhow.workspace = true client-api = { workspace = true } -flowy-error = { workspace = true } \ No newline at end of file +flowy-error = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database-pub/src/cloud.rs b/frontend/rust-lib/flowy-database-pub/src/cloud.rs index a29cf650c4..8666e6c764 100644 --- a/frontend/rust-lib/flowy-database-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-database-pub/src/cloud.rs @@ -4,8 +4,9 @@ use collab_entity::CollabType; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; use std::collections::HashMap; +use uuid::Uuid; -pub type EncodeCollabByOid = HashMap; +pub type EncodeCollabByOid = HashMap; pub type SummaryRowContent = HashMap; pub type TranslateRowContent = Vec; @@ -13,8 +14,8 @@ pub type TranslateRowContent = Vec; pub trait DatabaseAIService: Send + Sync { async fn summary_database_row( &self, - _workspace_id: &str, - _object_id: &str, + _workspace_id: &Uuid, + _object_id: &Uuid, _summary_row: SummaryRowContent, ) -> Result { Ok("".to_string()) @@ -22,7 +23,7 @@ pub trait DatabaseAIService: Send + Sync { async fn translate_database_row( &self, - _workspace_id: &str, + _workspace_id: &Uuid, _translate_row: TranslateRowContent, _language: &str, ) -> Result { @@ -41,29 +42,29 @@ pub trait DatabaseAIService: Send + Sync { pub trait DatabaseCloudService: Send + Sync { async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn create_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError>; async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result; async fn get_database_collab_object_snapshots( &self, - object_id: &str, + object_id: &Uuid, limit: usize, ) -> Result, FlowyError>; } diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index d7bee42420..ec0eb94210 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -51,6 +51,7 @@ strum_macros = "0.25" validator = { workspace = true, features = ["derive"] } tokio-util.workspace = true moka = { version = "0.12.8", features = ["future"] } +uuid.workspace = true [dev-dependencies] event-integration-test = { path = "../event-integration-test", default-features = false } diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index ed766885c7..6117a8ee8a 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -1261,7 +1261,7 @@ pub(crate) async fn summarize_row_handler( let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let result = manager - .summarize_row(data.view_id, row_id, data.field_id) + .summarize_row(&data.view_id, row_id, data.field_id) .await; let _ = tx.send(result); }); @@ -1280,7 +1280,7 @@ pub(crate) async fn translate_row_handler( let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let result = manager - .translate_row(data.view_id, row_id, data.field_id) + .translate_row(&data.view_id, row_id, data.field_id) .await; let _ = tx.send(result); }); diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 63f95011bd..076b77e1d0 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -20,6 +20,7 @@ use collab_entity::{CollabObject, CollabType, EncodedCollab}; use collab_plugins::local_storage::kv::KVTransactionDB; use rayon::prelude::*; use std::collections::HashMap; +use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::sync::Mutex; @@ -42,12 +43,13 @@ use crate::services::database_view::DatabaseLayoutDepsResolver; use crate::services::field_settings::default_field_settings_by_layout_map; use crate::services::share::csv::{CSVFormat, CSVImporter, ImportResult}; use tokio::sync::RwLock as TokioRwLock; +use uuid::Uuid; pub trait DatabaseUser: Send + Sync { fn user_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; - fn workspace_id(&self) -> Result; - fn workspace_database_object_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn workspace_database_object_id(&self) -> Result; } pub(crate) type DatabaseEditorMap = HashMap>; @@ -110,7 +112,7 @@ impl DatabaseManager { let workspace_database_object_id = self.user.workspace_database_object_id()?; let workspace_database_collab = collab_service .build_collab( - workspace_database_object_id.as_str(), + workspace_database_object_id.to_string().as_str(), CollabType::WorkspaceDatabase, None, ) @@ -189,8 +191,10 @@ impl DatabaseManager { }) } - pub async fn encode_database(&self, view_id: &str) -> FlowyResult { - let editor = self.get_database_editor_with_view_id(view_id).await?; + pub async fn encode_database(&self, view_id: &Uuid) -> FlowyResult { + let editor = self + .get_database_editor_with_view_id(view_id.to_string().as_str()) + .await?; let collabs = editor .database .read() @@ -207,10 +211,12 @@ impl DatabaseManager { pub async fn get_database_row_metas_with_view_id( &self, - view_id: &str, + view_id: &Uuid, row_ids: Vec, ) -> FlowyResult> { - let database = self.get_database_editor_with_view_id(view_id).await?; + let database = self + .get_database_editor_with_view_id(view_id.to_string().as_str()) + .await?; let view_id = view_id.to_string(); let mut row_metas: Vec = vec![]; for row_id in row_ids { @@ -275,11 +281,11 @@ impl DatabaseManager { /// Open the database view #[instrument(level = "trace", skip_all, err)] - pub async fn open_database_view>(&self, view_id: T) -> FlowyResult<()> { - let view_id = view_id.as_ref(); + pub async fn open_database_view(&self, view_id: &Uuid) -> FlowyResult<()> { + let view_id = view_id.to_string(); let lock = self.workspace_database()?; let workspace_database = lock.read().await; - let result = workspace_database.get_database_id_with_view_id(view_id); + let result = workspace_database.get_database_id_with_view_id(&view_id); drop(workspace_database); if let Some(database_id) = result { @@ -292,8 +298,7 @@ impl DatabaseManager { } #[instrument(level = "trace", skip_all, err)] - pub async fn close_database_view>(&self, view_id: T) -> FlowyResult<()> { - let view_id = view_id.as_ref(); + pub async fn close_database_view(&self, view_id: &str) -> FlowyResult<()> { let lock = self.workspace_database()?; let workspace_database = lock.read().await; let database_id = workspace_database.get_database_id_with_view_id(view_id); @@ -518,7 +523,9 @@ impl DatabaseManager { layout: DatabaseLayoutPB, ) -> FlowyResult<()> { let database = self.get_database_editor_with_view_id(view_id).await?; - database.update_view_layout(view_id, layout.into()).await + database + .update_view_layout(view_id.to_string().as_str(), layout.into()) + .await } pub async fn get_database_snapshots( @@ -526,7 +533,7 @@ impl DatabaseManager { view_id: &str, limit: usize, ) -> FlowyResult> { - let database_id = self.get_database_id_with_view_id(view_id).await?; + let database_id = Uuid::from_str(&self.get_database_id_with_view_id(view_id).await?)?; let snapshots = self .cloud_service .get_database_collab_object_snapshots(&database_id, limit) @@ -553,11 +560,11 @@ impl DatabaseManager { #[instrument(level = "debug", skip_all)] pub async fn summarize_row( &self, - view_id: String, + view_id: &str, row_id: RowId, field_id: String, ) -> FlowyResult<()> { - let database = self.get_database_editor_with_view_id(&view_id).await?; + let database = self.get_database_editor_with_view_id(view_id).await?; let mut summary_row_content = SummaryRowContent::new(); if let Some(row) = database.get_row(&view_id, &row_id).await { let fields = database.get_fields(&view_id, None).await; @@ -583,7 +590,11 @@ impl DatabaseManager { ); let response = self .ai_service - .summary_database_row(&self.user.workspace_id()?, &row_id, summary_row_content) + .summary_database_row( + &self.user.workspace_id()?, + &Uuid::from_str(&row_id)?, + summary_row_content, + ) .await?; trace!("[AI]:summarize row response: {}", response); @@ -597,11 +608,12 @@ impl DatabaseManager { #[instrument(level = "debug", skip_all)] pub async fn translate_row( &self, - view_id: String, + view_id: &str, row_id: RowId, field_id: String, ) -> FlowyResult<()> { - let database = self.get_database_editor_with_view_id(&view_id).await?; + let database = self.get_database_editor_with_view_id(view_id).await?; + let view_id = view_id.to_string(); let mut translate_row_content = TranslateRowContent::new(); let mut language = "english".to_string(); @@ -703,10 +715,13 @@ impl WorkspaceDatabaseCollabServiceImpl { async fn get_encode_collab( &self, - object_id: &str, + object_id: &Uuid, object_ty: CollabType, ) -> Result, DatabaseError> { - let workspace_id = self.user.workspace_id().unwrap(); + let workspace_id = self + .user + .workspace_id() + .map_err(|e| DatabaseError::Internal(e.into()))?; trace!("[Database]: fetch {}:{} from remote", object_id, object_ty); let encode_collab = self .cloud_service @@ -718,7 +733,7 @@ impl WorkspaceDatabaseCollabServiceImpl { async fn batch_get_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, ) -> Result { let workspace_id = self @@ -730,7 +745,13 @@ impl WorkspaceDatabaseCollabServiceImpl { .batch_get_database_encode_collab(object_ids, object_ty, &workspace_id) .await .map_err(|err| DatabaseError::Internal(err.into()))?; - Ok(updates) + + Ok( + updates + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) } fn collab_db(&self) -> Result, DatabaseError> { @@ -746,7 +767,7 @@ impl WorkspaceDatabaseCollabServiceImpl { fn build_collab_object( &self, - object_id: &str, + object_id: &Uuid, object_type: CollabType, ) -> Result { let uid = self @@ -776,8 +797,12 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { collab_type: CollabType, encoded_collab: Option<(EncodedCollab, bool)>, ) -> Result { - let object = self.build_collab_object(object_id, collab_type)?; - let data_source = if self.persistence.is_collab_exist(object_id) { + let object_id = Uuid::parse_str(object_id)?; + let object = self.build_collab_object(&object_id, collab_type)?; + let data_source = if self + .persistence + .is_collab_exist(object_id.to_string().as_str()) + { trace!( "build collab: {}:{} from local encode collab", collab_type, @@ -796,7 +821,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { object_id, encoded_collab.is_none(), ); - match self.get_encode_collab(object_id, collab_type).await { + match self.get_encode_collab(&object_id, collab_type).await { Ok(Some(encode_collab)) => { info!( "build collab: {}:{} with remote encode collab, {} bytes", @@ -837,12 +862,11 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { ); self .persistence - .save_collab(object_id, encoded_collab.clone())?; + .save_collab(object_id.to_string().as_str(), encoded_collab.clone())?; // TODO(nathan): cover database rows and other database collab type if matches!(collab_type, CollabType::Database) { if let Ok(workspace_id) = self.user.workspace_id() { - let object_id = object_id.to_string(); let cloned_encoded_collab = encoded_collab.clone(); let cloud_service = self.cloud_service.clone(); tokio::spawn(async move { @@ -878,6 +902,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { if object_ids.is_empty() { return Ok(EncodeCollabByOid::new()); } + let mut encoded_collab_by_id = EncodeCollabByOid::new(); // 1. Collect local disk collabs into a HashMap let local_disk_encoded_collab: HashMap = object_ids @@ -900,6 +925,10 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { } if !object_ids.is_empty() { + let object_ids = object_ids + .into_iter() + .flat_map(|v| Uuid::from_str(&v).ok()) + .collect::>(); // 2. Fetch remaining collabs from remote let remote_collabs = self .batch_get_encode_collab(object_ids, collab_type) @@ -927,7 +956,7 @@ pub struct DatabasePersistenceImpl { } impl DatabasePersistenceImpl { - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { let workspace_id = self .user .workspace_id() @@ -947,7 +976,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { if let Ok((uid, Ok(Some(collab_db)))) = result { let object_id = collab.object_id().to_string(); let db_read = collab_db.read_txn(); - if !db_read.is_exist(uid, &workspace_id, &object_id) { + if !db_read.is_exist(uid, workspace_id.to_string().as_str(), &object_id) { trace!( "[Database]: collab:{} not exist in local storage", object_id @@ -957,7 +986,12 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { trace!("[Database]: start loading collab:{} from disk", object_id); let mut txn = collab.transact_mut(); - match db_read.load_doc_with_txn(uid, &workspace_id, &object_id, &mut txn) { + match db_read.load_doc_with_txn( + uid, + workspace_id.to_string().as_str(), + &object_id, + &mut txn, + ) { Ok(update_count) => { trace!( "[Database]: did load collab:{}, update_count:{}", @@ -976,7 +1010,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { } fn get_encoded_collab(&self, object_id: &str, collab_type: CollabType) -> Option { - let workspace_id = self.user.workspace_id().ok()?; + let workspace_id = self.user.workspace_id().ok()?.to_string(); let uid = self.user.user_id().ok()?; let db = self.user.collab_db(uid).ok()?.upgrade()?; let read_txn = db.read_txn(); @@ -995,7 +1029,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { } fn delete_collab(&self, object_id: &str) -> Result<(), DatabaseError> { - let workspace_id = self.workspace_id()?; + let workspace_id = self.workspace_id()?.to_string(); let uid = self .user .user_id() @@ -1017,7 +1051,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { object_id: &str, encoded_collab: EncodedCollab, ) -> Result<(), DatabaseError> { - let workspace_id = self.workspace_id()?; + let workspace_id = self.workspace_id()?.to_string(); let uid = self .user .user_id() @@ -1051,7 +1085,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { Ok(uid) => { if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { let read_txn = collab_db.read_txn(); - return read_txn.is_exist(uid, workspace_id.as_str(), object_id); + return read_txn.is_exist(uid, workspace_id.to_string().as_str(), object_id); } false }, @@ -1073,7 +1107,8 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { let workspace_id = self .user .workspace_id() - .map_err(|err| DatabaseError::Internal(err.into()))?; + .map_err(|err| DatabaseError::Internal(err.into()))? + .to_string(); if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { let write_txn = collab_db.write_txn(); diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index e284466054..ace2ec52fc 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -44,6 +44,7 @@ use lib_infra::box_any::BoxAny; use lib_infra::priority_task::TaskDispatcher; use lib_infra::util::timestamp; use std::collections::HashMap; +use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::select; @@ -53,11 +54,12 @@ use tokio::sync::{broadcast, oneshot}; use tokio::task::yield_now; use tokio_util::sync::CancellationToken; use tracing::{debug, error, event, info, instrument, trace, warn}; +use uuid::Uuid; type OpenDatabaseResult = oneshot::Sender>; pub struct DatabaseEditor { - database_id: String, + database_id: Uuid, pub(crate) database: Arc>, pub cell_cache: CellCache, pub(crate) database_views: Arc, @@ -117,6 +119,7 @@ impl DatabaseEditor { .await?, ); + let database_id = Uuid::from_str(&database_id)?; let collab_object = collab_builder.collab_object( &user.workspace_id()?, user.user_id()?, @@ -806,10 +809,11 @@ impl DatabaseEditor { let is_finalized = self.finalized_rows.get(row_id.as_str()).await.is_some(); if !is_finalized { trace!("[Database]: finalize database row: {}", row_id); + let row_id = Uuid::from_str(row_id.as_str())?; let collab_object = self.collab_builder.collab_object( &self.user.workspace_id()?, self.user.user_id()?, - row_id, + &row_id, CollabType::DatabaseRow, )?; diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs index 081d23f1b3..1c965995ec 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs @@ -16,6 +16,7 @@ use futures::StreamExt; use std::sync::Arc; use tracing::{error, trace, warn}; +use uuid::Uuid; pub(crate) async fn observe_sync_state(database_id: &str, database: &Arc>) { let weak_database = Arc::downgrade(database); @@ -112,7 +113,7 @@ pub(crate) async fn observe_field_change(database_id: &str, database: &Arc) { +pub(crate) async fn observe_view_change(database_id: &Uuid, database_editor: &Arc) { let database_id = database_id.to_string(); let weak_database_editor = Arc::downgrade(database_editor); let view_change = database_editor @@ -289,7 +290,7 @@ async fn handle_did_update_row_orders( } } -pub(crate) async fn observe_block_event(database_id: &str, database_editor: &Arc) { +pub(crate) async fn observe_block_event(database_id: &Uuid, database_editor: &Arc) { let database_id = database_id.to_string(); let mut block_event_rx = database_editor .database diff --git a/frontend/rust-lib/flowy-document-pub/Cargo.toml b/frontend/rust-lib/flowy-document-pub/Cargo.toml index 93a282f5cc..cbb74de5c4 100644 --- a/frontend/rust-lib/flowy-document-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-document-pub/Cargo.toml @@ -9,5 +9,5 @@ edition = "2021" lib-infra = { workspace = true } flowy-error = { workspace = true } collab-document = { workspace = true } -anyhow.workspace = true -collab = { workspace = true } \ No newline at end of file +collab = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document-pub/src/cloud.rs b/frontend/rust-lib/flowy-document-pub/src/cloud.rs index f34a91bfd4..d5c25053a8 100644 --- a/frontend/rust-lib/flowy-document-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-document-pub/src/cloud.rs @@ -1,8 +1,8 @@ use collab::entity::EncodedCollab; pub use collab_document::blocks::DocumentData; - use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use uuid::Uuid; /// A trait for document cloud service. /// Each kind of server should implement this trait. Check out the [AppFlowyServerProvider] of @@ -11,27 +11,27 @@ use lib_infra::async_trait::async_trait; pub trait DocumentCloudService: Send + Sync + 'static { async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn get_document_snapshots( &self, - document_id: &str, + document_id: &Uuid, limit: usize, workspace_id: &str, ) -> Result, FlowyError>; async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn create_document_collab( &self, - workspace_id: &str, - document_id: &str, + workspace_id: &Uuid, + document_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError>; } diff --git a/frontend/rust-lib/flowy-document/src/document.rs b/frontend/rust-lib/flowy-document/src/document.rs index ffdd0c900e..aa871cf4bc 100644 --- a/frontend/rust-lib/flowy-document/src/document.rs +++ b/frontend/rust-lib/flowy-document/src/document.rs @@ -6,9 +6,10 @@ use collab::preclude::Collab; use collab_document::document::Document; use futures::StreamExt; use lib_infra::sync_trace; +use uuid::Uuid; -pub fn subscribe_document_changed(doc_id: &str, document: &mut Document) { - let doc_id_clone_for_block_changed = doc_id.to_owned(); +pub fn subscribe_document_changed(doc_id: &Uuid, document: &mut Document) { + let doc_id_clone_for_block_changed = doc_id.to_string(); document.subscribe_block_changed("key", move |events, is_remote| { sync_trace!( "[Document] block changed in doc_id: {}, is_remote: {}, events: {:?}", @@ -35,7 +36,7 @@ pub fn subscribe_document_changed(doc_id: &str, document: &mut Document) { ); document_notification_builder( - &doc_id_clone_for_awareness_state, + &doc_id_clone_for_awareness_state.to_string(), DocumentNotification::DidUpdateDocumentAwarenessState, ) .payload::(events.into()) diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index 74157c6124..c8a6765fd6 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use collab::core::collab_state::SyncState; use collab_document::{ blocks::{json_str_to_hashmap, Block, BlockAction, DocumentData}, @@ -8,10 +6,12 @@ use collab_document::{ DocumentAwarenessUser, }, }; - use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use lib_infra::validator_fn::{required_not_empty_str, required_valid_path}; +use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; use validator::Validate; use crate::parse::{NotEmptyStr, NotEmptyVec}; @@ -31,7 +31,7 @@ pub struct OpenDocumentPayloadPB { } pub struct OpenDocumentParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for OpenDocumentPayloadPB { @@ -39,9 +39,9 @@ impl TryInto for OpenDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(OpenDocumentParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + + Ok(OpenDocumentParams { document_id }) } } @@ -52,7 +52,7 @@ pub struct DocumentRedoUndoPayloadPB { } pub struct DocumentRedoUndoParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for DocumentRedoUndoPayloadPB { @@ -60,9 +60,8 @@ impl TryInto for DocumentRedoUndoPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(DocumentRedoUndoParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + Ok(DocumentRedoUndoParams { document_id }) } } @@ -132,7 +131,7 @@ pub struct CreateDocumentPayloadPB { } pub struct CreateDocumentParams { - pub document_id: String, + pub document_id: Uuid, pub initial_data: Option, } @@ -141,9 +140,10 @@ impl TryInto for CreateDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let initial_data = self.initial_data.map(|data| data.into()); Ok(CreateDocumentParams { - document_id: document_id.0, + document_id, initial_data, }) } @@ -156,7 +156,7 @@ pub struct CloseDocumentPayloadPB { } pub struct CloseDocumentParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for CloseDocumentPayloadPB { @@ -164,9 +164,8 @@ impl TryInto for CloseDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(CloseDocumentParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + Ok(CloseDocumentParams { document_id }) } } @@ -180,7 +179,7 @@ pub struct ApplyActionPayloadPB { } pub struct ApplyActionParams { - pub document_id: String, + pub document_id: Uuid, pub actions: Vec, } @@ -189,10 +188,11 @@ impl TryInto for ApplyActionPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let actions = NotEmptyVec::parse(self.actions).map_err(|_| ErrorCode::ApplyActionsIsEmpty)?; let actions = actions.0.into_iter().map(BlockAction::from).collect(); Ok(ApplyActionParams { - document_id: document_id.0, + document_id, actions, }) } @@ -525,7 +525,7 @@ pub struct TextDeltaPayloadPB { } pub struct TextDeltaParams { - pub document_id: String, + pub document_id: Uuid, pub text_id: String, pub delta: String, } @@ -535,10 +535,11 @@ impl TryInto for TextDeltaPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let text_id = NotEmptyStr::parse(self.text_id).map_err(|_| ErrorCode::TextIdIsEmpty)?; let delta = self.delta.map_or_else(|| "".to_string(), |delta| delta); Ok(TextDeltaParams { - document_id: document_id.0, + document_id, text_id: text_id.0, delta, }) diff --git a/frontend/rust-lib/flowy-document/src/event_handler.rs b/frontend/rust-lib/flowy-document/src/event_handler.rs index 387e216f08..cb16129c40 100644 --- a/frontend/rust-lib/flowy-document/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document/src/event_handler.rs @@ -3,7 +3,7 @@ * as well as performing actions on documents. These functions make use of a DocumentManager, * which you can think of as a higher-level interface to interact with documents. */ - +use std::str::FromStr; use std::sync::{Arc, Weak}; use collab_document::blocks::{ @@ -11,10 +11,6 @@ use collab_document::blocks::{ DocumentData, }; -use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; -use tracing::instrument; - use crate::entities::*; use crate::parser::document_data_parser::DocumentDataParser; use crate::parser::external::parser::ExternalDataToNestedJSONParser; @@ -23,6 +19,10 @@ use crate::parser::parser_entities::{ ConvertDocumentParams, ConvertDocumentPayloadPB, ConvertDocumentResponsePB, }; use crate::{manager::DocumentManager, parser::json::parser::JsonToDocumentParser}; +use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use tracing::instrument; +use uuid::Uuid; fn upgrade_document( document_manager: AFPluginState>, @@ -499,7 +499,7 @@ pub(crate) async fn set_awareness_local_state_handler( ) -> FlowyResult<()> { let manager = upgrade_document(manager)?; let data = data.into_inner(); - let doc_id = data.document_id.clone(); + let doc_id = Uuid::from_str(&data.document_id)?; manager .set_document_awareness_local_state(&doc_id, data) .await?; diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index b84469872b..9e78b02f9b 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -14,21 +14,21 @@ use collab_document::document_awareness::DocumentAwarenessUser; use collab_document::document_data::default_document_data; use collab_entity::CollabType; -use collab_plugins::CollabKVDB; -use dashmap::DashMap; -use lib_infra::util::timestamp; -use tracing::{event, instrument}; -use tracing::{info, trace}; - use crate::document::{ subscribe_document_changed, subscribe_document_snapshot_state, subscribe_document_sync_state, }; use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, }; +use collab_plugins::CollabKVDB; +use dashmap::DashMap; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; use flowy_storage_pub::storage::{CreatedUpload, StorageService}; +use lib_infra::util::timestamp; +use tracing::{event, instrument}; +use tracing::{info, trace}; +use uuid::Uuid; use crate::entities::UpdateDocumentAwarenessStatePB; use crate::entities::{ @@ -39,7 +39,7 @@ use crate::reminder::DocumentReminderAction; pub trait DocumentUserService: Send + Sync { fn user_id(&self) -> Result; fn device_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; } @@ -54,8 +54,8 @@ pub trait DocumentSnapshotService: Send + Sync { pub struct DocumentManager { pub user_service: Arc, collab_builder: Arc, - documents: Arc>>>, - removing_documents: Arc>>>, + documents: Arc>>>, + removing_documents: Arc>>>, cloud_service: Arc, storage_service: Weak, snapshot_service: Arc, @@ -81,7 +81,7 @@ impl DocumentManager { } /// Get the encoded collab of the document. - pub async fn get_encoded_collab_with_view_id(&self, doc_id: &str) -> FlowyResult { + pub async fn get_encoded_collab_with_view_id(&self, doc_id: &Uuid) -> FlowyResult { let uid = self.user_service.user_id()?; let workspace_id = self.user_service.workspace_id()?; let doc_state = @@ -139,7 +139,7 @@ impl DocumentManager { pub async fn create_document( &self, _uid: i64, - doc_id: &str, + doc_id: &Uuid, data: Option, ) -> FlowyResult { if self.is_doc_exist(doc_id).await.unwrap_or(false) { @@ -151,17 +151,17 @@ impl DocumentManager { let encoded_collab = doc_state_from_document_data(doc_id, data).await?; self .persistence()? - .save_collab_to_disk(doc_id, encoded_collab.clone()) + .save_collab_to_disk(doc_id.to_string().as_str(), encoded_collab.clone()) .map_err(internal_error)?; // Send the collab data to server with a background task. let cloud_service = self.cloud_service.clone(); let cloned_encoded_collab = encoded_collab.clone(); - let document_id = doc_id.to_string(); let workspace_id = self.user_service.workspace_id()?; + let doc_id = doc_id.clone(); tokio::spawn(async move { let _ = cloud_service - .create_document_collab(&workspace_id, &document_id, cloned_encoded_collab) + .create_document_collab(&workspace_id, &doc_id, cloned_encoded_collab) .await; }); Ok(encoded_collab) @@ -171,7 +171,7 @@ impl DocumentManager { async fn collab_for_document( &self, uid: i64, - doc_id: &str, + doc_id: &Uuid, data_source: DataSource, sync_enable: bool, ) -> FlowyResult>> { @@ -195,7 +195,7 @@ impl DocumentManager { } /// Return a document instance if the document is already opened. - pub async fn editable_document(&self, doc_id: &str) -> FlowyResult>> { + pub async fn editable_document(&self, doc_id: &Uuid) -> FlowyResult>> { if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) { return Ok(doc); } @@ -213,7 +213,7 @@ impl DocumentManager { #[tracing::instrument(level = "info", skip(self), err)] async fn create_document_instance( &self, - doc_id: &str, + doc_id: &Uuid, enable_sync: bool, ) -> FlowyResult>> { let uid = self.user_service.user_id()?; @@ -260,7 +260,7 @@ impl DocumentManager { subscribe_document_snapshot_state(&lock); subscribe_document_sync_state(&lock); } - self.documents.insert(doc_id.to_string(), document.clone()); + self.documents.insert(doc_id.clone(), document.clone()); } Ok(document) }, @@ -273,21 +273,21 @@ impl DocumentManager { } } - pub async fn get_document_data(&self, doc_id: &str) -> FlowyResult { + pub async fn get_document_data(&self, doc_id: &Uuid) -> FlowyResult { let document = self.get_document(doc_id).await?; let document = document.read().await; document.get_document_data().map_err(internal_error) } - pub async fn get_document_text(&self, doc_id: &str) -> FlowyResult { + pub async fn get_document_text(&self, doc_id: &Uuid) -> FlowyResult { let document = self.get_document(doc_id).await?; let document = document.read().await; - let text = document.to_plain_text(true, false)?; + let text = document.paragraphs().join("\n"); Ok(text) } /// Return a document instance. /// The returned document might or might not be able to sync with the cloud. - async fn get_document(&self, doc_id: &str) -> FlowyResult>> { + async fn get_document(&self, doc_id: &Uuid) -> FlowyResult>> { if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) { return Ok(doc); } @@ -300,7 +300,7 @@ impl DocumentManager { Ok(document) } - pub async fn open_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn open_document(&self, doc_id: &Uuid) -> FlowyResult<()> { if let Some(mutex_document) = self.restore_document_from_removing(doc_id) { let lock = mutex_document.read().await; lock.start_init_sync(); @@ -314,7 +314,7 @@ impl DocumentManager { Ok(()) } - pub async fn close_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn close_document(&self, doc_id: &Uuid) -> FlowyResult<()> { if let Some((doc_id, document)) = self.documents.remove(doc_id) { { // clear the awareness state when close the document @@ -340,11 +340,12 @@ impl DocumentManager { Ok(()) } - pub async fn delete_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn delete_document(&self, doc_id: &Uuid) -> FlowyResult<()> { let uid = self.user_service.user_id()?; let workspace_id = self.user_service.workspace_id()?; if let Some(db) = self.user_service.collab_db(uid)?.upgrade() { - db.delete_doc(uid, &workspace_id, doc_id).await?; + db.delete_doc(uid, &workspace_id.to_string(), &doc_id.to_string()) + .await?; // When deleting a document, we need to remove it from the cache. self.documents.remove(doc_id); } @@ -354,7 +355,7 @@ impl DocumentManager { #[instrument(level = "debug", skip_all, err)] pub async fn set_document_awareness_local_state( &self, - doc_id: &str, + doc_id: &Uuid, state: UpdateDocumentAwarenessStatePB, ) -> FlowyResult { let uid = self.user_service.user_id()?; @@ -379,12 +380,12 @@ impl DocumentManager { /// Return the list of snapshots of the document. pub async fn get_document_snapshot_meta( &self, - document_id: &str, + document_id: &Uuid, _limit: usize, ) -> FlowyResult> { let metas = self .snapshot_service - .get_document_snapshot_metas(document_id)? + .get_document_snapshot_metas(document_id.to_string().as_str())? .into_iter() .map(|meta| DocumentSnapshotMetaPB { snapshot_id: meta.snapshot_id, @@ -434,11 +435,13 @@ impl DocumentManager { Ok(()) } - async fn is_doc_exist(&self, doc_id: &str) -> FlowyResult { + async fn is_doc_exist(&self, doc_id: &Uuid) -> FlowyResult { let uid = self.user_service.user_id()?; let workspace_id = self.user_service.workspace_id()?; if let Some(collab_db) = self.user_service.collab_db(uid)?.upgrade() { - let is_exist = collab_db.is_exist(uid, &workspace_id, doc_id).await?; + let is_exist = collab_db + .is_exist(uid, &workspace_id.to_string(), &doc_id.to_string()) + .await?; Ok(is_exist) } else { Ok(false) @@ -463,7 +466,7 @@ impl DocumentManager { &self.storage_service } - fn restore_document_from_removing(&self, doc_id: &str) -> Option>> { + fn restore_document_from_removing(&self, doc_id: &Uuid) -> Option>> { let (doc_id, doc) = self.removing_documents.remove(doc_id)?; trace!( "move document {} from removing_documents to documents", @@ -475,7 +478,7 @@ impl DocumentManager { } async fn doc_state_from_document_data( - doc_id: &str, + doc_id: &Uuid, data: Option, ) -> Result { let doc_id = doc_id.to_string(); diff --git a/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs b/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs index 8acdecae36..94680b32d3 100644 --- a/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs +++ b/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; +use uuid::Uuid; use validator::Validate; #[derive(Default, ProtoBuf)] @@ -96,7 +97,7 @@ pub struct ParseType { } pub struct ConvertDocumentParams { - pub document_id: String, + pub document_id: Uuid, pub range: Option, pub parse_types: ParseType, } @@ -140,10 +141,11 @@ impl TryInto for ConvertDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::parse_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let range = self.range.map(|data| data.into()); Ok(ConvertDocumentParams { - document_id: document_id.0, + document_id, range, parse_types: self.parse_types.into(), }) diff --git a/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs index b11cd2ecde..2a47ec93c4 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs @@ -9,8 +9,8 @@ use crate::document::util::{gen_document_id, gen_id, DocumentTest}; async fn undo_redo_test() { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test diff --git a/frontend/rust-lib/flowy-document/tests/document/document_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_test.rs index d7906bc114..8323a645c7 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_test.rs @@ -11,8 +11,8 @@ async fn restore_document() { let test = DocumentTest::new(); // create a document - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); let uid = test.user_service.user_id().unwrap(); test .create_document(uid, &doc_id, Some(data.clone())) @@ -55,8 +55,8 @@ async fn restore_document() { async fn document_apply_insert_action() { let test = DocumentTest::new(); let uid = test.user_service.user_id().unwrap(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; @@ -111,9 +111,9 @@ async fn document_apply_insert_action() { #[tokio::test] async fn document_apply_update_page_action() { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); + let doc_id = gen_document_id(); let uid = test.user_service.user_id().unwrap(); - let data = default_document_data(&doc_id); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; @@ -158,8 +158,8 @@ async fn document_apply_update_page_action() { async fn document_apply_update_action() { let test = DocumentTest::new(); let uid = test.user_service.user_id().unwrap(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; diff --git a/frontend/rust-lib/flowy-document/tests/document/util.rs b/frontend/rust-lib/flowy-document/tests/document/util.rs index 20e6b5d79d..a7ae759709 100644 --- a/frontend/rust-lib/flowy-document/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document/tests/document/util.rs @@ -7,11 +7,6 @@ use collab::preclude::CollabPlugin; use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_document::document_data::default_document_data; -use nanoid::nanoid; -use tempfile::TempDir; -use tokio::sync::RwLock; -use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; - use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, WorkspaceCollabIntegrate, @@ -24,6 +19,11 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_storage_pub::storage::{CreatedUpload, FileProgressReceiver, StorageService}; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; +use nanoid::nanoid; +use tempfile::TempDir; +use tokio::sync::RwLock; +use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; +use uuid::Uuid; pub struct DocumentTest { inner: DocumentManager, @@ -63,7 +63,7 @@ impl Deref for DocumentTest { } pub struct FakeUser { - workspace_id: String, + workspace_id: Uuid, collab_db: Arc, } @@ -74,7 +74,7 @@ impl FakeUser { let tempdir = TempDir::new().unwrap(); let path = tempdir.into_path(); let collab_db = Arc::new(CollabKVDB::open(path).unwrap()); - let workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_id = uuid::Uuid::new_v4(); Self { collab_db, @@ -88,7 +88,7 @@ impl DocumentUserService for FakeUser { Ok(1) } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { Ok(self.workspace_id.clone()) } @@ -115,8 +115,8 @@ pub fn setup_log() { pub async fn create_and_open_empty_document() -> (DocumentTest, Arc>, String) { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); let uid = test.user_service.user_id().unwrap(); // create a document test @@ -130,9 +130,8 @@ pub async fn create_and_open_empty_document() -> (DocumentTest, Arc String { - let uuid = uuid::Uuid::new_v4(); - uuid.to_string() +pub fn gen_document_id() -> Uuid { + uuid::Uuid::new_v4() } pub fn gen_id() -> String { @@ -145,8 +144,8 @@ pub struct LocalTestDocumentCloudServiceImpl(); impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_doc_state( &self, - document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let document_id = document_id.to_string(); Err(FlowyError::new( @@ -157,26 +156,26 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, + document_id: &Uuid, + limit: usize, + workspace_id: &str, ) -> Result, FlowyError> { Ok(vec![]) } async fn get_document_data( &self, - _document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { Ok(None) } async fn create_document_collab( &self, - _workspace_id: &str, - _document_id: &str, - _encoded_collab: EncodedCollab, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) } @@ -257,14 +256,14 @@ impl DocumentSnapshotService for DocumentTestSnapshot { } struct WorkspaceCollabIntegrateImpl { - workspace_id: String, + workspace_id: Uuid, } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { Ok(self.workspace_id.clone()) } - fn device_id(&self) -> Result { + fn device_id(&self) -> Result { Ok("fake_device_id".to_string()) } } diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index 7502370a0f..69897435e9 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -34,6 +34,7 @@ collab-plugins = { workspace = true, optional = true } collab-folder = { workspace = true, optional = true } client-api = { workspace = true, optional = true } tantivy = { version = "0.22.0", optional = true } +uuid.workspace = true [features] default = ["impl_from_dispatch_error", "impl_from_serde", "impl_from_reqwest", "impl_from_sqlite"] diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index 4cef7a8990..96b9d1c3cf 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -256,3 +256,9 @@ impl From for FlowyError { } } } + +impl From for FlowyError { + fn from(value: uuid::Error) -> Self { + FlowyError::internal().with_context(value) + } +} diff --git a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs index 3e7776d0bf..52ed4b7314 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs @@ -15,7 +15,7 @@ pub trait FolderCloudService: Send + Sync + 'static { /// Returns error if the cloud service doesn't support multiple workspaces async fn create_workspace(&self, uid: i64, name: &str) -> Result; - async fn open_workspace(&self, workspace_id: &str) -> Result<(), FlowyError>; + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; /// Returns all workspaces of the user. /// Returns vec![] if the cloud service doesn't support multiple workspaces @@ -23,7 +23,7 @@ pub trait FolderCloudService: Send + Sync + 'static { async fn get_folder_data( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: &i64, ) -> Result, FlowyError>; @@ -35,21 +35,21 @@ pub trait FolderCloudService: Send + Sync + 'static { async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, collab_type: CollabType, - object_id: &str, + object_id: &Uuid, ) -> Result, FlowyError>; async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, params: FullSyncCollabParams, ) -> Result<(), FlowyError>; async fn batch_create_folder_collab_objects( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError>; @@ -57,64 +57,64 @@ pub trait FolderCloudService: Send + Sync + 'static { async fn publish_view( &self, - workspace_id: &str, + workspace_id: &Uuid, payload: Vec, ) -> Result<(), FlowyError>; async fn unpublish_views( &self, - workspace_id: &str, - view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError>; - async fn get_publish_info(&self, view_id: &str) -> Result; + async fn get_publish_info(&self, view_id: &Uuid) -> Result; async fn set_publish_name( &self, - workspace_id: &str, - view_id: String, + workspace_id: &Uuid, + view_id: Uuid, new_name: String, ) -> Result<(), FlowyError>; async fn set_publish_namespace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_namespace: String, ) -> Result<(), FlowyError>; async fn list_published_views( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn get_default_published_view_info( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result; async fn set_default_published_view( &self, - workspace_id: &str, + workspace_id: &Uuid, view_id: uuid::Uuid, ) -> Result<(), FlowyError>; - async fn remove_default_published_view(&self, workspace_id: &str) -> Result<(), FlowyError>; + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; - async fn get_publish_namespace(&self, workspace_id: &str) -> Result; + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result; async fn import_zip(&self, file_path: &str) -> Result<(), FlowyError>; } #[derive(Debug)] pub struct FolderCollabParams { - pub object_id: String, + pub object_id: Uuid, pub encoded_collab_v1: Vec, pub collab_type: CollabType, } #[derive(Debug)] pub struct FullSyncCollabParams { - pub object_id: String, + pub object_id: Uuid, pub encoded_collab: EncodedCollab, pub collab_type: CollabType, } diff --git a/frontend/rust-lib/flowy-folder-pub/src/query.rs b/frontend/rust-lib/flowy-folder-pub/src/query.rs index 7b4682885d..74761e44db 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/query.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/query.rs @@ -3,6 +3,7 @@ use collab_entity::CollabType; use collab_folder::ViewLayout; use flowy_error::FlowyResult; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct QueryCollab { pub collab_type: CollabType, @@ -17,14 +18,14 @@ pub trait FolderQueryService: Send + Sync + 'static { /// the provided view layout, given that the parent view is not a space async fn get_surrounding_view_ids_with_view_layout( &self, - parent_view_id: &str, + parent_view_id: &Uuid, view_layout: ViewLayout, - ) -> Vec; + ) -> Vec; - async fn get_collab(&self, object_id: &str, collab_type: CollabType) -> Option; + async fn get_collab(&self, object_id: &Uuid, collab_type: CollabType) -> Option; } #[async_trait] pub trait FolderViewEdit: Send + Sync + 'static { - async fn set_view_title_if_empty(&self, view_id: &str, title: &str) -> FlowyResult<()>; + async fn set_view_title_if_empty(&self, view_id: &Uuid, title: &str) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-folder/src/entities/import.rs b/frontend/rust-lib/flowy-folder/src/entities/import.rs index 4189dfaa6d..83e8bdf874 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/import.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/import.rs @@ -4,6 +4,8 @@ use crate::share::{ImportData, ImportItem, ImportParams, ImportType}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::FlowyError; use lib_infra::validator_fn::required_not_empty_str; +use std::str::FromStr; +use uuid::Uuid; use validator::Validate; #[derive(Clone, Debug, ProtoBuf_Enum)] @@ -76,6 +78,8 @@ impl TryInto for ImportPayloadPB { .map_err(|_| FlowyError::invalid_view_id())? .0; + let parent_view_id = Uuid::from_str(&parent_view_id)?; + let items = self .items .into_iter() diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index a8f331a91c..5e29702d53 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -1,12 +1,13 @@ use collab_folder::{View, ViewIcon, ViewLayout}; -use std::collections::HashMap; -use std::convert::TryInto; -use std::ops::{Deref, DerefMut}; -use std::sync::Arc; - use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use flowy_folder_pub::cloud::gen_view_id; +use std::collections::HashMap; +use std::convert::TryInto; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; +use std::sync::Arc; +use uuid::Uuid; use crate::entities::icon::ViewIconPB; use crate::entities::parser::view::{ViewIdentify, ViewName, ViewThumbnail}; @@ -322,10 +323,10 @@ pub struct CreateOrphanViewPayloadPB { #[derive(Debug, Clone)] pub struct CreateViewParams { - pub parent_view_id: String, + pub parent_view_id: Uuid, pub name: String, pub layout: ViewLayoutPB, - pub view_id: String, + pub view_id: Uuid, pub initial_data: ViewData, pub meta: HashMap, // Mark the view as current view after creation. @@ -346,9 +347,13 @@ impl TryInto for CreateViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; - let parent_view_id = ViewIdentify::parse(self.parent_view_id)?.0; + let parent_view_id = ViewIdentify::parse(self.parent_view_id) + .and_then(|id| Uuid::from_str(&id.0).map_err(|err| ErrorCode::InvalidParams))?; // if view_id is not provided, generate a new view_id - let view_id = self.view_id.unwrap_or_else(|| gen_view_id().to_string()); + let view_id = self + .view_id + .and_then(|v| Uuid::parse_str(&v).ok()) + .unwrap_or_else(|| gen_view_id()); Ok(CreateViewParams { parent_view_id, @@ -371,13 +376,13 @@ impl TryInto for CreateOrphanViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; - let parent_view_id = ViewIdentify::parse(self.view_id.clone())?.0; + let view_id = Uuid::parse_str(&self.view_id).map_err(|_| ErrorCode::InvalidParams)?; Ok(CreateViewParams { - parent_view_id, + parent_view_id: view_id.clone(), name, layout: self.layout, - view_id: self.view_id, + view_id, initial_data: ViewData::Data(self.initial_data.into()), meta: Default::default(), set_as_current: false, @@ -564,9 +569,9 @@ impl TryInto for MoveViewPayloadPB { #[derive(Debug)] pub struct MoveNestedViewParams { - pub view_id: String, - pub new_parent_id: String, - pub prev_view_id: Option, + pub view_id: Uuid, + pub new_parent_id: Uuid, + pub prev_view_id: Option, pub from_section: Option, pub to_section: Option, } @@ -575,9 +580,20 @@ impl TryInto for MoveNestedViewPayloadPB { type Error = ErrorCode; fn try_into(self) -> Result { - let view_id = ViewIdentify::parse(self.view_id)?.0; + let view_id = Uuid::from_str(&ViewIdentify::parse(self.view_id)?.0) + .map_err(|_| ErrorCode::InvalidParams)?; + let new_parent_id = ViewIdentify::parse(self.new_parent_id)?.0; - let prev_view_id = self.prev_view_id; + let new_parent_id = Uuid::from_str(&new_parent_id).map_err(|_| ErrorCode::InvalidParams)?; + + let prev_view_id = match self.prev_view_id { + Some(prev_view_id) => Some( + Uuid::from_str(&ViewIdentify::parse(prev_view_id)?.0) + .map_err(|_| ErrorCode::InvalidParams)?, + ), + None => None, + }; + Ok(MoveNestedViewParams { view_id, new_parent_id, diff --git a/frontend/rust-lib/flowy-folder/src/event_handler.rs b/frontend/rust-lib/flowy-folder/src/event_handler.rs index 30cbd29d1c..c20eb8a7ad 100644 --- a/frontend/rust-lib/flowy-folder/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/event_handler.rs @@ -1,8 +1,9 @@ -use std::sync::{Arc, Weak}; -use tracing::instrument; - use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use tracing::instrument; +use uuid::Uuid; use crate::entities::*; use crate::manager::FolderManager; @@ -443,7 +444,12 @@ pub(crate) async fn unpublish_views_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let params = data.into_inner(); - folder.unpublish_views(params.view_ids).await?; + let view_ids = params + .view_ids + .into_iter() + .flat_map(|id| Uuid::from_str(&id).ok()) + .collect::>(); + folder.unpublish_views(view_ids).await?; Ok(()) } @@ -454,6 +460,7 @@ pub(crate) async fn get_publish_info_handler( ) -> DataResult { let folder = upgrade_folder(folder)?; let view_id = data.into_inner().value; + let view_id = Uuid::from_str(&view_id)?; let info = folder.get_publish_info(&view_id).await?; data_result_ok(PublishInfoResponsePB::from(info)) } @@ -465,6 +472,7 @@ pub(crate) async fn set_publish_name_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let SetPublishNamePB { view_id, new_name } = data.into_inner(); + let view_id = Uuid::from_str(&view_id)?; folder.set_publish_name(view_id, new_name).await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index e2ec405884..51edf3a11c 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -44,16 +44,18 @@ use flowy_sqlite::kv::KVStorePreferences; use futures::future; use std::collections::HashMap; use std::fmt::{Display, Formatter}; +use std::str::FromStr; use std::sync::{Arc, Weak}; use tokio::sync::RwLockWriteGuard; use tracing::{error, info, instrument}; +use uuid::Uuid; pub trait FolderUser: Send + Sync { fn user_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; - fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &str) -> FlowyResult; + fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &Uuid) -> FlowyResult; } pub struct FolderManager { @@ -111,7 +113,7 @@ impl FolderManager { Ok::(workspace) }; - match folder.get_workspace_info(&workspace_id) { + match folder.get_workspace_info(&workspace_id.to_string()) { None => Err(FlowyError::record_not_found().with_context("Can not find the workspace")), Some(workspace) => workspace_pb_from_workspace(workspace, &folder), } @@ -127,14 +129,14 @@ impl FolderManager { .ok_or_else(|| internal_error("The folder is not initialized"))? .read() .await - .get_folder_data(&workspace_id) + .get_folder_data(&workspace_id.to_string()) .ok_or_else(|| internal_error("Workspace id not match the id in current folder"))?; Ok(data) } pub async fn gather_publish_encode_collab( &self, - view_id: &str, + view_id: &Uuid, layout: &ViewLayout, ) -> FlowyResult { let handler = self.get_handler(layout)?; @@ -177,7 +179,7 @@ impl FolderManager { pub(crate) async fn make_folder>>( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, data_source: Option, folder_notifier: T, @@ -187,8 +189,7 @@ impl FolderManager { let config = CollabBuilderConfig::default().sync_enable(true); let data_source = data_source.unwrap_or_else(|| { - CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.to_string()) - .into_data_source() + CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.clone()).into_data_source() }); let object_id = workspace_id; @@ -218,8 +219,11 @@ impl FolderManager { "Clear the folder data and try to open the folder again due to: {}", err ); + if let Some(db) = self.user.collab_db(uid).ok().and_then(|a| a.upgrade()) { - let _ = db.delete_doc(uid, workspace_id, workspace_id).await; + let _ = db + .delete_doc(uid, &workspace_id.to_string(), &object_id.to_string()) + .await; } Err(err.into()) }, @@ -229,7 +233,7 @@ impl FolderManager { pub(crate) async fn create_folder_with_data( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, notifier: Option, folder_data: Option, @@ -240,8 +244,8 @@ impl FolderManager { .collab_builder .collab_object(workspace_id, uid, object_id, CollabType::Folder)?; - let doc_state = CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.to_string()) - .into_data_source(); + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.clone()).into_data_source(); let folder = self .collab_builder .create_folder( @@ -317,7 +321,7 @@ impl FolderManager { _token: &str, is_new: bool, data_source: FolderInitDataSource, - workspace_id: &str, + workspace_id: &Uuid, ) -> FlowyResult<()> { // Create the default workspace if the user is new info!("initialize_when_sign_up: is_new: {}", is_new); @@ -377,7 +381,7 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; let latest_view = self.get_current_view().await; Ok(WorkspaceSettingPB { - workspace_id, + workspace_id: workspace_id.to_string(), latest_view, }) } @@ -495,7 +499,7 @@ impl FolderManager { .ok_or_else(|| FlowyError::internal().with_context("folder is not initialized"))?; let folder = lock.read().await; let workspace = folder - .get_workspace_info(&workspace_id) + .get_workspace_info(&workspace_id.to_string()) .ok_or_else(|| FlowyError::record_not_found().with_context("Can not find the workspace"))?; let views = folder @@ -606,8 +610,9 @@ impl FolderManager { // Drop the folder lock explicitly to avoid deadlock when following calls contains 'self' drop(folder); + let view_id = Uuid::from_str(view_id)?; let handler = self.get_handler(&view.layout)?; - handler.close_view(view_id).await?; + handler.close_view(&view_id).await?; } } Ok(()) @@ -844,24 +849,28 @@ impl FolderManager { let prev_view_id = params.prev_view_id; let from_section = params.from_section; let to_section = params.to_section; - let view = self.get_view_pb(&view_id).await?; + let view = self.get_view_pb(&view_id.to_string()).await?; // if the view is locked, the view can't be moved if view.is_locked.unwrap_or(false) { return Err(FlowyError::view_is_locked()); } - let old_parent_id = view.parent_view_id; + let old_parent_id = Uuid::from_str(&view.parent_view_id)?; if let Some(lock) = self.mutex_folder.load_full() { let mut folder = lock.write().await; - folder.move_nested_view(&view_id, &new_parent_id, prev_view_id); + folder.move_nested_view( + &view_id.to_string(), + &new_parent_id.to_string(), + prev_view_id.map(|s| s.to_string()), + ); if from_section != to_section { if to_section == Some(ViewSectionPB::Private) { - folder.add_private_view_ids(vec![view_id.clone()]); + folder.add_private_view_ids(vec![view_id.to_string()]); } else { - folder.delete_private_view_ids(vec![view_id.clone()]); + folder.delete_private_view_ids(vec![view_id.to_string()]); } } - notify_parent_view_did_change(&workspace_id, &folder, vec![new_parent_id, old_parent_id]); + notify_parent_view_did_change(workspace_id, &folder, vec![new_parent_id, old_parent_id]); } Ok(()) } @@ -912,7 +921,8 @@ impl FolderManager { if let Some(lock) = self.mutex_folder.load_full() { let mut folder = lock.write().await; folder.move_view(view_id, actual_from_index as u32, actual_to_index as u32); - notify_parent_view_did_change(&workspace_id, &folder, vec![parent_view_id]); + let parent_view_id = Uuid::from_str(&parent_view_id)?; + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); } } } @@ -1115,7 +1125,8 @@ impl FolderManager { view.name, view.layout ); - let view_data = handler.duplicate_view(&view.id).await?; + let view_id = Uuid::from_str(&view.id)?; + let view_data = handler.duplicate_view(&view_id).await?; let index = self .get_view_relation(¤t_parent_id) @@ -1151,12 +1162,13 @@ impl FolderManager { view.name.clone() }; + let parent_view_id = Uuid::from_str(¤t_parent_id)?; let duplicate_params = CreateViewParams { - parent_view_id: current_parent_id.clone(), + parent_view_id, name, layout: view.layout.clone().into(), initial_data: ViewData::DuplicateData(view_data), - view_id: gen_view_id().to_string(), + view_id: gen_view_id(), meta: Default::default(), set_as_current: is_source_view && open_after_duplicated, index, @@ -1176,7 +1188,7 @@ impl FolderManager { if sync_after_create { if let Some(encoded_collab) = encoded_collab { - let object_id = duplicated_view.id.clone(); + let object_id = Uuid::from_str(&duplicated_view.id)?; let collab_type = match duplicated_view.layout { ViewLayout::Document => CollabType::Document, ViewLayout::Board | ViewLayout::Grid | ViewLayout::Calendar => CollabType::Database, @@ -1208,20 +1220,20 @@ impl FolderManager { is_source_view = false } - let workspace_id = &self.user.workspace_id()?; + let workspace_id = self.user.workspace_id()?; + let parent_view_id = Uuid::from_str(&parent_view_id)?; // Sync the view to the cloud if sync_after_create { self .cloud_service - .batch_create_folder_collab_objects(workspace_id, objects) + .batch_create_folder_collab_objects(&workspace_id, objects) .await?; } // notify the update here let folder = lock.read().await; - notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id.to_string()]); - + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); let duplicated_view = self.get_view_pb(&new_view_id).await?; Ok(duplicated_view) @@ -1242,6 +1254,7 @@ impl FolderManager { let view_layout: ViewLayout = view.layout.clone().into(); if let Some(handle) = self.operation_handlers.get(&view_layout) { info!("Open view: {}-{}", view.name, view.id); + let view_id = Uuid::from_str(&view.id)?; if let Err(err) = handle.open_view(&view_id).await { error!("Open view error: {:?}", err); } @@ -1250,7 +1263,7 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; let setting = WorkspaceSettingPB { - workspace_id, + workspace_id: workspace_id.to_string(), latest_view: view, }; send_current_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); @@ -1367,18 +1380,18 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; self .cloud_service - .publish_view(workspace_id.as_str(), payload) + .publish_view(&workspace_id, payload) .await?; Ok(()) } /// Unpublish the view with the given view id. #[tracing::instrument(level = "debug", skip(self), err)] - pub async fn unpublish_views(&self, view_ids: Vec) -> FlowyResult<()> { + pub async fn unpublish_views(&self, view_ids: Vec) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; self .cloud_service - .unpublish_views(workspace_id.as_str(), view_ids) + .unpublish_views(&workspace_id, view_ids) .await?; Ok(()) } @@ -1386,14 +1399,14 @@ impl FolderManager { /// Get the publish info of the view with the given view id. /// The publish info contains the namespace and publish_name of the view. #[tracing::instrument(level = "debug", skip(self))] - pub async fn get_publish_info(&self, view_id: &str) -> FlowyResult { + pub async fn get_publish_info(&self, view_id: &Uuid) -> FlowyResult { let publish_info = self.cloud_service.get_publish_info(view_id).await?; Ok(publish_info) } /// Sets the publish name of the view with the given view id. #[tracing::instrument(level = "debug", skip(self))] - pub async fn set_publish_name(&self, view_id: String, new_name: String) -> FlowyResult<()> { + pub async fn set_publish_name(&self, view_id: Uuid, new_name: String) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; self .cloud_service @@ -1409,7 +1422,7 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; self .cloud_service - .set_publish_namespace(workspace_id.as_str(), new_namespace) + .set_publish_namespace(&workspace_id, new_namespace) .await?; Ok(()) } @@ -1420,7 +1433,7 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; let namespace = self .cloud_service - .get_publish_namespace(workspace_id.as_str()) + .get_publish_namespace(&workspace_id) .await?; Ok(namespace) } @@ -1502,7 +1515,7 @@ impl FolderManager { }; if let Ok(payload) = self - .get_publish_payload(¤t_view_id, publish_name, layout) + .get_publish_payload(&Uuid::from_str(¤t_view_id)?, publish_name, layout) .await { payloads.push(payload); @@ -1551,7 +1564,7 @@ impl FolderManager { async fn get_publish_payload( &self, - view_id: &str, + view_id: &Uuid, publish_name: Option, layout: ViewLayout, ) -> FlowyResult { @@ -1559,18 +1572,20 @@ impl FolderManager { let encoded_collab_wrapper: GatherEncodedCollab = handler .gather_publish_encode_collab(&self.user, view_id) .await?; - let view = self.get_view_pb(view_id).await?; + + let view_str_id = view_id.to_string(); + let view = self.get_view_pb(&view_str_id).await?; let publish_name = publish_name.unwrap_or_else(|| generate_publish_name(&view.id, &view.name)); let child_views = self - .build_publish_views(view_id) + .build_publish_views(&view_str_id) .await .and_then(|v| v.child_views) .unwrap_or_default(); let ancestor_views = self - .get_view_ancestors_pb(view_id) + .get_view_ancestors_pb(&view_str_id) .await? .iter() .map(view_pb_to_publish_view) @@ -1720,8 +1735,9 @@ impl FolderManager { }; if let Some(view) = view { + let view_id = Uuid::from_str(view_id)?; if let Ok(handler) = self.get_handler(&view.layout) { - handler.delete_view(view_id).await?; + handler.delete_view(&view_id).await?; } } } @@ -1733,11 +1749,11 @@ impl FolderManager { #[instrument(level = "debug", skip_all, err)] pub(crate) async fn import_single_file( &self, - parent_view_id: String, + parent_view_id: Uuid, import_data: ImportItem, ) -> FlowyResult<(View, Vec<(String, CollabType, EncodedCollab)>)> { let handler = self.get_handler(&import_data.view_layout)?; - let view_id = gen_view_id().to_string(); + let view_id = gen_view_id(); let uid = self.user.user_id()?; let mut encoded_collab = vec![]; @@ -1745,7 +1761,7 @@ impl FolderManager { match import_data.data { ImportData::FilePath { file_path } => { handler - .import_from_file_path(&view_id, &import_data.name, file_path) + .import_from_file_path(&view_id.to_string(), &import_data.name, file_path) .await?; }, ImportData::Bytes { bytes } => { @@ -1804,11 +1820,13 @@ impl FolderManager { views.push(view_pb_without_child_views(view)); for (object_id, collab_type, encode_collab) in encoded_collabs { - match self.get_folder_collab_params(object_id, collab_type, encode_collab) { - Ok(params) => objects.push(params), - Err(e) => { - error!("import error {}", e); - }, + if let Ok(object_id) = Uuid::from_str(&object_id) { + match self.get_folder_collab_params(object_id, collab_type, encode_collab) { + Ok(params) => objects.push(params), + Err(e) => { + error!("import error {}", e); + }, + } } } } @@ -1822,7 +1840,7 @@ impl FolderManager { // Notify that the parent view has changed if let Some(lock) = self.mutex_folder.load_full() { let folder = lock.read().await; - notify_parent_view_did_change(&workspace_id, &folder, vec![import_data.parent_view_id]); + notify_parent_view_did_change(workspace_id, &folder, vec![import_data.parent_view_id]); } Ok(RepeatedViewPB { items: views }) @@ -1887,7 +1905,7 @@ impl FolderManager { fn get_folder_collab_params( &self, - object_id: String, + object_id: Uuid, collab_type: CollabType, encoded_collab: EncodedCollab, ) -> FlowyResult { @@ -1911,18 +1929,20 @@ impl FolderManager { let folder = lock.read().await; let view = folder.get_view(view_id)?; match folder.get_view(&view.parent_view_id) { - None => folder.get_workspace_info(&workspace_id).map(|workspace| { - ( - true, - workspace.id, - workspace - .child_views - .items - .into_iter() - .map(|view| view.id) - .collect::>(), - ) - }), + None => folder + .get_workspace_info(&workspace_id.to_string()) + .map(|workspace| { + ( + true, + workspace.id, + workspace + .child_views + .items + .into_iter() + .map(|view| view.id) + .collect::>(), + ) + }), Some(parent_view) => Some(( false, parent_view.id.clone(), @@ -2031,17 +2051,17 @@ impl FolderManager { .collect() } - pub fn remove_indices_for_workspace(&self, workspace_id: String) -> FlowyResult<()> { + pub fn remove_indices_for_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { self .folder_indexer - .remove_indices_for_workspace(workspace_id)?; + .remove_indices_for_workspace(workspace_id.clone())?; Ok(()) } } /// Return the views that belong to the workspace. The views are filtered by the trash and all the private views. -pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) -> Vec { +pub(crate) fn get_workspace_public_view_pbs(workspace_id: &Uuid, folder: &Folder) -> Vec { // get the trash ids let trash_ids = folder .get_all_trash_sections() @@ -2056,7 +2076,7 @@ pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) .map(|view| view.id) .collect::>(); - let mut views = folder.get_views_belong_to(workspace_id); + let mut views = folder.get_views_belong_to(&workspace_id.to_string()); // filter the views that are in the trash and all the private views views.retain(|view| !trash_ids.contains(&view.id) && !private_view_ids.contains(&view.id)); @@ -2082,7 +2102,7 @@ fn get_all_child_view_ids(folder: &Folder, view_id: &str) -> Vec { } /// Get the current private views of the user. -pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder) -> Vec { +pub(crate) fn get_workspace_private_view_pbs(workspace_id: &Uuid, folder: &Folder) -> Vec { // get the trash ids let trash_ids = folder .get_all_trash_sections() @@ -2097,7 +2117,7 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder .map(|view| view.id) .collect::>(); - let mut views = folder.get_views_belong_to(workspace_id); + let mut views = folder.get_views_belong_to(&workspace_id.to_string()); // filter the views that are in the trash and not in the private view ids views.retain(|view| !trash_ids.contains(&view.id) && private_view_ids.contains(&view.id)); diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index 4393bfbb29..c319d2ef77 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -10,6 +10,7 @@ use flowy_error::{FlowyError, FlowyResult}; use std::sync::{Arc, Weak}; use tokio::task::spawn_blocking; use tracing::{event, info, Level}; +use uuid::Uuid; impl FolderManager { /// Called immediately after the application launched if the user already sign in/sign up. @@ -17,7 +18,7 @@ impl FolderManager { pub async fn initialize( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, initial_data: FolderInitDataSource, ) -> FlowyResult<()> { // Update the workspace id @@ -37,7 +38,6 @@ impl FolderManager { ); } - let workspace_id = workspace_id.to_string(); // Get the collab db for the user with given user id. let collab_db = self.user.collab_db(uid)?; @@ -54,20 +54,20 @@ impl FolderManager { } => { let is_exist = self .user - .is_folder_exist_on_disk(uid, &workspace_id) + .is_folder_exist_on_disk(uid, workspace_id) .unwrap_or(false); // 1. if the folder exists, open it from local disk if is_exist { event!(Level::INFO, "Init folder from local disk"); self - .make_folder(uid, &workspace_id, collab_db, None, folder_notifier) + .make_folder(uid, workspace_id, collab_db, None, folder_notifier) .await? } else if create_if_not_exist { // 2. if the folder doesn't exist and create_if_not_exist is true, create a default folder // Currently, this branch is only used when the server type is supabase. For appflowy cloud, // the default workspace is already created when the user sign up. self - .create_default_folder(uid, &workspace_id, collab_db, folder_notifier) + .create_default_folder(uid, workspace_id, collab_db, folder_notifier) .await? } else { // 3. If the folder doesn't exist and create_if_not_exist is false, try to fetch the folder data from cloud/ @@ -147,7 +147,7 @@ impl FolderManager { async fn create_default_folder( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, folder_notifier: FolderNotify, ) -> Result>, FlowyError> { @@ -170,24 +170,22 @@ impl FolderManager { Ok(folder) } - fn handle_index_folder(&self, workspace_id: String, folder: &Folder) { + fn handle_index_folder(&self, workspace_id: Uuid, folder: &Folder) { let mut index_all = true; let encoded_collab = self .store_preferences - .get_object::(&workspace_id); + .get_object::(workspace_id.to_string().as_str()); if let Some(encoded_collab) = encoded_collab { if let Ok(changes) = folder.calculate_view_changes(encoded_collab) { let folder_indexer = self.folder_indexer.clone(); let views = folder.get_all_views(); - let wid = workspace_id.clone(); - if !changes.is_empty() && !views.is_empty() { spawn_blocking(move || { // We index the changes - folder_indexer.index_view_changes(views, changes, wid); + folder_indexer.index_view_changes(views, changes, workspace_id); }); index_all = false; } @@ -197,15 +195,13 @@ impl FolderManager { if index_all { let views = folder.get_all_views(); let folder_indexer = self.folder_indexer.clone(); - let wid = workspace_id.clone(); - // We spawn a blocking task to index all views in the folder spawn_blocking(move || { // We remove old indexes just in case - let _ = folder_indexer.remove_indices_for_workspace(wid.clone()); + let _ = folder_indexer.remove_indices_for_workspace(workspace_id.clone()); // We index all views from the workspace - folder_indexer.index_all_views(views, wid); + folder_indexer.index_all_views(views, workspace_id); }); } diff --git a/frontend/rust-lib/flowy-folder/src/manager_observer.rs b/frontend/rust-lib/flowy-folder/src/manager_observer.rs index dec4ff062d..a6faede8b5 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_observer.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_observer.rs @@ -13,14 +13,16 @@ use collab_folder::{ use lib_infra::sync_trace; use std::collections::HashSet; +use std::str::FromStr; use std::sync::Weak; use tokio_stream::wrappers::WatchStream; use tokio_stream::StreamExt; use tracing::{event, trace, Level}; +use uuid::Uuid; /// Listen on the [ViewChange] after create/delete/update events happened pub(crate) fn subscribe_folder_view_changed( - workspace_id: String, + workspace_id: Uuid, mut rx: ViewChangeReceiver, weak_mutex_folder: Weak>, user: Weak, @@ -46,9 +48,10 @@ pub(crate) fn subscribe_folder_view_changed( ChildViewChangeReason::Create, ); let folder = lock.read().await; - let parent_view_id = view.parent_view_id.clone(); - notify_parent_view_did_change(&workspace_id, &folder, vec![parent_view_id]); - sync_trace!("[Folder] create view: {:?}", view); + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + sync_trace!("[Folder] create view: {:?}", view); + } }, ViewChange::DidDeleteView { views } => { for view in views { @@ -69,7 +72,9 @@ pub(crate) fn subscribe_folder_view_changed( ChildViewChangeReason::Update, ); let folder = lock.read().await; - notify_parent_view_did_change(&workspace_id, &folder, vec![view.parent_view_id]); + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + } }, }; } @@ -78,7 +83,7 @@ pub(crate) fn subscribe_folder_view_changed( } pub(crate) fn subscribe_folder_sync_state_changed( - workspace_id: String, + workspace_id: Uuid, mut folder_sync_state_rx: WatchStream, user: Weak, ) { @@ -93,16 +98,19 @@ pub(crate) fn subscribe_folder_sync_state_changed( } } - folder_notification_builder(&workspace_id, FolderNotification::DidUpdateFolderSyncUpdate) - .payload(FolderSyncStatePB::from(state)) - .send(); + folder_notification_builder( + &workspace_id.to_string(), + FolderNotification::DidUpdateFolderSyncUpdate, + ) + .payload(FolderSyncStatePB::from(state)) + .send(); } }); } /// Listen on the [TrashChange]s and notify the frontend some views were changed. pub(crate) fn subscribe_folder_trash_changed( - workspace_id: String, + workspace_id: Uuid, mut rx: SectionChangeReceiver, weak_mutex_folder: Weak>, user: Weak, @@ -131,7 +139,9 @@ pub(crate) fn subscribe_folder_trash_changed( let folder = lock.read().await; let views = folder.get_views(&ids); for view in views { - unique_ids.insert(view.parent_view_id.clone()); + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + unique_ids.insert(parent_view_id); + } } let repeated_trash: RepeatedTrashPB = folder.get_my_trash_info().into(); @@ -140,7 +150,7 @@ pub(crate) fn subscribe_folder_trash_changed( .send(); let parent_view_ids = unique_ids.into_iter().collect(); - notify_parent_view_did_change(&workspace_id, &folder, parent_view_ids); + notify_parent_view_did_change(workspace_id, &folder, parent_view_ids); }, } } @@ -150,10 +160,10 @@ pub(crate) fn subscribe_folder_trash_changed( /// Notify the list of parent view ids that its child views were changed. #[tracing::instrument(level = "debug", skip(folder, parent_view_ids))] -pub(crate) fn notify_parent_view_did_change>( - workspace_id: &str, +pub(crate) fn notify_parent_view_did_change( + workspace_id: Uuid, folder: &Folder, - parent_view_ids: Vec, + parent_view_ids: Vec, ) -> Option<()> { let trash_ids = folder .get_all_trash_sections() @@ -162,24 +172,23 @@ pub(crate) fn notify_parent_view_did_change>( .collect::>(); for parent_view_id in parent_view_ids { - let parent_view_id = parent_view_id.as_ref(); - // if the view's parent id equal to workspace id. Then it will fetch the current // workspace views. Because the workspace is not a view stored in the views map. if parent_view_id == workspace_id { - notify_did_update_workspace(workspace_id, folder); - notify_did_update_section_views(workspace_id, folder); + notify_did_update_workspace(&workspace_id, folder); + notify_did_update_section_views(&workspace_id, folder); } else { // Parent view can contain a list of child views. Currently, only get the first level // child views. - let parent_view = folder.get_view(parent_view_id)?; - let mut child_views = folder.get_views_belong_to(parent_view_id); + let parent_view_id = parent_view_id.to_string(); + let parent_view = folder.get_view(&parent_view_id)?; + let mut child_views = folder.get_views_belong_to(&parent_view_id); child_views.retain(|view| !trash_ids.contains(&view.id)); event!(Level::DEBUG, child_views_count = child_views.len()); // Post the notification let parent_view_pb = view_pb_with_child_views(parent_view, child_views); - folder_notification_builder(parent_view_id, FolderNotification::DidUpdateView) + folder_notification_builder(&parent_view_id, FolderNotification::DidUpdateView) .payload(parent_view_pb) .send(); } @@ -188,7 +197,7 @@ pub(crate) fn notify_parent_view_did_change>( None } -pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folder) { +pub(crate) fn notify_did_update_section_views(workspace_id: &Uuid, folder: &Folder) { let public_views = get_workspace_public_view_pbs(workspace_id, folder); let private_views = get_workspace_private_view_pbs(workspace_id, folder); trace!( @@ -214,7 +223,7 @@ pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folde .send(); } -pub(crate) fn notify_did_update_workspace(workspace_id: &str, folder: &Folder) { +pub(crate) fn notify_did_update_workspace(workspace_id: &Uuid, folder: &Folder) { let repeated_view: RepeatedViewPB = get_workspace_public_view_pbs(workspace_id, folder).into(); folder_notification_builder(workspace_id, FolderNotification::DidUpdateWorkspaceViews) .payload(repeated_view) diff --git a/frontend/rust-lib/flowy-folder/src/notification.rs b/frontend/rust-lib/flowy-folder/src/notification.rs index 8d9bfd5dea..5629ef4133 100644 --- a/frontend/rust-lib/flowy-folder/src/notification.rs +++ b/frontend/rust-lib/flowy-folder/src/notification.rs @@ -1,6 +1,7 @@ use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; use lib_dispatch::prelude::ToBytes; +use tracing::trace; const FOLDER_OBSERVABLE_SOURCE: &str = "Workspace"; @@ -68,9 +69,14 @@ impl std::convert::From for FolderNotification { } } -#[tracing::instrument(level = "trace")] -pub(crate) fn folder_notification_builder(id: &str, ty: FolderNotification) -> NotificationBuilder { - NotificationBuilder::new(id, ty, FOLDER_OBSERVABLE_SOURCE) +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn folder_notification_builder( + id: T, + ty: FolderNotification, +) -> NotificationBuilder { + let id = id.to_string(); + trace!("folder_notification_builder: id = {id}, ty = {ty:?}"); + NotificationBuilder::new(&id, ty, FOLDER_OBSERVABLE_SOURCE) } /// The [CURRENT_WORKSPACE] represents as the current workspace that opened by the diff --git a/frontend/rust-lib/flowy-folder/src/share/import.rs b/frontend/rust-lib/flowy-folder/src/share/import.rs index 2abac3540d..6fd8d8feab 100644 --- a/frontend/rust-lib/flowy-folder/src/share/import.rs +++ b/frontend/rust-lib/flowy-folder/src/share/import.rs @@ -1,5 +1,6 @@ use collab_folder::ViewLayout; use std::fmt::{Display, Formatter}; +use uuid::Uuid; #[derive(Clone, Debug)] pub enum ImportType { @@ -35,6 +36,6 @@ impl Display for ImportData { #[derive(Clone, Debug)] pub struct ImportParams { - pub parent_view_id: String, + pub parent_view_id: Uuid, pub items: Vec, } diff --git a/frontend/rust-lib/flowy-folder/src/util.rs b/frontend/rust-lib/flowy-folder/src/util.rs index 89d49f8a23..98b87be52d 100644 --- a/frontend/rust-lib/flowy-folder/src/util.rs +++ b/frontend/rust-lib/flowy-folder/src/util.rs @@ -1,11 +1,12 @@ use crate::entities::UserFolderPB; use flowy_error::{ErrorCode, FlowyError}; +use uuid::Uuid; pub(crate) fn folder_not_init_error() -> FlowyError { FlowyError::internal().with_context("Folder not initialized") } -pub(crate) fn workspace_data_not_sync_error(uid: i64, workspace_id: &str) -> FlowyError { +pub(crate) fn workspace_data_not_sync_error(uid: i64, workspace_id: &Uuid) -> FlowyError { FlowyError::from(ErrorCode::WorkspaceDataNotSync).with_payload(UserFolderPB { uid, workspace_id: workspace_id.to_string(), diff --git a/frontend/rust-lib/flowy-folder/src/view_operation.rs b/frontend/rust-lib/flowy-folder/src/view_operation.rs index 2b0a9667c9..17919e07b1 100644 --- a/frontend/rust-lib/flowy-folder/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder/src/view_operation.rs @@ -6,11 +6,11 @@ use collab_folder::hierarchy_builder::NestedViewBuilder; pub use collab_folder::View; use collab_folder::ViewLayout; use dashmap::DashMap; +use flowy_error::FlowyError; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; - -use flowy_error::FlowyError; +use uuid::Uuid; use lib_infra::util::timestamp; @@ -51,23 +51,23 @@ pub trait FolderOperationHandler: Send + Sync { Ok(()) } - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError>; + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Closes the view and releases the resources that this view has in /// the backend - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError>; + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Called when the view is deleted. /// This will called after the view is deleted from the trash. - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError>; + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Returns the [ViewData] that can be used to create the same view. - async fn duplicate_view(&self, view_id: &str) -> Result; + async fn duplicate_view(&self, view_id: &Uuid) -> Result; /// get the encoded collab data from the disk. async fn gather_publish_encode_collab( &self, _user: &Arc, - _view_id: &str, + _view_id: &Uuid, ) -> Result { Err(FlowyError::not_support()) } @@ -102,8 +102,8 @@ pub trait FolderOperationHandler: Send + Sync { async fn create_default_view( &self, user_id: i64, - parent_view_id: &str, - view_id: &str, + parent_view_id: &Uuid, + view_id: &Uuid, name: &str, layout: ViewLayout, ) -> Result<(), FlowyError>; @@ -114,7 +114,7 @@ pub trait FolderOperationHandler: Send + Sync { async fn import_from_bytes( &self, uid: i64, - view_id: &str, + view_id: &Uuid, name: &str, import_type: ImportType, bytes: Vec, @@ -152,8 +152,8 @@ impl From for ViewLayout { pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout) -> View { let time = timestamp(); View { - id: params.view_id, - parent_view_id: params.parent_view_id, + id: params.view_id.to_string(), + parent_view_id: params.parent_view_id.to_string(), name: params.name, created_at: time, is_favorite: false, diff --git a/frontend/rust-lib/flowy-search-pub/Cargo.toml b/frontend/rust-lib/flowy-search-pub/Cargo.toml index 631f2d2c83..907942303d 100644 --- a/frontend/rust-lib/flowy-search-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-search-pub/Cargo.toml @@ -11,4 +11,4 @@ collab = { workspace = true } collab-folder = { workspace = true } flowy-error = { workspace = true } client-api = { workspace = true } -futures = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-search-pub/src/cloud.rs b/frontend/rust-lib/flowy-search-pub/src/cloud.rs index f2ffb3c439..b62e226b92 100644 --- a/frontend/rust-lib/flowy-search-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-search-pub/src/cloud.rs @@ -1,12 +1,13 @@ use client_api::entity::search_dto::SearchDocumentResponseItem; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use uuid::Uuid; #[async_trait] pub trait SearchCloudService: Send + Sync + 'static { async fn document_search( &self, - workspace_id: &str, + workspace_id: &Uuid, query: String, ) -> Result, FlowyError>; } diff --git a/frontend/rust-lib/flowy-search-pub/src/entities.rs b/frontend/rust-lib/flowy-search-pub/src/entities.rs index 65e23a9ddb..b7145e993f 100644 --- a/frontend/rust-lib/flowy-search-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-search-pub/src/entities.rs @@ -4,44 +4,45 @@ use std::sync::Arc; use collab::core::collab::IndexContentReceiver; use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewLayout}; use flowy_error::FlowyError; +use uuid::Uuid; pub struct IndexableData { pub id: String, pub data: String, pub icon: Option, pub layout: ViewLayout, - pub workspace_id: String, + pub workspace_id: Uuid, } impl IndexableData { - pub fn from_view(view: Arc, workspace_id: String) -> Self { + pub fn from_view(view: Arc, workspace_id: Uuid) -> Self { IndexableData { id: view.id.clone(), data: view.name.clone(), icon: view.icon.clone(), layout: view.layout.clone(), - workspace_id: workspace_id.clone(), + workspace_id, } } } pub trait IndexManager: Send + Sync { - fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: String); + fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: Uuid); fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; - fn remove_indices_for_workspace(&self, workspace_id: String) -> Result<(), FlowyError>; + fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError>; fn is_indexed(&self) -> bool; fn as_any(&self) -> &dyn Any; } pub trait FolderIndexManager: IndexManager { - fn index_all_views(&self, views: Vec>, workspace_id: String); + fn index_all_views(&self, views: Vec>, workspace_id: Uuid); fn index_view_changes( &self, views: Vec>, changes: Vec, - workspace_id: String, + workspace_id: Uuid, ); } diff --git a/frontend/rust-lib/flowy-search/Cargo.toml b/frontend/rust-lib/flowy-search/Cargo.toml index f4d6207783..dd313d1aea 100644 --- a/frontend/rust-lib/flowy-search/Cargo.toml +++ b/frontend/rust-lib/flowy-search/Cargo.toml @@ -18,7 +18,6 @@ flowy-error = { workspace = true, features = [ "impl_from_serde", ] } flowy-notification.workspace = true -flowy-sqlite.workspace = true flowy-user.workspace = true flowy-search-pub.workspace = true flowy-folder = { workspace = true } @@ -37,12 +36,7 @@ async-stream = "0.3.4" strsim = "0.11.0" strum_macros = "0.26.1" tantivy = { version = "0.22.0" } -tempfile = "3.9.0" -validator = { workspace = true, features = ["derive"] } - -diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } -diesel_migrations = { version = "2.1.0", features = ["sqlite"] } +uuid.workspace = true [build-dependencies] flowy-codegen.workspace = true diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index 4f963033e0..d447d27dfb 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,10 +1,11 @@ -use std::sync::Arc; -use tracing::{trace, warn}; - use flowy_error::FlowyResult; use flowy_folder::{manager::FolderManager, ViewLayout}; use flowy_search_pub::cloud::SearchCloudService; use lib_infra::async_trait::async_trait; +use std::str::FromStr; +use std::sync::Arc; +use tracing::{trace, warn}; +use uuid::Uuid; use crate::{ entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResultPB}, @@ -49,6 +50,7 @@ impl SearchHandler for DocumentSearchHandler { None => return Ok(vec![]), }; + let workspace_id = Uuid::from_str(&workspace_id)?; let results = self .cloud_service .document_search(&workspace_id, query) @@ -61,7 +63,7 @@ impl SearchHandler for DocumentSearchHandler { let mut search_results: Vec = vec![]; for result in results { - if let Some(view) = views.iter().find(|v| v.id == result.object_id) { + if let Some(view) = views.iter().find(|v| v.id == result.object_id.to_string()) { // If there is no View for the result, we don't add it to the results // If possible we will extract the icon to display for the result let icon: Option = match view.icon.clone() { @@ -77,12 +79,12 @@ impl SearchHandler for DocumentSearchHandler { search_results.push(SearchResultPB { index_type: IndexTypePB::Document, - view_id: result.object_id.clone(), - id: result.object_id.clone(), + view_id: result.object_id.to_string(), + id: result.object_id.to_string(), data: view.name.clone(), icon, score: result.score, - workspace_id: result.workspace_id, + workspace_id: result.workspace_id.to_string(), preview: result.preview, }); } else { diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 8c1d5633ac..509abcb955 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -20,13 +20,13 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_search_pub::entities::{FolderIndexManager, IndexManager, IndexableData}; use flowy_user::services::authenticate_user::AuthenticateUser; +use super::entities::FolderIndexData; use strsim::levenshtein; use tantivy::{ collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, Index, IndexReader, IndexWriter, TantivyDocument, Term, }; - -use super::entities::FolderIndexData; +use uuid::Uuid; #[derive(Clone)] pub struct FolderIndexManagerImpl { @@ -139,7 +139,7 @@ impl FolderIndexManagerImpl { title_field => data.data.clone(), icon_field => icon.unwrap_or_default(), icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.clone(), + workspace_id_field => data.workspace_id.to_string(), ]); } @@ -293,7 +293,7 @@ impl IndexManager for FolderIndexManagerImpl { .unwrap_or(false) } - fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: String) { + fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: Uuid) { let indexer = self.clone(); let wid = workspace_id.clone(); tokio::spawn(async move { @@ -352,7 +352,7 @@ impl IndexManager for FolderIndexManagerImpl { title_field => data.data, icon_field => icon.unwrap_or_default(), icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.clone(), + workspace_id_field => data.workspace_id.to_string(), ]); index_writer.commit()?; @@ -389,7 +389,7 @@ impl IndexManager for FolderIndexManagerImpl { title_field => data.data, icon_field => icon.unwrap_or_default(), icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id, + workspace_id_field => data.workspace_id.to_string(), ]); index_writer.commit()?; @@ -400,14 +400,14 @@ impl IndexManager for FolderIndexManagerImpl { /// Removes all indexes that are related by workspace id. This is useful /// for cleaning indexes when eg. removing/leaving a workspace. /// - fn remove_indices_for_workspace(&self, workspace_id: String) -> Result<(), FlowyError> { + fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError> { let mut index_writer = self.get_index_writer()?; let folder_schema = self.get_folder_schema()?; let id_field = folder_schema .schema .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - let delete_term = Term::from_field_text(id_field, &workspace_id); + let delete_term = Term::from_field_text(id_field, &workspace_id.to_string()); index_writer.delete_term(delete_term); index_writer.commit()?; @@ -421,7 +421,7 @@ impl IndexManager for FolderIndexManagerImpl { } impl FolderIndexManager for FolderIndexManagerImpl { - fn index_all_views(&self, views: Vec>, workspace_id: String) { + fn index_all_views(&self, views: Vec>, workspace_id: Uuid) { let indexable_data = views .into_iter() .map(|view| IndexableData::from_view(view, workspace_id.clone())) @@ -434,7 +434,7 @@ impl FolderIndexManager for FolderIndexManagerImpl { &self, views: Vec>, changes: Vec, - workspace_id: String, + workspace_id: Uuid, ) { let mut views_iter = views.into_iter(); for change in changes { diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index b0f09b1530..db75ea93fe 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,4 +1,5 @@ use flowy_error::FlowyResult; +use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; pub const USER_UUID: &str = "uuid"; @@ -8,5 +9,5 @@ pub const USER_DEVICE_ID: &str = "device_id"; /// Represents a user that is currently using the server. pub trait ServerUser: Send + Sync { /// different user might return different workspace id. - fn workspace_id(&self) -> FlowyResult; + fn workspace_id(&self) -> FlowyResult; } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index e14f247d88..6f5edc93cc 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -18,6 +18,7 @@ use serde_json::Value; use std::collections::HashMap; use std::path::Path; use tracing::trace; +use uuid::Uuid; pub(crate) struct AFCloudChatCloudServiceImpl { pub inner: T, @@ -30,10 +31,10 @@ where { async fn create_chat( &self, - _uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, ) -> Result<(), FlowyError> { let chat_id = chat_id.to_string(); let try_get_client = self.inner.try_get_client(); @@ -52,13 +53,12 @@ where async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, metadata: &[ChatMessageMetadata], ) -> Result { - let workspace_id = workspace_id.to_string(); let chat_id = chat_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = CreateChatMessageParams { @@ -68,7 +68,7 @@ where }; let message = try_get_client? - .create_question(&workspace_id, &chat_id, params) + .create_question(workspace_id, &chat_id, params) .await .map_err(FlowyError::from)?; Ok(message) @@ -76,8 +76,8 @@ where async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -89,7 +89,7 @@ where question_message_id: question_id, }; let message = try_get_client? - .save_answer(workspace_id, chat_id, params) + .save_answer(workspace_id, chat_id.to_string().as_str(), params) .await .map_err(FlowyError::from)?; Ok(message) @@ -97,8 +97,8 @@ where async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, format: ResponseFormat, ai_model: Option, @@ -129,13 +129,17 @@ where async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, question_message_id: i64, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_answer(workspace_id, chat_id, question_message_id) + .get_answer( + workspace_id, + chat_id.to_string().as_str(), + question_message_id, + ) .await .map_err(FlowyError::from)?; Ok(resp) @@ -143,14 +147,14 @@ where async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_chat_messages(workspace_id, chat_id, offset, limit) + .get_chat_messages(workspace_id, chat_id.to_string().as_str(), offset, limit) .await .map_err(FlowyError::from)?; @@ -159,13 +163,17 @@ where async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, answer_message_id: i64, ) -> Result { let try_get_client = self.inner.try_get_client()?; let resp = try_get_client - .get_question_message_from_answer_id(workspace_id, chat_id, answer_message_id) + .get_question_message_from_answer_id( + workspace_id, + chat_id.to_string().as_str(), + answer_message_id, + ) .await .map_err(FlowyError::from)? .ok_or_else(FlowyError::record_not_found)?; @@ -175,13 +183,13 @@ where async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_chat_related_question(workspace_id, chat_id, message_id) + .get_chat_related_question(workspace_id, chat_id.to_string().as_str(), message_id) .await .map_err(FlowyError::from)?; @@ -190,7 +198,7 @@ where async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, ai_model: Option, ) -> Result { @@ -207,10 +215,10 @@ where async fn embed_file( &self, - _workspace_id: &str, - _file_path: &Path, - _chat_id: &str, - _metadata: Option>, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, ) -> Result<(), FlowyError> { return Err( FlowyError::not_support() @@ -218,7 +226,7 @@ where ); } - async fn get_local_ai_config(&self, workspace_id: &str) -> Result { + async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { let system = get_operating_system(); let platform = match system { OperatingSystem::MacOS => "macos", @@ -232,51 +240,51 @@ where let config = self .inner .try_get_client()? - .get_local_ai_config(workspace_id, platform) + .get_local_ai_config(workspace_id.to_string().as_str(), platform) .await?; Ok(config) } async fn get_workspace_plan( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { let plans = self .inner .try_get_client()? - .get_active_workspace_subscriptions(workspace_id) + .get_active_workspace_subscriptions(workspace_id.to_string().as_str()) .await?; Ok(plans) } async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { let settings = self .inner .try_get_client()? - .get_chat_settings(workspace_id, chat_id) + .get_chat_settings(workspace_id, chat_id.to_string().as_str()) .await?; Ok(settings) } async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError> { self .inner .try_get_client()? - .update_chat_settings(workspace_id, chat_id, params) + .update_chat_settings(workspace_id, chat_id.to_string().as_str(), params) .await?; Ok(()) } - async fn get_available_models(&self, workspace_id: &str) -> Result { + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { let list = self .inner .try_get_client()? @@ -285,11 +293,11 @@ where Ok(list) } - async fn get_workspace_default_model(&self, workspace_id: &str) -> Result { + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { let setting = self .inner .try_get_client()? - .get_workspace_settings(workspace_id) + .get_workspace_settings(workspace_id.to_string().as_str()) .await?; Ok(setting.ai_model) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index 51e01bf4c1..f862c57fc4 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -1,3 +1,6 @@ +use crate::af_cloud::define::ServerUser; +use crate::af_cloud::impls::util::check_request_workspace_id_is_match; +use crate::af_cloud::AFServer; use client_api::entity::ai_dto::{ SummarizeRowData, SummarizeRowParams, TranslateRowData, TranslateRowParams, }; @@ -6,20 +9,16 @@ use client_api::entity::{CreateCollabParams, QueryCollab, QueryCollabParams}; use client_api::error::ErrorCode::RecordNotFound; use collab::entity::EncodedCollab; use collab_entity::CollabType; -use serde_json::{Map, Value}; -use std::sync::Arc; -use tracing::{error, instrument}; - use flowy_database_pub::cloud::{ DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, TranslateRowContent, TranslateRowResponse, }; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; - -use crate::af_cloud::define::ServerUser; -use crate::af_cloud::impls::util::check_request_workspace_id_is_match; -use crate::af_cloud::AFServer; +use serde_json::{Map, Value}; +use std::sync::Arc; +use tracing::{error, instrument}; +use uuid::Uuid; pub(crate) struct AFCloudDatabaseCloudServiceImpl { pub inner: T, @@ -35,12 +34,10 @@ where #[allow(clippy::blocks_in_conditions)] async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); - let object_id = object_id.to_string(); let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { @@ -51,7 +48,7 @@ where match result { Ok(data) => { check_request_workspace_id_is_match( - &workspace_id, + workspace_id, &cloned_user, format!("get database object: {}:{}", object_id, collab_type), )?; @@ -71,17 +68,17 @@ where #[allow(clippy::blocks_in_conditions)] async fn create_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let encoded_collab_v1 = encoded_collab .encode_to_bytes() .map_err(|err| FlowyError::internal().with_context(err))?; let params = CreateCollabParams { - workspace_id: workspace_id.to_string(), - object_id: object_id.to_string(), + workspace_id: workspace_id.clone(), + object_id: object_id.clone(), encoded_collab_v1, collab_type, }; @@ -92,11 +89,10 @@ where #[instrument(level = "debug", skip_all)] async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let client = try_get_client?; @@ -104,8 +100,8 @@ where .into_iter() .map(|object_id| QueryCollab::new(object_id, object_ty)) .collect(); - let results = client.batch_get_collab(&workspace_id, params).await?; - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "batch get database object")?; + let results = client.batch_get_collab(workspace_id, params).await?; + check_request_workspace_id_is_match(workspace_id, &cloned_user, "batch get database object")?; Ok( results .0 @@ -131,8 +127,8 @@ where async fn get_database_collab_object_snapshots( &self, - _object_id: &str, - _limit: usize, + object_id: &Uuid, + limit: usize, ) -> Result, FlowyError> { Ok(vec![]) } @@ -145,17 +141,17 @@ where { async fn summary_database_row( &self, - workspace_id: &str, - _object_id: &str, - summary_row: SummaryRowContent, + workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, ) -> Result { let try_get_client = self.inner.try_get_client(); - let map: Map = summary_row + let map: Map = _summary_row .into_iter() .map(|(key, value)| (key, Value::String(value))) .collect(); let params = SummarizeRowParams { - workspace_id: workspace_id.to_string(), + workspace_id: workspace_id.clone(), data: SummarizeRowData::Content(map), }; let data = try_get_client?.summarize_row(params).await?; @@ -164,19 +160,21 @@ where async fn translate_database_row( &self, - workspace_id: &str, - translate_row: TranslateRowContent, - language: &str, + workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, ) -> Result { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let data = TranslateRowData { - cells: translate_row, - language: language.to_string(), + cells: _translate_row, + language: _language.to_string(), include_header: false, }; - let params = TranslateRowParams { workspace_id, data }; + let params = TranslateRowParams { + workspace_id: workspace_id.to_string(), + data, + }; let data = try_get_client?.translate_row(params).await?; Ok(data) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index d73bbe4c75..e5e271b1e5 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -5,12 +5,12 @@ use collab::entity::EncodedCollab; use collab::preclude::Collab; use collab_document::document::Document; use collab_entity::CollabType; -use std::sync::Arc; -use tracing::instrument; - use flowy_document_pub::cloud::*; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use std::sync::Arc; +use tracing::instrument; +use uuid::Uuid; use crate::af_cloud::define::ServerUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; @@ -29,12 +29,12 @@ where #[instrument(level = "debug", skip_all, fields(document_id = %document_id))] async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let params = QueryCollabParams { - workspace_id: workspace_id.to_string(), - inner: QueryCollab::new(document_id.to_string(), CollabType::Document), + workspace_id: workspace_id.clone(), + inner: QueryCollab::new(document_id.clone(), CollabType::Document), }; let doc_state = self .inner @@ -57,9 +57,9 @@ where async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, + document_id: &Uuid, + limit: usize, + workspace_id: &str, ) -> Result, FlowyError> { Ok(vec![]) } @@ -67,12 +67,12 @@ where #[instrument(level = "debug", skip_all)] async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let params = QueryCollabParams { - workspace_id: workspace_id.to_string(), - inner: QueryCollab::new(document_id.to_string(), CollabType::Document), + workspace_id: workspace_id.clone(), + inner: QueryCollab::new(document_id.clone(), CollabType::Document), }; let doc_state = self .inner @@ -89,7 +89,7 @@ where )?; let collab = Collab::new_with_source( CollabOrigin::Empty, - document_id, + document_id.to_string().as_str(), DataSource::DocStateV1(doc_state), vec![], false, @@ -100,13 +100,13 @@ where async fn create_document_collab( &self, - workspace_id: &str, - document_id: &str, + workspace_id: &Uuid, + document_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let params = CreateCollabParams { - workspace_id: workspace_id.to_string(), - object_id: document_id.to_string(), + workspace_id: workspace_id.clone(), + object_id: document_id.clone(), encoded_collab_v1: encoded_collab .encode_to_bytes() .map_err(|err| FlowyError::internal().with_context(err))?, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs index 9f4e15f430..8db806a0da 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs @@ -1,9 +1,10 @@ use crate::af_cloud::AFServer; use client_api::entity::{CompleteUploadRequest, CreateUploadRequest}; -use flowy_error::{ErrorCode, FlowyError}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct AFCloudFileStorageServiceImpl { pub client: T, @@ -56,10 +57,10 @@ where async fn get_object_url_v1( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, - ) -> Result { + ) -> FlowyResult { let url = self .client .try_get_client()? @@ -67,14 +68,14 @@ where Ok(url) } - async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)> { + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { let value = self.client.try_get_client().ok()?.parse_blob_url_v1(url)?; Some(value) } async fn create_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, content_type: &str, @@ -109,7 +110,7 @@ where async fn upload_part( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -134,7 +135,7 @@ where async fn complete_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index 5457164a87..3a912306fc 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -58,12 +58,10 @@ where }) } - async fn open_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.inner.try_get_client(); - let client = try_get_client?; - let _ = client.open_workspace(&workspace_id).await?; + let _ = client.open_workspace(workspace_id).await?; Ok(()) } @@ -88,11 +86,10 @@ where #[instrument(level = "debug", skip_all)] async fn get_folder_data( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: &i64, ) -> Result, FlowyError> { let uid = *uid; - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { @@ -111,10 +108,10 @@ where uid, CollabOrigin::Empty, DataSource::DocStateV1(doc_state), - &workspace_id, + &workspace_id.to_string(), vec![], )?; - Ok(folder.get_folder_data(&workspace_id)) + Ok(folder.get_folder_data(&workspace_id.to_string())) } async fn get_folder_snapshots( @@ -128,18 +125,16 @@ where #[instrument(level = "debug", skip_all)] async fn get_folder_doc_state( &self, - workspace_id: &str, - _uid: i64, + workspace_id: &Uuid, + uid: i64, collab_type: CollabType, - object_id: &str, + object_id: &Uuid, ) -> Result, FlowyError> { - let object_id = object_id.to_string(); - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id, collab_type), + inner: QueryCollab::new(object_id.clone(), collab_type), }; let doc_state = try_get_client? .get_collab(params) @@ -154,10 +149,9 @@ where async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, params: FullSyncCollabParams, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); try_get_client? .collab_full_sync( @@ -173,10 +167,9 @@ where async fn batch_create_folder_collab_objects( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = objects .into_iter() @@ -189,7 +182,7 @@ where }) .collect::>(); try_get_client? - .create_collab_list(&workspace_id, params) + .create_collab_list(workspace_id, params) .await?; Ok(()) } @@ -200,10 +193,9 @@ where async fn publish_view( &self, - workspace_id: &str, + workspace_id: &Uuid, payload: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = payload .into_iter() @@ -235,29 +227,20 @@ where async fn unpublish_views( &self, - workspace_id: &str, - view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); - let view_uuids = view_ids - .iter() - .map(|id| Uuid::parse_str(id).unwrap_or(Uuid::nil())) - .collect::>(); try_get_client? - .unpublish_collabs(&workspace_id, &view_uuids) + .unpublish_collabs(&workspace_id, &view_ids) .await?; Ok(()) } - async fn get_publish_info(&self, view_id: &str) -> Result { + async fn get_publish_info(&self, view_id: &Uuid) -> Result { let try_get_client = self.inner.try_get_client(); - let view_id = Uuid::parse_str(view_id) - .map_err(|_| FlowyError::new(ErrorCode::InvalidParams, "Invalid view id")); - - let view_id = view_id?; let info = try_get_client? - .get_published_collab_info(&view_id) + .get_published_collab_info(view_id) .await .map_err(FlowyError::from)?; Ok(info) @@ -265,14 +248,11 @@ where async fn set_publish_name( &self, - workspace_id: &str, - view_id: String, + workspace_id: &Uuid, + view_id: Uuid, new_name: String, ) -> Result<(), FlowyError> { let try_get_client = self.inner.try_get_client()?; - let view_id = Uuid::parse_str(&view_id) - .map_err(|_| FlowyError::new(ErrorCode::InvalidParams, "Invalid view id"))?; - try_get_client .patch_published_collabs( workspace_id, @@ -290,36 +270,33 @@ where async fn set_publish_namespace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_namespace: String, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); try_get_client? - .set_workspace_publish_namespace(&workspace_id, new_namespace) + .set_workspace_publish_namespace(workspace_id, new_namespace) .await?; Ok(()) } - async fn get_publish_namespace(&self, workspace_id: &str) -> Result { - let workspace_id = workspace_id.to_string(); + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { let namespace = self .inner .try_get_client()? - .get_workspace_publish_namespace(&workspace_id) + .get_workspace_publish_namespace(workspace_id) .await?; Ok(namespace) } async fn list_published_views( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); let published_views = self .inner .try_get_client()? - .list_published_views(&workspace_id) + .list_published_views(workspace_id) .await .map_err(FlowyError::from)?; Ok(published_views) @@ -327,7 +304,7 @@ where async fn get_default_published_view_info( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { let default_published_view_info = self .inner @@ -340,7 +317,7 @@ where async fn set_default_published_view( &self, - workspace_id: &str, + workspace_id: &Uuid, view_id: uuid::Uuid, ) -> Result<(), FlowyError> { self @@ -352,7 +329,7 @@ where Ok(()) } - async fn remove_default_published_view(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { self .inner .try_get_client()? diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs index 552a94068a..35fa6b153a 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs @@ -2,6 +2,7 @@ use client_api::entity::search_dto::SearchDocumentResponseItem; use flowy_error::FlowyError; use flowy_search_pub::cloud::SearchCloudService; use lib_infra::async_trait::async_trait; +use uuid::Uuid; use crate::af_cloud::AFServer; @@ -22,7 +23,7 @@ where { async fn document_search( &self, - workspace_id: &str, + workspace_id: &Uuid, query: String, ) -> Result, FlowyError> { let client = self.inner.try_get_client()?; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 208281fc5f..45ba817bce 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::str::FromStr; use std::sync::Arc; use anyhow::anyhow; @@ -189,9 +190,8 @@ where Ok(profile) } - async fn open_workspace(&self, workspace_id: &str) -> Result { + async fn open_workspace(&self, workspace_id: &Uuid) -> Result { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); let client = try_get_client?; let af_workspace = client.open_workspace(&workspace_id).await?; Ok(to_user_workspace(af_workspace)) @@ -222,17 +222,14 @@ where async fn patch_workspace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_workspace_name: Option<&str>, new_workspace_icon: Option<&str>, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let owned_workspace_id = workspace_id.to_owned(); + let workspace_id = workspace_id.to_owned(); let owned_workspace_name = new_workspace_name.map(|s| s.to_owned()); let owned_workspace_icon = new_workspace_icon.map(|s| s.to_owned()); - let workspace_id: Uuid = owned_workspace_id - .parse() - .map_err(|_| ErrorCode::InvalidParams)?; let client = try_get_client?; client .patch_workspace(PatchWorkspaceParam { @@ -244,18 +241,17 @@ where Ok(()) } - async fn delete_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let workspace_id_owned = workspace_id.to_owned(); let client = try_get_client?; - client.delete_workspace(&workspace_id_owned).await?; + client.delete_workspace(workspace_id).await?; Ok(()) } async fn invite_workspace_member( &self, invitee_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); @@ -300,11 +296,11 @@ where async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); try_get_client? - .remove_workspace_members(workspace_id, vec![user_email]) + .remove_workspace_members(&workspace_id, vec![user_email]) .await?; Ok(()) } @@ -312,20 +308,20 @@ where async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); let changeset = WorkspaceMemberChangeset::new(user_email).with_role(to_af_role(role)); try_get_client? - .update_workspace_member(workspace_id, changeset) + .update_workspace_member(&workspace_id, changeset) .await?; Ok(()) } async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let members = try_get_client? @@ -339,15 +335,12 @@ where async fn get_workspace_member( &self, - workspace_id: String, + workspace_id: Uuid, uid: i64, ) -> Result { let try_get_client = self.server.try_get_client(); let client = try_get_client?; - let query = QueryWorkspaceMember { - workspace_id: workspace_id.clone(), - uid, - }; + let query = QueryWorkspaceMember { workspace_id, uid }; let member = client.get_workspace_member(query).await?; Ok(from_af_workspace_member(member)) } @@ -355,17 +348,15 @@ where #[instrument(level = "debug", skip_all)] async fn get_user_awareness_doc_state( &self, - _uid: i64, - workspace_id: &str, - object_id: &str, + uid: i64, + workspace_id: &Uuid, + object_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); - let object_id = object_id.to_string(); let try_get_client = self.server.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id, CollabType::UserAwareness), + inner: QueryCollab::new(object_id.clone(), CollabType::UserAwareness), }; let resp = try_get_client?.get_collab(params).await?; check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get user awareness object")?; @@ -389,9 +380,12 @@ where let try_get_client = self.server.try_get_client(); let collab_object = collab_object.clone(); let client = try_get_client?; + let workspace_id = Uuid::from_str(&collab_object.workspace_id)?; + let object_id = Uuid::from_str(&collab_object.object_id)?; + let params = CreateCollabParams { - workspace_id: collab_object.workspace_id, - object_id: collab_object.object_id, + workspace_id, + object_id, collab_type: collab_object.collab_type, encoded_collab_v1: data, }; @@ -401,33 +395,35 @@ where async fn batch_create_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.server.try_get_client(); let params = objects .into_iter() - .map(|object| { - CollabParams::new( - object.object_id, - u8::from(object.collab_type).into(), - object.encoded_collab, - ) + .flat_map(|object| { + Uuid::from_str(&object.object_id) + .and_then(|object_id| { + Ok(CollabParams::new( + object_id, + u8::from(object.collab_type).into(), + object.encoded_collab, + )) + }) + .ok() }) .collect::>(); try_get_client? - .create_collab_list(&workspace_id, params) + .create_collab_list(workspace_id, params) .await .map_err(FlowyError::from)?; Ok(()) } - async fn leave_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); let client = try_get_client?; - client.leave_workspace(&workspace_id).await?; + client.leave_workspace(workspace_id).await?; Ok(()) } @@ -454,14 +450,13 @@ where async fn get_workspace_member_info( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, ) -> Result { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); let client = try_get_client?; let params = QueryWorkspaceMember { - workspace_id: workspace_id.to_string(), + workspace_id: workspace_id.clone(), uid, }; let member = client.get_workspace_member(params).await?; @@ -518,12 +513,12 @@ where async fn get_workspace_plan( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let client = try_get_client?; let plans = client - .get_active_workspace_subscriptions(&workspace_id) + .get_active_workspace_subscriptions(&workspace_id.to_string()) .await?; Ok(plans) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs index 4075a5b908..0d91de8412 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs @@ -2,21 +2,22 @@ use crate::af_cloud::define::ServerUser; use flowy_error::{FlowyError, FlowyResult}; use std::sync::Arc; use tracing::warn; +use uuid::Uuid; /// Validates the workspace_id provided in the request. /// It checks that the workspace_id from the request matches the current user's active workspace_id. /// This ensures that the operation is being performed in the correct workspace context, enhancing security. pub fn check_request_workspace_id_is_match( - expected_workspace_id: &str, + expected_workspace_id: &Uuid, user: &Arc, action: impl AsRef, ) -> FlowyResult<()> { let actual_workspace_id = user.workspace_id()?; - if expected_workspace_id != actual_workspace_id { + if expected_workspace_id != &actual_workspace_id { warn!( "{}, expect workspace_id: {}, actual workspace_id: {}", action.as_ref(), - expected_workspace_id, + expected_workspace_id.to_string(), actual_workspace_id ); diff --git a/frontend/rust-lib/flowy-server/src/default_impl.rs b/frontend/rust-lib/flowy-server/src/default_impl.rs index 7dca08754b..73633216b3 100644 --- a/frontend/rust-lib/flowy-server/src/default_impl.rs +++ b/frontend/rust-lib/flowy-server/src/default_impl.rs @@ -9,6 +9,7 @@ use lib_infra::async_trait::async_trait; use serde_json::Value; use std::collections::HashMap; use std::path::Path; +use uuid::Uuid; pub(crate) struct DefaultChatCloudServiceImpl; @@ -16,104 +17,104 @@ pub(crate) struct DefaultChatCloudServiceImpl; impl ChatCloudService for DefaultChatCloudServiceImpl { async fn create_chat( &self, - _uid: &i64, - _workspace_id: &str, - _chat_id: &str, - _rag_ids: Vec, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, ) -> Result<(), FlowyError> { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn create_question( &self, - _workspace_id: &str, - _chat_id: &str, - _message: &str, - _message_type: ChatMessageType, - _metadata: &[ChatMessageMetadata], + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + metadata: &[ChatMessageMetadata], ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn create_answer( &self, - _workspace_id: &str, - _chat_id: &str, - _message: &str, - _question_id: i64, - _metadata: Option, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn stream_answer( &self, - _workspace_id: &str, - _chat_id: &str, - _message_id: i64, - _format: ResponseFormat, - _ai_model: Option, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + format: ResponseFormat, + ai_model: Option, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_chat_messages( &self, - _workspace_id: &str, - _chat_id: &str, - _offset: MessageCursor, - _limit: u64, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_question_from_answer_id( &self, - _workspace_id: &str, - _chat_id: &str, - _answer_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_related_message( &self, - _workspace_id: &str, - _chat_id: &str, - _message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_answer( &self, - _workspace_id: &str, - _chat_id: &str, - _question_message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_message_id: i64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn stream_complete( &self, - _workspace_id: &str, - _params: CompleteTextParams, - _ai_model: Option, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, ) -> Result { Err(FlowyError::not_support().with_context("complete text is not supported in local server.")) } async fn embed_file( &self, - _workspace_id: &str, - _file_path: &Path, - _chat_id: &str, - _metadata: Option>, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, ) -> Result<(), FlowyError> { Err(FlowyError::not_support().with_context("indexing file is not supported in local server.")) } - async fn get_local_ai_config(&self, _workspace_id: &str) -> Result { + async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { Err( FlowyError::not_support() .with_context("Get local ai config is not supported in local server."), @@ -122,7 +123,7 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { async fn get_workspace_plan( &self, - _workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { Err( FlowyError::not_support() @@ -132,26 +133,26 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { async fn get_chat_settings( &self, - _workspace_id: &str, - _chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn update_chat_settings( &self, - _workspace_id: &str, - _chat_id: &str, - _params: UpdateChatParams, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, ) -> Result<(), FlowyError> { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } - async fn get_available_models(&self, _workspace_id: &str) -> Result { + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } - async fn get_workspace_default_model(&self, _workspace_id: &str) -> Result { + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index d22088a2c4..36d31902f0 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -7,6 +7,7 @@ use collab_user::core::default_user_awareness_data; use flowy_database_pub::cloud::{DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid}; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub(crate) struct LocalServerDatabaseCloudServiceImpl(); @@ -14,50 +15,51 @@ pub(crate) struct LocalServerDatabaseCloudServiceImpl(); impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - _workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { + let object_id = object_id.to_string(); match collab_type { CollabType::Document => { - let encode_collab = default_document_collab_data(object_id)?; + let encode_collab = default_document_collab_data(&object_id)?; Ok(Some(encode_collab)) }, - CollabType::Database => default_database_data(object_id) + CollabType::Database => default_database_data(&object_id) .await .map(Some) .map_err(Into::into), - CollabType::WorkspaceDatabase => Ok(Some(default_workspace_database_data(object_id))), + CollabType::WorkspaceDatabase => Ok(Some(default_workspace_database_data(&object_id))), CollabType::Folder => Ok(None), CollabType::DatabaseRow => Ok(None), - CollabType::UserAwareness => Ok(Some(default_user_awareness_data(object_id))), + CollabType::UserAwareness => Ok(Some(default_user_awareness_data(&object_id))), CollabType::Unknown => Ok(None), } } async fn create_database_encode_collab( &self, - _object_id: &str, - _collab_type: CollabType, - _workspace_id: &str, - _encoded_collab: EncodedCollab, + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) } async fn batch_get_database_encode_collab( &self, - _object_ids: Vec, - _object_ty: CollabType, - _workspace_id: &str, + object_ids: Vec, + object_ty: CollabType, + workspace_id: &Uuid, ) -> Result { Ok(EncodeCollabByOid::default()) } async fn get_database_collab_object_snapshots( &self, - _object_id: &str, - _limit: usize, + object_id: &Uuid, + limit: usize, ) -> Result, FlowyError> { Ok(vec![]) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs index 152dcb78d8..fe44470921 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs @@ -2,6 +2,7 @@ use collab::entity::EncodedCollab; use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError}; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub(crate) struct LocalServerDocumentCloudServiceImpl(); @@ -9,8 +10,8 @@ pub(crate) struct LocalServerDocumentCloudServiceImpl(); impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { async fn get_document_doc_state( &self, - document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let document_id = document_id.to_string(); @@ -22,26 +23,26 @@ impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, + document_id: &Uuid, + limit: usize, + workspace_id: &str, ) -> Result, FlowyError> { Ok(vec![]) } async fn get_document_data( &self, - _document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { Ok(None) } async fn create_document_collab( &self, - _workspace_id: &str, - _document_id: &str, - _encoded_collab: EncodedCollab, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 52d9a9e98c..a90352a976 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,10 +1,9 @@ use std::sync::Arc; +use crate::local_server::LocalServerDB; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; use collab_entity::CollabType; - -use crate::local_server::LocalServerDB; use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ gen_workspace_id, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, @@ -12,6 +11,7 @@ use flowy_folder_pub::cloud::{ }; use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub(crate) struct LocalServerFolderCloudServiceImpl { #[allow(dead_code)] @@ -29,7 +29,7 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { )) } - async fn open_workspace(&self, _workspace_id: &str) -> Result<(), FlowyError> { + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Ok(()) } @@ -39,8 +39,8 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn get_folder_data( &self, - _workspace_id: &str, - _uid: &i64, + workspace_id: &Uuid, + uid: &i64, ) -> Result, FlowyError> { Ok(None) } @@ -55,18 +55,18 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn get_folder_doc_state( &self, - _workspace_id: &str, - _uid: i64, - _collab_type: CollabType, - _object_id: &str, + workspace_id: &Uuid, + uid: i64, + collab_type: CollabType, + object_id: &Uuid, ) -> Result, FlowyError> { Err(FlowyError::local_version_not_support()) } async fn batch_create_folder_collab_objects( &self, - _workspace_id: &str, - _objects: Vec, + workspace_id: &Uuid, + objects: Vec, ) -> Result<(), FlowyError> { Ok(()) } @@ -77,68 +77,68 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn publish_view( &self, - _workspace_id: &str, - _payload: Vec, + workspace_id: &Uuid, + payload: Vec, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } async fn unpublish_views( &self, - _workspace_id: &str, - _view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } - async fn get_publish_info(&self, _view_id: &str) -> Result { + async fn get_publish_info(&self, view_id: &Uuid) -> Result { Err(FlowyError::local_version_not_support()) } async fn set_publish_namespace( &self, - _workspace_id: &str, - _new_namespace: String, + workspace_id: &Uuid, + new_namespace: String, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } - async fn get_publish_namespace(&self, _workspace_id: &str) -> Result { + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { Err(FlowyError::local_version_not_support()) } async fn set_publish_name( &self, - _workspace_id: &str, - _view_id: String, - _new_name: String, + workspace_id: &Uuid, + view_id: Uuid, + new_name: String, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } async fn list_published_views( &self, - _workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { Err(FlowyError::local_version_not_support()) } async fn get_default_published_view_info( &self, - _workspace_id: &str, + workspace_id: &Uuid, ) -> Result { Err(FlowyError::local_version_not_support()) } async fn set_default_published_view( &self, - _workspace_id: &str, - _view_id: uuid::Uuid, + workspace_id: &Uuid, + view_id: uuid::Uuid, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } - async fn remove_default_published_view(&self, _workspace_id: &str) -> Result<(), FlowyError> { + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } @@ -148,8 +148,8 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn full_sync_collab_object( &self, - _workspace_id: &str, - _params: FullSyncCollabParams, + workspace_id: &Uuid, + params: FullSyncCollabParams, ) -> Result<(), FlowyError> { Ok(()) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index c800cc7ced..ddea5353d8 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -134,7 +134,7 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { } } - async fn open_workspace(&self, _workspace_id: &str) -> Result { + async fn open_workspace(&self, workspace_id: &Uuid) -> Result { Err( FlowyError::local_version_not_support() .with_context("local server doesn't support open workspace"), @@ -147,11 +147,16 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { async fn get_user_awareness_doc_state( &self, - _uid: i64, - _workspace_id: &str, - object_id: &str, + uid: i64, + workspace_id: &Uuid, + object_id: &Uuid, ) -> Result, FlowyError> { - let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + let collab = Collab::new_with_origin( + CollabOrigin::Empty, + object_id.to_string().as_str(), + vec![], + false, + ); let awareness = UserAwareness::create(collab, None)?; let encode_collab = awareness.encode_collab_v1(|_collab| Ok::<_, FlowyError>(()))?; Ok(encode_collab.doc_state.to_vec()) @@ -171,8 +176,8 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { async fn batch_create_collab_object( &self, - _workspace_id: &str, - _objects: Vec, + workspace_id: &Uuid, + objects: Vec, ) -> Result<(), FlowyError> { Err( FlowyError::local_version_not_support() @@ -187,7 +192,7 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { ) } - async fn delete_workspace(&self, _workspace_id: &str) -> Result<(), FlowyError> { + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Err( FlowyError::local_version_not_support() .with_context("local server doesn't support multiple workspaces"), @@ -196,9 +201,9 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { async fn patch_workspace( &self, - _workspace_id: &str, - _new_workspace_name: Option<&str>, - _new_workspace_icon: Option<&str>, + workspace_id: &Uuid, + new_workspace_name: Option<&str>, + new_workspace_icon: Option<&str>, ) -> Result<(), FlowyError> { Err( FlowyError::local_version_not_support() diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index ecf34ec31d..9c88917df8 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -39,7 +39,7 @@ pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc struct FakeServerUserImpl; impl ServerUser for FakeServerUserImpl { - fn workspace_id(&self) -> FlowyResult { + fn workspace_id(&self) -> FlowyResult { todo!() } } diff --git a/frontend/rust-lib/flowy-storage-pub/Cargo.toml b/frontend/rust-lib/flowy-storage-pub/Cargo.toml index ecab2212f8..d36c997432 100644 --- a/frontend/rust-lib/flowy-storage-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-storage-pub/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" [dependencies] lib-infra.workspace = true -serde_json.workspace = true serde.workspace = true async-trait.workspace = true mime = "0.3.17" @@ -17,4 +16,4 @@ mime_guess = "2.0.4" client-api-entity = { workspace = true } tokio = { workspace = true, features = ["sync", "io-util"] } anyhow = "1.0.86" -tracing.workspace = true +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage-pub/src/cloud.rs b/frontend/rust-lib/flowy-storage-pub/src/cloud.rs index 6f12779899..5a72262ac9 100644 --- a/frontend/rust-lib/flowy-storage-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-storage-pub/src/cloud.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use bytes::Bytes; use flowy_error::{FlowyError, FlowyResult}; use mime::Mime; +use uuid::Uuid; #[async_trait] pub trait StorageCloudService: Send + Sync { @@ -47,17 +48,17 @@ pub trait StorageCloudService: Send + Sync { async fn get_object(&self, url: String) -> Result; async fn get_object_url_v1( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, ) -> FlowyResult; /// Return workspace_id, parent_dir, file_id - async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)>; + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)>; async fn create_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, content_type: &str, @@ -66,7 +67,7 @@ pub trait StorageCloudService: Send + Sync { async fn upload_part( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -76,7 +77,7 @@ pub trait StorageCloudService: Send + Sync { async fn complete_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -85,7 +86,7 @@ pub trait StorageCloudService: Send + Sync { } pub struct ObjectIdentity { - pub workspace_id: String, + pub workspace_id: Uuid, pub file_id: String, pub ext: String, } @@ -97,7 +98,7 @@ pub struct ObjectValue { } pub struct StorageObject { - pub workspace_id: String, + pub workspace_id: Uuid, pub file_name: String, pub value: ObjectValueSupabase, } @@ -126,9 +127,9 @@ impl StorageObject { /// * `name`: The name of the storage object. /// * `file_path`: The file path to the storage object's data. /// - pub fn from_file(workspace_id: &str, file_name: &str, file_path: T) -> Self { + pub fn from_file(workspace_id: &Uuid, file_name: &str, file_path: T) -> Self { Self { - workspace_id: workspace_id.to_string(), + workspace_id: *workspace_id, file_name: file_name.to_string(), value: ObjectValueSupabase::File { file_path: file_path.to_string(), @@ -145,14 +146,14 @@ impl StorageObject { /// * `mime`: The MIME type of the storage object. /// pub fn from_bytes>( - workspace_id: &str, + workspace_id: &Uuid, file_name: &str, bytes: B, mime: String, ) -> Self { let bytes = bytes.into(); Self { - workspace_id: workspace_id.to_string(), + workspace_id: *workspace_id, file_name: file_name.to_string(), value: ObjectValueSupabase::Bytes { bytes, mime }, } diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index c57cfde484..add7996439 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -17,8 +17,6 @@ tokio = { workspace = true, features = ["sync", "io-util"] } tracing.workspace = true flowy-sqlite.workspace = true mime_guess = "2.0.4" -fxhash = "0.2.1" -anyhow = "1.0.86" chrono = "0.4.33" flowy-notification = { workspace = true } flowy-derive.workspace = true @@ -26,8 +24,8 @@ protobuf = { workspace = true } dashmap.workspace = true strum_macros = "0.25.2" allo-isolate = { version = "^0.1", features = ["catch-unwind"] } -futures-util = "0.3.30" collab-importer = { workspace = true } +uuid.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/frontend/rust-lib/flowy-storage/src/manager.rs b/frontend/rust-lib/flowy-storage/src/manager.rs index 66ad44e0fd..dc1bf053ea 100644 --- a/frontend/rust-lib/flowy-storage/src/manager.rs +++ b/frontend/rust-lib/flowy-storage/src/manager.rs @@ -24,15 +24,17 @@ use lib_infra::box_any::BoxAny; use lib_infra::isolate_stream::{IsolateSink, SinkExt}; use lib_infra::util::timestamp; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::atomic::AtomicBool; use std::sync::Arc; use tokio::io::AsyncWriteExt; use tokio::sync::{broadcast, watch}; use tracing::{debug, error, info, instrument, trace}; +use uuid::Uuid; pub trait StorageUserService: Send + Sync + 'static { fn user_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn sqlite_connection(&self, uid: i64) -> Result; fn get_application_root_dir(&self) -> &str; } @@ -157,7 +159,8 @@ impl StorageManager { let uid = self.user_service.user_id().ok()?; let mut conn = self.user_service.sqlite_connection(uid).ok()?; - let is_finish = is_upload_completed(&mut conn, &workspace_id, &parent_dir, &file_id).ok()?; + let is_finish = + is_upload_completed(&mut conn, &workspace_id.to_string(), &parent_dir, &file_id).ok()?; if let Err(err) = self.global_notifier.send(FileProgress::new_progress( url.to_string(), @@ -229,7 +232,7 @@ async fn prepare_upload_task( if let Ok(uid) = user_service.user_id() { let workspace_id = user_service.workspace_id()?; let conn = user_service.sqlite_connection(uid)?; - let upload_files = batch_select_upload_file(conn, &workspace_id, 100, false)?; + let upload_files = batch_select_upload_file(conn, &workspace_id.to_string(), 100, false)?; let tasks = upload_files .into_iter() .map(|upload_file| UploadTask::BackgroundTask { @@ -269,7 +272,7 @@ impl StorageService for StorageServiceImpl { self .task_queue - .remove_task(&workspace_id, &parent_dir, &file_id) + .remove_task(&workspace_id.to_string(), &parent_dir, &file_id) .await; trace!("[File] delete progress notifier: {}", file_id); @@ -278,7 +281,7 @@ impl StorageService for StorageServiceImpl { self .user_service .sqlite_connection(self.user_service.user_id()?)?, - &workspace_id, + &workspace_id.to_string(), &parent_dir, &file_id, ) { @@ -384,9 +387,10 @@ impl StorageService for StorageServiceImpl { let conn = self .user_service .sqlite_connection(self.user_service.user_id()?)?; + let workspace_id = Uuid::from_str(&record.workspace_id)?; let url = self .cloud_service - .get_object_url_v1(&record.workspace_id, &record.parent_dir, &record.file_id) + .get_object_url_v1(&workspace_id, &record.parent_dir, &record.file_id) .await?; let file_id = record.file_id.clone(); match insert_upload_file(conn, &record) { @@ -478,7 +482,8 @@ impl StorageService for StorageServiceImpl { .user_service .sqlite_connection(self.user_service.user_id()?)?; let workspace_id = self.user_service.workspace_id()?; - is_upload_completed(&mut conn, &workspace_id, parent_idr, file_id).unwrap_or(false) + is_upload_completed(&mut conn, &workspace_id.to_string(), parent_idr, file_id) + .unwrap_or(false) }; if is_completed { @@ -590,9 +595,10 @@ async fn start_upload( upload_file.file_id ); + let workspace_id = Uuid::from_str(&upload_file.workspace_id)?; let create_upload_resp_result = cloud_service .create_upload( - &upload_file.workspace_id, + &workspace_id, &upload_file.parent_dir, &upload_file.file_id, &upload_file.content_type, @@ -601,11 +607,7 @@ async fn start_upload( .await; let file_url = cloud_service - .get_object_url_v1( - &upload_file.workspace_id, - &upload_file.parent_dir, - &upload_file.file_id, - ) + .get_object_url_v1(&workspace_id, &upload_file.parent_dir, &upload_file.file_id) .await?; if let Err(err) = create_upload_resp_result.as_ref() { @@ -653,7 +655,7 @@ async fn start_upload( match upload_part( cloud_service, user_service, - &upload_file.workspace_id, + &workspace_id, &upload_file.parent_dir, &upload_file.upload_id, &upload_file.file_id, @@ -782,7 +784,7 @@ async fn resume_upload( async fn upload_part( cloud_service: &Arc, user_service: &Arc, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -822,12 +824,9 @@ async fn complete_upload( parts: Vec, global_notifier: &GlobalNotifier, ) -> Result<(), FlowyError> { + let workspace_id = Uuid::from_str(&upload_file.workspace_id)?; let file_url = cloud_service - .get_object_url_v1( - &upload_file.workspace_id, - &upload_file.parent_dir, - &upload_file.file_id, - ) + .get_object_url_v1(&workspace_id, &upload_file.parent_dir, &upload_file.file_id) .await?; info!( @@ -838,7 +837,7 @@ async fn complete_upload( ); match cloud_service .complete_upload( - &upload_file.workspace_id, + &workspace_id, &upload_file.parent_dir, &upload_file.upload_id, &upload_file.file_id, diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index d68bf3f809..b549c549fc 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -171,7 +171,7 @@ pub trait UserCloudService: Send + Sync + 'static { /// return None if the user is not found async fn get_user_profile(&self, credential: UserCredentials) -> Result; - async fn open_workspace(&self, workspace_id: &str) -> Result; + async fn open_workspace(&self, workspace_id: &Uuid) -> Result; /// Return the all the workspaces of the user async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError>; @@ -183,18 +183,18 @@ pub trait UserCloudService: Send + Sync + 'static { // Updates the workspace name and icon async fn patch_workspace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_workspace_name: Option<&str>, new_workspace_icon: Option<&str>, ) -> Result<(), FlowyError>; /// Deletes a workspace owned by the user. - async fn delete_workspace(&self, workspace_id: &str) -> Result<(), FlowyError>; + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; async fn invite_workspace_member( &self, invitee_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { Ok(()) @@ -214,7 +214,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> Result<(), FlowyError> { Ok(()) } @@ -222,7 +222,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { Ok(()) @@ -230,14 +230,14 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { Ok(vec![]) } async fn get_workspace_member( &self, - workspace_id: String, + workspace_id: Uuid, uid: i64, ) -> Result { Err(FlowyError::not_support()) @@ -246,8 +246,8 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_user_awareness_doc_state( &self, uid: i64, - workspace_id: &str, - object_id: &str, + workspace_id: &Uuid, + object_id: &Uuid, ) -> Result, FlowyError>; fn receive_realtime_event(&self, _json: Value) {} @@ -266,11 +266,11 @@ pub trait UserCloudService: Send + Sync + 'static { async fn batch_create_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError>; - async fn leave_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Ok(()) } @@ -286,7 +286,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_member_info( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, ) -> Result { Err(FlowyError::not_support()) @@ -318,7 +318,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_plan( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { Err(FlowyError::not_support()) } diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 8f31edf740..857a735edb 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -3,6 +3,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; pub use client_api::entity::billing_dto::RecurringInterval; use client_api::entity::AFRole; +use flowy_error::FlowyResult; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_repr::*; @@ -151,6 +152,11 @@ pub struct UserWorkspace { } impl UserWorkspace { + pub fn workspace_id(&self) -> FlowyResult { + let id = Uuid::from_str(&self.id)?; + Ok(id) + } + pub fn new_local(workspace_id: &str, _uid: i64) -> Self { Self { id: workspace_id.to_string(), diff --git a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs index 2b1b58ae05..122ce2e60f 100644 --- a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs +++ b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs @@ -3,6 +3,7 @@ use flowy_error::FlowyResult; use flowy_folder_pub::entities::ImportFrom; use lib_infra::async_trait::async_trait; use std::collections::HashMap; +use uuid::Uuid; #[async_trait] pub trait UserWorkspaceService: Send + Sync { @@ -19,5 +20,5 @@ pub trait UserWorkspaceService: Send + Sync { ) -> FlowyResult<()>; /// Removes local indexes when a workspace is left/deleted - fn did_delete_workspace(&self, workspace_id: String) -> FlowyResult<()>; + fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index c22af4f1c1..dfd166b1a6 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -5,9 +5,11 @@ use flowy_user_pub::entities::*; use lib_dispatch::prelude::*; use lib_infra::box_any::BoxAny; use serde_json::Value; +use std::str::FromStr; use std::sync::Weak; use std::{convert::TryInto, sync::Arc}; use tracing::{event, trace}; +use uuid::Uuid; use crate::entities::*; use crate::notification::{send_notification, UserNotification}; @@ -515,7 +517,8 @@ pub async fn open_workspace_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; - manager.open_workspace(¶ms.workspace_id).await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + manager.open_workspace(&workspace_id).await?; Ok(()) } @@ -645,8 +648,9 @@ pub async fn delete_workspace_member_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; manager - .remove_workspace_member(data.email, data.workspace_id) + .remove_workspace_member(data.email, workspace_id) .await?; Ok(()) } @@ -658,8 +662,9 @@ pub async fn get_workspace_members_handler( ) -> DataResult { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; let members = manager - .get_workspace_members(data.workspace_id) + .get_workspace_members(workspace_id) .await? .into_iter() .map(WorkspaceMemberPB::from) @@ -674,8 +679,9 @@ pub async fn update_workspace_member_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; manager - .update_workspace_member(data.email, data.workspace_id, data.role.into()) + .update_workspace_member(data.email, workspace_id, data.role.into()) .await?; Ok(()) } @@ -698,6 +704,7 @@ pub async fn delete_workspace_handler( ) -> Result<(), FlowyError> { let workspace_id = delete_workspace_param.try_into_inner()?.workspace_id; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&workspace_id)?; manager.delete_workspace(&workspace_id).await?; Ok(()) } @@ -709,8 +716,9 @@ pub async fn rename_workspace_handler( ) -> Result<(), FlowyError> { let params = rename_workspace_param.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; manager - .patch_workspace(¶ms.workspace_id, Some(¶ms.new_name), None) + .patch_workspace(&workspace_id, Some(¶ms.new_name), None) .await?; Ok(()) } @@ -722,8 +730,9 @@ pub async fn change_workspace_icon_handler( ) -> Result<(), FlowyError> { let params = change_workspace_icon_param.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; manager - .patch_workspace(¶ms.workspace_id, None, Some(¶ms.new_icon)) + .patch_workspace(&workspace_id, None, Some(¶ms.new_icon)) .await?; Ok(()) } @@ -735,8 +744,9 @@ pub async fn invite_workspace_member_handler( ) -> Result<(), FlowyError> { let param = param.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(¶m.workspace_id)?; manager - .invite_member_to_workspace(param.workspace_id, param.invitee_email, param.role.into()) + .invite_member_to_workspace(workspace_id, param.invitee_email, param.role.into()) .await?; Ok(()) } @@ -772,6 +782,7 @@ pub async fn leave_workspace_handler( manager: AFPluginState>, ) -> Result<(), FlowyError> { let workspace_id = param.into_inner().workspace_id; + let workspace_id = Uuid::from_str(&workspace_id)?; let manager = upgrade_manager(manager)?; manager.leave_workspace(&workspace_id).await?; Ok(()) diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index 7d770e8123..84d43fe4f2 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -13,8 +13,10 @@ use flowy_sqlite::DBConnection; use flowy_user_pub::entities::UserWorkspace; use flowy_user_pub::session::Session; use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; use tracing::{error, info}; +use uuid::Uuid; const SQLITE_VACUUM_042: &str = "sqlite_vacuum_042_version"; @@ -68,14 +70,16 @@ impl AuthenticateUser { Ok(self.user_config.device_id.to_string()) } - pub fn workspace_id(&self) -> FlowyResult { + pub fn workspace_id(&self) -> FlowyResult { let session = self.get_session()?; - Ok(session.user_workspace.id.clone()) + let workspace_uuid = Uuid::from_str(&session.user_workspace.id)?; + Ok(workspace_uuid) } - pub fn workspace_database_object_id(&self) -> FlowyResult { + pub fn workspace_database_object_id(&self) -> FlowyResult { let session = self.get_session()?; - Ok(session.user_workspace.workspace_database_id.clone()) + let id = Uuid::from_str(&session.user_workspace.workspace_database_id)?; + Ok(id) } pub fn get_collab_db(&self, uid: i64) -> FlowyResult> { diff --git a/frontend/rust-lib/flowy-user/src/services/billing_check.rs b/frontend/rust-lib/flowy-user/src/services/billing_check.rs index b0b123fc41..b15f811ab6 100644 --- a/frontend/rust-lib/flowy-user/src/services/billing_check.rs +++ b/frontend/rust-lib/flowy-user/src/services/billing_check.rs @@ -4,6 +4,7 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_user_pub::cloud::UserCloudServiceProvider; use std::sync::Weak; use std::time::Duration; +use uuid::Uuid; /// `PeriodicallyCheckBillingState` is designed to periodically verify the subscription /// plan of a given workspace. It utilizes a cloud service provider to fetch the current @@ -13,7 +14,7 @@ use std::time::Duration; /// at specified intervals until the expected plan is found or the maximum number of /// attempts is reached. pub struct PeriodicallyCheckBillingState { - workspace_id: String, + workspace_id: Uuid, cloud_service: Weak, expected_plan: Option, user: Weak, @@ -21,7 +22,7 @@ pub struct PeriodicallyCheckBillingState { impl PeriodicallyCheckBillingState { pub fn new( - workspace_id: String, + workspace_id: Uuid, expected_plan: Option, cloud_service: Weak, user: Weak, diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index c2325815d6..9efbf81932 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -43,6 +43,8 @@ use std::ops::{Deref, DerefMut}; use std::path::Path; use std::sync::{Arc, Weak}; use tracing::{error, event, info, instrument, warn}; +use uuid::Uuid; + pub(crate) struct ImportedFolder { pub imported_session: Session, pub imported_collab_db: Arc, @@ -1172,7 +1174,7 @@ impl DerefMut for OldToNewIdMap { pub async fn upload_collab_objects_data( uid: i64, user_collab_db: Weak, - workspace_id: &str, + workspace_id: &Uuid, user_authenticator: &Authenticator, collab_data: ImportedCollabData, user_cloud_service: Arc, @@ -1275,7 +1277,7 @@ pub async fn upload_collab_objects_data( async fn batch_create( uid: i64, - workspace_id: &str, + workspace_id: &Uuid, user_cloud_service: &Arc, size_counter: &usize, objects: Vec, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index f04c988f5c..c41485f6c9 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -14,6 +14,7 @@ use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::cloud::{UserCloudServiceProvider, UserUpdate}; use flowy_user_pub::entities::*; use flowy_user_pub::workspace_service::UserWorkspaceService; +use lib_infra::box_any::BoxAny; use semver::Version; use serde_json::Value; use std::string::ToString; @@ -22,8 +23,7 @@ use std::sync::{Arc, Weak}; use tokio::sync::Mutex; use tokio_stream::StreamExt; use tracing::{debug, error, event, info, instrument, warn}; - -use lib_infra::box_any::BoxAny; +use uuid::Uuid; use crate::entities::{AuthStateChangedPB, AuthStatePB, UserProfilePB, UserSettingPB}; use crate::event_map::{DefaultUserStatusCallback, UserStatusCallback}; @@ -58,7 +58,7 @@ pub struct UserManager { auth_process: Mutex>, pub(crate) authenticate_user: Arc, refresh_user_profile_since: AtomicI64, - pub(crate) is_loading_awareness: Arc>, + pub(crate) is_loading_awareness: Arc>, } impl UserManager { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index d8c749ac0c..cc9cddabb5 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -8,13 +8,13 @@ use collab_entity::CollabType; use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, }; +use collab_integrate::CollabKVDB; use collab_user::core::{UserAwareness, UserAwarenessNotifier}; use dashmap::try_result::TryResult; -use tracing::{error, info, instrument, trace}; - -use collab_integrate::CollabKVDB; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_user_pub::entities::{user_awareness_object_id, Authenticator}; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; use crate::entities::ReminderPB; use crate::user_manager::UserManager; @@ -122,8 +122,7 @@ impl UserManager { authenticator: &Authenticator, ) -> FlowyResult<()> { let authenticator = authenticator.clone(); - let object_id = - user_awareness_object_id(&session.user_uuid, &session.user_workspace.id).to_string(); + let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); // Try to acquire mutable access to `is_loading_awareness`. // Thread-safety is ensured by DashMap @@ -156,7 +155,7 @@ impl UserManager { let is_exist_on_disk = self .authenticate_user - .is_collab_on_disk(session.user_id, &object_id)?; + .is_collab_on_disk(session.user_id, &object_id.to_string())?; if authenticator.is_local() || is_exist_on_disk { trace!( "Initializing new user awareness from disk:{}, {:?}", @@ -164,15 +163,13 @@ impl UserManager { authenticator ); let collab_db = self.get_collab_db(session.user_id)?; - let doc_state = CollabPersistenceImpl::new( - collab_db.clone(), - session.user_id, - session.user_workspace.id.clone(), - ) - .into_data_source(); + let workspace_id = session.user_workspace.workspace_id()?; + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); let awareness = Self::collab_for_user_awareness( &self.collab_builder.clone(), - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -211,7 +208,7 @@ impl UserManager { fn load_awareness_from_server( &self, session: &Session, - object_id: String, + object_id: Uuid, authenticator: Authenticator, ) -> FlowyResult<()> { // Clone necessary data @@ -231,16 +228,14 @@ impl UserManager { } }; + let workspace_id = session.user_workspace.workspace_id()?; let create_awareness = if authenticator.is_local() { - let doc_state = CollabPersistenceImpl::new( - collab_db.clone(), - session.user_id, - session.user_workspace.id.clone(), - ) - .into_data_source(); + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id.clone()) + .into_data_source(); Self::collab_for_user_awareness( &weak_builder, - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -251,7 +246,7 @@ impl UserManager { } else { let result = cloud_services .get_user_service()? - .get_user_awareness_doc_state(session.user_id, &session.user_workspace.id, &object_id) + .get_user_awareness_doc_state(session.user_id, &workspace_id, &object_id) .await; match result { @@ -259,7 +254,7 @@ impl UserManager { trace!("Fetched user awareness collab from remote: {}", data.len()); Self::collab_for_user_awareness( &weak_builder, - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -274,12 +269,12 @@ impl UserManager { let doc_state = CollabPersistenceImpl::new( collab_db.clone(), session.user_id, - session.user_workspace.id.clone(), + workspace_id.clone(), ) .into_data_source(); Self::collab_for_user_awareness( &weak_builder, - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -329,9 +324,9 @@ impl UserManager { /// user awareness. async fn collab_for_user_awareness( collab_builder: &Weak, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, - object_id: &str, + object_id: &Uuid, collab_db: Weak, doc_state: DataSource, notifier: Option, @@ -375,8 +370,7 @@ impl UserManager { info!("User awareness is not loaded when trying to access it"); let session = self.get_session()?; - let object_id = - user_awareness_object_id(&session.user_uuid, &session.user_workspace.id).to_string(); + let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); let is_loading = self .is_loading_awareness .get(&object_id) diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index e666d2486a..3f3d90127d 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -3,12 +3,11 @@ use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlanDetail} use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; use std::convert::TryFrom; +use std::str::FromStr; use std::sync::Arc; use collab_entity::{CollabObject, CollabType}; use collab_integrate::CollabKVDB; -use tracing::{error, info, instrument, trace, warn}; - use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; use flowy_sqlite::schema::user_workspace_table; @@ -17,6 +16,8 @@ use flowy_user_pub::entities::{ Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; +use tracing::{error, info, instrument, trace, warn}; +use uuid::Uuid; use crate::entities::{ RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, @@ -123,7 +124,7 @@ impl UserManager { match upload_collab_objects_data( user_id, weak_user_collab_db, - ¤t_session.user_workspace.id, + ¤t_session.user_workspace.workspace_id()?, &user.authenticator, collab_data, weak_user_cloud_service, @@ -161,7 +162,7 @@ impl UserManager { } #[instrument(skip(self), err)] - pub async fn open_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn open_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("open workspace: {}", workspace_id); let user_workspace = self .cloud_services @@ -221,7 +222,7 @@ impl UserManager { pub async fn patch_workspace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_workspace_name: Option<&str>, new_workspace_icon: Option<&str>, ) -> FlowyResult<()> { @@ -262,7 +263,7 @@ impl UserManager { } #[instrument(level = "info", skip(self), err)] - pub async fn leave_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn leave_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("leave workspace: {}", workspace_id); self .cloud_services @@ -273,15 +274,15 @@ impl UserManager { // delete workspace from local sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id)?; + delete_user_workspaces(conn, workspace_id.to_string().as_str())?; self .user_workspace_service - .did_delete_workspace(workspace_id.to_string()) + .did_delete_workspace(workspace_id) } #[instrument(level = "info", skip(self), err)] - pub async fn delete_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("delete workspace: {}", workspace_id); self .cloud_services @@ -290,18 +291,18 @@ impl UserManager { .await?; let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id)?; + delete_user_workspaces(conn, workspace_id.to_string().as_str())?; self .user_workspace_service - .did_delete_workspace(workspace_id.to_string())?; + .did_delete_workspace(workspace_id)?; Ok(()) } pub async fn invite_member_to_workspace( &self, - workspace_id: String, + workspace_id: Uuid, invitee_email: String, role: Role, ) -> FlowyResult<()> { @@ -335,7 +336,7 @@ impl UserManager { pub async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> FlowyResult<()> { self .cloud_services @@ -347,7 +348,7 @@ impl UserManager { pub async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> FlowyResult> { let members = self .cloud_services @@ -359,7 +360,7 @@ impl UserManager { pub async fn get_workspace_member( &self, - workspace_id: String, + workspace_id: Uuid, uid: i64, ) -> FlowyResult { let member = self @@ -373,7 +374,7 @@ impl UserManager { pub async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> FlowyResult<()> { self @@ -384,9 +385,9 @@ impl UserManager { Ok(()) } - pub fn get_user_workspace(&self, uid: i64, workspace_id: &str) -> Option { + pub fn get_user_workspace(&self, uid: i64, workspace_id: &Uuid) -> Option { let conn = self.db_connection(uid).ok()?; - get_user_workspace_op(workspace_id, conn) + get_user_workspace_op(workspace_id.to_string().as_str(), conn) } pub async fn get_all_user_workspaces(&self, uid: i64) -> FlowyResult> { @@ -581,10 +582,10 @@ impl UserManager { } pub async fn get_workspace_member_info(&self, uid: i64) -> FlowyResult { - let workspace_id = self.get_session()?.user_workspace.id.clone(); + let workspace_id = self.get_session()?.user_workspace.workspace_id()?; let db = self.authenticate_user.get_sqlite_connection(uid)?; // Can opt in using memory cache - if let Ok(member_record) = select_workspace_member(db, &workspace_id, uid) { + if let Ok(member_record) = select_workspace_member(db, &workspace_id.to_string(), uid) { if is_older_than_n_minutes(member_record.updated_at, 10) { self .get_workspace_member_info_from_remote(&workspace_id, uid) @@ -608,7 +609,7 @@ impl UserManager { async fn get_workspace_member_info_from_remote( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, ) -> FlowyResult { trace!("get workspace member info from remote: {}", workspace_id); @@ -638,8 +639,9 @@ impl UserManager { success: SuccessWorkspaceSubscriptionPB, ) -> FlowyResult<()> { // periodically check the billing state + let workspace_id = Uuid::from_str(&success.workspace_id)?; let plans = PeriodicallyCheckBillingState::new( - success.workspace_id, + workspace_id, success.plan.map(SubscriptionPlan::from), Arc::downgrade(&self.cloud_services), Arc::downgrade(&self.authenticate_user), From 91397c963a4d63ead18828dc9f9f67bbc24b3b1e Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 7 Apr 2025 19:34:26 +0800 Subject: [PATCH 096/207] chore: clippy --- .../src/persistence/collab_metadata_sql.rs | 2 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 29 +++--- frontend/rust-lib/flowy-ai/src/chat.rs | 18 ++-- frontend/rust-lib/flowy-ai/src/completion.rs | 2 +- .../flowy-ai/src/local_ai/controller.rs | 4 +- .../flowy-core/src/deps_resolve/chat_deps.rs | 2 +- .../src/deps_resolve/cloud_service_impl.rs | 8 +- .../folder_deps/folder_deps_chat_impl.rs | 16 ++-- .../folder_deps/folder_deps_database_impl.rs | 3 +- .../folder_deps/folder_deps_doc_impl.rs | 8 +- .../src/deps_resolve/folder_deps/mod.rs | 2 +- .../rust-lib/flowy-database2/src/manager.rs | 6 +- .../src/services/database/database_editor.rs | 2 +- .../rust-lib/flowy-document/src/manager.rs | 6 +- .../flowy-document/tests/document/util.rs | 25 +++-- .../flowy-folder/src/entities/view.rs | 6 +- frontend/rust-lib/flowy-folder/src/manager.rs | 10 +- .../rust-lib/flowy-folder/src/manager_init.rs | 24 ++--- .../flowy-folder/src/manager_observer.rs | 2 +- .../flowy-search/src/folder/indexer.rs | 12 +-- .../flowy-server/src/af_cloud/impls/chat.rs | 1 + .../src/af_cloud/impls/database.rs | 11 ++- .../src/af_cloud/impls/document.rs | 13 +-- .../flowy-server/src/af_cloud/impls/folder.rs | 22 ++--- .../af_cloud/impls/user/cloud_service_impl.rs | 18 ++-- .../rust-lib/flowy-server/src/default_impl.rs | 96 +++++++++---------- .../src/local_server/impls/database.rs | 1 + .../src/local_server/impls/document.rs | 1 + .../src/local_server/impls/folder.rs | 1 + .../src/local_server/impls/user.rs | 1 + .../flowy-user/src/services/billing_check.rs | 2 +- .../user_manager/manager_user_awareness.rs | 11 +-- 32 files changed, 181 insertions(+), 184 deletions(-) diff --git a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs index bc5f28e642..adb8b72de1 100644 --- a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs +++ b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs @@ -56,7 +56,7 @@ pub fn batch_select_collab_metadata( .filter(af_collab_metadata::object_id.eq_any(&object_ids)) .load::(&mut conn)? .into_iter() - .flat_map(|m| Uuid::from_str(&m.object_id).and_then(|v| Ok((v, m)))) + .flat_map(|m| Uuid::from_str(&m.object_id).map(|v| (v, m))) .collect(); Ok(metadata) } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index e11f2328e4..0b27f4f525 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -135,10 +135,10 @@ impl AIManager { } pub async fn open_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { - self.chats.entry(chat_id.clone()).or_insert_with(|| { + self.chats.entry(*chat_id).or_insert_with(|| { Arc::new(Chat::new( self.user_service.user_id().unwrap(), - chat_id.clone(), + *chat_id, self.user_service.clone(), self.cloud_service_wm.clone(), )) @@ -152,7 +152,7 @@ impl AIManager { let cloud_service_wm = self.cloud_service_wm.clone(); let store_preferences = self.store_preferences.clone(); let external_service = self.external_service.clone(); - let chat_id = chat_id.clone(); + let chat_id = *chat_id; tokio::spawn(async move { match refresh_chat_setting( &user_service, @@ -238,11 +238,11 @@ impl AIManager { let chat = Arc::new(Chat::new( self.user_service.user_id()?, - chat_id.clone(), + *chat_id, self.user_service.clone(), self.cloud_service_wm.clone(), )); - self.chats.insert(chat_id.clone(), chat.clone()); + self.chats.insert(*chat_id, chat.clone()); Ok(chat) } @@ -533,11 +533,11 @@ impl AIManager { None => { let chat = Arc::new(Chat::new( self.user_service.user_id()?, - chat_id.clone(), + *chat_id, self.user_service.clone(), self.cloud_service_wm.clone(), )); - self.chats.insert(chat_id.clone(), chat.clone()); + self.chats.insert(*chat_id, chat.clone()); Ok(chat) }, Some(chat) => Ok(chat), @@ -739,18 +739,15 @@ async fn refresh_chat_setting( error!("failed to set chat settings: {}", err); } - chat_notification_builder( - &chat_id.to_string(), - ChatNotification::DidUpdateChatSettings, - ) - .payload(ChatSettingsPB { - rag_ids: settings.rag_ids.clone(), - }) - .send(); + chat_notification_builder(chat_id.to_string(), ChatNotification::DidUpdateChatSettings) + .payload(ChatSettingsPB { + rag_ids: settings.rag_ids.clone(), + }) + .send(); Ok(settings) } fn setting_store_key(chat_id: &Uuid) -> String { - format!("chat_settings_{}", chat_id.to_string()) + format!("chat_settings_{}", chat_id) } diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index d893ff2b51..44e2a1d41c 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -204,7 +204,7 @@ impl Chat { ai_model: Option, ) { let stop_stream = self.stop_stream.clone(); - let chat_id = self.chat_id.clone(); + let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); let user_service = self.user_service.clone(); tokio::spawn(async move { @@ -258,7 +258,7 @@ impl Chat { chat_id: chat_id.to_string(), error_message: err.to_string(), }; - chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError) + chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError) .payload(pb) .send(); return Err(err); @@ -297,14 +297,14 @@ impl Chat { chat_id: chat_id.to_string(), error_message: err.to_string(), }; - chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError) + chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError) .payload(pb) .send(); return Err(err); }, } - chat_notification_builder(&chat_id, ChatNotification::FinishStreaming).send(); + chat_notification_builder(chat_id, ChatNotification::FinishStreaming).send(); trace!("[Chat] finish streaming"); if answer_stream_buffer.lock().await.is_empty() { @@ -360,7 +360,7 @@ impl Chat { has_more: true, total: 0, }; - chat_notification_builder(&self.chat_id, ChatNotification::DidLoadPrevChatMessage) + chat_notification_builder(self.chat_id, ChatNotification::DidLoadPrevChatMessage) .payload(pb.clone()) .send(); return Ok(pb); @@ -433,7 +433,7 @@ impl Chat { after_message_id ); let workspace_id = self.user_service.workspace_id()?; - let chat_id = self.chat_id.clone(); + let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); let user_service = self.user_service.clone(); let uid = self.uid; @@ -481,11 +481,11 @@ impl Chat { } else { *prev_message_state.write().await = PrevMessageState::NoMore; } - chat_notification_builder(&chat_id, ChatNotification::DidLoadPrevChatMessage) + chat_notification_builder(chat_id, ChatNotification::DidLoadPrevChatMessage) .payload(pb) .send(); } else { - chat_notification_builder(&chat_id, ChatNotification::DidLoadLatestChatMessage) + chat_notification_builder(chat_id, ChatNotification::DidLoadLatestChatMessage) .payload(pb) .send(); } @@ -511,7 +511,7 @@ impl Chat { } let workspace_id = self.user_service.workspace_id()?; - let chat_id = self.chat_id.clone(); + let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); let question = cloud_service diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index 53d2f3915e..31acde4ae7 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -130,7 +130,7 @@ impl CompletionTask { completion_type: Some(complete_type), metadata: Some(CompletionMetadata { object_id, - workspace_id: Some(self.workspace_id.clone()), + workspace_id: Some(self.workspace_id), rag_ids: Some(self.context.rag_ids), completion_history, custom_prompt: self diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 8b3285507c..6fbe7ce2fc 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -253,7 +253,7 @@ impl LocalAIController { self.close_chat(current_chat_id); } - self.current_chat_id.store(Some(Arc::new(chat_id.clone()))); + self.current_chat_id.store(Some(Arc::new(*chat_id))); let chat_id = chat_id.to_string(); let weak_ctrl = Arc::downgrade(&self.ai_plugin); tokio::spawn(async move { @@ -620,5 +620,5 @@ impl LLMResourceService for LLMResourceServiceImpl { const APPFLOWY_LOCAL_AI_ENABLED: &str = "appflowy_local_ai_enabled"; fn local_ai_enabled_key(workspace_id: &Uuid) -> String { - format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id.to_string()) + format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id) } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs index 5a369eb9de..2d9a57b331 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -112,7 +112,7 @@ impl AIExternalService for ChatQueryServiceImpl { // Perform full sync if changes are detected or no state vector is found let params = FullSyncCollabParams { - object_id: rag_id.clone(), + object_id: rag_id, collab_type: CollabType::Document, encoded_collab: query_collab.encoded_collab.clone(), }; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 896a03cfd5..32cf633a6f 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -444,7 +444,7 @@ impl DatabaseCloudService for ServerProvider { let server = self.get_server()?; server .database_service() - .get_database_encode_collab(object_id, collab_type, &workspace_id) + .get_database_encode_collab(object_id, collab_type, workspace_id) .await } @@ -485,7 +485,7 @@ impl DatabaseCloudService for ServerProvider { server .database_service() - .get_database_collab_object_snapshots(&object_id, limit) + .get_database_collab_object_snapshots(object_id, limit) .await } } @@ -686,7 +686,7 @@ impl ChatCloudService for ServerProvider { self .get_server()? .chat_service() - .create_question(&workspace_id, &chat_id, &message, message_type, metadata) + .create_question(workspace_id, chat_id, &message, message_type, metadata) .await } @@ -716,7 +716,7 @@ impl ChatCloudService for ServerProvider { let server = self.get_server()?; server .chat_service() - .stream_answer(&workspace_id, &chat_id, message_id, format, ai_model) + .stream_answer(workspace_id, chat_id, message_id, format, ai_model) .await } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs index 585cba84c5..fc7a861cb8 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs @@ -30,7 +30,7 @@ impl FolderOperationHandler for ChatFolderOperation { self.0.delete_chat(view_id).await } - async fn duplicate_view(&self, view_id: &Uuid) -> Result { + async fn duplicate_view(&self, _view_id: &Uuid) -> Result { Err(FlowyError::not_support()) } @@ -47,8 +47,8 @@ impl FolderOperationHandler for ChatFolderOperation { user_id: i64, parent_view_id: &Uuid, view_id: &Uuid, - name: &str, - layout: ViewLayout, + _name: &str, + _layout: ViewLayout, ) -> Result<(), FlowyError> { self .0 @@ -59,11 +59,11 @@ impl FolderOperationHandler for ChatFolderOperation { async fn import_from_bytes( &self, - uid: i64, - view_id: &Uuid, - name: &str, - import_type: ImportType, - bytes: Vec, + _uid: i64, + _view_id: &Uuid, + _name: &str, + _import_type: ImportType, + _bytes: Vec, ) -> Result, FlowyError> { Err(FlowyError::not_support()) } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs index d57eea2ae4..d98e32f67d 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use bytes::Bytes; use collab::entity::EncodedCollab; use collab_entity::CollabType; @@ -68,7 +69,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { .await?; let row_metas = self .0 - .get_database_row_metas_with_view_id(&view_id, row_oids.clone()) + .get_database_row_metas_with_view_id(view_id, row_oids.clone()) .await?; let row_document_ids = row_metas .iter() diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs index bb00d8c6bd..a843a8eb1f 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs @@ -117,9 +117,9 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn create_default_view( &self, user_id: i64, - parent_view_id: &Uuid, + _parent_view_id: &Uuid, view_id: &Uuid, - name: &str, + _name: &str, layout: ViewLayout, ) -> Result<(), FlowyError> { debug_assert_eq!(layout, ViewLayout::Document); @@ -139,8 +139,8 @@ impl FolderOperationHandler for DocumentFolderOperation { &self, uid: i64, view_id: &Uuid, - name: &str, - import_type: ImportType, + _name: &str, + _import_type: ImportType, bytes: Vec, ) -> Result, FlowyError> { let data = DocumentDataPB::try_from(Bytes::from(bytes))?; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs index 9c064acbe3..02b26e71b6 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs @@ -196,7 +196,7 @@ impl FolderQueryService for FolderServiceImpl { } }) .collect::>(); - children.push(parent_view_id.clone()); + children.push(*parent_view_id); children }, _ => vec![], diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 076b77e1d0..b2b2b0a676 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -566,8 +566,8 @@ impl DatabaseManager { ) -> FlowyResult<()> { let database = self.get_database_editor_with_view_id(view_id).await?; let mut summary_row_content = SummaryRowContent::new(); - if let Some(row) = database.get_row(&view_id, &row_id).await { - let fields = database.get_fields(&view_id, None).await; + if let Some(row) = database.get_row(view_id, &row_id).await { + let fields = database.get_fields(view_id, None).await; for field in fields { // When summarizing a row, skip the content in the "AI summary" cell; it does not need to // be summarized. @@ -600,7 +600,7 @@ impl DatabaseManager { // Update the cell with the response from the cloud service. database - .update_cell_with_changeset(&view_id, &row_id, &field_id, BoxAny::new(response)) + .update_cell_with_changeset(view_id, &row_id, &field_id, BoxAny::new(response)) .await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index ace2ec52fc..d079bdc8c2 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -133,7 +133,7 @@ impl DatabaseEditor { database.clone(), )?; let this = Arc::new(Self { - database_id: database_id.clone(), + database_id, user, database, cell_cache, diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 9e78b02f9b..9225b89c03 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -158,7 +158,7 @@ impl DocumentManager { let cloud_service = self.cloud_service.clone(); let cloned_encoded_collab = encoded_collab.clone(); let workspace_id = self.user_service.workspace_id()?; - let doc_id = doc_id.clone(); + let doc_id = *doc_id; tokio::spawn(async move { let _ = cloud_service .create_document_collab(&workspace_id, &doc_id, cloned_encoded_collab) @@ -260,7 +260,7 @@ impl DocumentManager { subscribe_document_snapshot_state(&lock); subscribe_document_sync_state(&lock); } - self.documents.insert(doc_id.clone(), document.clone()); + self.documents.insert(*doc_id, document.clone()); } Ok(document) }, @@ -322,7 +322,7 @@ impl DocumentManager { lock.clean_awareness_local_state(); } - let clone_doc_id = doc_id.clone(); + let clone_doc_id = doc_id; trace!("move document to removing_documents: {}", doc_id); self.removing_documents.insert(doc_id, document); diff --git a/frontend/rust-lib/flowy-document/tests/document/util.rs b/frontend/rust-lib/flowy-document/tests/document/util.rs index a7ae759709..231bb3852e 100644 --- a/frontend/rust-lib/flowy-document/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document/tests/document/util.rs @@ -1,7 +1,6 @@ use std::ops::Deref; use std::sync::{Arc, OnceLock}; -use anyhow::Error; use collab::entity::EncodedCollab; use collab::preclude::CollabPlugin; use collab_document::blocks::DocumentData; @@ -39,7 +38,7 @@ impl DocumentTest { let builder = Arc::new(AppFlowyCollabBuilder::new( DefaultCollabStorageProvider(), WorkspaceCollabIntegrateImpl { - workspace_id: user.workspace_id.clone(), + workspace_id: user.workspace_id, }, )); @@ -89,7 +88,7 @@ impl DocumentUserService for FakeUser { } fn workspace_id(&self) -> Result { - Ok(self.workspace_id.clone()) + Ok(self.workspace_id) } fn collab_db(&self, _uid: i64) -> Result, FlowyError> { @@ -145,7 +144,7 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_doc_state( &self, document_id: &Uuid, - workspace_id: &Uuid, + _workspace_id: &Uuid, ) -> Result, FlowyError> { let document_id = document_id.to_string(); Err(FlowyError::new( @@ -156,26 +155,26 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_snapshots( &self, - document_id: &Uuid, - limit: usize, - workspace_id: &str, + _document_id: &Uuid, + _limit: usize, + _workspace_id: &str, ) -> Result, FlowyError> { Ok(vec![]) } async fn get_document_data( &self, - document_id: &Uuid, - workspace_id: &Uuid, + _document_id: &Uuid, + _workspace_id: &Uuid, ) -> Result, FlowyError> { Ok(None) } async fn create_document_collab( &self, - workspace_id: &Uuid, - document_id: &Uuid, - encoded_collab: EncodedCollab, + _workspace_id: &Uuid, + _document_id: &Uuid, + _encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) } @@ -260,7 +259,7 @@ struct WorkspaceCollabIntegrateImpl { } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { fn workspace_id(&self) -> Result { - Ok(self.workspace_id.clone()) + Ok(self.workspace_id) } fn device_id(&self) -> Result { diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 5e29702d53..4f2304846b 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -348,12 +348,12 @@ impl TryInto for CreateViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; let parent_view_id = ViewIdentify::parse(self.parent_view_id) - .and_then(|id| Uuid::from_str(&id.0).map_err(|err| ErrorCode::InvalidParams))?; + .and_then(|id| Uuid::from_str(&id.0).map_err(|_| ErrorCode::InvalidParams))?; // if view_id is not provided, generate a new view_id let view_id = self .view_id .and_then(|v| Uuid::parse_str(&v).ok()) - .unwrap_or_else(|| gen_view_id()); + .unwrap_or_else(gen_view_id); Ok(CreateViewParams { parent_view_id, @@ -379,7 +379,7 @@ impl TryInto for CreateOrphanViewPayloadPB { let view_id = Uuid::parse_str(&self.view_id).map_err(|_| ErrorCode::InvalidParams)?; Ok(CreateViewParams { - parent_view_id: view_id.clone(), + parent_view_id: view_id, name, layout: self.layout, view_id, diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 51edf3a11c..180a5dc1f3 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -189,7 +189,7 @@ impl FolderManager { let config = CollabBuilderConfig::default().sync_enable(true); let data_source = data_source.unwrap_or_else(|| { - CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.clone()).into_data_source() + CollabPersistenceImpl::new(collab_db.clone(), uid, *workspace_id).into_data_source() }); let object_id = workspace_id; @@ -245,7 +245,7 @@ impl FolderManager { .collab_object(workspace_id, uid, object_id, CollabType::Folder)?; let doc_state = - CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.clone()).into_data_source(); + CollabPersistenceImpl::new(collab_db.clone(), uid, *workspace_id).into_data_source(); let folder = self .collab_builder .create_folder( @@ -1221,7 +1221,7 @@ impl FolderManager { } let workspace_id = self.user.workspace_id()?; - let parent_view_id = Uuid::from_str(&parent_view_id)?; + let parent_view_id = Uuid::from_str(parent_view_id)?; // Sync the view to the cloud if sync_after_create { @@ -1815,7 +1815,7 @@ impl FolderManager { for data in import_data.items { // Import a single file and get the view and encoded collab data let (view, encoded_collabs) = self - .import_single_file(import_data.parent_view_id.clone(), data) + .import_single_file(import_data.parent_view_id, data) .await?; views.push(view_pb_without_child_views(view)); @@ -2054,7 +2054,7 @@ impl FolderManager { pub fn remove_indices_for_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { self .folder_indexer - .remove_indices_for_workspace(workspace_id.clone())?; + .remove_indices_for_workspace(*workspace_id)?; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index c319d2ef77..d61de1f237 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -74,13 +74,13 @@ impl FolderManager { // This will happen user can't fetch the folder data when the user sign in. let doc_state = self .cloud_service - .get_folder_doc_state(&workspace_id, uid, CollabType::Folder, &workspace_id) + .get_folder_doc_state(workspace_id, uid, CollabType::Folder, workspace_id) .await?; self .make_folder( uid, - &workspace_id, + workspace_id, collab_db.clone(), Some(DataSource::DocStateV1(doc_state)), folder_notifier.clone(), @@ -92,14 +92,14 @@ impl FolderManager { if doc_state.is_empty() { event!(Level::ERROR, "remote folder data is empty, open from local"); self - .make_folder(uid, &workspace_id, collab_db, None, folder_notifier) + .make_folder(uid, workspace_id, collab_db, None, folder_notifier) .await? } else { event!(Level::INFO, "Restore folder from remote data"); self .make_folder( uid, - &workspace_id, + workspace_id, collab_db.clone(), Some(DataSource::DocStateV1(doc_state)), folder_notifier.clone(), @@ -115,27 +115,23 @@ impl FolderManager { let index_content_rx = folder.subscribe_index_content(); self .folder_indexer - .set_index_content_receiver(index_content_rx, workspace_id.clone()); - self.handle_index_folder(workspace_id.clone(), &folder); + .set_index_content_receiver(index_content_rx, *workspace_id); + self.handle_index_folder(*workspace_id, &folder); folder_state_rx }; self.mutex_folder.store(Some(folder.clone())); let weak_mutex_folder = Arc::downgrade(&folder); - subscribe_folder_sync_state_changed( - workspace_id.clone(), - folder_state_rx, - Arc::downgrade(&self.user), - ); + subscribe_folder_sync_state_changed(*workspace_id, folder_state_rx, Arc::downgrade(&self.user)); subscribe_folder_trash_changed( - workspace_id.clone(), + *workspace_id, section_change_rx, weak_mutex_folder.clone(), Arc::downgrade(&self.user), ); subscribe_folder_view_changed( - workspace_id.clone(), + *workspace_id, view_rx, weak_mutex_folder.clone(), Arc::downgrade(&self.user), @@ -198,7 +194,7 @@ impl FolderManager { // We spawn a blocking task to index all views in the folder spawn_blocking(move || { // We remove old indexes just in case - let _ = folder_indexer.remove_indices_for_workspace(workspace_id.clone()); + let _ = folder_indexer.remove_indices_for_workspace(workspace_id); // We index all views from the workspace folder_indexer.index_all_views(views, workspace_id); diff --git a/frontend/rust-lib/flowy-folder/src/manager_observer.rs b/frontend/rust-lib/flowy-folder/src/manager_observer.rs index a6faede8b5..5d3034b5aa 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_observer.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_observer.rs @@ -99,7 +99,7 @@ pub(crate) fn subscribe_folder_sync_state_changed( } folder_notification_builder( - &workspace_id.to_string(), + workspace_id.to_string(), FolderNotification::DidUpdateFolderSyncUpdate, ) .payload(FolderSyncStatePB::from(state)) diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 509abcb955..17989aca29 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -295,7 +295,7 @@ impl IndexManager for FolderIndexManagerImpl { fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: Uuid) { let indexer = self.clone(); - let wid = workspace_id.clone(); + let wid = workspace_id; tokio::spawn(async move { while let Ok(msg) = rx.recv().await { match msg { @@ -306,7 +306,7 @@ impl IndexManager for FolderIndexManagerImpl { data: view.name, icon: view.icon, layout: view.layout, - workspace_id: wid.clone(), + workspace_id: wid, }); }, Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), @@ -318,7 +318,7 @@ impl IndexManager for FolderIndexManagerImpl { data: view.name, icon: view.icon, layout: view.layout, - workspace_id: wid.clone(), + workspace_id: wid, }); }, Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), @@ -424,7 +424,7 @@ impl FolderIndexManager for FolderIndexManagerImpl { fn index_all_views(&self, views: Vec>, workspace_id: Uuid) { let indexable_data = views .into_iter() - .map(|view| IndexableData::from_view(view, workspace_id.clone())) + .map(|view| IndexableData::from_view(view, workspace_id)) .collect(); let _ = self.index_all(indexable_data); @@ -442,14 +442,14 @@ impl FolderIndexManager for FolderIndexManagerImpl { FolderViewChange::Inserted { view_id } => { let view = views_iter.find(|view| view.id == view_id); if let Some(view) = view { - let indexable_data = IndexableData::from_view(view, workspace_id.clone()); + let indexable_data = IndexableData::from_view(view, workspace_id); let _ = self.add_index(indexable_data); } }, FolderViewChange::Updated { view_id } => { let view = views_iter.find(|view| view.id == view_id); if let Some(view) = view { - let indexable_data = IndexableData::from_view(view, workspace_id.clone()); + let indexable_data = IndexableData::from_view(view, workspace_id); let _ = self.update_index(indexable_data); } }, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 6f5edc93cc..24798d768d 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use crate::af_cloud::AFServer; use client_api::entity::ai_dto::{ ChatQuestionQuery, CompleteTextParams, RepeatedRelatedQuestion, ResponseFormat, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index f862c57fc4..c493dda344 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use crate::af_cloud::define::ServerUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; @@ -41,8 +42,8 @@ where let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id.clone(), collab_type), + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, collab_type), }; let result = try_get_client?.get_collab(params).await; match result { @@ -77,8 +78,8 @@ where .encode_to_bytes() .map_err(|err| FlowyError::internal().with_context(err))?; let params = CreateCollabParams { - workspace_id: workspace_id.clone(), - object_id: object_id.clone(), + workspace_id: *workspace_id, + object_id: *object_id, encoded_collab_v1, collab_type, }; @@ -151,7 +152,7 @@ where .map(|(key, value)| (key, Value::String(value))) .collect(); let params = SummarizeRowParams { - workspace_id: workspace_id.clone(), + workspace_id: *workspace_id, data: SummarizeRowData::Content(map), }; let data = try_get_client?.summarize_row(params).await?; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index e5e271b1e5..4909d96fef 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use client_api::entity::{CreateCollabParams, QueryCollab, QueryCollabParams}; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; @@ -33,8 +34,8 @@ where workspace_id: &Uuid, ) -> Result, FlowyError> { let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(document_id.clone(), CollabType::Document), + workspace_id: *workspace_id, + inner: QueryCollab::new(*document_id, CollabType::Document), }; let doc_state = self .inner @@ -71,8 +72,8 @@ where workspace_id: &Uuid, ) -> Result, FlowyError> { let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(document_id.clone(), CollabType::Document), + workspace_id: *workspace_id, + inner: QueryCollab::new(*document_id, CollabType::Document), }; let doc_state = self .inner @@ -105,8 +106,8 @@ where encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let params = CreateCollabParams { - workspace_id: workspace_id.clone(), - object_id: document_id.clone(), + workspace_id: *workspace_id, + object_id: *document_id, encoded_collab_v1: encoded_collab .encode_to_bytes() .map_err(|err| FlowyError::internal().with_context(err))?, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index 3a912306fc..5b8efa4b32 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use tracing::{instrument, trace}; use uuid::Uuid; -use flowy_error::{ErrorCode, FlowyError}; +use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ Folder, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, FullSyncCollabParams, Workspace, WorkspaceRecord, @@ -93,8 +93,8 @@ where let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(workspace_id.clone(), CollabType::Folder), + workspace_id: *workspace_id, + inner: QueryCollab::new(*workspace_id, CollabType::Folder), }; let doc_state = try_get_client? .get_collab(params) @@ -103,7 +103,7 @@ where .encode_collab .doc_state .to_vec(); - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get folder data")?; + check_request_workspace_id_is_match(workspace_id, &cloned_user, "get folder data")?; let folder = Folder::from_collab_doc_state( uid, CollabOrigin::Empty, @@ -126,15 +126,15 @@ where async fn get_folder_doc_state( &self, workspace_id: &Uuid, - uid: i64, + _uid: i64, collab_type: CollabType, object_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id.clone(), collab_type), + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, collab_type), }; let doc_state = try_get_client? .get_collab(params) @@ -143,7 +143,7 @@ where .encode_collab .doc_state .to_vec(); - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get folder doc state")?; + check_request_workspace_id_is_match(workspace_id, &cloned_user, "get folder doc state")?; Ok(doc_state) } @@ -155,7 +155,7 @@ where let try_get_client = self.inner.try_get_client(); try_get_client? .collab_full_sync( - &workspace_id, + workspace_id, ¶ms.object_id, params.collab_type, params.encoded_collab.doc_state.to_vec(), @@ -220,7 +220,7 @@ where }) .collect::>(); try_get_client? - .publish_collabs(&workspace_id, params) + .publish_collabs(workspace_id, params) .await?; Ok(()) } @@ -232,7 +232,7 @@ where ) -> Result<(), FlowyError> { let try_get_client = self.inner.try_get_client(); try_get_client? - .unpublish_collabs(&workspace_id, &view_ids) + .unpublish_collabs(workspace_id, &view_ids) .await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 45ba817bce..6b2917cc62 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -193,7 +193,7 @@ where async fn open_workspace(&self, workspace_id: &Uuid) -> Result { let try_get_client = self.server.try_get_client(); let client = try_get_client?; - let af_workspace = client.open_workspace(&workspace_id).await?; + let af_workspace = client.open_workspace(workspace_id).await?; Ok(to_user_workspace(af_workspace)) } @@ -348,18 +348,18 @@ where #[instrument(level = "debug", skip_all)] async fn get_user_awareness_doc_state( &self, - uid: i64, + _uid: i64, workspace_id: &Uuid, object_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id.clone(), CollabType::UserAwareness), + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, CollabType::UserAwareness), }; let resp = try_get_client?.get_collab(params).await?; - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get user awareness object")?; + check_request_workspace_id_is_match(workspace_id, &cloned_user, "get user awareness object")?; Ok(resp.encode_collab.doc_state.to_vec()) } @@ -403,12 +403,12 @@ where .into_iter() .flat_map(|object| { Uuid::from_str(&object.object_id) - .and_then(|object_id| { - Ok(CollabParams::new( + .map(|object_id| { + CollabParams::new( object_id, u8::from(object.collab_type).into(), object.encoded_collab, - )) + ) }) .ok() }) @@ -456,7 +456,7 @@ where let try_get_client = self.server.try_get_client(); let client = try_get_client?; let params = QueryWorkspaceMember { - workspace_id: workspace_id.clone(), + workspace_id: *workspace_id, uid, }; let member = client.get_workspace_member(params).await?; diff --git a/frontend/rust-lib/flowy-server/src/default_impl.rs b/frontend/rust-lib/flowy-server/src/default_impl.rs index 73633216b3..02e313115f 100644 --- a/frontend/rust-lib/flowy-server/src/default_impl.rs +++ b/frontend/rust-lib/flowy-server/src/default_impl.rs @@ -17,104 +17,104 @@ pub(crate) struct DefaultChatCloudServiceImpl; impl ChatCloudService for DefaultChatCloudServiceImpl { async fn create_chat( &self, - uid: &i64, - workspace_id: &Uuid, - chat_id: &Uuid, - rag_ids: Vec, + _uid: &i64, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _rag_ids: Vec, ) -> Result<(), FlowyError> { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn create_question( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - message: &str, - message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], + _workspace_id: &Uuid, + _chat_id: &Uuid, + _message: &str, + _message_type: ChatMessageType, + _metadata: &[ChatMessageMetadata], ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn create_answer( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - message: &str, - question_id: i64, - metadata: Option, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _message: &str, + _question_id: i64, + _metadata: Option, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn stream_answer( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - message_id: i64, - format: ResponseFormat, - ai_model: Option, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _message_id: i64, + _format: ResponseFormat, + _ai_model: Option, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_chat_messages( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - offset: MessageCursor, - limit: u64, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _offset: MessageCursor, + _limit: u64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_question_from_answer_id( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - answer_message_id: i64, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _answer_message_id: i64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_related_message( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - message_id: i64, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _message_id: i64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_answer( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - question_message_id: i64, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _question_message_id: i64, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn stream_complete( &self, - workspace_id: &Uuid, - params: CompleteTextParams, - ai_model: Option, + _workspace_id: &Uuid, + _params: CompleteTextParams, + _ai_model: Option, ) -> Result { Err(FlowyError::not_support().with_context("complete text is not supported in local server.")) } async fn embed_file( &self, - workspace_id: &Uuid, - file_path: &Path, - chat_id: &Uuid, - metadata: Option>, + _workspace_id: &Uuid, + _file_path: &Path, + _chat_id: &Uuid, + _metadata: Option>, ) -> Result<(), FlowyError> { Err(FlowyError::not_support().with_context("indexing file is not supported in local server.")) } - async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { + async fn get_local_ai_config(&self, _workspace_id: &Uuid) -> Result { Err( FlowyError::not_support() .with_context("Get local ai config is not supported in local server."), @@ -123,7 +123,7 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { async fn get_workspace_plan( &self, - workspace_id: &Uuid, + _workspace_id: &Uuid, ) -> Result, FlowyError> { Err( FlowyError::not_support() @@ -133,26 +133,26 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { async fn get_chat_settings( &self, - workspace_id: &Uuid, - chat_id: &Uuid, + _workspace_id: &Uuid, + _chat_id: &Uuid, ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn update_chat_settings( &self, - workspace_id: &Uuid, - chat_id: &Uuid, - params: UpdateChatParams, + _workspace_id: &Uuid, + _id: &Uuid, + _s: UpdateChatParams, ) -> Result<(), FlowyError> { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } - async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + async fn get_available_models(&self, _workspace_id: &Uuid) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } - async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + async fn get_workspace_default_model(&self, _workspace_id: &Uuid) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index 36d31902f0..46b0cdd649 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use collab::entity::EncodedCollab; use collab_database::database::default_database_data; use collab_database::workspace_database::default_workspace_database_data; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs index fe44470921..c553026274 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use collab::entity::EncodedCollab; use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError}; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index a90352a976..7bb3139953 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use std::sync::Arc; use crate::local_server::LocalServerDB; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index ddea5353d8..962c0506bf 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_entity::CollabObject; diff --git a/frontend/rust-lib/flowy-user/src/services/billing_check.rs b/frontend/rust-lib/flowy-user/src/services/billing_check.rs index b15f811ab6..ea5bc65b75 100644 --- a/frontend/rust-lib/flowy-user/src/services/billing_check.rs +++ b/frontend/rust-lib/flowy-user/src/services/billing_check.rs @@ -47,7 +47,7 @@ impl PeriodicallyCheckBillingState { while attempts < max_attempts { let plans = cloud_service .get_user_service()? - .get_workspace_plan(self.workspace_id.clone()) + .get_workspace_plan(self.workspace_id) .await?; // If the expected plan is not set, return the plans immediately. Otherwise, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index cc9cddabb5..d055621398 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -231,7 +231,7 @@ impl UserManager { let workspace_id = session.user_workspace.workspace_id()?; let create_awareness = if authenticator.is_local() { let doc_state = - CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id.clone()) + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) .into_data_source(); Self::collab_for_user_awareness( &weak_builder, @@ -266,12 +266,9 @@ impl UserManager { Err(err) => { if err.is_record_not_found() { info!("User awareness not found, creating new"); - let doc_state = CollabPersistenceImpl::new( - collab_db.clone(), - session.user_id, - workspace_id.clone(), - ) - .into_data_source(); + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); Self::collab_for_user_awareness( &weak_builder, &workspace_id, From 24d57336a979829ba6850f0ddf7a9a125bd98c3d Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 7 Apr 2025 21:15:30 +0800 Subject: [PATCH 097/207] chore: bump collab id --- frontend/rust-lib/Cargo.lock | 16 ++++----- frontend/rust-lib/Cargo.toml | 16 ++++----- .../collab-integrate/src/collab_builder.rs | 13 ++++--- .../tests/folder/local_test/folder_test.rs | 8 ++--- .../local_test/publish_document_test.rs | 25 ++++++------- .../tests/user/migration_test/version_test.rs | 35 ------------------- .../rust-lib/flowy-document/src/manager.rs | 8 ++++- 7 files changed, 46 insertions(+), 75 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index e10038c7d3..baa5c5ee6d 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "arc-swap", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "async-trait", @@ -1313,7 +1313,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "arc-swap", @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "bytes", @@ -1354,7 +1354,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "arc-swap", @@ -1376,7 +1376,7 @@ dependencies = [ [[package]] name = "collab-importer" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "async-recursion", @@ -1439,7 +1439,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "async-stream", @@ -1517,7 +1517,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4a0e2cc07f50f17d1b6605c579e622a431e94998#4a0e2cc07f50f17d1b6605c579e622a431e94998" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" dependencies = [ "anyhow", "collab", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index e3cb5a4178..78f67f8f90 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -139,14 +139,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } -collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4a0e2cc07f50f17d1b6605c579e622a431e94998" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index 226a5b679b..e31c38d043 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -1,6 +1,5 @@ use std::borrow::BorrowMut; use std::fmt::{Debug, Display}; -use std::str::FromStr; use std::sync::{Arc, Weak}; use crate::CollabKVDB; @@ -138,7 +137,6 @@ impl AppFlowyCollabBuilder { )); } let device_id = self.workspace_integrate.device_id()?; - let workspace_id = self.workspace_integrate.workspace_id()?; Ok(CollabObject::new( uid, object_id.to_string(), @@ -426,13 +424,13 @@ impl CollabPersistence for CollabPersistenceImpl { .upgrade() .ok_or_else(|| CollabError::Internal(anyhow!("collab_db is dropped")))?; - let object_id = - Uuid::from_str(collab.object_id()).map_err(|v| CollabError::Internal(v.into()))?; + let object_id = collab.object_id().to_string(); let rocksdb_read = collab_db.read_txn(); + let workspace_id = self.workspace_id.to_string(); - if rocksdb_read.is_exist(self.uid, &self.workspace_id, &object_id) { + if rocksdb_read.is_exist(self.uid, &workspace_id, &object_id) { let mut txn = collab.transact_mut(); - match rocksdb_read.load_doc_with_txn(self.uid, &self.workspace_id, &object_id, &mut txn) { + match rocksdb_read.load_doc_with_txn(self.uid, &workspace_id, &object_id, &mut txn) { Ok(update_count) => { trace!( "did load collab:{}-{} from disk, update_count:{}", @@ -457,6 +455,7 @@ impl CollabPersistence for CollabPersistenceImpl { object_id: &str, encoded_collab: EncodedCollab, ) -> Result<(), CollabError> { + let workspace_id = self.workspace_id.to_string(); let collab_db = self .db .upgrade() @@ -465,7 +464,7 @@ impl CollabPersistence for CollabPersistenceImpl { write_txn .flush_doc( self.uid, - self.workspace_id.to_string().as_str(), + workspace_id.as_str(), object_id, encoded_collab.state_vector.to_vec(), encoded_collab.doc_state.to_vec(), diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs index d0a4a28429..5857190b8b 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs @@ -1,8 +1,8 @@ use collab_folder::ViewLayout; - use event_integration_test::EventIntegrationTest; use flowy_folder::entities::icon::{ViewIconPB, ViewIconTypePB}; use flowy_folder::entities::ViewLayoutPB; +use uuid::Uuid; use crate::folder::local_test::script::FolderScript::*; use crate::folder::local_test::script::FolderTest; @@ -338,11 +338,11 @@ async fn move_view_event_test() { async fn create_orphan_child_view_and_get_its_ancestors_test() { let test = EventIntegrationTest::new_anon().await; let name = "Orphan View"; - let view_id = "20240521"; + let view_id = Uuid::new_v4().to_string(); test - .create_orphan_view(name, view_id, ViewLayoutPB::Grid) + .create_orphan_view(name, &view_id, ViewLayoutPB::Grid) .await; - let ancestors = test.get_view_ancestors(view_id).await; + let ancestors = test.get_view_ancestors(&view_id).await; assert_eq!(ancestors.len(), 1); assert_eq!(ancestors[0].name, "Orphan View"); assert_eq!(ancestors[0].id, view_id); diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs index 0d5b9bc08c..089310b260 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs @@ -6,6 +6,7 @@ use flowy_folder::view_operation::GatherEncodedCollab; use flowy_folder_pub::entities::{ PublishDocumentPayload, PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData, }; +use uuid::Uuid; async fn mock_single_document_view_publish_payload( test: &EventIntegrationTest, @@ -140,11 +141,11 @@ async fn create_nested_document(test: &EventIntegrationTest, view_id: &str, name #[tokio::test] async fn single_document_get_publish_view_payload_test() { let test = EventIntegrationTest::new_anon().await; - let view_id = "20240521"; + let view_id = Uuid::new_v4().to_string(); let name = "Orphan View"; - create_single_document(&test, view_id, name).await; - let view = test.get_view(view_id).await; - let payload = test.get_publish_payload(view_id, true).await; + create_single_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, true).await; let expect_payload = mock_single_document_view_publish_payload( &test, @@ -160,10 +161,10 @@ async fn single_document_get_publish_view_payload_test() { async fn nested_document_get_publish_view_payload_test() { let test = EventIntegrationTest::new_anon().await; let name = "Orphan View"; - let view_id = "20240521"; - create_nested_document(&test, view_id, name).await; - let view = test.get_view(view_id).await; - let payload = test.get_publish_payload(view_id, true).await; + let view_id = Uuid::new_v4().to_string(); + create_nested_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, true).await; let expect_payload = mock_nested_document_view_publish_payload( &test, @@ -180,10 +181,10 @@ async fn nested_document_get_publish_view_payload_test() { async fn no_children_publish_view_payload_test() { let test = EventIntegrationTest::new_anon().await; let name = "Orphan View"; - let view_id = "20240521"; - create_nested_document(&test, view_id, name).await; - let view = test.get_view(view_id).await; - let payload = test.get_publish_payload(view_id, false).await; + let view_id = Uuid::new_v4().to_string(); + create_nested_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, false).await; let data = mock_single_document_view_publish_payload( &test, diff --git a/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs b/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs index 5a75197a9c..61833429aa 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs @@ -1,44 +1,9 @@ use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_folder::entities::ViewLayoutPB; use std::time::Duration; use crate::util::unzip; -#[tokio::test] -async fn migrate_020_historical_empty_document_test() { - let user_db_path = unzip( - "./tests/user/migration_test/history_user_db", - "020_historical_user_data", - ) - .unwrap(); - let test = - EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; - - let mut views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 1); - - // Check the parent view - let parent_view = views.pop().unwrap(); - assert_eq!(parent_view.layout, ViewLayoutPB::Document); - let data = test.open_document(parent_view.id.clone()).await.data; - assert!(!data.page_id.is_empty()); - assert_eq!(data.blocks.len(), 2); - assert!(!data.meta.children_map.is_empty()); - - // Check the child views of the parent view - let child_views = test.get_view(&parent_view.id).await.child_views; - assert_eq!(child_views.len(), 4); - assert_eq!(child_views[0].layout, ViewLayoutPB::Document); - assert_eq!(child_views[1].layout, ViewLayoutPB::Grid); - assert_eq!(child_views[2].layout, ViewLayoutPB::Calendar); - assert_eq!(child_views[3].layout, ViewLayoutPB::Board); - - let database = test.get_database(&child_views[1].id).await; - assert_eq!(database.fields.len(), 8); - assert_eq!(database.rows.len(), 3); -} - #[tokio::test] async fn migrate_036_fav_v1_workspace_array_test() { // Used to test migration: FavoriteV1AndWorkspaceArrayMigration diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 9225b89c03..704dcd0865 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -482,7 +482,13 @@ async fn doc_state_from_document_data( data: Option, ) -> Result { let doc_id = doc_id.to_string(); - let data = data.unwrap_or_else(|| default_document_data(&doc_id)); + let data = data.unwrap_or_else(|| { + trace!( + "{} document data is None, use default document data", + doc_id.to_string() + ); + default_document_data(&doc_id) + }); // spawn_blocking is used to avoid blocking the tokio thread pool if the document is large. let encoded_collab = tokio::task::spawn_blocking(move || { let collab = Collab::new_with_origin(CollabOrigin::Empty, doc_id, vec![], false); From 20bcdd1f904e67cf322521a10ae14ad2edb9325e Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 7 Apr 2025 21:28:48 +0800 Subject: [PATCH 098/207] chore: fmt --- .../rust-lib/flowy-core/src/deps_resolve/document_deps.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs index 533ac6f931..3527bc42d6 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs @@ -1,5 +1,3 @@ -use std::sync::{Arc, Weak}; -use uuid::Uuid; use crate::deps_resolve::CollabSnapshotSql; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; @@ -10,6 +8,8 @@ use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{FlowyError, FlowyResult}; use flowy_storage_pub::storage::StorageService; use flowy_user::services::authenticate_user::AuthenticateUser; +use std::sync::{Arc, Weak}; +use uuid::Uuid; pub struct DocumentDepsResolver(); impl DocumentDepsResolver { From 4896d7c1bec9799abb8ec4f333d2f9693dd399a9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 7 Apr 2025 22:04:32 +0800 Subject: [PATCH 099/207] chore: enable sync log by default --- .../settings/widgets/setting_appflowy_cloud.dart | 2 +- frontend/rust-lib/flowy-core/src/config.rs | 10 ++++------ frontend/rust-lib/flowy-document/src/event_handler.rs | 10 ++++------ .../flowy-server-pub/src/native/af_cloud_config.rs | 2 +- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index 645c3daa65..a9cb362bf3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -130,7 +130,7 @@ class CustomAppFlowyCloudView extends StatelessWidget { final List children = []; children.addAll([ const AppFlowyCloudEnableSync(), - const AppFlowyCloudSyncLogEnabled(), + // const AppFlowyCloudSyncLogEnabled(), const VSpace(40), ]); diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 2b379ab63a..33386249e6 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -84,14 +84,12 @@ impl AppFlowyCoreConfig { ) -> Self { let cloud_config = AFCloudConfiguration::from_env().ok(); let mut log_crates = vec![]; + // By default enable sync trace log + log_crates.push("sync_trace_log".to_string()); + let storage_path = match &cloud_config { None => custom_application_path, - Some(config) => { - if config.enable_sync_trace { - log_crates.push("sync_trace_log".to_string()); - } - make_user_data_folder(&custom_application_path, &config.base_url) - }, + Some(config) => make_user_data_folder(&custom_application_path, &config.base_url), }; let log_filter = create_log_filter( diff --git a/frontend/rust-lib/flowy-document/src/event_handler.rs b/frontend/rust-lib/flowy-document/src/event_handler.rs index cb16129c40..acf45777eb 100644 --- a/frontend/rust-lib/flowy-document/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document/src/event_handler.rs @@ -21,6 +21,7 @@ use crate::parser::parser_entities::{ use crate::{manager::DocumentManager, parser::json::parser::JsonToDocumentParser}; use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use lib_infra::sync_trace; use tracing::instrument; use uuid::Uuid; @@ -124,9 +125,7 @@ pub(crate) async fn apply_action_handler( let doc_id = params.document_id; let document = manager.editable_document(&doc_id).await?; let actions = params.actions; - if cfg!(feature = "verbose_log") { - tracing::trace!("{} applying actions: {:?}", doc_id, actions); - } + sync_trace!("{} applying action: {:?}", doc_id, actions); document.write().await.apply_action(actions)?; Ok(()) } @@ -141,6 +140,7 @@ pub(crate) async fn create_text_handler( let doc_id = params.document_id; let document = manager.editable_document(&doc_id).await?; let mut document = document.write().await; + sync_trace!("{} creating text: {:?}", doc_id, params.delta); document.apply_text_delta(¶ms.text_id, params.delta); Ok(()) } @@ -157,9 +157,7 @@ pub(crate) async fn apply_text_delta_handler( let text_id = params.text_id; let delta = params.delta; let mut document = document.write().await; - if cfg!(feature = "verbose_log") { - tracing::trace!("{} applying delta: {:?}", doc_id, delta); - } + sync_trace!("{} applying delta: {:?}", doc_id, delta); document.apply_text_delta(&text_id, delta); Ok(()) } diff --git a/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs b/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs index 14a72c6ce6..9c74850fcd 100644 --- a/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs +++ b/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs @@ -60,7 +60,7 @@ impl AFCloudConfiguration { let enable_sync_trace = std::env::var(APPFLOWY_ENABLE_SYNC_TRACE) .map(|v| v == "true" || v == "1") - .unwrap_or(false); + .unwrap_or(true); Ok(Self { base_url, From 0286678286fc4fa05118bee63ebd1254b5f42106 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 8 Apr 2025 10:30:48 +0800 Subject: [PATCH 100/207] chore: clippy --- frontend/rust-lib/flowy-core/src/config.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 33386249e6..0067eff5a1 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -83,10 +83,8 @@ impl AppFlowyCoreConfig { name: String, ) -> Self { let cloud_config = AFCloudConfiguration::from_env().ok(); - let mut log_crates = vec![]; // By default enable sync trace log - log_crates.push("sync_trace_log".to_string()); - + let log_crates = vec!["sync_trace_log".to_string()]; let storage_path = match &cloud_config { None => custom_application_path, Some(config) => make_user_data_folder(&custom_application_path, &config.base_url), From 9e30b1816fe613a3d6e6051c5b016f30f1e2a2b8 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:46:06 +0800 Subject: [PATCH 101/207] fix(flutter_desktop): grid bug (#7697) * fix: field width doesn't persist * test: add test * test: fix test * test: grid width integration test --- .../database_field_settings_test.dart | 41 +++++++++++++++++++ .../shared/database_test_op.dart | 25 +++++++++++ .../application/field/field_controller.dart | 17 +++++--- .../widgets/header/desktop_field_cell.dart | 9 ++-- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart index 9b9434d3d7..a71110f1e0 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart @@ -15,6 +15,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); + // create a database and add a linked database view await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); @@ -29,6 +30,11 @@ void main() { await tester.tapHidePropertyButton(); tester.noFieldWithName('New field 1'); + // create another field, New field 1 to be hidden still + await tester.tapNewPropertyButton(); + await tester.dismissFieldEditor(); + tester.noFieldWithName('New field 1'); + // go back to inline database view, expect field to be shown await tester.tapTabBarLinkedViewByViewName('Untitled'); tester.findFieldWithName('New field 1'); @@ -60,5 +66,40 @@ void main() { await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1"); }); + + testWidgets('field cell width', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a database and add a linked database view + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); + + // create a field + await tester.scrollToRight(find.byType(GridPage)); + await tester.tapNewPropertyButton(); + await tester.renameField('New field 1'); + await tester.dismissFieldEditor(); + + // check the width of the field + expect(tester.getFieldWidth('New field 1'), 150); + + // change the width of the field + await tester.changeFieldWidth('New field 1', 200); + expect(tester.getFieldWidth('New field 1'), 205); + + // create another field, New field 1 to be same width + await tester.tapNewPropertyButton(); + await tester.dismissFieldEditor(); + expect(tester.getFieldWidth('New field 1'), 205); + + // go back to inline database view, expect New field 1 to be 150px + await tester.tapTabBarLinkedViewByViewName('Untitled'); + expect(tester.getFieldWidth('New field 1'), 150); + + // go back to linked database view, expect New field 1 to be 205px + await tester.tapTabBarLinkedViewByViewName('Grid'); + expect(tester.getFieldWidth('New field 1'), 205); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index 9a82c881e0..970965f294 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -942,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester { await pumpAndSettle(const Duration(milliseconds: 200)); } + Future changeFieldWidth(String fieldName, double width) async { + final field = find.byWidgetPredicate( + (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, + ); + await hoverOnWidget( + field, + onHover: () async { + final dragHandle = find.descendant( + of: field, + matching: find.byType(DragToExpandLine), + ); + await drag(dragHandle, Offset(width - getSize(field).width, 0)); + await pumpAndSettle(const Duration(milliseconds: 200)); + }, + ); + } + + double getFieldWidth(String fieldName) { + final field = find.byWidgetPredicate( + (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, + ); + + return getSize(field).width; + } + Future findDateEditor(dynamic matcher) async { final finder = find.byType(DateCellEditor); expect(finder, matcher); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart index 8370bd9bff..93fd69bcfc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart @@ -411,23 +411,28 @@ class FieldController { /// Listen for field setting changes in the backend. void _listenOnFieldSettingsChanged() { FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) { - final List newFields = fieldInfos; - var updatedField = newFields.firstOrNull; + final newFields = [...fieldInfos]; - if (updatedField == null) { + if (newFields.isEmpty) { return null; } final index = newFields .indexWhere((field) => field.id == updatedFieldSettings.fieldId); + if (index != -1) { newFields[index] = newFields[index].copyWith(fieldSettings: updatedFieldSettings); - updatedField = newFields[index]; + _fieldNotifier.fieldInfos = newFields; + _fieldSettings + ..removeWhere( + (field) => field.fieldId == updatedFieldSettings.fieldId, + ) + ..add(updatedFieldSettings); + return newFields[index]; } - _fieldNotifier.fieldInfos = newFields; - return updatedField; + return null; } _fieldSettingsListener.start( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart index 23c2fe1f91..915bf70a61 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart @@ -108,7 +108,7 @@ class _GridFieldCellState extends State { top: 0, bottom: 0, right: 0, - child: _DragToExpandLine(), + child: DragToExpandLine(), ); return _GridHeaderCellContainer( @@ -158,8 +158,11 @@ class _GridHeaderCellContainer extends StatelessWidget { } } -class _DragToExpandLine extends StatelessWidget { - const _DragToExpandLine(); +@visibleForTesting +class DragToExpandLine extends StatelessWidget { + const DragToExpandLine({ + super.key, + }); @override Widget build(BuildContext context) { From 462c822255b91f276dfd122d6b8752b41c807aa4 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:08:10 +0800 Subject: [PATCH 102/207] fix: translations (#7694) --- .../presentation/base/view_page/more_bottom_sheet.dart | 2 +- .../presentation/message/ai_message_action_bar.dart | 2 +- .../ai_chat/presentation/message/ai_message_bubble.dart | 2 +- .../database/widgets/cell/editable_cell_skeleton/url.dart | 2 +- .../lib/plugins/shared/share/export_tab.dart | 2 +- .../lib/plugins/shared/share/publish_tab.dart | 2 +- .../lib/plugins/shared/share/share_tab.dart | 2 +- .../presentation/settings/pages/sites/constants.dart | 2 +- frontend/resources/translations/ar-SA.json | 3 +-- frontend/resources/translations/de-DE.json | 3 +-- frontend/resources/translations/el-GR.json | 5 ++--- frontend/resources/translations/en.json | 7 +++---- frontend/resources/translations/es-VE.json | 3 +-- frontend/resources/translations/fr-FR.json | 3 +-- frontend/resources/translations/he.json | 3 +-- frontend/resources/translations/ja-JP.json | 3 +-- frontend/resources/translations/ko-KR.json | 3 +-- frontend/resources/translations/ru-RU.json | 3 +-- frontend/resources/translations/th-TH.json | 3 +-- frontend/resources/translations/tr-TR.json | 3 +-- frontend/resources/translations/uk-UA.json | 3 +-- frontend/resources/translations/vi-VN.json | 3 +-- frontend/resources/translations/zh-CN.json | 3 +-- frontend/resources/translations/zh-TW.json | 3 +-- 24 files changed, 27 insertions(+), 43 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index dd659420d6..080d83e83c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -203,7 +203,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { ); showToastNotification( context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } } 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 150ce20192..945613490a 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 @@ -185,7 +185,7 @@ class CopyButton extends StatelessWidget { if (context.mounted) { showToastNotification( context, - message: LocaleKeys.grid_url_copiedNotification.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } }, 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 eed5f0a520..3ce80e2919 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 @@ -377,7 +377,7 @@ class ChatAIMessagePopup extends StatelessWidget { if (context.mounted) { showToastNotification( context, - message: LocaleKeys.grid_url_copiedNotification.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart index 0dc7779e55..39616dbcf8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart @@ -203,7 +203,7 @@ class MobileURLEditor extends StatelessWidget { ClipboardData(text: textEditingController.text), ); Fluttertoast.showToast( - msg: LocaleKeys.grid_url_copiedNotification.tr(), + msg: LocaleKeys.message_copy_success.tr(), gravity: ToastGravity.BOTTOM, ); context.pop(); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart index fc2a4507bd..e4a23b6459 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart @@ -175,7 +175,7 @@ class ExportTab extends StatelessWidget { ); showToastNotification( context, - message: LocaleKeys.grid_url_copiedNotification.tr(), + message: LocaleKeys.message_copy_success.tr(), ); }, (error) => showToastNotification(context, message: error.msg), diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart index 1f754a4372..0ffa22a43b 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -183,7 +183,7 @@ class _PublishedWidgetState extends State<_PublishedWidget> { showToastNotification( context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); }, onSubmitted: (pathName) { diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart index 6980edce46..1b925cfec6 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart @@ -118,7 +118,7 @@ class _ShareTabContent extends StatelessWidget { showToastNotification( context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart index f764bec9e7..1a3b305c0b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart @@ -54,7 +54,7 @@ class SettingsPageSitesEvent { getIt().setData(ClipboardServiceData(plainText: url)); showToastNotification( context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } } diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index cc5d11719d..4afd35c40e 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -1672,8 +1672,7 @@ "url": { "launch": "فتح في المتصفح", "copy": "إنسخ الرابط", - "textFieldHint": "أدخل عنوان URL", - "copiedNotification": "تمت نسخها إلى الحافظة!" + "textFieldHint": "أدخل عنوان URL" }, "relation": { "relatedDatabasePlaceLabel": "قاعدة البيانات ذات الصلة", diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index e95b40d26d..60bb99b2fb 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -1625,8 +1625,7 @@ "url": { "launch": "Im Browser öffnen", "copy": "Webadresse kopieren", - "textFieldHint": "Gebe eine URL ein", - "copiedNotification": "In die Zwischenablage kopiert!" + "textFieldHint": "Gebe eine URL ein" }, "relation": { "relatedDatabasePlaceLabel": "Verwandte Datenbank", diff --git a/frontend/resources/translations/el-GR.json b/frontend/resources/translations/el-GR.json index 6d6524a8bf..a329a8998c 100644 --- a/frontend/resources/translations/el-GR.json +++ b/frontend/resources/translations/el-GR.json @@ -723,9 +723,8 @@ }, "url": { "launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης", - "copy": "Copied link to clipboard", - "textFieldHint": "Enter a URL", - "copiedNotification": "Copied to clipboard!" + "copy": "Copy link to clipboard", + "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 27b98fdce5..47a2bec391 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1647,9 +1647,8 @@ }, "url": { "launch": "Open link in browser", - "copy": "Copied link to clipboard", - "textFieldHint": "Enter a URL", - "copiedNotification": "Copied to clipboard!" + "copy": "Copy link to clipboard", + "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", @@ -2273,7 +2272,7 @@ }, "message": { "copy": { - "success": "Copied!", + "success": "Copied to clipboard", "fail": "Unable to copy" } }, diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index 31174c3dc5..e73165349a 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -866,8 +866,7 @@ "url": { "launch": "Abrir en el navegador", "copy": "Copiar URL", - "textFieldHint": "Introduce una URL", - "copiedNotification": "¡Copiado al portapapeles!" + "textFieldHint": "Introduce una URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de datos relacionada", diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index 0d542e916b..f2c3e1270d 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -1612,8 +1612,7 @@ "url": { "launch": "Ouvrir dans le navigateur", "copy": "Copier l'URL", - "textFieldHint": "Entrez une URL", - "copiedNotification": "Copié dans le presse-papier!" + "textFieldHint": "Entrez une URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de données associée", diff --git a/frontend/resources/translations/he.json b/frontend/resources/translations/he.json index d47c33c6da..eda0cd920f 100644 --- a/frontend/resources/translations/he.json +++ b/frontend/resources/translations/he.json @@ -1243,8 +1243,7 @@ "url": { "launch": "פתיחת קישור בדפדפן", "copy": "העתקת קישור ללוח הגזירים", - "textFieldHint": "נא למלא כתובת", - "copiedNotification": "הועתק ללוח הגזירים!" + "textFieldHint": "נא למלא כתובת" }, "relation": { "relatedDatabasePlaceLabel": "מסד נתונים קשור", diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index cd41f16530..15333ff3da 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -1577,8 +1577,7 @@ "url": { "launch": "リンクをブラウザで開く", "copy": "リンクをクリップボードにコピー", - "textFieldHint": "URLを入力", - "copiedNotification": "クリップボードにコピーされました!" + "textFieldHint": "URLを入力" }, "relation": { "relatedDatabasePlaceLabel": "関連データベース", diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 66240d954a..0b2aabe508 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -1634,8 +1634,7 @@ "url": { "launch": "브라우저에서 링크 열기", "copy": "링크를 클립보드에 복사", - "textFieldHint": "URL 입력", - "copiedNotification": "클립보드에 복사되었습니다!" + "textFieldHint": "URL 입력" }, "relation": { "relatedDatabasePlaceLabel": "관련 데이터베이스", diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index 6ca29ffc03..2bac18a879 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -1420,8 +1420,7 @@ "url": { "launch": "Открыть в браузере", "copy": "Скопировать URL", - "textFieldHint": "Введите URL-адрес", - "copiedNotification": "Скопировано в буфер обмена!" + "textFieldHint": "Введите URL-адрес" }, "relation": { "relatedDatabasePlaceLabel": "Связанная база данных", diff --git a/frontend/resources/translations/th-TH.json b/frontend/resources/translations/th-TH.json index 0e888fdb9b..0be97f517e 100644 --- a/frontend/resources/translations/th-TH.json +++ b/frontend/resources/translations/th-TH.json @@ -1570,8 +1570,7 @@ "url": { "launch": "เปิดในเบราว์เซอร์", "copy": "คัดลอก URL", - "textFieldHint": "ป้อน URL", - "copiedNotification": "คัดลอกไปยังคลิปบอร์ดแล้ว!" + "textFieldHint": "ป้อน URL" }, "relation": { "relatedDatabasePlaceLabel": "ฐานข้อมูลที่เกี่ยวข้อง", diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index 346742f295..0e830b62c4 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -1608,8 +1608,7 @@ "url": { "launch": "Bağlantıyı tarayıcıda aç", "copy": "Bağlantıyı panoya kopyala", - "textFieldHint": "Bir URL girin", - "copiedNotification": "Panoya kopyalandı!" + "textFieldHint": "Bir URL girin" }, "relation": { "relatedDatabasePlaceLabel": "İlişkili Veritabanı", diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index 8227fc4c53..3262de34a7 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -1445,8 +1445,7 @@ "url": { "launch": "Відкрити посилання в браузері", "copy": "Копіювати посилання в буфер обміну", - "textFieldHint": "Введіть URL", - "copiedNotification": "Скопійовано в буфер обміну!" + "textFieldHint": "Введіть URL" }, "relation": { "relatedDatabasePlaceLabel": "Пов'язана база даних", diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index 7e27e8a648..957fdd6e03 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -1439,8 +1439,7 @@ "url": { "launch": "Mở liên kết trong trình duyệt", "copy": "Sao chép URL", - "textFieldHint": "Nhập một URL", - "copiedNotification": "Đã sao chép vào bảng tạm!" + "textFieldHint": "Nhập một URL" }, "relation": { "relatedDatabasePlaceLabel": "Cơ sở dữ liệu liên quan", diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 557916b5b5..44c46d8b5c 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -1270,8 +1270,7 @@ "url": { "launch": "在浏览器中打开链接", "copy": "将链接复制到剪贴板", - "textFieldHint": "输入 URL", - "copiedNotification": "已复制到剪贴板!" + "textFieldHint": "输入 URL" }, "relation": { "rowSearchTextFieldPlaceholder": "搜索" diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index fe66a58aa8..acd121049e 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -838,8 +838,7 @@ "url": { "launch": "在瀏覽器中開啟", "copy": "複製網址", - "textFieldHint": "輸入網址", - "copiedNotification": "已複製到剪貼簿" + "textFieldHint": "輸入網址" }, "menuName": "網格", "referencedGridPrefix": "檢視", From efb98d28ef2dcd8e90e41e9c34621b96a41adf42 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 8 Apr 2025 15:53:34 +0800 Subject: [PATCH 103/207] chore: rename data path --- .../lib/startup/tasks/rust_sdk.dart | 4 +- .../auth/backend_auth_service.dart | 5 +- frontend/rust-lib/Cargo.lock | 4 +- frontend/rust-lib/flowy-core/Cargo.toml | 1 + frontend/rust-lib/flowy-core/src/config.rs | 55 +++++++++++++++---- .../rust-lib/flowy-user-pub/src/entities.rs | 1 + frontend/rust-lib/flowy-user/Cargo.toml | 3 - .../rust-lib/flowy-user/src/entities/auth.rs | 4 ++ .../rust-lib/flowy-user/src/event_handler.rs | 6 +- .../flowy-user/src/user_manager/manager.rs | 1 - frontend/rust-lib/lib-infra/src/file_util.rs | 2 +- 11 files changed, 58 insertions(+), 28 deletions(-) diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index 55f2f53512..c406dd161a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -73,10 +73,10 @@ Future appFlowyApplicationDataDirectory() async { case IntegrationMode.develop: final Directory documentsDir = await getApplicationSupportDirectory() .then((directory) => directory.create()); - return Directory(path.join(documentsDir.path, 'data_dev')).create(); + return Directory(path.join(documentsDir.path, 'data_dev')); case IntegrationMode.release: final Directory documentsDir = await getApplicationSupportDirectory(); - return Directory(path.join(documentsDir.path, 'data')).create(); + return Directory(path.join(documentsDir.path, 'data')); case IntegrationMode.unitTest: case IntegrationMode.integrationTest: return Directory(path.join(Directory.current.path, '.sandbox')); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart index 9147fb4fb9..b5cf413603 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -8,7 +8,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.dart'; import '../../../generated/locale_keys.g.dart'; import 'device_id.dart'; @@ -65,13 +64,13 @@ class BackendAuthService implements AuthService { Map params = const {}, }) async { const password = "Guest!@123456"; - final uid = uuid(); - final userEmail = "$uid@appflowy.io"; + final userEmail = "anon@appflowy.io"; final request = SignUpPayloadPB.create() ..name = LocaleKeys.defaultUsername.tr() ..email = userEmail ..password = password + ..isAnon = true // When sign up as guest, the auth type is always local. ..authType = AuthenticatorPB.Local ..deviceId = await getDeviceId(); diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index baa5c5ee6d..a72f4596f6 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -2604,6 +2604,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "url", "uuid", ] @@ -3049,7 +3050,6 @@ dependencies = [ "collab-user", "dashmap 6.0.1", "diesel", - "diesel_derives", "fake", "fancy-regex 0.11.0", "flowy-codegen", @@ -3064,7 +3064,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "once_cell", "protobuf", "quickcheck", "quickcheck_macros", @@ -3074,7 +3073,6 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_repr", "strum", "strum_macros 0.25.2", "tokio", diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 6225652078..6499f79284 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -58,6 +58,7 @@ serde_repr.workspace = true uuid.workspace = true sysinfo = "0.30.5" semver = { version = "1.0.22", features = ["serde"] } +url = "2.5.0" [features] profiling = ["console-subscriber", "tokio/tracing"] diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 0067eff5a1..27f658de8d 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -1,9 +1,10 @@ use std::fmt; -use std::path::Path; +use std::path::{Path, PathBuf}; use base64::Engine; use semver::Version; use tracing::{error, info}; +use url::Url; use crate::log_filter::create_log_filter; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; @@ -46,30 +47,60 @@ impl fmt::Debug for AppFlowyCoreConfig { } fn make_user_data_folder(root: &str, url: &str) -> String { - // Isolate the user data folder by using the base url of AppFlowy cloud. This is to avoid - // the user data folder being shared by different AppFlowy cloud. - let storage_path = if !url.is_empty() { - let server_base64 = URL_SAFE_ENGINE.encode(url); - format!("{}_{}", root, server_base64) + // If a URL is provided, try to parse it and extract the domain name. + // This isolates the user data folder by the domain, which prevents data sharing + // between different AppFlowy cloud instances. + print!("Creating user data folder for URL: {}, root:{}", url, root); + let mut storage_path = if url.is_empty() { + PathBuf::from(root) } else { - root.to_string() + let server_base64 = URL_SAFE_ENGINE.encode(url); + PathBuf::from(format!("{}_{}", root, server_base64)) }; + // Only use new storage path if the old one doesn't exist + if !storage_path.exists() { + let anon_path = format!("{}_anonymous", root); + // We use domain name as suffix to isolate the user data folder since version 0.8.9 + let new_storage_path = if url.is_empty() { + // if the url is empty, then it's anonymous mode + anon_path + } else { + match Url::parse(url) { + Ok(parsed_url) => { + if let Some(domain) = parsed_url.host_str() { + format!("{}_{}", root, domain) + } else { + anon_path + } + }, + Err(_) => anon_path, + } + }; + + storage_path = PathBuf::from(new_storage_path); + } + // Copy the user data folder from the root path to the isolated path // The root path without any suffix is the created by the local version AppFlowy - if !Path::new(&storage_path).exists() && Path::new(root).exists() { - info!("Copy dir from {} to {}", root, storage_path); + if !storage_path.exists() && Path::new(root).exists() { + info!("Copy dir from {} to {:?}", root, storage_path); let src = Path::new(root); - match copy_dir_recursive(src, Path::new(&storage_path)) { - Ok(_) => storage_path, + match copy_dir_recursive(src, &storage_path) { + Ok(_) => storage_path + .into_os_string() + .into_string() + .unwrap_or_else(|_| root.to_string()), Err(err) => { - // when the copy dir failed, use the root path as the storage path error!("Copy dir failed: {}", err); root.to_string() }, } } else { storage_path + .into_os_string() + .into_string() + .unwrap_or_else(|_| root.to_string()) } } diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 857a735edb..fce59b7994 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -42,6 +42,7 @@ pub struct SignUpParams { pub password: String, pub auth_type: Authenticator, pub device_id: String, + pub is_anon: bool, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 5ad579a0fb..4d021161ac 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -31,12 +31,9 @@ tracing.workspace = true bytes.workspace = true serde = { workspace = true, features = ["rc"] } serde_json.workspace = true -serde_repr.workspace = true protobuf.workspace = true lazy_static = "1.4.0" diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } -once_cell = "1.17.1" strum = "0.25" strum_macros = "0.25.2" tokio = { workspace = true, features = ["rt"] } diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index dbfd9b811a..09b71eb6d4 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -57,6 +57,9 @@ pub struct SignUpPayloadPB { #[pb(index = 5)] pub device_id: String, + + #[pb(index = 6)] + pub is_anon: bool, } impl TryInto for SignUpPayloadPB { @@ -73,6 +76,7 @@ impl TryInto for SignUpPayloadPB { password: password.0, auth_type: self.auth_type.into(), device_id: self.device_id, + is_anon: self.is_anon, }) } } diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index dfd166b1a6..4d2ee57ce8 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -76,14 +76,14 @@ pub async fn sign_up( let params: SignUpParams = data.into_inner().try_into()?; let authenticator = params.auth_type.clone(); - let old_authenticator = manager.cloud_services.get_user_authenticator(); + let prev_authenticator = manager.cloud_services.get_user_authenticator(); match manager.sign_up(authenticator, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), Err(err) => { manager .cloud_services - .set_user_authenticator(&old_authenticator); - return Err(err); + .set_user_authenticator(&prev_authenticator); + Err(err) }, } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index c41485f6c9..864629d169 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -403,7 +403,6 @@ impl UserManager { ) -> Result { // sign out the current user if there is one let migration_user = self.get_migration_user(&authenticator).await; - self.cloud_services.set_user_authenticator(&authenticator); let auth_service = self.cloud_services.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; diff --git a/frontend/rust-lib/lib-infra/src/file_util.rs b/frontend/rust-lib/lib-infra/src/file_util.rs index 6de14b2304..b088256c26 100644 --- a/frontend/rust-lib/lib-infra/src/file_util.rs +++ b/frontend/rust-lib/lib-infra/src/file_util.rs @@ -10,7 +10,7 @@ use zip::write::FileOptions; use zip::ZipWriter; use zip::{CompressionMethod, ZipArchive}; -pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { +pub fn copy_dir_recursive(src: &Path, dst: &PathBuf) -> io::Result<()> { for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); let relative_path = path.strip_prefix(src).unwrap(); From d1d598940d1ee76b8e1afddc5a9e72566f25e4fc Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 8 Apr 2025 16:05:52 +0800 Subject: [PATCH 104/207] chore: clippy --- .../lib/user/application/auth/backend_auth_service.dart | 1 - frontend/rust-lib/flowy-user-pub/src/entities.rs | 1 - frontend/rust-lib/flowy-user/src/entities/auth.rs | 4 ---- 3 files changed, 6 deletions(-) diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart index b5cf413603..4d03788c8c 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -70,7 +70,6 @@ class BackendAuthService implements AuthService { ..name = LocaleKeys.defaultUsername.tr() ..email = userEmail ..password = password - ..isAnon = true // When sign up as guest, the auth type is always local. ..authType = AuthenticatorPB.Local ..deviceId = await getDeviceId(); diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index fce59b7994..857a735edb 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -42,7 +42,6 @@ pub struct SignUpParams { pub password: String, pub auth_type: Authenticator, pub device_id: String, - pub is_anon: bool, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index 09b71eb6d4..dbfd9b811a 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -57,9 +57,6 @@ pub struct SignUpPayloadPB { #[pb(index = 5)] pub device_id: String, - - #[pb(index = 6)] - pub is_anon: bool, } impl TryInto for SignUpPayloadPB { @@ -76,7 +73,6 @@ impl TryInto for SignUpPayloadPB { password: password.0, auth_type: self.auth_type.into(), device_id: self.device_id, - is_anon: self.is_anon, }) } } From 23f2d85e708dba315f6ad3d3c4b989b31351ce46 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 8 Apr 2025 20:23:25 +0800 Subject: [PATCH 105/207] chore: clippy --- frontend/rust-lib/lib-infra/src/file_util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/rust-lib/lib-infra/src/file_util.rs b/frontend/rust-lib/lib-infra/src/file_util.rs index b088256c26..6de14b2304 100644 --- a/frontend/rust-lib/lib-infra/src/file_util.rs +++ b/frontend/rust-lib/lib-infra/src/file_util.rs @@ -10,7 +10,7 @@ use zip::write::FileOptions; use zip::ZipWriter; use zip::{CompressionMethod, ZipArchive}; -pub fn copy_dir_recursive(src: &Path, dst: &PathBuf) -> io::Result<()> { +pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); let relative_path = path.strip_prefix(src).unwrap(); From 6886261692ac9854a5fdb68602de0d5a63192f23 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 8 Apr 2025 20:49:15 +0800 Subject: [PATCH 106/207] chore: ensure path --- frontend/rust-lib/flowy-core/src/config.rs | 18 ++++++++++++++++++ frontend/rust-lib/flowy-core/src/lib.rs | 2 ++ 2 files changed, 20 insertions(+) diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 27f658de8d..2bad578627 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -29,7 +29,25 @@ pub struct AppFlowyCoreConfig { pub(crate) log_filter: String, pub cloud_config: Option, } +impl AppFlowyCoreConfig { + pub fn ensure_path(&self) { + let create_if_needed = |path_str: &str, label: &str| { + let dir = std::path::Path::new(path_str); + if !dir.exists() { + match std::fs::create_dir_all(dir) { + Ok(_) => info!("Created {} path: {}", label, path_str), + Err(err) => error!( + "Failed to create {} path: {}. Error: {}", + label, path_str, err + ), + } + } + }; + create_if_needed(&self.storage_path, "storage"); + create_if_needed(&self.application_path, "application"); + } +} impl fmt::Debug for AppFlowyCoreConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = f.debug_struct("AppFlowy Configuration"); diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 1562c44a22..79a889f86f 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -105,6 +105,8 @@ impl AppFlowyCore { #[instrument(skip(config, runtime))] async fn init(config: AppFlowyCoreConfig, runtime: Arc) -> Self { + config.ensure_path(); + // Init the key value database let store_preference = Arc::new(KVStorePreferences::new(&config.storage_path).unwrap()); info!("🔥{:?}", &config); From 145d1e5fdb1d2b707f69ce3e9ac33db33f3133cb Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 9 Apr 2025 09:36:53 +0800 Subject: [PATCH 107/207] feat: support white label on windows (#7706) --- .../shared/error_page}/error_page.dart | 34 ++-- .../notification/notification_service.dart | 7 +- .../pages/settings_shortcuts_view.dart | 2 +- .../widgets/setting_appflowy_cloud.dart | 2 +- .../theme_upload_learn_more_button.dart | 2 +- .../scripts/white_label/i18n_white_label.sh | 103 ++++++++++++ .../scripts/white_label/icon_white_label.sh | 84 ++++++++++ .../white_label/resources/my_company_logo.ico | Bin 0 -> 67646 bytes .../white_label/resources/my_company_logo.png | Bin 0 -> 4121 bytes .../white_label/resources/my_company_logo.svg | 1 + frontend/scripts/white_label/white_label.sh | 118 ++++++++++++++ .../white_label/windows_white_label.sh | 151 ++++++++++++++++++ 12 files changed, 481 insertions(+), 23 deletions(-) rename frontend/appflowy_flutter/{packages/flowy_infra_ui/lib/widget => lib/shared/error_page}/error_page.dart (88%) create mode 100644 frontend/scripts/white_label/i18n_white_label.sh create mode 100644 frontend/scripts/white_label/icon_white_label.sh create mode 100644 frontend/scripts/white_label/resources/my_company_logo.ico create mode 100644 frontend/scripts/white_label/resources/my_company_logo.png create mode 100644 frontend/scripts/white_label/resources/my_company_logo.svg create mode 100644 frontend/scripts/white_label/white_label.sh create mode 100644 frontend/scripts/white_label/windows_white_label.sh diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart b/frontend/appflowy_flutter/lib/shared/error_page/error_page.dart similarity index 88% rename from frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart rename to frontend/appflowy_flutter/lib/shared/error_page/error_page.dart index d395873bd7..9661fd822a 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart +++ b/frontend/appflowy_flutter/lib/shared/error_page/error_page.dart @@ -1,14 +1,18 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_svg/flowy_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; class FlowyErrorPage extends StatelessWidget { factory FlowyErrorPage.error( @@ -86,7 +90,9 @@ class FlowyErrorPage extends StatelessWidget { Listener( behavior: HitTestBehavior.translucent, onPointerDown: (_) async { - await Clipboard.setData(ClipboardData(text: message)); + await getIt().setData( + ClipboardServiceData(plainText: message), + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -188,8 +194,8 @@ class StackTracePreview extends StatelessWidget { "Copy", ), useIntrinsicWidth: true, - onTap: () => Clipboard.setData( - ClipboardData(text: stackTrace), + onTap: () => getIt().setData( + ClipboardServiceData(plainText: stackTrace), ), ), ), @@ -252,18 +258,14 @@ class GitHubRedirectButton extends StatelessWidget { Widget build(BuildContext context) { return FlowyButton( leftIconSize: const Size.square(_height), - text: const FlowyText( - "AppFlowy", - ), + text: FlowyText(LocaleKeys.appName.tr()), useIntrinsicWidth: true, leftIcon: const Padding( padding: EdgeInsets.all(4.0), child: FlowySvg(FlowySvgData('login/github-mark')), ), onTap: () async { - if (await canLaunchUrl(_gitHubNewBugUri)) { - await launchUrl(_gitHubNewBugUri); - } + await afLaunchUri(_gitHubNewBugUri); }, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart index 5418eb2b1c..7a19e2a822 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart @@ -1,9 +1,8 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; - import 'package:local_notifier/local_notifier.dart'; -const _appName = "AppFlowy"; - /// Manages Local Notifications /// /// Currently supports: @@ -13,7 +12,7 @@ const _appName = "AppFlowy"; /// class NotificationService { static Future initialize() async { - await localNotifier.setup(appName: _appName); + await localNotifier.setup(appName: LocaleKeys.appName.tr()); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart index 3552205ba4..0d3716c7dc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart'; +import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; @@ -21,7 +22,6 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index a9cb362bf3..f423156025 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -3,6 +3,7 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; @@ -19,7 +20,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart index 9e25dece0e..bdc5ef0546 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart @@ -1,12 +1,12 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; class ThemeUploadLearnMoreButton extends StatelessWidget { diff --git a/frontend/scripts/white_label/i18n_white_label.sh b/frontend/scripts/white_label/i18n_white_label.sh new file mode 100644 index 0000000000..60152d1630 --- /dev/null +++ b/frontend/scripts/white_label/i18n_white_label.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --company-name Set the custom company name" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --company-name \"MyCompany Ltd.\"" +} + +CUSTOM_COMPANY_NAME="" +I18N_DIR="resources/translations" + +while [[ $# -gt 0 ]]; do + case $1 in + --company-name) + CUSTOM_COMPANY_NAME="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$CUSTOM_COMPANY_NAME" ]; then + echo "Error: Company name is required" + show_usage + exit 1 +fi + +if [ ! -d "$I18N_DIR" ]; then + echo "Error: Translation directory not found at $I18N_DIR" + exit 1 +fi + +echo "Replacing 'AppFlowy' with '$CUSTOM_COMPANY_NAME' in translation files..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +echo "Processing translation files..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Check if directory exists and has JSON files + if [ ! -d "$I18N_DIR" ] || [ -z "$(ls -A "$I18N_DIR"/*.json 2>/dev/null)" ]; then + echo "Error: No JSON files found in $I18N_DIR directory" + exit 1 + fi + + # Process each JSON file in the directory + for file in "$I18N_DIR"/*.json; do + echo "Updating $(basename "$file")" + # Use jq to replace AppFlowy with custom company name in values only + if command -v jq >/dev/null 2>&1; then + # Create a temporary file for the transformation + jq --arg company "$CUSTOM_COMPANY_NAME" 'walk(if type == "string" then gsub("AppFlowy"; $company) else . end)' "$file" > "${file}.tmp" + # Check if transformation was successful + if [ $? -eq 0 ]; then + mv "${file}.tmp" "$file" + else + echo "Error: Failed to process $file with jq" + rm -f "${file}.tmp" + exit 1 + fi + else + # Fallback to sed if jq is not available + # First, escape any special characters in the company name + ESCAPED_COMPANY_NAME=$(echo "$CUSTOM_COMPANY_NAME" | sed 's/[\/&]/\\&/g') + # Replace AppFlowy with the custom company name in JSON values + sed $SED_INPLACE 's/\(".*"\): *"\(.*\)AppFlowy\(.*\)"/\1: "\2'"$ESCAPED_COMPANY_NAME"'\3"/g' "$file" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $file with sed" + exit 1 + fi + fi + done +else + for file in $(find "$I18N_DIR" -name "*.json" -type f); do + echo "Updating $(basename "$file")" + # Use jq to only replace values, not keys + if command -v jq >/dev/null 2>&1; then + jq 'walk(if type == "string" then gsub("AppFlowy"; "'"$CUSTOM_COMPANY_NAME"'") else . end)' "$file" > "$file.tmp" && mv "$file.tmp" "$file" + else + # Fallback to sed with a more specific pattern that targets values but not keys + sed $SED_INPLACE 's/: *"[^"]*AppFlowy[^"]*"/: "&"/g; s/: *"&"/: "'"$CUSTOM_COMPANY_NAME"'"/g' "$file" + # Fix any double colons that might have been introduced + sed $SED_INPLACE 's/: *: */: /g' "$file" + fi + done +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/icon_white_label.sh b/frontend/scripts/white_label/icon_white_label.sh new file mode 100644 index 0000000000..eb45d0f02f --- /dev/null +++ b/frontend/scripts/white_label/icon_white_label.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --icon-path Set the path to the application icon (.svg file)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --icon-path \"/path/to/new/icon.svg\"" +} + +NEW_ICON_PATH="" +ICON_DIR="resources/flowy_icons" +ICON_NAME_NEED_REPLACE=("flowy_logo.svg" "flowy_ai_chat_logo.svg" "flowy_logo_dark_mode.svg" "flowy_logo_text.svg") + +while [[ $# -gt 0 ]]; do + case $1 in + --icon-path) + NEW_ICON_PATH="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$NEW_ICON_PATH" ]; then + echo "Error: Icon path is required" + show_usage + exit 1 +fi + +if [ ! -d "$ICON_DIR" ]; then + echo "Error: Icon directory not found at $ICON_DIR" + exit 1 +fi + +echo "Replacing icon..." + +echo "Processing icon files..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + for subdir in "${ICON_DIR}"/*/; do + if [ -d "$subdir" ]; then + echo "Checking subdirectory: $(basename "$subdir")" + for file in "${subdir}"*.svg; do + if [ -f "$file" ] && [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then + echo "Updating: $(basename "$subdir")/$(basename "$file")" + cp "$NEW_ICON_PATH" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") in $(basename "$subdir") with new icon" + else + echo "Error: Failed to replace $(basename "$file") in $(basename "$subdir")" + exit 1 + fi + fi + done + fi + done +else + for file in $(find "$ICON_DIR" -name "*.svg" -type f); do + if [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then + echo "Updating: $(basename "$file")" + + cp "$NEW_ICON_PATH" "$file" + + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") with new icon" + else + echo "Error: Failed to replace $(basename "$file")" + exit 1 + fi + fi + done +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/resources/my_company_logo.ico b/frontend/scripts/white_label/resources/my_company_logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..c922a6b36d45bbab0da4d142aa63e53757a04eed GIT binary patch literal 67646 zcmeHQ+mB?&U9Rrw*|}`&>?O7zVteP(b4eCy1r|5Ic#`$*&h&K8+F-{-2rVLnyr3W< zUf^Zs!2wG=#1evljFbh%iHQmR16Wud>|iB4geZ_G3c*em*;xcpmIdr(cbo64s;~R> znbUpF={`Nv(^F^l+d6eF^}Bq(y8NoD&lpp}znvX}|L4r=bjjRrj9CTY5--~8JmuN{ zJnG2h!~eko!2-bo!2-bo!2-bo!2-bo!2-bo!2-bo!2-bo<7I(bwPH4_vu1M%RGoI` z)frO*)t5lHcWz;Fyp#^w0B>SVoTn|}d;(Mfl|AvC*X(mlx2jXYa${hD`hvsWV=KHZ z&QdRed&m9y{EQw6=>fFB)*|XW+VxEm>Uw=~wzR%7MT{?i{uuN(p8f>sdVOgc_hw5t z=QFO&SeZV6ng@|B_O{KpII3-b2)O<&s0q63)9Cr%;o6)V8=yac|B!0I2M&}&#jt1J z&bqt;dJ{yv|IgEHq$K5e@B5%t5Z|c;A2?9Zn`G=~iT(FL!k%do(GPh28t4O{-~$H; z{tVc&t|#Fhy{2b^4;()12LkrN2M&M_6x81f>uzc23r5fv_6U3VMDT$T z@__=ezk_&yB%FJt!3Rdb2MWfXbAY7a10#TKng|7Aue$slU{7Mb*R@`4K=e7z4}^Kb zG|&pVKLqUm4eLS+nBScTj>IyGZFV163iE^oLEN>NAz$#R6d|+_giYQ?>_Uw~~i@oFy zJ|Ii%Pb%!|+H=Enab_9gGVR+byxzw(4L%UJ$lm04VZVqzd2xCP*lWMu(6N`M!3SiC zy-7z2dubeeK$aM8I*Qm!wKQrbB*O7VlPdC56BYv(s96E8V4VcC9}zQA zLHjbdlTG6Er-Bcp+lWt$Blglf_<$^toQ^a0(m42lEYX=B2JEGA@BvxErHuuz>X7^LzYv;`(k2cKHDLFIpX_OFw(dzwh6t z4%(kMa^E8o_N}>{HunAQ-RRWY*)f;iaqRIOu<{dgQ}lsPg5L6Y==Wd5hSxrM?#HG| zc$eS*;W-J*cN7$J8yT|4?CPT+wZ9cx;n)UEw4)yotjb!Yj zm?xbV=L2K9w|vjA&+*;GIQFP}N%cMn@1$$N2f9?c1A#rqZf*=0ZS#|G?sYBrK+FdY z0``bOO6%_Xy@OyczX(3yePF}IAFcKKQJB|@xA_HQAABHSf9QRH@jT)ek(@ z{T|hOVXw4jUr--pe!$tc_}Fkcd+ZeJ>TNdO=6gRF7wq-jC&v9e7tX!Xm=7G~93kfl zZT%QYAIK3yMSVbjGsWx1xijiG2=<%<@A*62DF38$`?nn0ZBd#1J?8dk|GZ;8&HKKK zcS*|k!d~}UK7cjFGS-hed;oDrlIV%ocFhw~w?+G%dfJNo`uW}4s-1Ug&pBD&OcbEY z#aR1V1oqdU%LiI=X>KQnZ{mIRK};?7eU9^0?`hv(0{tS0=Xu%zeJB3qt?H5TMz!E} zV6ZhKXKQCK_cttX{`f-nzh?wn$kuAxs<`&1w^Lr9?5y{`>^$@NN%ejm>p768pL#F( ze=VZt$GvWOd|-I(fU6%|0~w?p;CRB;70hEJo$XTFJ1?)V;90k3?$*JpGw(w=-}3g6 z->WU`mdeJIf7e>hW{x#%uS3fpF~47`b+-9gWn#UTT*7`5*GMd1KEP-AY&YGa*V{g@ zfW83x3h`F`D1YRvwFearM5!F^=D}Qt`ax19{p3`QOWp#i>#80bzx;OxTcfq5PYN z8OA2At*~pQwE2fX#F+L-!a9DfH}*N=5y}dlBGxI@Ew%Zwdt8QiKB?YUYQUbp7~wI9 z56IpxH?VG4i;o8~*j@E<2<^aGuj2&Mw8OXk_E|iy^>X1Fw{0}9X9jNb{XP<51K$Pq z3x)Of4txJMA+2rNz83cZ>XfxFkUydw7>T|>c)VnFep%T3jJNB=ZZ4fy@SN~}66eHUbvxSD>s;l37tgilcAT%7 zJmY*yfigtcpF5*{c*0(FOT4?Klu0scoZr#rcWT_uS^jLZoE(GrKu2HTeSqT^`?sds z9MjKw_`tuQJla4QB-ulD_&Kjr7W}36FZl9RMpF6G^Y?uDN9?#QXPZweJpG`twm~_i z3mB`SA1Ermx8wZHSexI?^3=oj1I`a*3&jPrMK60tZ6w=#5pm!|ZT=|ziu)4G)2F*} zPre@Wfg_X=M4OYJ_(XZ@T-bfozf(!((7uZ2RR7QW@_V&0vc(Va?7W{h#M~V5%QoL2 z&XK>#4%|8ZnE4~mfAD(WrOg-bcnZF~`hq+@Ala!4bee^3{2S!$u}7SDQl7T-HesEh zZRSC=IdK*S!aSM^y9;=Z?`5g~;<1S5-vOoZkdM6}9uCWVI#O@S=gVM!MZS_hs#DTA zZ6G;1+WZa1c{%M(S@@29*Ug2*+X2ee%kEJbrNd9-9qL$S`FRe$*Kl=0_MxrGLpn$K z>0I)0eWS`UK&ia>+@r62flVcZAEQ#O_?NvU*rmgRwLIay`mJKmP6m$b3$oy1SqgxNoA zJ$$eE$tT_TDvd9H4<#9q{w9d!Ne7i3Pi6lr4bC&c&uBNKY3y3}`7B8O{RNzphsurf zQ$87cwy}NI`<6id#NrRxygvQ-fOOzIYD>pBpKf`+<9TzdP~pNu_mXT48|@w(YK?K<^g z8xWH*>zGhF{DHSiCLAaepZD|Asux+t*OpoPU5mV1M7{ zZ0dfC*IqY_$|hMUJ2CltygR}7agIGJ>Fa_TKjC@VkTzvmvazs^r?SnL8*_-ESB{V; z#t`W|WrqfyJ-d3$`HhqLtmjR=;{2j{-TzNKcZz&LCH4dSjdibs@o{XNQ^mXA0kM4P zr1I(CzXD?U?YW%-+x%{_TAsV-h$u^3M&-YadyDoxH>Y8?7Vha*yuGg9rQ;p-!?Y!B zN?XVVJWnF=eH`~Kh96yJJae=s&-hKgyTx%-+iySb`KfJ5;>~Ni|AP4IH~4WL@w|VY zGIQ>i=}8dhIsO^+AE57nI3M^(tKOq7u|5^q-^b;O=fS@~SmE0>h|1U2` znh)|m=~0lyvjH1%KWVSS+mX*ZACUhq**1`4(P=v#a{E)T);`t3p;x)XxM%wU`hkm= zAdB}y$Ot*#i}@O?w^rS-j6WoyF$#i<=3E6Pf#9?8TR|Ap>A<(Me^-@OV+0`_PWS63i4(-pd-rYX!mo+d*~NW*uD|_r*lP^ct?ui7YmXn} zd}3AfQ3LjV45(OUpm=bL@em~Q7ro?K{cn`jF!ney7 zllnu&jr)@9#<69?jxR$zm}H-<*9waFe%1L9)|TwPi(=xvG#&gn9&=xqW0()|V4A&> z?z7Gg8t*;1SShbB+20=9vXb}=;~MvM*H7PTV-4?rAs)J2LdmFY58IAcf!0jv|3E({nC za(&;oZ)2Bw{55x)cYW`*jVk^&h5O!^wTE_eE<2}E}XynXc z+W_a=TF1(>K1kpAU(hQcu4Vqkm=bmSD-d<;Bwx+lu$N4+q~E8aeza5acwQ z{l$aSS^7%Rs5jGbB5_^^9_y#H$IrSKB#uBo9^Lydp|GNRBb!@#N6n`d#OGE*&B=p+x>vP=ku;_ z+XB|RV*uI4e=lfeqA}uQzHWZNHo)%_E`50MJ)GN}0`6Bl_P3CRwxEqaZ9$u`PF$g_ z&bqZ7x2G$|x7)knu^-nXW5E)C|A}>P&UrJ&r=HZAeI3WdJZDUFn20(n9^g4`@(OHr z)c?&LV9&Ymz1h<>4FAC2+-3VO;`ijs_4hr@{3k$ffv9tqSeVa^BT0AK;uF=$llVQW z{jIRph|K!xaQAZ#1NUf|=a(wNpDFdi{4o4_iNBGa`DM_TK;Hsg2mKJl-wKU0QAd6s zfZuZYOVB0Idwrc}%-q6$Ycch6jZssN?79E8ThtG%o*yzBSTj6=eWXXQCN_O9bmx9@ z{?4E2IFfX`7rz^y;Qr4?79OC_wwX+^unFB^3`bm)=ueCH2>em zyb{e{H|CjW{u{=ej^`MZ~mi3)IM&6ubFw|0z)3IO&U0)Tyo0N*gB zou~jWnetjxfG5o4<%j@aKZ{G-k_hbY!z)*n2m8|~@{;mk{|s2REWmSK(|tb6ca&#Y zEK_;%B2S-pr)>e;qiq3PQEdSntF{2{tSx|hvMqossV#ux)Dl1^5+)!vwv1<2=?F_%1Fo7^f(4L2*}P`Tmu{_@mFDZrAII~Rt9RyLvFFU08)gQVXSVM?j|ZPO zzj4D%BY&dVTu1(xfjpDjubZW2^K<4TbieqnDQ~}RHk-|_n+NW$na{ohdEYjlX*Pdg z9)K-Q-QFoZcCB=#*}S>4g7fX$Yo$}y%a@wXTWim}vr~SI@7#RuYO{Ig_!*XkGCzIm zC$^i-yY)*y0@`VG|FiAurqTq;E6nk@#wrl0CxEUAVNbfdJbj?qd=(1%<2kd) zmu8fIo<&`Ug8n$hDo6!gp6WH>6=mhuSY@f8yHE9+O)BW}R1bM5=<-w#dO$&!r+Uak zL6^7cHLZg7f>ke~pjD3w&KY}Ds2;UC=JL}{(^sYN>r{^lwhn74IJgem!rV~KP(3Qx zI-njEY#pbVzrCZpQ}p(l@>I_%=r1@!K|z0bL#`?B`wY0QJK#k;{&B};OTX{(H_vpY OaDR7Pb=Sx0|NjT{45)Gd literal 0 HcmV?d00001 diff --git a/frontend/scripts/white_label/resources/my_company_logo.png b/frontend/scripts/white_label/resources/my_company_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8f50872743ff4611e9e205744c1446593a671fab GIT binary patch literal 4121 zcmbtWdpMKrAKzBXHixo=M2&4EMM4LMMmfyJ97fWKPUvW*-U-=WIyfa!5kp5S(e#!? zQI4I&Ya0moK z(UEHFjzFNm6otUd0AEkCNYB6`$JNQh4lEJY)>smKDu`1P47@|bkeskYCoCAJ0SE#B z)8HjpHj?N#5c&>aR3O@eQJz4<=}aCuPBD;SPB|Sw<%0X0UstwCWWYQCKnAsBSa|FrYD9tg@-XD zC&k5XSh7qD(Fq60$ut}Z4$Pz$GEH8a0cv6NoMrOqSX~&5(*a8-P)mX243=0OXV5V0 z%#$WAy=mrPSKa8T5hrNBLd4grQX>8An^1)2w*fO5c`NoFEQhtzei%z6Cl)<|M#?iL67|naP+1c4VipnM6k*ZT7-R(_kcU52weFDFwp8(izCd z$gTikXb%`>$@-jb67CjcAwY_(?qms^j2~g(373F-m{ddumohn5*0F^XWhppV5tzeB zK!VVKJp^+&okWudxw0FXWQIpzvaBE2mc>+%2+AQ4Tnv#0-cUFZ;d#}k<`7tP9C2qb z5i_%)TUp~{TQ+R=@|f`aj0yhEln$lO=cx{=n^tPxOymC|YekGS*hY~y#d>eO-C3W6 z&dDX#Jrc!94G9$z3=bem>c*yhI`ayU)c47h6CE+A6A|Y=84^UT-32(j-zms+H^XX$}e&HHFR7Hl9wzVy>hze)K10S{lgbdd{ww3Ui-33$@kmrjBP(x zeDNud#2x!Mt8DqkH8mGME!1giC8S(NB_prRP(d2=X=<17ke;n)e(sx~e(s z+{8XX-u~ysA}34#mxcXC!lQK)7NVh*CIa|};lF%rSUnw!a*mDPbx7M}hL1_}+cpv0 zemrS2%jiH_z2TBZs^hY~Eq^)dWF42Z&CpmQhf7{X<#{Y@xpq0*L~jEvEY8Ye;Sh9V z=tVwWZ7i$c>O|P4pW>t_^?blrRDuX%U6bfCKjqaYF=(g-2T^` zg66xf5xeNP{ARbu3{*#Lc&*?)e?+W`dCc1RZ+yP1_5 zr-O|tc;S|sphhCB;47z|=VXyJ{OYB4Wts{a;>wlxu5W} zyuQ4SJ47PrQ&EW#B;R7wB<^(rR^3F-(b_#>CJF6W7QAL1d_>SsNxoka|9A^Hrs5L0 z*L|gK*RkH4)X{V^Z8f_@F43N*a=&zULWD6%xEqhXF-u?F2DRF{%>s29HuwY;64KEIzx=aGSiDS03X^37 zbHr!TP*v#>Ce*TL%z;RfZ;O8L(Nq~CiaBuM+O!$do}AZjS`XumWTn-EaAIAUcZzDk9A}Ca zOtDRBP+(*!xHmM;)Qqg}h#Ovs-sfoTGHnzq$}x$1i0^BH?Fz(6*Pr68H#)~%%Ln9a zTzC|$OGdd0EztYB@A8(QgqgLmQx0{iSUQ0_F4UXwa;Dzuh{_(V&rmm}RR^Yr_84t#H23hj!xa&uH}tK}7O1|D;=gT?C|jLPcUfwbz++Vq z(4X{#`x0m1#Ni%B*Y6Bgf6?|llN+Ml|DI2lE55iIeYOZP5C+sB7iC^Qeiu4681J8U z-{|q9501F-jfZRV9q-y z0(;9HZ~8t^-k9ugzSOP~R%L_o`{SE+T$gHKm-zLc0r!2%o++m$JUy1lb?GiUax_$2 znk@Jlid)D|;J)!tR2AOfEb|c)(3y4pGsE&Wfek+Aoz(>;_dK#-E-N>?9A!Bq*wo>L z8_nbgV8pdiB@ZN2yx-w8lMI;Zj{;zuD%ZqT_3!lI!N0 z*M*7)@dGRF`W5&&`=72Cl_tBGQ_HJy-%XKUPiN0!b5DbX3J?#OvWx|@6jnL(n^PHw-_n8vxi2cLiRh^nm>|0-C##rN- zgO3TL85{EiAL;^;)d7diEi^5mq=v4}f{1xNPk9T{!<0RC6=m0G2_?V0tQLRikiWX1 zl2>St4XF2ew;#E7tWl&nv4NGSZc^PV;0|lLJwXTe`anANO_1k6L1$fCcLDcy_uHtK zO05}CiDm$MXmhzk<#|4DLX~wX^)vEu24^B8WqW1>|7pT{i}Vtu&a~_-HaAn`Gi2v4 zIHSK!+i$$$2Vf4nz3xmu!l=G9ey{TSUTDcf_u7;I9(TCbU4kCz@}cNvHSvYpjN9`L zp*}D-Z@-I}JrO84GV&p1QS^My3K#28v0E%USmeVotZL%>Cad4QT=})fSG6|Zn;R(Q zyeCvOsf8R%OJP;@_4w*~d|X;)1dXwy(8Kzx9h^pPwy%mXyu-{F*!-N}n@joet%T9I zl)Tr5$iDg8b_B_d1WD!})3A-g(6~&l4*U&7O7{HZh z^|!7?pN+*1rJA*e>rU$$OsO-N+O!xy`qZ`&p_AoNH~oEaqbBOI2eOZ!@_WgMm@7k zDy4jFo)C;KxVy8Oq1dAo)jR$X+Qq&&>jC%Xc+c$}FLFao^Z3@bKTE}}r1x8{UipL^ z) z$9oPWnk;mi-Or`xh`GZ0QpP#W@4JHKF`uBqxQ>L{u7pgKv=tIle$-mDeL`wyLa{j8 zjb*;_WfWO(YURr~z2L#WZhJ?VQ2V7-_RAl6N@_IxZW8d%kt41t7Ku;;^}iE~Mp36O z2s6>Y7nEM~{yPmew+x5dUw8 z>VD^)`nMNw(U)nKMMhTpOyyU8m)5fibGOK~d+k}9#VzbWY9;ikN#>udi}dP3Se8V( zEIzWLx?nHGa@^t$q~7$P?Py=5*Yn}QX4`d7F3z7fO7Y0q+W1n0pDVw1up!RwU3T`h vgQ-wMvi \ No newline at end of file diff --git a/frontend/scripts/white_label/white_label.sh b/frontend/scripts/white_label/white_label.sh new file mode 100644 index 0000000000..2d6004cf9d --- /dev/null +++ b/frontend/scripts/white_label/white_label.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# Default values +APP_NAME="AppFlowy" +APP_IDENTIFIER="com.appflowy.appflowy" +COMPANY_NAME="AppFlowy Inc." +COPYRIGHT="Copyright © 2025 AppFlowy Inc." +ICON_PATH="" +WINDOWS_ICON_PATH="" +PLATFORMS=("windows" "linux" "macos" "ios" "android") + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --app-name Set the application name" + echo " --app-identifier Set the application identifier" + echo " --company-name Set the company name" + echo " --copyright Set the copyright information" + echo " --icon-path Set the path to the application icon (.svg)" + echo " --windows-icon-path Set the path to the windows application icon (.ico)" + echo " --platforms Comma-separated list of platforms to white label (windows,linux,macos,ios,android)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --app-name \"MyCompany\" --app-identifier \"com.mycompany.mycompany\" \\" + echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" + echo " --platforms \"windows,linux,macos\" \\" + echo " --windows-icon-path \"./assets/icons/mycompany.ico\" \\" + echo " --icon-path \"./assets/icons/mycompany.svg\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app-name) + APP_NAME="$2" + shift 2 + ;; + --app-identifier) + APP_IDENTIFIER="$2" + shift 2 + ;; + --company-name) + COMPANY_NAME="$2" + shift 2 + ;; + --copyright) + COPYRIGHT="$2" + shift 2 + ;; + --icon-path) + ICON_PATH="$2" + shift 2 + ;; + --windows-icon-path) + WINDOWS_ICON_PATH="$2" + shift 2 + ;; + --platforms) + IFS=',' read -ra PLATFORMS <<< "$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$APP_NAME" ] || [ -z "$APP_IDENTIFIER" ] || [ -z "$COMPANY_NAME" ] || [ -z "$COPYRIGHT" ] || [ -z "$ICON_PATH" ]; then + echo "Error: All parameters are required" + show_usage + exit 1 +fi + +if [ ! -f "$ICON_PATH" ]; then + echo "Error: Icon file not found at $ICON_PATH" + exit 1 +fi + +if [ ! -f "$WINDOWS_ICON_PATH" ]; then + echo "Error: Windows icon file not found at $WINDOWS_ICON_PATH" + exit 1 +fi + +run_platform_script() { + local platform=$1 + local script_path="scripts/white_label/${platform}_white_label.sh" + + if [ ! -f "$script_path" ]; then + echo -e "\033[31mWarning: White label script not found for platform: $platform\033[0m" + return + fi + + echo -e "\033[32mRunning white label script for $platform...\033[0m" + bash "$script_path" \ + --app-name "$APP_NAME" \ + --app-identifier "$APP_IDENTIFIER" \ + --company-name "$COMPANY_NAME" \ + --copyright "$COPYRIGHT" \ + --icon-path "$WINDOWS_ICON_PATH" +} + +echo -e "\033[32mRunning i18n white label script...\033[0m" +bash "scripts/white_label/i18n_white_label.sh" --company-name "$COMPANY_NAME" + +echo -e "\033[32mRunning icon white label script...\033[0m" +bash "scripts/white_label/icon_white_label.sh" --icon-path "$ICON_PATH" + +for platform in "${PLATFORMS[@]}"; do + run_platform_script "$platform" +done + +echo -e "\033[32mWhite labeling process completed successfully!\033[0m" diff --git a/frontend/scripts/white_label/windows_white_label.sh b/frontend/scripts/white_label/windows_white_label.sh new file mode 100644 index 0000000000..58801424ff --- /dev/null +++ b/frontend/scripts/white_label/windows_white_label.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +APP_NAME="AppFlowy" +APP_IDENTIFIER="com.appflowy.appflowy" +COMPANY_NAME="AppFlowy Inc." +COPYRIGHT="Copyright © 2025 AppFlowy Inc." +ICON_PATH="" + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --app-name Set the application name" + echo " --app-identifier Set the application identifier" + echo " --company-name Set the company name" + echo " --copyright Set the copyright information" + echo " --icon-path Set the path to the application icon (.ico file)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --app-name \"MyCompany\" --app-identifier \"com.mycompany.mycompany\" \\" + echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" + echo " --icon-path \"./assets/icons/company.ico\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app-name) + APP_NAME="$2" + shift 2 + ;; + --app-identifier) + APP_IDENTIFIER="$2" + shift 2 + ;; + --company-name) + COMPANY_NAME="$2" + shift 2 + ;; + --copyright) + COPYRIGHT="$2" + shift 2 + ;; + --icon-path) + ICON_PATH="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$APP_NAME" ]; then + echo -e "\033[31mError: Application name is required\033[0m" + exit 1 +fi + +if [ -z "$APP_IDENTIFIER" ]; then + echo -e "\033[31mError: Application identifier is required\033[0m" + exit 1 +fi + +if [ -z "$COMPANY_NAME" ]; then + echo -e "\033[31mError: Company name is required\033[0m" + exit 1 +fi + +if [ -z "$COPYRIGHT" ]; then + echo -e "\033[31mError: Copyright information is required\033[0m" + exit 1 +fi + +if [ -z "$ICON_PATH" ]; then + echo -e "\033[31mError: Icon path is required\033[0m" + exit 1 +fi + +echo "Starting Windows application customization..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +update_runner_files() { + runner_dir="appflowy_flutter/windows/runner" + + if [ -f "$runner_dir/Runner.rc" ]; then + sed $SED_INPLACE "s/VALUE \"CompanyName\", .*$/VALUE \"CompanyName\", \"$COMPANY_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"FileDescription\", .*$/VALUE \"FileDescription\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"InternalName\", .*$/VALUE \"InternalName\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"OriginalFilename\", .*$/VALUE \"OriginalFilename\", \"$APP_NAME.exe\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"LegalCopyright\", .*$/VALUE \"LegalCopyright\", \"$COPYRIGHT\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"ProductName\", .*$/VALUE \"ProductName\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + echo -e "Runner.rc updated successfully" + else + echo -e "\033[31mRunner.rc file not found\033[0m" + fi +} + +update_icon() { + if [ ! -z "$ICON_PATH" ] && [ -f "$ICON_PATH" ]; then + app_icon_path="appflowy_flutter/windows/runner/resources/app_icon.ico" + cp "$ICON_PATH" "$app_icon_path" + echo -e "Application icon updated successfully" + else + echo -e "\033[31mApplication icon file not found\033[0m" + fi +} + +update_cmake_lists() { + cmake_file="appflowy_flutter/windows/CMakeLists.txt" + if [ -f "$cmake_file" ]; then + sed $SED_INPLACE "s/set(BINARY_NAME .*)$/set(BINARY_NAME \"$APP_NAME\")/" "$cmake_file" + echo -e "CMake configuration updated successfully" + else + echo -e "\033[31mCMake configuration file not found\033[0m" + fi +} + +update_main_cpp() { + main_cpp_file="appflowy_flutter/windows/runner/main.cpp" + if [ -f "$main_cpp_file" ]; then + sed $SED_INPLACE "s/HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L\"AppFlowyMutex\");/HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L\"${APP_NAME}Mutex\");/" "$main_cpp_file" + sed $SED_INPLACE "s/HWND handle = FindWindowA(NULL, \"AppFlowy\");/HWND handle = FindWindowA(NULL, \"$APP_NAME\");/" "$main_cpp_file" + sed $SED_INPLACE "s/if (window.SendAppLinkToInstance(L\"AppFlowy\")) {/if (window.SendAppLinkToInstance(L\"$APP_NAME\")) {/" "$main_cpp_file" + sed $SED_INPLACE "s/if (!window.Create(L\"AppFlowy\", origin, size)) {/if (!window.Create(L\"$APP_NAME\", origin, size)) {/" "$main_cpp_file" + echo -e "main.cpp updated successfully" + else + echo -e "\033[31mMain.cpp file not found\033[0m" + fi +} + +echo "Applying customizations..." +update_runner_files +update_icon +update_cmake_lists +update_main_cpp + +echo "Windows application customization completed successfully!" From bbc7b6d172c975c9d2516509b17fcf043ea26db3 Mon Sep 17 00:00:00 2001 From: Aniket Patil <63067583+Aniket404@users.noreply.github.com> Date: Tue, 8 Apr 2025 18:37:21 -0700 Subject: [PATCH 108/207] feat(i18n): add initial Marathi (mr-IN) translations (#7667) * feat(i18n): Add initial Marathi (mr-IN) translations * fix(i18n): Force add mr-IN.json in assets folder for localization * fix(i18n): Sync mr-IN.json from assets to resources * fix(i18n): Final sync of full Marathi translation in assets * feat(i18n): Force add Marathi translations to ignored directory * feat(i18n): Complete Marathi translations --- .../assets/translations/mr-IN.json | 3210 +++++++++++++++++ .../lib/startup/tasks/app_widget.dart | 1 + .../packages/flowy_infra/lib/language.dart | 4 +- frontend/resources/translations/mr-IN.json | 3210 +++++++++++++++++ 4 files changed, 6424 insertions(+), 1 deletion(-) create mode 100644 frontend/appflowy_flutter/assets/translations/mr-IN.json create mode 100644 frontend/resources/translations/mr-IN.json diff --git a/frontend/appflowy_flutter/assets/translations/mr-IN.json b/frontend/appflowy_flutter/assets/translations/mr-IN.json new file mode 100644 index 0000000000..f86a1e0081 --- /dev/null +++ b/frontend/appflowy_flutter/assets/translations/mr-IN.json @@ -0,0 +1,3210 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "मी", + "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", + "welcomeTo": "मध्ये आ पले स्वागत आ हे", + "githubStarText": "GitHub वर स्टार करा", + "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", + "letsGoButtonText": "क्विक स्टार्ट", + "title": "Title", + "youCanAlso": "तुम्ही देखील", + "and": "आ णि", + "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", + "blockActions": { + "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "वर जोडण्यासाठी", + "dragTooltip": "Drag to move", + "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" + }, + "signUp": { + "buttonText": "साइन अप", + "title": "साइन अप to @:appName", + "getStartedText": "सुरुवात करा", + "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", + "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "emailHint": "Email", + "passwordHint": "Password", + "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", + "signUpWith": "यामध्ये साइन अप करा:" + }, + "signIn": { + "loginTitle": "@:appName मध्ये लॉगिन करा", + "loginButtonText": "लॉगिन", + "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", + "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", + "anonymous": "अनामिक", + "buttonText": "साइन इन", + "signingInText": "साइन इन होत आहे...", + "forgotPassword": "पासवर्ड विसरलात?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "तुमचं खाते नाही?", + "createAccount": "खाते तयार करा", + "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", + "or": "किंवा", + "signInWithGoogle": "Google सह पुढे जा", + "signInWithGithub": "GitHub सह पुढे जा", + "signInWithDiscord": "Discord सह पुढे जा", + "signInWithApple": "Apple सह पुढे जा", + "continueAnotherWay": "इतर पर्यायांनी पुढे जा", + "signUpWithGoogle": "Google सह साइन अप करा", + "signUpWithGithub": "GitHub सह साइन अप करा", + "signUpWithDiscord": "Discord सह साइन अप करा", + "signInWith": "यासह पुढे जा:", + "signInWithEmail": "ईमेलसह पुढे जा", + "signInWithMagicLink": "पुढे जा", + "signUpWithMagicLink": "Magic Link सह साइन अप करा", + "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", + "settings": "सेटिंग्ज", + "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", + "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "logIn": "लॉगिन", + "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", + "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", + "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." + }, + "workspace": { + "chooseWorkspace": "तुमचे workspace निवडा", + "defaultName": "माझे Workspace", + "create": "नवीन workspace तयार करा", + "new": "नवीन workspace", + "importFromNotion": "Notion मधून आयात करा", + "learnMore": "अधिक जाणून घ्या", + "reset": "workspace रीसेट करा", + "renameWorkspace": "workspace चे नाव बदला", + "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", + "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", + "hint": "workspace", + "notFoundError": "workspace सापडले नाही", + "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", + "errorActions": { + "reportIssue": "समस्या नोंदवा", + "reportIssueOnGithub": "Github वर समस्या नोंदवा", + "exportLogFiles": "लॉग फाइल्स निर्यात करा", + "reachOut": "Discord वर संपर्क करा" + }, + "menuTitle": "कार्यक्षेत्रे", + "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", + "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", + "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", + "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", + "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", + "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", + "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", + "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", + "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", + "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", + "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", + "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", + "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", + "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", + "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", + "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" + }, + "shareAction": { + "buttonText": "शेअर करा", + "workInProgress": "लवकरच येत आहे", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "क्लिपबोर्डवर कॉपी करा", + "csv": "CSV", + "copyLink": "लिंक कॉपी करा", + "publishToTheWeb": "वेबवर प्रकाशित करा", + "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", + "publish": "प्रकाशित करा", + "unPublish": "अप्रकाशित करा", + "visitSite": "साइटला भेट द्या", + "exportAsTab": "या स्वरूपात निर्यात करा", + "publishTab": "प्रकाशित करा", + "shareTab": "शेअर करा", + "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", + "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", + "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", + "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", + "copyShareLink": "शेअर लिंक कॉपी करा", + "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", + "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", + "updatePathName": "पथाचे नाव अपडेट करा" + }, + "moreAction": { + "small": "लहान", + "medium": "मध्यम", + "large": "मोठा", + "fontSize": "फॉन्ट आकार", + "import": "Import", + "moreOptions": "अधिक पर्याय", + "wordCount": "शब्द संख्या: {}", + "charCount": "अक्षर संख्या: {}", + "createdAt": "निर्मिती: {}", + "deleteView": "हटवा", + "duplicateView": "प्रत बनवा", + "wordCountLabel": "शब्द संख्या: ", + "charCountLabel": "अक्षर संख्या: ", + "createdAtLabel": "निर्मिती: ", + "syncedAtLabel": "सिंक केले: ", + "saveAsNewPage": "संदेश पृष्ठात जोडा", + "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" + }, + "importPanel": { + "textAndMarkdown": "मजकूर आणि Markdown", + "documentFromV010": "v0.1.0 पासून दस्तऐवज", + "databaseFromV010": "v0.1.0 पासून डेटाबेस", + "notionZip": "Notion निर्यात केलेली Zip फाईल", + "csv": "CSV", + "database": "डेटाबेस" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", + "placeholderUpload": "अपलोड", + "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", + "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", + "change": "बदला" + } + }, + "disclosureAction": { + "rename": "नाव बदला", + "delete": "हटवा", + "duplicate": "प्रत बनवा", + "unfavorite": "आवडतीतून काढा", + "favorite": "आवडतीत जोडा", + "openNewTab": "नवीन टॅबमध्ये उघडा", + "moveTo": "या ठिकाणी हलवा", + "addToFavorites": "आवडतीत जोडा", + "copyLink": "लिंक कॉपी करा", + "changeIcon": "आयकॉन बदला", + "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", + "movePageTo": "पृष्ठ हलवा", + "move": "हलवा", + "lockPage": "पृष्ठ लॉक करा" + }, + "blankPageTitle": "रिक्त पृष्ठ", + "newPageText": "नवीन पृष्ठ", + "newDocumentText": "नवीन दस्तऐवज", + "newGridText": "नवीन ग्रिड", + "newCalendarText": "नवीन कॅलेंडर", + "newBoardText": "नवीन बोर्ड", + "chat": { + "newChat": "AI गप्पा", + "inputMessageHint": "@:appName AI ला विचार करा", + "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", + "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", + "relatedQuestion": "सूचवलेले", + "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", + "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", + "retry": "पुन्हा प्रयत्न करा", + "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", + "regenerateAnswer": "उत्तर पुन्हा तयार करा", + "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", + "question2": "GTD पद्धत समजावून सांगा", + "question3": "Rust का वापरावा", + "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", + "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", + "question6": "या आठवड्याची माझी कामांची यादी तयार करा", + "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", + "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", + "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", + "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", + "referenceSource": { + "zero": "0 स्रोत सापडले", + "one": "{count} स्रोत सापडला", + "other": "{count} स्रोत सापडले" + } + }, + "clickToMention": "पृष्ठाचा उल्लेख करा", + "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", + "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", + "indexingFile": "{} अनुक्रमित करत आहे", + "generatingResponse": "उत्तर तयार होत आहे", + "selectSources": "स्रोत निवडा", + "currentPage": "सध्याचे पृष्ठ", + "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", + "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", + "regenerate": "पुन्हा प्रयत्न करा", + "addToPageButton": "संदेश पृष्ठावर जोडा", + "addToPageTitle": "या पृष्ठात संदेश जोडा...", + "addToNewPage": "नवीन पृष्ठ तयार करा", + "addToNewPageName": "\"{}\" मधून काढलेले संदेश", + "addToNewPageSuccessToast": "संदेश जोडण्यात आला", + "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", + "changeFormat": { + "actionButton": "फॉरमॅट बदला", + "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", + "textOnly": "मजकूर", + "imageOnly": "फक्त प्रतिमा", + "textAndImage": "मजकूर आणि प्रतिमा", + "text": "परिच्छेद", + "bullet": "बुलेट यादी", + "number": "क्रमांकित यादी", + "table": "सारणी", + "blankDescription": "उत्तराचे फॉरमॅट ठरवा", + "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", + "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", + "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", + "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", + " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" + }, + "switchModel": { + "label": "मॉडेल बदला", + "localModel": "स्थानिक मॉडेल", + "cloudModel": "क्लाऊड मॉडेल", + "autoModel": "स्वयंचलित" + }, + "selectBanner": { + "saveButton": "… मध्ये जोडा", + "selectMessages": "संदेश निवडा", + "nSelected": "{} निवडले गेले", + "allSelected": "सर्व निवडले गेले" + }, + "stopTooltip": "उत्पन्न करणे थांबवा", + "trash": { + "text": "कचरा", + "restoreAll": "सर्व पुनर्संचयित करा", + "restore": "पुनर्संचयित करा", + "deleteAll": "सर्व हटवा", + "pageHeader": { + "fileName": "फाईलचे नाव", + "lastModified": "शेवटचा बदल", + "created": "निर्मिती" + } + }, + "confirmDeleteAll": { + "title": "कचरापेटीतील सर्व पृष्ठे", + "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "confirmRestoreAll": { + "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", + "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "restorePage": { + "title": "पुनर्संचयित करा: {}", + "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" + }, + "mobile": { + "actions": "कचरा क्रिया", + "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", + "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", + "isDeleted": "हटवले गेले आहे", + "isRestored": "पुनर्संचयित केले गेले आहे" + }, + "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", + "deletePagePrompt": { + "text": "हे पृष्ठ कचरापेटीत आहे", + "restore": "पृष्ठ पुनर्संचयित करा", + "deletePermanent": "कायमचे हटवा", + "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "dialogCreatePageNameHint": "पृष्ठाचे नाव", + "questionBubble": { + "shortcuts": "शॉर्टकट्स", + "whatsNew": "नवीन काय आहे?", + "help": "मदत आणि समर्थन", + "markdown": "Markdown", + "debug": { + "name": "डीबग माहिती", + "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", + "fail": "डीबग माहिती कॉपी करता आली नाही" + }, + "feedback": "अभिप्राय" + }, + "menuAppHeader": { + "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", + "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", + "defaultNewPageName": "शीर्षक नसलेले", + "renameDialog": "नाव बदला", + "pageNameSuffix": "प्रत" + }, + "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", + "toolbar": { + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "bold": "ठळक", + "italic": "तिरकस", + "underline": "अधोरेखित", + "strike": "मागे ओढलेले", + "numList": "क्रमांकित यादी", + "bulletList": "बुलेट यादी", + "checkList": "चेक यादी", + "inlineCode": "इनलाइन कोड", + "quote": "उद्धरण ब्लॉक", + "header": "शीर्षक", + "highlight": "हायलाइट", + "color": "रंग", + "addLink": "लिंक जोडा" + }, + "tooltip": { + "lightMode": "लाइट मोडमध्ये स्विच करा", + "darkMode": "डार्क मोडमध्ये स्विच करा", + "openAsPage": "पृष्ठ म्हणून उघडा", + "addNewRow": "नवीन पंक्ती जोडा", + "openMenu": "मेनू उघडण्यासाठी क्लिक करा", + "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", + "viewDataBase": "डेटाबेस पहा", + "referencePage": "हे {name} संदर्भित आहे", + "addBlockBelow": "खाली एक ब्लॉक जोडा", + "aiGenerate": "निर्मिती करा" + }, + "sideBar": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "साइडबार उघडा", + "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", + "personal": "वैयक्तिक", + "private": "खाजगी", + "workspace": "कार्यक्षेत्र", + "favorites": "आवडती", + "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", + "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", + "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", + "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", + "addAPage": "नवीन पृष्ठ जोडा", + "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", + "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", + "recent": "अलीकडील", + "today": "आज", + "thisWeek": "या आठवड्यात", + "others": "पूर्वीच्या आवडती", + "earlier": "पूर्वीचे", + "justNow": "आत्ताच", + "minutesAgo": "{count} मिनिटांपूर्वी", + "lastViewed": "शेवटी पाहिलेले", + "favoriteAt": "आवडते म्हणून चिन्हांकित", + "emptyRecent": "अलीकडील पृष्ठे नाहीत", + "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", + "emptyFavorite": "आवडती पृष्ठे नाहीत", + "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", + "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", + "removeSuccess": "यशस्वीरित्या काढले गेले", + "favoriteSpace": "आवडती", + "RecentSpace": "अलीकडील", + "Spaces": "जागा", + "upgradeToPro": "Pro मध्ये अपग्रेड करा", + "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", + "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", + "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", + "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", + "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", + "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", + "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", + "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", + "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", + "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", + "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", + "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", + "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", + "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", + "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", + "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", + "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", + "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" +}, + "notifications": { + "export": { + "markdown": "टीप Markdown मध्ये निर्यात केली", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "या आठवड्यात काय घडत आहे?", + "addContact": "संपर्क जोडा", + "editContact": "संपर्क संपादित करा" + }, + "button": { + "ok": "ठीक आहे", + "confirm": "खात्री करा", + "done": "पूर्ण", + "cancel": "रद्द करा", + "signIn": "साइन इन", + "signOut": "साइन आउट", + "complete": "पूर्ण करा", + "save": "जतन करा", + "generate": "निर्माण करा", + "esc": "ESC", + "keep": "ठेवा", + "tryAgain": "पुन्हा प्रयत्न करा", + "discard": "टाका", + "replace": "बदला", + "insertBelow": "खाली घाला", + "insertAbove": "वर घाला", + "upload": "अपलोड करा", + "edit": "संपादित करा", + "delete": "हटवा", + "copy": "कॉपी करा", + "duplicate": "प्रत बनवा", + "putback": "परत ठेवा", + "update": "अद्यतनित करा", + "share": "शेअर करा", + "removeFromFavorites": "आवडतीतून काढा", + "removeFromRecent": "अलीकडील यादीतून काढा", + "addToFavorites": "आवडतीत जोडा", + "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", + "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", + "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", + "rename": "नाव बदला", + "helpCenter": "मदत केंद्र", + "add": "जोड़ा", + "yes": "होय", + "no": "नाही", + "clear": "साफ करा", + "remove": "काढा", + "dontRemove": "काढू नका", + "copyLink": "लिंक कॉपी करा", + "align": "जुळवा", + "login": "लॉगिन", + "logout": "लॉगआउट", + "deleteAccount": "खाते हटवा", + "back": "मागे", + "signInGoogle": "Google सह पुढे जा", + "signInGithub": "GitHub सह पुढे जा", + "signInDiscord": "Discord सह पुढे जा", + "more": "अधिक", + "create": "तयार करा", + "close": "बंद करा", + "next": "पुढे", + "previous": "मागील", + "submit": "सबमिट करा", + "download": "डाउनलोड करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "viewing": "पाहत आहात", + "editing": "संपादन करत आहात", + "gotIt": "समजले", + "retry": "पुन्हा प्रयत्न करा", + "uploadFailed": "अपलोड अयशस्वी.", + "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" + }, + "label": { + "welcome": "स्वागत आहे!", + "firstName": "पहिले नाव", + "middleName": "मधले नाव", + "lastName": "आडनाव", + "stepX": "पायरी {X}" + }, + "oAuth": { + "err": { + "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", + "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." + }, + "google": { + "title": "GOOGLE साइन-इन", + "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", + "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", + "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", + "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" + } + }, + "settings": { + "title": "सेटिंग्ज", + "popupMenuItem": { + "settings": "सेटिंग्ज", + "members": "सदस्य", + "trash": "कचरा", + "helpAndSupport": "मदत आणि समर्थन" + }, + "sites": { + "title": "साइट्स", + "namespaceTitle": "नेमस्पेस", + "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", + "namespaceHeader": "नेमस्पेस", + "homepageHeader": "मुख्यपृष्ठ", + "updateNamespace": "नेमस्पेस अद्यतनित करा", + "removeHomepage": "मुख्यपृष्ठ हटवा", + "selectHomePage": "एक पृष्ठ निवडा", + "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", + "customUrl": "स्वतःची URL", + "namespace": { + "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", + "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", + "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", + "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", + "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", + "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", + "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" + }, + "publishedPage": { + "title": "सर्व प्रकाशित पृष्ठे", + "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", + "page": "पृष्ठ", + "pathName": "पथाचे नाव", + "date": "प्रकाशन तारीख", + "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", + "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", + "settings": "प्रकाशन सेटिंग्ज", + "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", + "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" + } + } + }, + "error": { + "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", + "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", + "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", + "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", + "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", + "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", + "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", + "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", + "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", + "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", + "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", + "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", + "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", + "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", + "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" + }, + "success": { + "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", + "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", + "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", + "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" + }, + "accountPage": { + "menuLabel": "खाते आणि अ‍ॅप", + "title": "माझे खाते", + "general": { + "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", + "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" + }, + "email": { + "title": "ईमेल", + "actions": { + "change": "ईमेल बदला" + } + }, + "login": { + "title": "खाते लॉगिन", + "loginLabel": "लॉगिन", + "logoutLabel": "लॉगआउट" + }, + "isUpToDate": "@:appName अद्ययावत आहे!", + "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" +}, + "workspacePage": { + "menuLabel": "कार्यक्षेत्र", + "title": "कार्यक्षेत्र", + "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", + "workspaceName": { + "title": "कार्यक्षेत्राचे नाव" + }, + "workspaceIcon": { + "title": "कार्यक्षेत्राचे चिन्ह", + "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." + }, + "appearance": { + "title": "दृश्यरूप", + "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", + "options": { + "system": "स्वयंचलित", + "light": "लाइट", + "dark": "डार्क" + } + } + }, + "resetCursorColor": { + "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", + "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" + }, + "resetSelectionColor": { + "title": "दस्तऐवज निवडीचा रंग रीसेट करा", + "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" + }, + "resetWidth": { + "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" + }, + "theme": { + "title": "थीम", + "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", + "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" + }, + "workspaceFont": { + "title": "कार्यक्षेत्र फॉन्ट", + "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." + }, + "textDirection": { + "title": "मजकूर दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे", + "auto": "स्वयंचलित", + "enableRTLItems": "RTL टूलबार घटक सक्षम करा" + }, + "layoutDirection": { + "title": "लेआउट दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे" + }, + "dateTime": { + "title": "दिनांक आणि वेळ", + "example": "{} वाजता {} ({})", + "24HourTime": "२४-तास वेळ", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "सुलभ", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "भाषा" + }, + "deleteWorkspacePrompt": { + "title": "कार्यक्षेत्र हटवा", + "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." + }, + "leaveWorkspacePrompt": { + "title": "कार्यक्षेत्र सोडा", + "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", + "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", + "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." + }, + "manageWorkspace": { + "title": "कार्यक्षेत्र व्यवस्थापित करा", + "leaveWorkspace": "कार्यक्षेत्र सोडा", + "deleteWorkspace": "कार्यक्षेत्र हटवा" + }, + "manageDataPage": { + "menuLabel": "डेटा व्यवस्थापित करा", + "title": "डेटा व्यवस्थापन", + "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", + "dataStorage": { + "title": "फाइल संचयन स्थान", + "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", + "actions": { + "change": "मार्ग बदला", + "open": "फोल्डर उघडा", + "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", + "copy": "मार्ग कॉपी करा", + "copiedHint": "मार्ग कॉपी केला!", + "resetTooltip": "मूलभूत स्थानावर रीसेट करा" + }, + "resetDialog": { + "title": "तुम्हाला खात्री आहे का?", + "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." + } + }, + "importData": { + "title": "डेटा आयात करा", + "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", + "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", + "action": "फाइल निवडा" + }, + "encryption": { + "title": "एनक्रिप्शन", + "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", + "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", + "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", + "action": "डेटा एनक्रिप्ट करा", + "dialog": { + "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", + "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" + } + }, + "cache": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "dialog": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "successHint": "कॅशे साफ झाली!" + } + }, + "data": { + "fixYourData": "तुमचा डेटा सुधारा", + "fixButton": "सुधारा", + "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." + } + }, + "shortcutsPage": { + "menuLabel": "शॉर्टकट्स", + "title": "शॉर्टकट्स", + "editBindingHint": "नवीन बाइंडिंग टाका", + "searchHint": "शोधा", + "actions": { + "resetDefault": "मूलभूत रीसेट करा" + }, + "errorPage": { + "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", + "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." + }, + "resetDialog": { + "title": "शॉर्टकट्स रीसेट करा", + "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", + "buttonLabel": "रीसेट करा" + }, + "conflictDialog": { + "title": "{} आधीच वापरले जात आहे", + "descriptionPrefix": "हे कीबाइंडिंग सध्या ", + "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", + "confirmLabel": "पुढे जा" + }, + "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", + "keybindings": { + "toggleToDoList": "टू-डू सूची चालू/बंद करा", + "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", + "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", + "selectAllCodeblock": "सर्व निवडा", + "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", + "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", + "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", + "copy": "निवड कॉपी करा", + "paste": "मजकुरात पेस्ट करा", + "cut": "निवड कट करा", + "alignLeft": "मजकूर डावीकडे संरेखित करा", + "alignCenter": "मजकूर मधोमध संरेखित करा", + "alignRight": "मजकूर उजवीकडे संरेखित करा", + "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", + "backspace": "हटवा", + "deleteLeftWord": "डावीकडील शब्द हटवा", + "deleteLeftSentence": "डावीकडील वाक्य हटवा", + "delete": "उजवीकडील अक्षर हटवा", + "deleteMacOS": "डावीकडील अक्षर हटवा", + "deleteRightWord": "उजवीकडील शब्द हटवा", + "moveCursorLeft": "कर्सर डावीकडे हलवा", + "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", + "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", + "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", + "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", + "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", + "moveCursorRight": "कर्सर उजवीकडे हलवा", + "moveCursorEnd": "कर्सर शेवटी हलवा", + "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", + "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", + "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorUp": "कर्सर वर हलवा", + "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorTop": "कर्सर वर हलवा", + "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", + "moveCursorBottom": "कर्सर खाली हलवा", + "moveCursorDown": "कर्सर खाली हलवा", + "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", + "home": "वर स्क्रोल करा", + "end": "खाली स्क्रोल करा", + "toggleBold": "बोल्ड चालू/बंद करा", + "toggleItalic": "इटालिक चालू/बंद करा", + "toggleUnderline": "अधोरेखित चालू/बंद करा", + "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", + "toggleCode": "इनलाइन कोड चालू/बंद करा", + "toggleHighlight": "हायलाईट चालू/बंद करा", + "showLinkMenu": "लिंक मेनू दाखवा", + "openInlineLink": "इनलाइन लिंक उघडा", + "openLinks": "सर्व निवडलेले लिंक उघडा", + "indent": "इंडेंट", + "outdent": "आउटडेंट", + "exit": "संपादनातून बाहेर पडा", + "pageUp": "एक पृष्ठ वर स्क्रोल करा", + "pageDown": "एक पृष्ठ खाली स्क्रोल करा", + "selectAll": "सर्व निवडा", + "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", + "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", + "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", + "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", + "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", + "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", + "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", + "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", + "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", + "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" + }, + "commands": { + "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", + "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", + "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", + "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", + "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", + "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", + "textAlignLeft": "मजकूर डावीकडे संरेखित करा", + "textAlignCenter": "मजकूर मधोमध संरेखित करा", + "textAlignRight": "मजकूर उजवीकडे संरेखित करा" + }, + "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", + "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" +}, + "aiPage": { + "title": "AI सेटिंग्ज", + "menuLabel": "AI सेटिंग्ज", + "keys": { + "enableAISearchTitle": "AI शोध", + "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", + "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", + "llmModel": "भाषा मॉडेल", + "llmModelType": "भाषा मॉडेल प्रकार", + "downloadLLMPrompt": "{} डाउनलोड करा", + "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", + "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", + "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", + "downloadAIModelButton": "डाउनलोड करा", + "downloadingModel": "डाउनलोड करत आहे", + "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", + "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", + "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", + "localAIStopped": "स्थानिक AI थांबले आहे", + "localAIRunning": "स्थानिक AI चालू आहे", + "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", + "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", + "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", + "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", + "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", + "restartLocalAI": "पुन्हा सुरू करा", + "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", + "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", + "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", + "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", + "offlineAIInstruction1": "हे अनुसरा", + "offlineAIInstruction2": "सूचना", + "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", + "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", + "offlineAIDownload2": "डाउनलोड", + "offlineAIDownload3": "करा", + "activeOfflineAI": "सक्रिय", + "downloadOfflineAI": "डाउनलोड करा", + "openModelDirectory": "फोल्डर उघडा", + "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", + "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", + "pleaseFollowThese": "कृपया हे अनुसरा", + "instructions": "सूचना", + "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", + "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", + "downloadModel": "त्यांना डाउनलोड करण्यासाठी." + } +}, + "planPage": { + "menuLabel": "योजना", + "title": "दर योजना", + "planUsage": { + "title": "योजनेचा वापर सारांश", + "storageLabel": "स्टोरेज", + "storageUsage": "{} पैकी {} GB", + "unlimitedStorageLabel": "अमर्यादित स्टोरेज", + "collaboratorsLabel": "सदस्य", + "collaboratorsUsage": "{} पैकी {}", + "aiResponseLabel": "AI प्रतिसाद", + "aiResponseUsage": "{} पैकी {}", + "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", + "proBadge": "प्रो", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", + "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", + "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", + "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", + "aiCredit": { + "title": "@:appName AI क्रेडिट जोडा", + "price": "{}", + "priceDescription": "1,000 क्रेडिट्ससाठी", + "purchase": "AI खरेदी करा", + "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", + "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", + "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" + }, + "currentPlan": { + "bannerLabel": "सद्य योजना", + "freeTitle": "फ्री", + "proTitle": "प्रो", + "teamTitle": "टीम", + "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", + "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", + "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", + "upgrade": "योजना बदला", + "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "activeLabel": "जोडले गेले", + "aiMax": { + "title": "AI Max", + "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" + }, + "aiOnDevice": { + "title": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", + "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" + } + }, + "deal": { + "bannerLabel": "नववर्षाचे विशेष ऑफर!", + "title": "तुमची टीम वाढवा!", + "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", + "viewPlans": "योजना पहा" + } + } +}, + "billingPage": { + "menuLabel": "बिलिंग", + "title": "बिलिंग", + "plan": { + "title": "योजना", + "freeLabel": "फ्री", + "proLabel": "प्रो", + "planButtonLabel": "योजना बदला", + "billingPeriod": "बिलिंग कालावधी", + "periodButtonLabel": "कालावधी संपादित करा" + }, + "paymentDetails": { + "title": "पेमेंट तपशील", + "methodLabel": "पेमेंट पद्धत", + "methodButtonLabel": "पद्धत संपादित करा" + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "removeLabel": "काढा", + "renewLabel": "नवीन करा", + "aiMax": { + "label": "AI Max", + "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" + }, + "aiOnDevice": { + "label": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" + }, + "removeDialog": { + "title": "{} काढा", + "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." + } + }, + "currentPeriodBadge": "सद्य कालावधी", + "changePeriod": "कालावधी बदला", + "planPeriod": "{} कालावधी", + "monthlyInterval": "मासिक", + "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", + "annualInterval": "वार्षिक", + "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" +}, + "comparePlanDialog": { + "title": "योजना तुलना आणि निवड", + "planFeatures": "योजनेची\nवैशिष्ट्ये", + "current": "सध्याची", + "actions": { + "upgrade": "अपग्रेड करा", + "downgrade": "डाऊनग्रेड करा", + "current": "सध्याची" + }, + "freePlan": { + "title": "फ्री", + "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", + "price": "{}", + "priceInfo": "सदैव फ्री" + }, + "proPlan": { + "title": "प्रो", + "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", + "price": "{}", + "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" + }, + "planLabels": { + "itemOne": "वर्कस्पेसेस", + "itemTwo": "सदस्य", + "itemThree": "स्टोरेज", + "itemFour": "रिअल-टाइम सहकार्य", + "itemFive": "मोबाईल अ‍ॅप", + "itemSix": "AI प्रतिसाद", + "itemSeven": "AI प्रतिमा", + "itemFileUpload": "फाइल अपलोड", + "customNamespace": "सानुकूल नेमस्पेस", + "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", + "intelligentSearch": "स्मार्ट शोध", + "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", + "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" + }, + "freeLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "२ पर्यंत", + "itemThree": "५ GB", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "१० कायमस्वरूपी", + "itemSeven": "२ कायमस्वरूपी", + "itemFileUpload": "७ MB पर्यंत", + "intelligentSearch": "स्मार्ट शोध" + }, + "proLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "१० पर्यंत", + "itemThree": "अमर्यादित", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "अमर्यादित", + "itemSeven": "दर महिन्याला १० प्रतिमा", + "itemFileUpload": "अमर्यादित", + "intelligentSearch": "स्मार्ट शोध" + }, + "paymentSuccess": { + "title": "तुम्ही आता {} योजनेवर आहात!", + "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." + }, + "downgradeDialog": { + "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", + "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", + "downgradeLabel": "योजना डाऊनग्रेड करा" + } +}, + "cancelSurveyDialog": { + "title": "तुम्ही जात आहात याचे दुःख आहे", + "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", + "commonOther": "इतर", + "otherHint": "तुमचे उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", + "answerThree": "यापेक्षा चांगला पर्याय सापडला", + "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता", + "answerFive": "एकदम कमी शक्यता" + }, + "questionThree": { + "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", + "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", + "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", + "answerOne": "खूप छान", + "answerTwo": "चांगला", + "answerThree": "सरासरी", + "answerFour": "सरासरीपेक्षा कमी", + "answerFive": "असंतोषजनक" + } +}, + "common": { + "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", + "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", + "reset": "रीसेट करा" +}, + "menu": { + "appearance": "दृश्यरूप", + "language": "भाषा", + "user": "वापरकर्ता", + "files": "फाईल्स", + "notifications": "सूचना", + "open": "सेटिंग्ज उघडा", + "logout": "लॉगआउट", + "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", + "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", + "syncSetting": "सिंक्रोनायझेशन सेटिंग", + "cloudSettings": "क्लाऊड सेटिंग्ज", + "enableSync": "सिंक्रोनायझेशन सक्षम करा", + "enableSyncLog": "सिंक लॉगिंग सक्षम करा", + "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", + "enableEncrypt": "डेटा एन्क्रिप्ट करा", + "cloudURL": "बेस URL", + "webURL": "वेब URL", + "invalidCloudURLScheme": "अवैध स्कीम", + "cloudServerType": "क्लाऊड सर्व्हर", + "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", + "cloudLocal": "स्थानिक", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", + "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", + "clickToCopy": "क्लिपबोर्डवर कॉपी करा", + "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", + "selfHostContent": "दस्तऐवज", + "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", + "pleaseInputValidURL": "कृपया वैध URL टाका", + "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", + "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", + "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", + "cloudWSURL": "वेबसॉकेट URL", + "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", + "restartApp": "अ‍ॅप रीस्टार्ट करा", + "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", + "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", + "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", + "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", + "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", + "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", + "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", + "inputTextFieldHint": "तुमची गुप्तकी", + "historicalUserList": "वापरकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", + "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", + "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", + "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", + "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", + "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", + "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", + "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", + "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" +}, + "notifications": { + "enableNotifications": { + "label": "सूचना सक्षम करा", + "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." + }, + "showNotificationsIcon": { + "label": "सूचना चिन्ह दाखवा", + "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." + }, + "archiveNotifications": { + "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", + "success": "सूचना यशस्वीरित्या संग्रहित केली" + }, + "markAsReadNotifications": { + "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", + "success": "वाचलेले म्हणून चिन्हांकित केले" + }, + "action": { + "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", + "multipleChoice": "अधिक निवडा", + "archive": "संग्रहित करा" + }, + "settings": { + "settings": "सेटिंग्ज", + "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", + "archiveAll": "सर्व संग्रहित करा" + }, + "emptyInbox": { + "title": "इनबॉक्स झिरो!", + "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." + }, + "emptyUnread": { + "title": "कोणतीही न वाचलेली सूचना नाही", + "description": "तुम्ही सर्व वाचले आहे!" + }, + "emptyArchived": { + "title": "कोणतीही संग्रहित सूचना नाही", + "description": "संग्रहित सूचना इथे दिसतील." + }, + "tabs": { + "inbox": "इनबॉक्स", + "unread": "न वाचलेले", + "archived": "संग्रहित" + }, + "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", + "titles": { + "notifications": "सूचना", + "reminder": "रिमाइंडर" + } +}, + "appearance": { + "resetSetting": "रीसेट", + "fontFamily": { + "label": "फॉन्ट फॅमिली", + "search": "शोध", + "defaultFont": "सिस्टम" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टमशी जुळवा" + }, + "fontScaleFactor": "फॉन्ट स्केल घटक", + "displaySize": "डिस्प्ले आकार", + "documentSettings": { + "cursorColor": "डॉक्युमेंट कर्सरचा रंग", + "selectionColor": "डॉक्युमेंट निवडीचा रंग", + "width": "डॉक्युमेंटची रुंदी", + "changeWidth": "बदला", + "pickColor": "रंग निवडा", + "colorShade": "रंगाची छटा", + "opacity": "अपारदर्शकता", + "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", + "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", + "hexInvalidError": "अवैध Hex व्हॅल्यू", + "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", + "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", + "app": "अ‍ॅप", + "flowy": "Flowy", + "apply": "लागू करा" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "मूलभूत मजकूर दिशा", + "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयं", + "fallback": "लेआउट दिशेशी जुळवा" + }, + "themeUpload": { + "button": "अपलोड", + "uploadTheme": "थीम अपलोड करा", + "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", + "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", + "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", + "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", + "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", + "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" + }, + "theme": "थीम", + "builtInsLabel": "अंतर्गत थीम्स", + "pluginsLabel": "प्लगइन्स", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "अनौपचारिक", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "वेळ फॉरमॅट", + "twelveHour": "१२ तास", + "twentyFourHour": "२४ तास" + }, + "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", + "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", + "members": { + "title": "सदस्य सेटिंग्ज", + "inviteMembers": "सदस्यांना आमंत्रण द्या", + "inviteHint": "ईमेलद्वारे आमंत्रण द्या", + "sendInvite": "आमंत्रण पाठवा", + "copyInviteLink": "आमंत्रण दुवा कॉपी करा", + "label": "सदस्य", + "user": "वापरकर्ता", + "role": "भूमिका", + "removeFromWorkspace": "वर्कस्पेसमधून काढा", + "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", + "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", + "owner": "मालक", + "guest": "अतिथी", + "member": "सदस्य", + "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", + "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", + "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", + "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", + "members": "सदस्य", + "membersCount": { + "zero": "{} सदस्य", + "one": "{} सदस्य", + "other": "{} सदस्य" + }, + "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", + "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", + "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", + "memberLimitExceededUpgrade": "अपग्रेड करा", + "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", + "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", + "removeMember": "सदस्य काढा", + "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", + "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", + "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", + "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", + "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" + } +}, + "files": { + "copy": "कॉपी करा", + "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", + "exportData": "तुमचा डेटा निर्यात करा", + "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", + "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", + "customizeLocation": "इतर फोल्डर उघडा", + "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", + "exportDatabase": "डेटाबेस निर्यात करा", + "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", + "selectAll": "सर्व निवडा", + "deselectAll": "सर्व निवड रद्द करा", + "createNewFolder": "नवीन फोल्डर तयार करा", + "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", + "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", + "open": "उघडा", + "openFolder": "आधीक फोल्डर उघडा", + "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", + "folderHintText": "फोल्डरचे नाव", + "location": "नवीन फोल्डर तयार करत आहे", + "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", + "browser": "ब्राउझ करा", + "create": "तयार करा", + "set": "सेट करा", + "folderPath": "फोल्डर साठवण्याचा मार्ग", + "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", + "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", + "changeLocationTooltips": "डेटा डिरेक्टरी बदला", + "change": "बदला", + "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", + "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", + "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", + "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", + "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", + "export": "निर्यात करा", + "clearCache": "कॅशे साफ करा", + "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", + "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", + "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" +}, + "user": { + "name": "नाव", + "email": "ईमेल", + "tooltipSelectIcon": "चिन्ह निवडा", + "selectAnIcon": "चिन्ह निवडा", + "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", + "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" +}, + "mobile": { + "personalInfo": "वैयक्तिक माहिती", + "username": "वापरकर्तानाव", + "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", + "about": "विषयी", + "pushNotifications": "पुश सूचना", + "support": "सपोर्ट", + "joinDiscord": "Discord मध्ये सहभागी व्हा", + "privacyPolicy": "गोपनीयता धोरण", + "userAgreement": "वापरकर्ता करार", + "termsAndConditions": "अटी व शर्ती", + "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", + "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", + "selectLayout": "लेआउट निवडा", + "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", + "version": "आवृत्ती" +}, + "grid": { + "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", + "createView": "नवीन", + "title": { + "placeholder": "नाव नाही" + }, + "settings": { + "filter": "फिल्टर", + "sort": "क्रमवारी", + "sortBy": "यावरून क्रमवारी लावा", + "properties": "गुणधर्म", + "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", + "group": "समूह", + "addFilter": "फिल्टर जोडा", + "deleteFilter": "फिल्टर हटवा", + "filterBy": "यावरून फिल्टर करा", + "typeAValue": "मूल्य लिहा...", + "layout": "लेआउट", + "compactMode": "कॉम्पॅक्ट मोड", + "databaseLayout": "लेआउट", + "viewList": { + "zero": "० दृश्ये", + "one": "{count} दृश्य", + "other": "{count} दृश्ये" + }, + "editView": "दृश्य संपादित करा", + "boardSettings": "बोर्ड सेटिंग", + "calendarSettings": "कॅलेंडर सेटिंग", + "createView": "नवीन दृश्य", + "duplicateView": "दृश्याची प्रत बनवा", + "deleteView": "दृश्य हटवा", + "numberOfVisibleFields": "{} दर्शविले" + }, + "filter": { + "empty": "कोणतेही सक्रिय फिल्टर नाहीत", + "addFilter": "फिल्टर जोडा", + "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", + "conditon": "अट", + "where": "जिथे" + }, + "textFilter": { + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "endsWith": "याने समाप्त होते", + "startWith": "याने सुरू होते", + "is": "आहे", + "isNot": "नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही", + "choicechipPrefix": { + "isNot": "नाही", + "startWith": "याने सुरू होते", + "endWith": "याने समाप्त होते", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } + }, + "checkboxFilter": { + "isChecked": "निवडलेले आहे", + "isUnchecked": "निवडलेले नाही", + "choicechipPrefix": { + "is": "आहे" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण झाले आहे", + "isIncomplted": "अपूर्ण आहे" + }, + "selectOptionFilter": { + "is": "आहे", + "isNot": "नाही", + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"dateFilter": { + "is": "या दिवशी आहे", + "before": "पूर्वी आहे", + "after": "नंतर आहे", + "onOrBefore": "या दिवशी किंवा त्याआधी आहे", + "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", + "between": "दरम्यान आहे", + "empty": "रिकामे आहे", + "notEmpty": "रिकामे नाही", + "startDate": "सुरुवातीची तारीख", + "endDate": "शेवटची तारीख", + "choicechipPrefix": { + "before": "पूर्वी", + "after": "नंतर", + "between": "दरम्यान", + "onOrBefore": "या दिवशी किंवा त्याआधी", + "onOrAfter": "या दिवशी किंवा त्यानंतर", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } +}, +"numberFilter": { + "equal": "बरोबर आहे", + "notEqual": "बरोबर नाही", + "lessThan": "पेक्षा कमी आहे", + "greaterThan": "पेक्षा जास्त आहे", + "lessThanOrEqualTo": "किंवा कमी आहे", + "greaterThanOrEqualTo": "किंवा जास्त आहे", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"field": { + "label": "गुणधर्म", + "hide": "गुणधर्म लपवा", + "show": "गुणधर्म दर्शवा", + "insertLeft": "डावीकडे जोडा", + "insertRight": "उजवीकडे जोडा", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "wrapCellContent": "पाठ लपेटा", + "clear": "सेल्स रिकामे करा", + "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", + "textFieldName": "मजकूर", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "तारीख", + "updatedAtFieldName": "शेवटचे अपडेट", + "createdAtFieldName": "तयार झाले", + "numberFieldName": "संख्या", + "singleSelectFieldName": "सिंगल सिलेक्ट", + "multiSelectFieldName": "मल्टीसिलेक्ट", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "relationFieldName": "संबंध", + "summaryFieldName": "AI सारांश", + "timeFieldName": "वेळ", + "mediaFieldName": "फाईल्स आणि मीडिया", + "translateFieldName": "AI भाषांतर", + "translateTo": "मध्ये भाषांतर करा", + "numberFormat": "संख्या स्वरूप", + "dateFormat": "तारीख स्वरूप", + "includeTime": "वेळ जोडा", + "isRange": "शेवटची तारीख", + "dateFormatFriendly": "महिना दिवस, वर्ष", + "dateFormatISO": "वर्ष-महिना-दिनांक", + "dateFormatLocal": "महिना/दिवस/वर्ष", + "dateFormatUS": "वर्ष/महिना/दिवस", + "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", + "timeFormat": "वेळ स्वरूप", + "invalidTimeFormat": "अवैध स्वरूप", + "timeFormatTwelveHour": "१२ तास", + "timeFormatTwentyFourHour": "२४ तास", + "clearDate": "तारीख हटवा", + "dateTime": "तारीख व वेळ", + "startDateTime": "सुरुवातीची तारीख व वेळ", + "endDateTime": "शेवटची तारीख व वेळ", + "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", + "selectTime": "वेळ निवडा", + "selectDate": "तारीख निवडा", + "visibility": "दृश्यता", + "propertyType": "गुणधर्माचा प्रकार", + "addSelectOption": "पर्याय जोडा", + "typeANewOption": "नवीन पर्याय लिहा", + "optionTitle": "पर्याय", + "addOption": "पर्याय जोडा", + "editProperty": "गुणधर्म संपादित करा", + "newProperty": "नवीन गुणधर्म", + "openRowDocument": "पृष्ठ म्हणून उघडा", + "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", + "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", + "newColumn": "नवीन कॉलम", + "format": "स्वरूप", + "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", + "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" +}, + "rowPage": { + "newField": "नवीन फील्ड जोडा", + "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", + "showHiddenFields": { + "one": "{count} लपलेले फील्ड दाखवा", + "many": "{count} लपलेली फील्ड दाखवा", + "other": "{count} लपलेली फील्ड दाखवा" + }, + "hideHiddenFields": { + "one": "{count} लपलेले फील्ड लपवा", + "many": "{count} लपलेली फील्ड लपवा", + "other": "{count} लपलेली फील्ड लपवा" + }, + "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", + "moreRowActions": "अधिक पंक्ती क्रिया" +}, +"sort": { + "ascending": "चढत्या क्रमाने", + "descending": "उतरत्या क्रमाने", + "by": "द्वारे", + "empty": "सक्रिय सॉर्ट्स नाहीत", + "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", + "deleteAllSorts": "सर्व सॉर्ट्स हटवा", + "addSort": "सॉर्ट जोडा", + "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", + "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", + "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" +}, +"row": { + "label": "पंक्ती", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "titlePlaceholder": "शीर्षक नाही", + "textPlaceholder": "रिक्त", + "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", + "count": "संख्या", + "newRow": "नवीन पंक्ती", + "loadMore": "अधिक लोड करा", + "action": "क्रिया", + "add": "खाली जोडा वर क्लिक करा", + "drag": "हलवण्यासाठी ड्रॅग करा", + "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", + "insertRecordAbove": "वर रेकॉर्ड जोडा", + "insertRecordBelow": "खाली रेकॉर्ड जोडा", + "noContent": "माहिती नाही", + "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", + "createRowAboveDescription": "वर पंक्ती तयार करा", + "createRowBelowDescription": "खाली पंक्ती जोडा" +}, +"selectOption": { + "create": "तयार करा", + "purpleColor": "जांभळा", + "pinkColor": "गुलाबी", + "lightPinkColor": "फिकट गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पिवळा", + "limeColor": "लिंबू", + "greenColor": "हिरवा", + "aquaColor": "आक्वा", + "blueColor": "निळा", + "deleteTag": "टॅग हटवा", + "colorPanelTitle": "रंग", + "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", + "searchOption": "पर्याय शोधा", + "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", + "createNew": "नवीन तयार करा", + "orSelectOne": "किंवा पर्याय निवडा", + "typeANewOption": "नवीन पर्याय टाइप करा", + "tagName": "टॅग नाव" +}, +"checklist": { + "taskHint": "कार्याचे वर्णन", + "addNew": "नवीन कार्य जोडा", + "submitNewTask": "तयार करा", + "hideComplete": "पूर्ण कार्ये लपवा", + "showComplete": "सर्व कार्ये दाखवा" +}, +"url": { + "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", + "copy": "लिंक क्लिपबोर्डवर कॉपी करा", + "textFieldHint": "URL टाका", + "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" +}, +"relation": { + "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", + "relatedDatabasePlaceholder": "काही नाही", + "inRelatedDatabase": "या मध्ये", + "rowSearchTextFieldPlaceholder": "शोध", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", + "emptySearchResult": "कोणतीही नोंद सापडली नाही", + "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", + "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" +}, +"menuName": "ग्रिड", +"referencedGridPrefix": "दृश्य", +"calculate": "गणना करा", +"calculationTypeLabel": { + "none": "काही नाही", + "average": "सरासरी", + "max": "कमाल", + "median": "मध्यम", + "min": "किमान", + "sum": "बेरीज", + "count": "मोजणी", + "countEmpty": "रिकाम्यांची मोजणी", + "countEmptyShort": "रिक्त", + "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", + "countNonEmptyShort": "भरलेले" +}, +"media": { + "rename": "पुन्हा नाव द्या", + "download": "डाउनलोड करा", + "expand": "मोठे करा", + "delete": "हटवा", + "moreFilesHint": "+{}", + "addFileOrImage": "फाईल किंवा लिंक जोडा", + "attachmentsHint": "{}", + "addFileMobile": "फाईल जोडा", + "extraCount": "+{}", + "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "showFileNames": "फाईलचे नाव दाखवा", + "downloadSuccess": "फाईल डाउनलोड झाली", + "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", + "setAsCover": "कव्हर म्हणून सेट करा", + "openInBrowser": "ब्राउझरमध्ये उघडा", + "embedLink": "फाईल लिंक एम्बेड करा" + } +}, + "document": { + "menuName": "दस्तऐवज", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "creating": "तयार करत आहे...", + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", + "createANewBoard": "नवीन बोर्ड तयार करा" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", + "createANewGrid": "नवीन ग्रिड तयार करा" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", + "createANewCalendar": "नवीन दिनदर्शिका तयार करा" + }, + "document": { + "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" + }, + "name": { + "textStyle": "मजकुराची शैली", + "list": "यादी", + "toggle": "टॉगल", + "fileAndMedia": "फाईल व मीडिया", + "simpleTable": "सोपे टेबल", + "visuals": "दृश्य घटक", + "document": "दस्तऐवज", + "advanced": "प्रगत", + "text": "मजकूर", + "heading1": "शीर्षक 1", + "heading2": "शीर्षक 2", + "heading3": "शीर्षक 3", + "image": "प्रतिमा", + "bulletedList": "बुलेट यादी", + "numberedList": "क्रमांकित यादी", + "todoList": "करण्याची यादी", + "doc": "दस्तऐवज", + "linkedDoc": "पृष्ठाशी लिंक करा", + "grid": "ग्रिड", + "linkedGrid": "लिंक केलेला ग्रिड", + "kanban": "कानबन", + "linkedKanban": "लिंक केलेला कानबन", + "calendar": "दिनदर्शिका", + "linkedCalendar": "लिंक केलेली दिनदर्शिका", + "quote": "उद्धरण", + "divider": "विभाजक", + "table": "टेबल", + "callout": "महत्त्वाचा मजकूर", + "outline": "रूपरेषा", + "mathEquation": "गणिती समीकरण", + "code": "कोड", + "toggleList": "टॉगल यादी", + "toggleHeading1": "टॉगल शीर्षक 1", + "toggleHeading2": "टॉगल शीर्षक 2", + "toggleHeading3": "टॉगल शीर्षक 3", + "emoji": "इमोजी", + "aiWriter": "AI ला काहीही विचारा", + "dateOrReminder": "दिनांक किंवा स्मरणपत्र", + "photoGallery": "फोटो गॅलरी", + "file": "फाईल", + "twoColumns": "२ स्तंभ", + "threeColumns": "३ स्तंभ", + "fourColumns": "४ स्तंभ" + }, + "subPage": { + "name": "दस्तऐवज", + "keyword1": "उपपृष्ठ", + "keyword2": "पृष्ठ", + "keyword3": "चाइल्ड पृष्ठ", + "keyword4": "पृष्ठ जोडा", + "keyword5": "एम्बेड पृष्ठ", + "keyword6": "नवीन पृष्ठ", + "keyword7": "पृष्ठ तयार करा", + "keyword8": "दस्तऐवज" + } + }, + "selectionMenu": { + "outline": "रूपरेषा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "संदर्भित बोर्ड", + "referencedGrid": "संदर्भित ग्रिड", + "referencedCalendar": "संदर्भित दिनदर्शिका", + "referencedDocument": "संदर्भित दस्तऐवज", + "aiWriter": { + "userQuestion": "AI ला काहीही विचारा", + "continueWriting": "लेखन सुरू ठेवा", + "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", + "improveWriting": "लेखन सुधारित करा", + "summarize": "सारांश द्या", + "explain": "स्पष्टीकरण द्या", + "makeShorter": "लहान करा", + "makeLonger": "मोठे करा" + }, + "autoGeneratorMenuItemName": "AI लेखक", +"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", +"autoGeneratorLearnMore": "अधिक जाणून घ्या", +"autoGeneratorGenerate": "उत्पन्न करा", +"autoGeneratorHintText": "AI ला विचारा...", +"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", +"autoGeneratorRewrite": "पुन्हा लिहा", +"smartEdit": "AI ला विचारा", +"aI": "AI", +"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", +"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", +"smartEditSummarize": "सारांश द्या", +"smartEditImproveWriting": "लेखन सुधारित करा", +"smartEditMakeLonger": "लांब करा", +"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", +"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", +"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", +"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", +"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", +"createInlineMathEquation": "समीकरण तयार करा", +"fonts": "फॉन्ट्स", +"insertDate": "तारीख जोडा", +"emoji": "इमोजी", +"toggleList": "टॉगल यादी", +"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", +"quoteList": "उद्धरण यादी", +"numberedList": "क्रमांकित यादी", +"bulletedList": "बुलेट यादी", +"todoList": "करण्याची यादी", +"callout": "ठळक मजकूर", +"simpleTable": { + "moreActions": { + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "insertLeft": "डावीकडे घाला", + "insertRight": "उजवीकडे घाला", + "insertAbove": "वर घाला", + "insertBelow": "खाली घाला", + "headerColumn": "हेडर स्तंभ", + "headerRow": "हेडर ओळ", + "clearContents": "सामग्री साफ करा", + "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", + "distributeColumnsWidth": "स्तंभ समान करा", + "duplicateRow": "ओळ डुप्लिकेट करा", + "duplicateColumn": "स्तंभ डुप्लिकेट करा", + "textColor": "मजकूराचा रंग", + "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", + "duplicateTable": "टेबल डुप्लिकेट करा" + }, + "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", + "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", + "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", + "headerName": { + "table": "टेबल", + "alignText": "मजकूर पंक्तिबद्ध करा" + } +}, +"cover": { + "changeCover": "कव्हर बदला", + "colors": "रंग", + "images": "प्रतिमा", + "clearAll": "सर्व साफ करा", + "abstract": "ऍबस्ट्रॅक्ट", + "addCover": "कव्हर जोडा", + "addLocalImage": "स्थानिक प्रतिमा जोडा", + "invalidImageUrl": "अवैध प्रतिमा URL", + "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", + "enterImageUrl": "प्रतिमा URL लिहा", + "add": "जोडा", + "back": "मागे", + "saveToGallery": "गॅलरीत जतन करा", + "removeIcon": "आयकॉन काढा", + "removeCover": "कव्हर काढा", + "pasteImageUrl": "प्रतिमा URL पेस्ट करा", + "or": "किंवा", + "pickFromFiles": "फाईल्समधून निवडा", + "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", + "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", + "addIcon": "आयकॉन जोडा", + "changeIcon": "आयकॉन बदला", + "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", + "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" +}, +"mathEquation": { + "name": "गणिती समीकरण", + "addMathEquation": "TeX समीकरण जोडा", + "editMathEquation": "गणिती समीकरण संपादित करा" +}, +"optionAction": { + "click": "क्लिक", + "toOpenMenu": "मेनू उघडण्यासाठी", + "drag": "ओढा", + "toMove": "हलवण्यासाठी", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "turnInto": "मध्ये बदला", + "moveUp": "वर हलवा", + "moveDown": "खाली हलवा", + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "left": "डावीकडे", + "center": "मध्यभागी", + "right": "उजवीकडे", + "defaultColor": "डिफॉल्ट", + "depth": "खोली", + "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" +}, + "image": { + "addAnImage": "प्रतिमा जोडा", + "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "addAnImageDesktop": "प्रतिमा जोडा", + "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", + "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", + "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", + "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "errorCode": "त्रुटी कोड" +}, +"photoGallery": { + "name": "फोटो गॅलरी", + "imageKeyword": "प्रतिमा", + "imageGalleryKeyword": "प्रतिमा गॅलरी", + "photoKeyword": "फोटो", + "photoBrowserKeyword": "फोटो ब्राउझर", + "galleryKeyword": "गॅलरी", + "addImageTooltip": "प्रतिमा जोडा", + "changeLayoutTooltip": "लेआउट बदला", + "browserLayout": "ब्राउझर", + "gridLayout": "ग्रिड", + "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" +}, +"math": { + "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" +}, +"urlPreview": { + "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" +}, +"outline": { + "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", + "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." +}, +"table": { + "addAfter": "नंतर जोडा", + "addBefore": "आधी जोडा", + "delete": "हटा", + "clear": "सामग्री साफ करा", + "duplicate": "डुप्लिकेट करा", + "bgColor": "पार्श्वभूमीचा रंग" +}, +"contextMenu": { + "copy": "कॉपी करा", + "cut": "कापा", + "paste": "पेस्ट करा", + "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" +}, +"action": "कृती", +"database": { + "selectDataSource": "डेटा स्रोत निवडा", + "noDataSource": "डेटा स्रोत नाही", + "selectADataSource": "डेटा स्रोत निवडा", + "toContinue": "पुढे जाण्यासाठी", + "newDatabase": "नवीन डेटाबेस", + "linkToDatabase": "डेटाबेसशी लिंक करा" +}, +"date": "तारीख", +"video": { + "label": "व्हिडिओ", + "emptyLabel": "व्हिडिओ जोडा", + "placeholder": "व्हिडिओ लिंक पेस्ट करा", + "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "insertVideo": "व्हिडिओ जोडा", + "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", + "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", + "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" +}, +"file": { + "name": "फाईल", + "uploadTab": "अपलोड", + "uploadMobile": "फाईल निवडा", + "uploadMobileGallery": "फोटो गॅलरीमधून", + "networkTab": "लिंक एम्बेड करा", + "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", + "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", + "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", + "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", + "fileUploadHintSuffix": "ब्राउझ करा", + "networkHint": "फाईल लिंक पेस्ट करा", + "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", + "networkAction": "एम्बेड", + "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", + "renameFile": { + "title": "फाईलचे नाव बदला", + "description": "या फाईलसाठी नवीन नाव लिहा", + "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." + }, + "uploadedAt": "{} रोजी अपलोड केले", + "linkedAt": "{} रोजी लिंक जोडली", + "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" +}, +"subPage": { + "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", + "errors": { + "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", + "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", + "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", + "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", + "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" + } +}, + "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" +}, +"outlineBlock": { + "placeholder": "सामग्री सूची" +}, +"textBlock": { + "placeholder": "कमांडसाठी '/' टाइप करा" +}, +"title": { + "placeholder": "शीर्षक नाही" +}, +"imageBlock": { + "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", + "upload": { + "label": "अपलोड", + "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" + }, + "url": { + "label": "प्रतिमेची URL", + "placeholder": "प्रतिमेची URL टाका" + }, + "ai": { + "label": "AI द्वारे प्रतिमा तयार करा", + "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "stability_ai": { + "label": "Stability AI द्वारे प्रतिमा तयार करा", + "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अवैध प्रतिमा", + "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", + "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "अवैध प्रतिमेची URL", + "noImage": "अशी फाईल किंवा निर्देशिका नाही", + "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" + }, + "embedLink": { + "label": "लिंक एम्बेड करा", + "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "प्रतिमा शोधा", + "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", + "saveImageToGallery": "प्रतिमा जतन करा", + "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", + "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", + "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", + "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", + "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", + "imageIsUploading": "प्रतिमा अपलोड होत आहे", + "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "मागील प्रतिमा", + "nextImageTooltip": "पुढील प्रतिमा", + "zoomOutTooltip": "लहान करा", + "zoomInTooltip": "मोठी करा", + "changeZoomLevelTooltip": "झूम पातळी बदला", + "openLocalImage": "प्रतिमा उघडा", + "downloadImage": "प्रतिमा डाउनलोड करा", + "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", + "scalePercentage": "{}%", + "deleteImageTooltip": "प्रतिमा हटवा" + } + } +}, + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "auto": "स्वयंचलित" + }, + "copyTooltip": "कॉपी करा", + "searchLanguageHint": "भाषा शोधा", + "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" +}, +"inlineLink": { + "placeholder": "लिंक पेस्ट करा किंवा टाका", + "openInNewTab": "नवीन टॅबमध्ये उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL टाका" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक टाका" + } +}, +"mention": { + "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", + "page": { + "label": "पृष्ठाला लिंक करा", + "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" + }, + "deleted": "हटवले गेले", + "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", + "noAccess": "प्रवेश नाही", + "deletedPage": "हटवलेले पृष्ठ", + "trashHint": " - ट्रॅशमध्ये", + "morePages": "अजून पृष्ठे" +}, +"toolbar": { + "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", + "textSize": "मजकूराचा आकार", + "textColor": "मजकूराचा रंग", + "h1": "मथळा 1", + "h2": "मथळा 2", + "h3": "मथळा 3", + "alignLeft": "डावीकडे संरेखित करा", + "alignRight": "उजवीकडे संरेखित करा", + "alignCenter": "मध्यभागी संरेखित करा", + "link": "लिंक", + "textAlign": "मजकूर संरेखन", + "moreOptions": "अधिक पर्याय", + "font": "फॉन्ट", + "inlineCode": "इनलाइन कोड", + "suggestions": "सूचना", + "turnInto": "मध्ये रूपांतरित करा", + "equation": "समीकरण", + "insert": "घाला", + "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", + "pageOrURL": "पृष्ठ किंवा URL", + "linkName": "लिंकचे नाव", + "linkNameHint": "लिंकचे नाव प्रविष्ट करा" +}, +"errorBlock": { + "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", + "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", + "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", + "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", + "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" +}, +"mobilePageSelector": { + "title": "पृष्ठ निवडा", + "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", + "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" +}, +"attachmentMenu": { + "choosePhoto": "फोटो निवडा", + "takePicture": "फोटो काढा", + "chooseFile": "फाईल निवडा" + } + }, + "board": { + "column": { + "label": "स्तंभ", + "createNewCard": "नवीन", + "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", + "createNewColumn": "नवीन गट जोडा", + "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", + "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", + "renameColumn": "स्तंभाचे नाव बदला", + "hideColumn": "लपवा", + "newGroup": "नवीन गट", + "deleteColumn": "हटवा", + "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" + }, + "hiddenGroupSection": { + "sectionTitle": "लपवलेले गट", + "collapseTooltip": "लपवलेले गट लपवा", + "expandTooltip": "लपवलेले गट पाहा" + }, + "cardDetail": "कार्ड तपशील", + "cardActions": "कार्ड क्रिया", + "cardDuplicated": "कार्डची प्रत तयार झाली", + "cardDeleted": "कार्ड हटवले गेले", + "showOnCard": "कार्ड तपशिलावर दाखवा", + "setting": "सेटिंग", + "propertyName": "गुणधर्माचे नाव", + "menuName": "बोर्ड", + "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", + "ungroupedButtonText": "गट नसलेली", + "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", + "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", + "groupBy": "या आधारावर गट करा", + "groupCondition": "गट स्थिती", + "referencedBoardPrefix": "याचे दृश्य", + "notesTooltip": "नोट्स आहेत", + "mobile": { + "editURL": "URL संपादित करा", + "showGroup": "गट दाखवा", + "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", + "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" + }, + "dateCondition": { + "weekOf": "{} - {} ची आठवडा", + "today": "आज", + "yesterday": "काल", + "tomorrow": "उद्या", + "lastSevenDays": "शेवटचे ७ दिवस", + "nextSevenDays": "पुढील ७ दिवस", + "lastThirtyDays": "शेवटचे ३० दिवस", + "nextThirtyDays": "पुढील ३० दिवस" + }, + "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", + "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", + "media": { + "cardText": "{} {}", + "fallbackName": "फायली" + } +}, + "calendar": { + "menuName": "कॅलेंडर", + "defaultNewCalendarTitle": "नाव नाही", + "newEventButtonTooltip": "नवीन इव्हेंट जोडा", + "navigation": { + "today": "आज", + "jumpToday": "आजवर जा", + "previousMonth": "मागील महिना", + "nextMonth": "पुढील महिना", + "views": { + "day": "दिवस", + "week": "आठवडा", + "month": "महिना", + "year": "वर्ष" + } + }, + "mobileEventScreen": { + "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", + "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." + }, + "settings": { + "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", + "showWeekends": "सप्ताहांत दाखवा", + "firstDayOfWeek": "आठवड्याची सुरुवात", + "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", + "changeLayoutDateField": "मांडणी फील्ड बदला", + "noDateTitle": "तारीख नाही", + "noDateHint": { + "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", + "one": "{count} नियोजित नसलेली इव्हेंट", + "other": "{count} नियोजित नसलेल्या इव्हेंट्स" + }, + "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", + "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", + "name": "कॅलेंडर सेटिंग्ज", + "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" + }, + "referencedCalendarPrefix": "याचे दृश्य", + "quickJumpYear": "या वर्षावर जा", + "duplicateEvent": "इव्हेंट डुप्लिकेट करा" +}, + "errorDialog": { + "title": "@:appName त्रुटी", + "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", + "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", + "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", + "github": "GitHub वर पहा" +}, +"search": { + "label": "शोध", + "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", + "placeholder": { + "actions": "कृती शोधा..." + } +}, +"message": { + "copy": { + "success": "कॉपी झाले!", + "fail": "कॉपी करू शकत नाही" + } +}, +"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", +"views": { + "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", + "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." +}, + "colors": { + "custom": "सानुकूल", + "default": "डीफॉल्ट", + "red": "लाल", + "orange": "संत्रा", + "yellow": "पिवळा", + "green": "हिरवा", + "blue": "निळा", + "purple": "जांभळा", + "pink": "गुलाबी", + "brown": "तपकिरी", + "gray": "करड्या रंगाचा" +}, + "emoji": { + "emojiTab": "इमोजी", + "search": "इमोजी शोधा", + "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", + "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", + "filter": "फिल्टर", + "random": "योगायोगाने", + "selectSkinTone": "त्वचेचा टोन निवडा", + "remove": "इमोजी काढा", + "categories": { + "smileys": "स्मायली आणि भावना", + "people": "लोक", + "animals": "प्राणी आणि निसर्ग", + "food": "अन्न", + "activities": "क्रिया", + "places": "स्थळे", + "objects": "वस्तू", + "symbols": "चिन्हे", + "flags": "ध्वज", + "nature": "निसर्ग", + "frequentlyUsed": "नेहमी वापरलेले" + }, + "skinTone": { + "default": "डीफॉल्ट", + "light": "हलका", + "mediumLight": "मध्यम-हलका", + "medium": "मध्यम", + "mediumDark": "मध्यम-गडद", + "dark": "गडद" + }, + "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" +}, + "inlineActions": { + "noResults": "निकाल नाही", + "recentPages": "अलीकडील पृष्ठे", + "pageReference": "पृष्ठ संदर्भ", + "docReference": "दस्तऐवज संदर्भ", + "boardReference": "बोर्ड संदर्भ", + "calReference": "कॅलेंडर संदर्भ", + "gridReference": "ग्रिड संदर्भ", + "date": "तारीख", + "reminder": { + "groupTitle": "स्मरणपत्र", + "shortKeyword": "remind" + }, + "createPage": "\"{}\" उप-पृष्ठ तयार करा" +}, + "datePicker": { + "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", + "dateFormat": "तारीख फॉरमॅट", + "includeTime": "वेळ समाविष्ट करा", + "isRange": "शेवटची तारीख", + "timeFormat": "वेळ फॉरमॅट", + "clearDate": "तारीख साफ करा", + "reminderLabel": "स्मरणपत्र", + "selectReminder": "स्मरणपत्र निवडा", + "reminderOptions": { + "none": "काहीही नाही", + "atTimeOfEvent": "इव्हेंटच्या वेळी", + "fiveMinsBefore": "५ मिनिटे आधी", + "tenMinsBefore": "१० मिनिटे आधी", + "fifteenMinsBefore": "१५ मिनिटे आधी", + "thirtyMinsBefore": "३० मिनिटे आधी", + "oneHourBefore": "१ तास आधी", + "twoHoursBefore": "२ तास आधी", + "onDayOfEvent": "इव्हेंटच्या दिवशी", + "oneDayBefore": "१ दिवस आधी", + "twoDaysBefore": "२ दिवस आधी", + "oneWeekBefore": "१ आठवडा आधी", + "custom": "सानुकूल" + } +}, + "relativeDates": { + "yesterday": "काल", + "today": "आज", + "tomorrow": "उद्या", + "oneWeek": "१ आठवडा" +}, + "notificationHub": { + "title": "सूचना", + "mobile": { + "title": "अपडेट्स" + }, + "emptyTitle": "सर्व पूर्ण झाले!", + "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", + "tabs": { + "inbox": "इनबॉक्स", + "upcoming": "आगामी" + }, + "actions": { + "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", + "showAll": "सर्व", + "showUnreads": "न वाचलेल्या" + }, + "filters": { + "ascending": "आरोही", + "descending": "अवरोही", + "groupByDate": "तारीखेनुसार गटबद्ध करा", + "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", + "resetToDefault": "डीफॉल्टवर रीसेट करा" + } +}, + "reminderNotification": { + "title": "स्मरणपत्र", + "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", + "tooltipDelete": "हटवा", + "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", + "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" +}, + "findAndReplace": { + "find": "शोधा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "close": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "noResult": "कोणतेही निकाल नाहीत", + "caseSensitive": "केस सेंसिटिव्ह", + "searchMore": "अधिक निकालांसाठी शोधा" +}, + "error": { + "weAreSorry": "आम्ही क्षमस्व आहोत", + "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", + "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", + "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", + "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" +}, + "editor": { + "bold": "जाड", + "bulletedList": "बुलेट यादी", + "bulletedListShortForm": "बुलेट", + "checkbox": "चेकबॉक्स", + "embedCode": "कोड एम्बेड करा", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "हायलाइट", + "color": "रंग", + "image": "प्रतिमा", + "date": "तारीख", + "page": "पृष्ठ", + "italic": "तिरका", + "link": "लिंक", + "numberedList": "क्रमांकित यादी", + "numberedListShortForm": "क्रमांकित", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", + "quote": "कोट", + "strikethrough": "ओढून टाका", + "text": "मजकूर", + "underline": "अधोरेखित", + "fontColorDefault": "डीफॉल्ट", + "fontColorGray": "धूसर", + "fontColorBrown": "तपकिरी", + "fontColorOrange": "केशरी", + "fontColorYellow": "पिवळा", + "fontColorGreen": "हिरवा", + "fontColorBlue": "निळा", + "fontColorPurple": "जांभळा", + "fontColorPink": "पिंग", + "fontColorRed": "लाल", + "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", + "backgroundColorGray": "धूसर पार्श्वभूमी", + "backgroundColorBrown": "तपकिरी पार्श्वभूमी", + "backgroundColorOrange": "केशरी पार्श्वभूमी", + "backgroundColorYellow": "पिवळी पार्श्वभूमी", + "backgroundColorGreen": "हिरवी पार्श्वभूमी", + "backgroundColorBlue": "निळी पार्श्वभूमी", + "backgroundColorPurple": "जांभळी पार्श्वभूमी", + "backgroundColorPink": "पिंग पार्श्वभूमी", + "backgroundColorRed": "लाल पार्श्वभूमी", + "backgroundColorLime": "लिंबू पार्श्वभूमी", + "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", + "done": "पूर्ण", + "cancel": "रद्द करा", + "tint1": "टिंट 1", + "tint2": "टिंट 2", + "tint3": "टिंट 3", + "tint4": "टिंट 4", + "tint5": "टिंट 5", + "tint6": "टिंट 6", + "tint7": "टिंट 7", + "tint8": "टिंट 8", + "tint9": "टिंट 9", + "lightLightTint1": "जांभळा", + "lightLightTint2": "पिंग", + "lightLightTint3": "फिकट पिंग", + "lightLightTint4": "केशरी", + "lightLightTint5": "पिवळा", + "lightLightTint6": "लिंबू", + "lightLightTint7": "हिरवा", + "lightLightTint8": "पाणी", + "lightLightTint9": "निळा", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", + "textColor": "मजकूराचा रंग", + "backgroundColor": "पार्श्वभूमीचा रंग", + "addYourLink": "तुमची लिंक जोडा", + "openLink": "लिंक उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "editLink": "लिंक संपादित करा", + "linkText": "मजकूर", + "linkTextHint": "कृपया मजकूर प्रविष्ट करा", + "linkAddressHint": "कृपया URL प्रविष्ट करा", + "highlightColor": "हायलाइट रंग", + "clearHighlightColor": "हायलाइट काढा", + "customColor": "स्वतःचा रंग", + "hexValue": "Hex मूल्य", + "opacity": "अपारदर्शकता", + "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयंचलित", + "cut": "कट", + "copy": "कॉपी", + "paste": "पेस्ट", + "find": "शोधा", + "select": "निवडा", + "selectAll": "सर्व निवडा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "closeFind": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "regex": "Regex", + "caseSensitive": "केस सेंसिटिव्ह", + "uploadImage": "प्रतिमा अपलोड करा", + "urlImage": "URL प्रतिमा", + "incorrectLink": "चुकीची लिंक", + "upload": "अपलोड", + "chooseImage": "प्रतिमा निवडा", + "loading": "लोड करत आहे", + "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", + "divider": "विभाजक", + "table": "तक्त्याचे स्वरूप", + "colAddBefore": "यापूर्वी स्तंभ जोडा", + "rowAddBefore": "यापूर्वी पंक्ती जोडा", + "colAddAfter": "यानंतर स्तंभ जोडा", + "rowAddAfter": "यानंतर पंक्ती जोडा", + "colRemove": "स्तंभ काढा", + "rowRemove": "पंक्ती काढा", + "colDuplicate": "स्तंभ डुप्लिकेट", + "rowDuplicate": "पंक्ती डुप्लिकेट", + "colClear": "सामग्री साफ करा", + "rowClear": "सामग्री साफ करा", + "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", + "typeSomething": "काहीतरी लिहा...", + "toggleListShortForm": "टॉगल", + "quoteListShortForm": "कोट", + "mathEquationShortForm": "सूत्र", + "codeBlockShortForm": "कोड" +}, + "favorite": { + "noFavorite": "कोणतेही आवडते पृष्ठ नाही", + "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", + "removeFromSidebar": "साइडबारमधून काढा", + "addToSidebar": "साइडबारमध्ये पिन करा" +}, +"cardDetails": { + "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" +}, +"blockPlaceholders": { + "todoList": "करण्याची यादी", + "bulletList": "यादी", + "numberList": "क्रमांकित यादी", + "quote": "कोट", + "heading": "मथळा {}" +}, +"titleBar": { + "pageIcon": "पृष्ठ चिन्ह", + "language": "भाषा", + "font": "फॉन्ट", + "actions": "क्रिया", + "date": "तारीख", + "addField": "फील्ड जोडा", + "userIcon": "वापरकर्त्याचे चिन्ह" +}, +"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", +"newSettings": { + "myAccount": { + "title": "माझे खाते", + "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", + "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", + "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", + "accountSecurity": "खाते सुरक्षा", + "2FA": "2-स्टेप प्रमाणीकरण", + "aiKeys": "AI कीज", + "accountLogin": "खाते लॉगिन", + "updateNameError": "नाव अपडेट करण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "aboutAppFlowy": "@:appName विषयी", + "deleteAccount": { + "title": "खाते हटवा", + "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", + "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", + "deleteMyAccount": "माझे खाते हटवा", + "dialogTitle": "खाते हटवा", + "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", + "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", + "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", + "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", + "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", + "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", + "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" + } + }, + "workplace": { + "name": "वर्कस्पेस", + "title": "वर्कस्पेस सेटिंग्स", + "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", + "workplaceName": "वर्कस्पेसचे नाव", + "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", + "workplaceIcon": "वर्कस्पेस चिन्ह", + "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", + "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "chooseAnIcon": "चिन्ह निवडा", + "appearance": { + "name": "दृश्यरूप", + "themeMode": { + "auto": "स्वयंचलित", + "light": "प्रकाश मोड", + "dark": "गडद मोड" + }, + "language": "भाषा" + } + }, + "syncState": { + "syncing": "सिंक्रोनायझ करत आहे", + "synced": "सिंक्रोनायझ झाले", + "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" + } +}, + "pageStyle": { + "title": "पृष्ठ शैली", + "layout": "लेआउट", + "coverImage": "मुखपृष्ठ प्रतिमा", + "pageIcon": "पृष्ठ चिन्ह", + "colors": "रंग", + "gradient": "ग्रेडियंट", + "backgroundImage": "पार्श्वभूमी प्रतिमा", + "presets": "पूर्वनियोजित", + "photo": "फोटो", + "unsplash": "Unsplash", + "pageCover": "पृष्ठ कव्हर", + "none": "काही नाही", + "openSettings": "सेटिंग्स उघडा", + "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", + "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", + "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", + "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", + "doNotAllow": "परवानगी देऊ नका", + "image": "प्रतिमा" +}, +"commandPalette": { + "placeholder": "शोधा किंवा प्रश्न विचारा...", + "bestMatches": "सर्वोत्तम जुळवणी", + "recentHistory": "अलीकडील इतिहास", + "navigateHint": "नेव्हिगेट करण्यासाठी", + "loadingTooltip": "आम्ही निकाल शोधत आहोत...", + "betaLabel": "बेटा", + "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", + "fromTrashHint": "कचरापेटीतून", + "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", + "clearSearchTooltip": "शोध फील्ड साफ करा" +}, +"space": { + "delete": "हटवा", + "deleteConfirmation": "हटवा: ", + "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", + "rename": "स्पेसचे नाव बदला", + "changeIcon": "चिन्ह बदला", + "manage": "स्पेस व्यवस्थापित करा", + "addNewSpace": "स्पेस तयार करा", + "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", + "createNewSpace": "नवीन स्पेस तयार करा", + "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", + "spaceName": "स्पेसचे नाव", + "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", + "permission": "स्पेस परवानगी", + "publicPermission": "सार्वजनिक", + "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", + "privatePermission": "खाजगी", + "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", + "spaceIconBackground": "पार्श्वभूमीचा रंग", + "spaceIcon": "चिन्ह", + "dangerZone": "धोकादायक क्षेत्र", + "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", + "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", + "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", + "title": "स्पेसेस", + "defaultSpaceName": "सामान्य", + "upgradeSpaceTitle": "स्पेस सक्षम करा", + "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", + "upgrade": "अपग्रेड", + "upgradeYourSpace": "अनेक स्पेस तयार करा", + "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", + "duplicate": "स्पेस डुप्लिकेट करा", + "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", + "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", + "switchSpace": "स्पेस स्विच करा", + "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", + "success": { + "deleteSpace": "स्पेस यशस्वीरित्या हटवली", + "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", + "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", + "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" + }, + "error": { + "deleteSpace": "स्पेस हटवण्यात अयशस्वी", + "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", + "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", + "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" + }, + "createSpace": "स्पेस तयार करा", + "manageSpace": "स्पेस व्यवस्थापित करा", + "renameSpace": "स्पेसचे नाव बदला", + "mSpaceIconColor": "स्पेस चिन्हाचा रंग", + "mSpaceIcon": "स्पेस चिन्ह" +}, + "publish": { + "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", + "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", + "reportPage": "पृष्ठाची तक्रार करा", + "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", + "createdWith": "यांनी तयार केले", + "downloadApp": "AppFlowy डाउनलोड करा", + "copy": { + "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", + "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", + "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" + }, + "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", + "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", + "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", + "publishFailed": "प्रकाशित करण्यात अयशस्वी", + "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", + "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", + "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", + "fastWithAI": "AI सह जलद आणि सोपे.", + "tryItNow": "आत्ताच वापरून पहा", + "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", + "database": { + "zero": "{} निवडलेले दृश्य प्रकाशित करा", + "one": "{} निवडलेली दृश्ये प्रकाशित करा", + "many": "{} निवडलेली दृश्ये प्रकाशित करा", + "other": "{} निवडलेली दृश्ये प्रकाशित करा" + }, + "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", + "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", + "saveThisPage": "या टेम्पलेटपासून सुरू करा", + "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", + "selectWorkspace": "वर्कस्पेस निवडा", + "addTo": "मध्ये जोडा", + "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", + "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", + "downloadIt": "डाउनलोड करा", + "openApp": "अ‍ॅपमध्ये उघडा", + "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", + "membersCount": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "useThisTemplate": "हा टेम्पलेट वापरा" +}, +"web": { + "continue": "पुढे जा", + "or": "किंवा", + "continueWithGoogle": "Google सह पुढे जा", + "continueWithGithub": "GitHub सह पुढे जा", + "continueWithDiscord": "Discord सह पुढे जा", + "continueWithApple": "Apple सह पुढे जा", + "moreOptions": "अधिक पर्याय", + "collapse": "आकुंचन", + "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "and": "आणि", + "termOfUse": "वापर अटी", + "privacyPolicy": "गोपनीयता धोरण", + "signInError": "साइन इन त्रुटी", + "login": "साइन अप किंवा लॉग इन करा", + "fileBlock": { + "uploadedAt": "{time} रोजी अपलोड केले", + "linkedAt": "{time} रोजी लिंक जोडली", + "empty": "फाईल अपलोड करा किंवा एम्बेड करा", + "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "retry": "पुन्हा प्रयत्न करा" + }, + "importNotion": "Notion वरून आयात करा", + "import": "आयात करा", + "importSuccess": "यशस्वीरित्या अपलोड केले", + "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", + "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", + "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", + "error": { + "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" + } +}, + "globalComment": { + "comments": "टिप्पण्या", + "addComment": "टिप्पणी जोडा", + "reactedBy": "यांनी प्रतिक्रिया दिली", + "addReaction": "प्रतिक्रिया जोडा", + "reactedByMore": "आणि {count} इतर", + "showSeconds": { + "one": "1 सेकंदापूर्वी", + "other": "{count} सेकंदांपूर्वी", + "zero": "आत्ताच", + "many": "{count} सेकंदांपूर्वी" + }, + "showMinutes": { + "one": "1 मिनिटापूर्वी", + "other": "{count} मिनिटांपूर्वी", + "many": "{count} मिनिटांपूर्वी" + }, + "showHours": { + "one": "1 तासापूर्वी", + "other": "{count} तासांपूर्वी", + "many": "{count} तासांपूर्वी" + }, + "showDays": { + "one": "1 दिवसापूर्वी", + "other": "{count} दिवसांपूर्वी", + "many": "{count} दिवसांपूर्वी" + }, + "showMonths": { + "one": "1 महिन्यापूर्वी", + "other": "{count} महिन्यांपूर्वी", + "many": "{count} महिन्यांपूर्वी" + }, + "showYears": { + "one": "1 वर्षापूर्वी", + "other": "{count} वर्षांपूर्वी", + "many": "{count} वर्षांपूर्वी" + }, + "reply": "उत्तर द्या", + "deleteComment": "टिप्पणी हटवा", + "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", + "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", + "hasBeenDeleted": "हटवले गेले", + "replyingTo": "याला उत्तर देत आहे", + "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", + "collapse": "संकुचित करा", + "readMore": "अधिक वाचा", + "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", + "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", + "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" +}, + "template": { + "asTemplate": "टेम्पलेट म्हणून जतन करा", + "name": "टेम्पलेट नाव", + "description": "टेम्पलेट वर्णन", + "about": "टेम्पलेट माहिती", + "deleteFromTemplate": "टेम्पलेटमधून हटवा", + "preview": "टेम्पलेट पूर्वदृश्य", + "categories": "टेम्पलेट श्रेणी", + "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", + "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", + "relatedTemplates": "संबंधित टेम्पलेट्स", + "requiredField": "{field} आवश्यक आहे", + "addCategory": "\"{category}\" जोडा", + "addNewCategory": "नवीन श्रेणी जोडा", + "addNewCreator": "नवीन निर्माता जोडा", + "deleteCategory": "श्रेणी हटवा", + "editCategory": "श्रेणी संपादित करा", + "editCreator": "निर्माता संपादित करा", + "category": { + "name": "श्रेणीचे नाव", + "icon": "श्रेणी चिन्ह", + "bgColor": "श्रेणी पार्श्वभूमीचा रंग", + "priority": "श्रेणी प्राधान्य", + "desc": "श्रेणीचे वर्णन", + "type": "श्रेणी प्रकार", + "icons": "श्रेणी चिन्हे", + "colors": "श्रेणी रंग", + "byUseCase": "वापराच्या आधारे", + "byFeature": "वैशिष्ट्यांनुसार", + "deleteCategory": "श्रेणी हटवा", + "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", + "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." + }, + "creator": { + "label": "टेम्पलेट निर्माता", + "name": "निर्मात्याचे नाव", + "avatar": "निर्मात्याचा अवतार", + "accountLinks": "निर्मात्याचे खाते दुवे", + "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", + "deleteCreator": "निर्माता हटवा", + "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", + "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." + }, + "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", + "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", + "viewTemplate": "टेम्पलेट पहा", + "deleteTemplate": "टेम्पलेट हटवा", + "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", + "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", + "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", + "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", + "uploadAvatar": "अवतार अपलोड करा", + "searchInCategory": "{category} मध्ये शोधा", + "label": "टेम्पलेट्स" +}, + "fileDropzone": { + "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", + "uploading": "अपलोड करत आहे...", + "uploadFailed": "अपलोड अयशस्वी", + "uploadSuccess": "अपलोड यशस्वी", + "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", + "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", + "uploadingDescription": "फाइल अपलोड होत आहे" +}, + "gallery": { + "preview": "पूर्ण स्क्रीनमध्ये उघडा", + "copy": "कॉपी करा", + "download": "डाउनलोड", + "prev": "मागील", + "next": "पुढील", + "resetZoom": "झूम रिसेट करा", + "zoomIn": "झूम इन", + "zoomOut": "झूम आउट" +}, + "invitation": { + "join": "सामील व्हा", + "on": "वर", + "invitedBy": "यांनी आमंत्रित केले", + "membersCount": { + "zero": "{count} सदस्य", + "one": "{count} सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", + "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", + "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", + "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", + "openWorkspace": "AppFlowy उघडा", + "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", + "errorModal": { + "title": "काहीतरी चुकले आहे", + "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", + "contactOwner": "मालकाशी संपर्क करा", + "close": "मुख्यपृष्ठावर परत जा", + "changeAccount": "खाते बदला" + } +}, + "requestAccess": { + "title": "या पृष्ठासाठी प्रवेश नाही", + "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", + "requestAccess": "प्रवेशाची विनंती करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", + "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", + "successful": "विनंती यशस्वीपणे पाठवली गेली", + "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", + "requestError": "प्रवेशाची विनंती अयशस्वी", + "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" +}, + "approveAccess": { + "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", + "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", + "upgrade": "अपग्रेड", + "downloadApp": "AppFlowy डाउनलोड करा", + "approveButton": "मंजूर करा", + "approveSuccess": "मंजूर यशस्वी", + "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", + "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", + "memberCount": { + "zero": "कोणतेही सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", + "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", + "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", + "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", + "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", + "asMember": "सदस्य म्हणून" +}, + "upgradePlanModal": { + "title": "Pro प्लॅनवर अपग्रेड करा", + "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", + "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", + "step1": "1. सेटिंग्जमध्ये जा", + "step2": "2. 'योजना' वर क्लिक करा", + "step3": "3. 'योजना बदला' निवडा", + "appNote": "नोंद:", + "actionButton": "अपग्रेड करा", + "downloadLink": "अ‍ॅप डाउनलोड करा", + "laterButton": "नंतर", + "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", + "refresh": "येथे" +}, + "breadcrumbs": { + "label": "ब्रेडक्रम्स" +}, + "time": { + "justNow": "आत्ताच", + "seconds": { + "one": "1 सेकंद", + "other": "{count} सेकंद" + }, + "minutes": { + "one": "1 मिनिट", + "other": "{count} मिनिटे" + }, + "hours": { + "one": "1 तास", + "other": "{count} तास" + }, + "days": { + "one": "1 दिवस", + "other": "{count} दिवस" + }, + "weeks": { + "one": "1 आठवडा", + "other": "{count} आठवडे" + }, + "months": { + "one": "1 महिना", + "other": "{count} महिने" + }, + "years": { + "one": "1 वर्ष", + "other": "{count} वर्षे" + }, + "ago": "पूर्वी", + "yesterday": "काल", + "today": "आज" +}, + "members": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" +}, + "tabMenu": { + "close": "बंद करा", + "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", + "closeOthers": "इतर टॅब बंद करा", + "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", + "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", + "favorite": "आवडते", + "unfavorite": "आवडते काढा", + "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", + "pinTab": "पिन करा", + "unpinTab": "अनपिन करा" +}, + "openFileMessage": { + "success": "फाइल यशस्वीरित्या उघडली", + "fileNotFound": "फाइल सापडली नाही", + "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", + "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", + "unknownError": "फाइल उघडण्यात अयशस्वी" +}, + "inviteMember": { + "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", + "upgrade": "अपग्रेड करा", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "आमंत्रण पाठवा", + "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", + "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", + "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", + "emails": "ईमेल" +}, + "quickNote": { + "label": "झटपट नोंद", + "quickNotes": "झटपट नोंदी", + "search": "झटपट नोंदी शोधा", + "collapseFullView": "पूर्ण दृश्य लपवा", + "expandFullView": "पूर्ण दृश्य उघडा", + "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", + "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", + "emptyNote": "रिकामी नोंद", + "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", + "addNote": "नवीन नोंद", + "noAdditionalText": "अधिक माहिती नाही" +}, + "subscribe": { + "upgradePlanTitle": "योजना तुलना करा आणि निवडा", + "yearly": "वार्षिक", + "save": "{discount}% बचत", + "monthly": "मासिक", + "priceIn": "किंमत येथे: ", + "free": "फ्री", + "pro": "प्रो", + "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", + "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", + "proDuration": { + "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", + "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" + }, + "cancel": "खालच्या योजनेवर जा", + "changePlan": "प्रो योजनेवर अपग्रेड करा", + "everythingInFree": "फ्री योजनेतील सर्व काही +", + "currentPlan": "सध्याची योजना", + "freeDuration": "कायम", + "freePoints": { + "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", + "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", + "three": "5 GB संचयन", + "four": "बुद्धिमान शोध", + "five": "20 AI प्रतिसाद", + "six": "मोबाईल अ‍ॅप", + "seven": "रिअल-टाइम सहकार्य" + }, + "proPoints": { + "first": "अमर्यादित संचयन", + "second": "10 वर्कस्पेस सदस्यांपर्यंत", + "three": "अमर्यादित AI प्रतिसाद", + "four": "अमर्यादित फाइल अपलोड्स", + "five": "कस्टम नेमस्पेस" + }, + "cancelPlan": { + "title": "आपल्याला जाताना पाहून वाईट वाटते", + "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", + "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", + "commonOther": "इतर", + "otherHint": "आपले उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", + "answerThree": "चांगला पर्याय सापडला", + "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता आहे", + "answerFive": "शक्यता नाही" + }, + "questionThree": { + "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", + "answerOne": "मल्टी-यूजर सहकार्य", + "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", + "answerOne": "छान", + "answerTwo": "चांगला", + "answerThree": "सामान्य", + "answerFour": "थोडासा वाईट", + "answerFive": "असंतोषजनक" + } + } +}, + "ai": { + "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", + "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", + "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", + "limitReachedAction": { + "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", + "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", + "upgrade": "अपग्रेड करा", + "toThe": "या योजनेवर", + "proPlan": "प्रो योजना", + "orPurchaseAn": "किंवा खरेदी करा", + "aiAddon": "AI अ‍ॅड-ऑन" + }, + "editing": "संपादन करत आहे", + "analyzing": "विश्लेषण करत आहे", + "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", + "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", + "more": "अधिक" +}, + "autoUpdate": { + "criticalUpdateTitle": "अद्यतन आवश्यक आहे", + "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", + "criticalUpdateButton": "अद्यतन करा", + "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", + "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", + "bannerUpdateButton": "अद्यतन करा", + "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", + "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", + "settingsUpdateButton": "अद्यतन करा", + "settingsUpdateWhatsNew": "काय नवीन आहे" +}, + "lockPage": { + "lockPage": "लॉक केलेले", + "reLockPage": "पुन्हा लॉक करा", + "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", + "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", + "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." +}, + "suggestion": { + "accept": "स्वीकारा", + "keep": "जसे आहे तसे ठेवा", + "discard": "रद्द करा", + "close": "बंद करा", + "tryAgain": "पुन्हा प्रयत्न करा", + "rewrite": "पुन्हा लिहा", + "insertBelow": "खाली टाका" +} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 2be124e2fe..ce28ea68b0 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -98,6 +98,7 @@ class InitAppWidgetTask extends LaunchTask { Locale('zh', 'TW'), Locale('fa'), Locale('hin'), + Locale('mr','IN'), ], path: 'assets/translations', fallbackLocale: const Locale('en'), diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index 0b6ff4fb3f..5ad2435e99 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -48,6 +48,8 @@ String languageFromLocale(Locale locale) { default: return locale.languageCode; } + case "mr": + return "मराठी"; case "he": return "עברית"; case "hu": @@ -79,7 +81,7 @@ String languageFromLocale(Locale locale) { case "ur": return "اردو"; case "hin": - return "हिन्दी"; + return "हिन्दी"; } // If not found then the language code will be displayed return locale.languageCode; diff --git a/frontend/resources/translations/mr-IN.json b/frontend/resources/translations/mr-IN.json new file mode 100644 index 0000000000..f86a1e0081 --- /dev/null +++ b/frontend/resources/translations/mr-IN.json @@ -0,0 +1,3210 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "मी", + "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", + "welcomeTo": "मध्ये आ पले स्वागत आ हे", + "githubStarText": "GitHub वर स्टार करा", + "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", + "letsGoButtonText": "क्विक स्टार्ट", + "title": "Title", + "youCanAlso": "तुम्ही देखील", + "and": "आ णि", + "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", + "blockActions": { + "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "वर जोडण्यासाठी", + "dragTooltip": "Drag to move", + "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" + }, + "signUp": { + "buttonText": "साइन अप", + "title": "साइन अप to @:appName", + "getStartedText": "सुरुवात करा", + "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", + "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "emailHint": "Email", + "passwordHint": "Password", + "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", + "signUpWith": "यामध्ये साइन अप करा:" + }, + "signIn": { + "loginTitle": "@:appName मध्ये लॉगिन करा", + "loginButtonText": "लॉगिन", + "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", + "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", + "anonymous": "अनामिक", + "buttonText": "साइन इन", + "signingInText": "साइन इन होत आहे...", + "forgotPassword": "पासवर्ड विसरलात?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "तुमचं खाते नाही?", + "createAccount": "खाते तयार करा", + "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", + "or": "किंवा", + "signInWithGoogle": "Google सह पुढे जा", + "signInWithGithub": "GitHub सह पुढे जा", + "signInWithDiscord": "Discord सह पुढे जा", + "signInWithApple": "Apple सह पुढे जा", + "continueAnotherWay": "इतर पर्यायांनी पुढे जा", + "signUpWithGoogle": "Google सह साइन अप करा", + "signUpWithGithub": "GitHub सह साइन अप करा", + "signUpWithDiscord": "Discord सह साइन अप करा", + "signInWith": "यासह पुढे जा:", + "signInWithEmail": "ईमेलसह पुढे जा", + "signInWithMagicLink": "पुढे जा", + "signUpWithMagicLink": "Magic Link सह साइन अप करा", + "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", + "settings": "सेटिंग्ज", + "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", + "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "logIn": "लॉगिन", + "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", + "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", + "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." + }, + "workspace": { + "chooseWorkspace": "तुमचे workspace निवडा", + "defaultName": "माझे Workspace", + "create": "नवीन workspace तयार करा", + "new": "नवीन workspace", + "importFromNotion": "Notion मधून आयात करा", + "learnMore": "अधिक जाणून घ्या", + "reset": "workspace रीसेट करा", + "renameWorkspace": "workspace चे नाव बदला", + "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", + "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", + "hint": "workspace", + "notFoundError": "workspace सापडले नाही", + "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", + "errorActions": { + "reportIssue": "समस्या नोंदवा", + "reportIssueOnGithub": "Github वर समस्या नोंदवा", + "exportLogFiles": "लॉग फाइल्स निर्यात करा", + "reachOut": "Discord वर संपर्क करा" + }, + "menuTitle": "कार्यक्षेत्रे", + "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", + "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", + "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", + "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", + "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", + "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", + "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", + "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", + "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", + "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", + "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", + "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", + "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", + "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", + "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", + "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" + }, + "shareAction": { + "buttonText": "शेअर करा", + "workInProgress": "लवकरच येत आहे", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "क्लिपबोर्डवर कॉपी करा", + "csv": "CSV", + "copyLink": "लिंक कॉपी करा", + "publishToTheWeb": "वेबवर प्रकाशित करा", + "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", + "publish": "प्रकाशित करा", + "unPublish": "अप्रकाशित करा", + "visitSite": "साइटला भेट द्या", + "exportAsTab": "या स्वरूपात निर्यात करा", + "publishTab": "प्रकाशित करा", + "shareTab": "शेअर करा", + "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", + "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", + "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", + "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", + "copyShareLink": "शेअर लिंक कॉपी करा", + "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", + "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", + "updatePathName": "पथाचे नाव अपडेट करा" + }, + "moreAction": { + "small": "लहान", + "medium": "मध्यम", + "large": "मोठा", + "fontSize": "फॉन्ट आकार", + "import": "Import", + "moreOptions": "अधिक पर्याय", + "wordCount": "शब्द संख्या: {}", + "charCount": "अक्षर संख्या: {}", + "createdAt": "निर्मिती: {}", + "deleteView": "हटवा", + "duplicateView": "प्रत बनवा", + "wordCountLabel": "शब्द संख्या: ", + "charCountLabel": "अक्षर संख्या: ", + "createdAtLabel": "निर्मिती: ", + "syncedAtLabel": "सिंक केले: ", + "saveAsNewPage": "संदेश पृष्ठात जोडा", + "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" + }, + "importPanel": { + "textAndMarkdown": "मजकूर आणि Markdown", + "documentFromV010": "v0.1.0 पासून दस्तऐवज", + "databaseFromV010": "v0.1.0 पासून डेटाबेस", + "notionZip": "Notion निर्यात केलेली Zip फाईल", + "csv": "CSV", + "database": "डेटाबेस" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", + "placeholderUpload": "अपलोड", + "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", + "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", + "change": "बदला" + } + }, + "disclosureAction": { + "rename": "नाव बदला", + "delete": "हटवा", + "duplicate": "प्रत बनवा", + "unfavorite": "आवडतीतून काढा", + "favorite": "आवडतीत जोडा", + "openNewTab": "नवीन टॅबमध्ये उघडा", + "moveTo": "या ठिकाणी हलवा", + "addToFavorites": "आवडतीत जोडा", + "copyLink": "लिंक कॉपी करा", + "changeIcon": "आयकॉन बदला", + "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", + "movePageTo": "पृष्ठ हलवा", + "move": "हलवा", + "lockPage": "पृष्ठ लॉक करा" + }, + "blankPageTitle": "रिक्त पृष्ठ", + "newPageText": "नवीन पृष्ठ", + "newDocumentText": "नवीन दस्तऐवज", + "newGridText": "नवीन ग्रिड", + "newCalendarText": "नवीन कॅलेंडर", + "newBoardText": "नवीन बोर्ड", + "chat": { + "newChat": "AI गप्पा", + "inputMessageHint": "@:appName AI ला विचार करा", + "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", + "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", + "relatedQuestion": "सूचवलेले", + "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", + "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", + "retry": "पुन्हा प्रयत्न करा", + "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", + "regenerateAnswer": "उत्तर पुन्हा तयार करा", + "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", + "question2": "GTD पद्धत समजावून सांगा", + "question3": "Rust का वापरावा", + "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", + "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", + "question6": "या आठवड्याची माझी कामांची यादी तयार करा", + "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", + "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", + "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", + "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", + "referenceSource": { + "zero": "0 स्रोत सापडले", + "one": "{count} स्रोत सापडला", + "other": "{count} स्रोत सापडले" + } + }, + "clickToMention": "पृष्ठाचा उल्लेख करा", + "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", + "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", + "indexingFile": "{} अनुक्रमित करत आहे", + "generatingResponse": "उत्तर तयार होत आहे", + "selectSources": "स्रोत निवडा", + "currentPage": "सध्याचे पृष्ठ", + "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", + "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", + "regenerate": "पुन्हा प्रयत्न करा", + "addToPageButton": "संदेश पृष्ठावर जोडा", + "addToPageTitle": "या पृष्ठात संदेश जोडा...", + "addToNewPage": "नवीन पृष्ठ तयार करा", + "addToNewPageName": "\"{}\" मधून काढलेले संदेश", + "addToNewPageSuccessToast": "संदेश जोडण्यात आला", + "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", + "changeFormat": { + "actionButton": "फॉरमॅट बदला", + "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", + "textOnly": "मजकूर", + "imageOnly": "फक्त प्रतिमा", + "textAndImage": "मजकूर आणि प्रतिमा", + "text": "परिच्छेद", + "bullet": "बुलेट यादी", + "number": "क्रमांकित यादी", + "table": "सारणी", + "blankDescription": "उत्तराचे फॉरमॅट ठरवा", + "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", + "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", + "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", + "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", + " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" + }, + "switchModel": { + "label": "मॉडेल बदला", + "localModel": "स्थानिक मॉडेल", + "cloudModel": "क्लाऊड मॉडेल", + "autoModel": "स्वयंचलित" + }, + "selectBanner": { + "saveButton": "… मध्ये जोडा", + "selectMessages": "संदेश निवडा", + "nSelected": "{} निवडले गेले", + "allSelected": "सर्व निवडले गेले" + }, + "stopTooltip": "उत्पन्न करणे थांबवा", + "trash": { + "text": "कचरा", + "restoreAll": "सर्व पुनर्संचयित करा", + "restore": "पुनर्संचयित करा", + "deleteAll": "सर्व हटवा", + "pageHeader": { + "fileName": "फाईलचे नाव", + "lastModified": "शेवटचा बदल", + "created": "निर्मिती" + } + }, + "confirmDeleteAll": { + "title": "कचरापेटीतील सर्व पृष्ठे", + "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "confirmRestoreAll": { + "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", + "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "restorePage": { + "title": "पुनर्संचयित करा: {}", + "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" + }, + "mobile": { + "actions": "कचरा क्रिया", + "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", + "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", + "isDeleted": "हटवले गेले आहे", + "isRestored": "पुनर्संचयित केले गेले आहे" + }, + "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", + "deletePagePrompt": { + "text": "हे पृष्ठ कचरापेटीत आहे", + "restore": "पृष्ठ पुनर्संचयित करा", + "deletePermanent": "कायमचे हटवा", + "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "dialogCreatePageNameHint": "पृष्ठाचे नाव", + "questionBubble": { + "shortcuts": "शॉर्टकट्स", + "whatsNew": "नवीन काय आहे?", + "help": "मदत आणि समर्थन", + "markdown": "Markdown", + "debug": { + "name": "डीबग माहिती", + "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", + "fail": "डीबग माहिती कॉपी करता आली नाही" + }, + "feedback": "अभिप्राय" + }, + "menuAppHeader": { + "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", + "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", + "defaultNewPageName": "शीर्षक नसलेले", + "renameDialog": "नाव बदला", + "pageNameSuffix": "प्रत" + }, + "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", + "toolbar": { + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "bold": "ठळक", + "italic": "तिरकस", + "underline": "अधोरेखित", + "strike": "मागे ओढलेले", + "numList": "क्रमांकित यादी", + "bulletList": "बुलेट यादी", + "checkList": "चेक यादी", + "inlineCode": "इनलाइन कोड", + "quote": "उद्धरण ब्लॉक", + "header": "शीर्षक", + "highlight": "हायलाइट", + "color": "रंग", + "addLink": "लिंक जोडा" + }, + "tooltip": { + "lightMode": "लाइट मोडमध्ये स्विच करा", + "darkMode": "डार्क मोडमध्ये स्विच करा", + "openAsPage": "पृष्ठ म्हणून उघडा", + "addNewRow": "नवीन पंक्ती जोडा", + "openMenu": "मेनू उघडण्यासाठी क्लिक करा", + "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", + "viewDataBase": "डेटाबेस पहा", + "referencePage": "हे {name} संदर्भित आहे", + "addBlockBelow": "खाली एक ब्लॉक जोडा", + "aiGenerate": "निर्मिती करा" + }, + "sideBar": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "साइडबार उघडा", + "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", + "personal": "वैयक्तिक", + "private": "खाजगी", + "workspace": "कार्यक्षेत्र", + "favorites": "आवडती", + "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", + "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", + "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", + "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", + "addAPage": "नवीन पृष्ठ जोडा", + "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", + "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", + "recent": "अलीकडील", + "today": "आज", + "thisWeek": "या आठवड्यात", + "others": "पूर्वीच्या आवडती", + "earlier": "पूर्वीचे", + "justNow": "आत्ताच", + "minutesAgo": "{count} मिनिटांपूर्वी", + "lastViewed": "शेवटी पाहिलेले", + "favoriteAt": "आवडते म्हणून चिन्हांकित", + "emptyRecent": "अलीकडील पृष्ठे नाहीत", + "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", + "emptyFavorite": "आवडती पृष्ठे नाहीत", + "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", + "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", + "removeSuccess": "यशस्वीरित्या काढले गेले", + "favoriteSpace": "आवडती", + "RecentSpace": "अलीकडील", + "Spaces": "जागा", + "upgradeToPro": "Pro मध्ये अपग्रेड करा", + "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", + "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", + "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", + "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", + "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", + "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", + "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", + "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", + "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", + "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", + "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", + "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", + "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", + "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", + "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", + "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", + "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", + "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" +}, + "notifications": { + "export": { + "markdown": "टीप Markdown मध्ये निर्यात केली", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "या आठवड्यात काय घडत आहे?", + "addContact": "संपर्क जोडा", + "editContact": "संपर्क संपादित करा" + }, + "button": { + "ok": "ठीक आहे", + "confirm": "खात्री करा", + "done": "पूर्ण", + "cancel": "रद्द करा", + "signIn": "साइन इन", + "signOut": "साइन आउट", + "complete": "पूर्ण करा", + "save": "जतन करा", + "generate": "निर्माण करा", + "esc": "ESC", + "keep": "ठेवा", + "tryAgain": "पुन्हा प्रयत्न करा", + "discard": "टाका", + "replace": "बदला", + "insertBelow": "खाली घाला", + "insertAbove": "वर घाला", + "upload": "अपलोड करा", + "edit": "संपादित करा", + "delete": "हटवा", + "copy": "कॉपी करा", + "duplicate": "प्रत बनवा", + "putback": "परत ठेवा", + "update": "अद्यतनित करा", + "share": "शेअर करा", + "removeFromFavorites": "आवडतीतून काढा", + "removeFromRecent": "अलीकडील यादीतून काढा", + "addToFavorites": "आवडतीत जोडा", + "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", + "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", + "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", + "rename": "नाव बदला", + "helpCenter": "मदत केंद्र", + "add": "जोड़ा", + "yes": "होय", + "no": "नाही", + "clear": "साफ करा", + "remove": "काढा", + "dontRemove": "काढू नका", + "copyLink": "लिंक कॉपी करा", + "align": "जुळवा", + "login": "लॉगिन", + "logout": "लॉगआउट", + "deleteAccount": "खाते हटवा", + "back": "मागे", + "signInGoogle": "Google सह पुढे जा", + "signInGithub": "GitHub सह पुढे जा", + "signInDiscord": "Discord सह पुढे जा", + "more": "अधिक", + "create": "तयार करा", + "close": "बंद करा", + "next": "पुढे", + "previous": "मागील", + "submit": "सबमिट करा", + "download": "डाउनलोड करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "viewing": "पाहत आहात", + "editing": "संपादन करत आहात", + "gotIt": "समजले", + "retry": "पुन्हा प्रयत्न करा", + "uploadFailed": "अपलोड अयशस्वी.", + "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" + }, + "label": { + "welcome": "स्वागत आहे!", + "firstName": "पहिले नाव", + "middleName": "मधले नाव", + "lastName": "आडनाव", + "stepX": "पायरी {X}" + }, + "oAuth": { + "err": { + "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", + "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." + }, + "google": { + "title": "GOOGLE साइन-इन", + "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", + "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", + "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", + "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" + } + }, + "settings": { + "title": "सेटिंग्ज", + "popupMenuItem": { + "settings": "सेटिंग्ज", + "members": "सदस्य", + "trash": "कचरा", + "helpAndSupport": "मदत आणि समर्थन" + }, + "sites": { + "title": "साइट्स", + "namespaceTitle": "नेमस्पेस", + "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", + "namespaceHeader": "नेमस्पेस", + "homepageHeader": "मुख्यपृष्ठ", + "updateNamespace": "नेमस्पेस अद्यतनित करा", + "removeHomepage": "मुख्यपृष्ठ हटवा", + "selectHomePage": "एक पृष्ठ निवडा", + "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", + "customUrl": "स्वतःची URL", + "namespace": { + "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", + "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", + "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", + "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", + "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", + "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", + "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" + }, + "publishedPage": { + "title": "सर्व प्रकाशित पृष्ठे", + "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", + "page": "पृष्ठ", + "pathName": "पथाचे नाव", + "date": "प्रकाशन तारीख", + "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", + "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", + "settings": "प्रकाशन सेटिंग्ज", + "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", + "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" + } + } + }, + "error": { + "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", + "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", + "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", + "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", + "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", + "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", + "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", + "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", + "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", + "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", + "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", + "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", + "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", + "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", + "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" + }, + "success": { + "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", + "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", + "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", + "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" + }, + "accountPage": { + "menuLabel": "खाते आणि अ‍ॅप", + "title": "माझे खाते", + "general": { + "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", + "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" + }, + "email": { + "title": "ईमेल", + "actions": { + "change": "ईमेल बदला" + } + }, + "login": { + "title": "खाते लॉगिन", + "loginLabel": "लॉगिन", + "logoutLabel": "लॉगआउट" + }, + "isUpToDate": "@:appName अद्ययावत आहे!", + "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" +}, + "workspacePage": { + "menuLabel": "कार्यक्षेत्र", + "title": "कार्यक्षेत्र", + "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", + "workspaceName": { + "title": "कार्यक्षेत्राचे नाव" + }, + "workspaceIcon": { + "title": "कार्यक्षेत्राचे चिन्ह", + "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." + }, + "appearance": { + "title": "दृश्यरूप", + "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", + "options": { + "system": "स्वयंचलित", + "light": "लाइट", + "dark": "डार्क" + } + } + }, + "resetCursorColor": { + "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", + "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" + }, + "resetSelectionColor": { + "title": "दस्तऐवज निवडीचा रंग रीसेट करा", + "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" + }, + "resetWidth": { + "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" + }, + "theme": { + "title": "थीम", + "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", + "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" + }, + "workspaceFont": { + "title": "कार्यक्षेत्र फॉन्ट", + "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." + }, + "textDirection": { + "title": "मजकूर दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे", + "auto": "स्वयंचलित", + "enableRTLItems": "RTL टूलबार घटक सक्षम करा" + }, + "layoutDirection": { + "title": "लेआउट दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे" + }, + "dateTime": { + "title": "दिनांक आणि वेळ", + "example": "{} वाजता {} ({})", + "24HourTime": "२४-तास वेळ", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "सुलभ", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "भाषा" + }, + "deleteWorkspacePrompt": { + "title": "कार्यक्षेत्र हटवा", + "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." + }, + "leaveWorkspacePrompt": { + "title": "कार्यक्षेत्र सोडा", + "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", + "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", + "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." + }, + "manageWorkspace": { + "title": "कार्यक्षेत्र व्यवस्थापित करा", + "leaveWorkspace": "कार्यक्षेत्र सोडा", + "deleteWorkspace": "कार्यक्षेत्र हटवा" + }, + "manageDataPage": { + "menuLabel": "डेटा व्यवस्थापित करा", + "title": "डेटा व्यवस्थापन", + "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", + "dataStorage": { + "title": "फाइल संचयन स्थान", + "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", + "actions": { + "change": "मार्ग बदला", + "open": "फोल्डर उघडा", + "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", + "copy": "मार्ग कॉपी करा", + "copiedHint": "मार्ग कॉपी केला!", + "resetTooltip": "मूलभूत स्थानावर रीसेट करा" + }, + "resetDialog": { + "title": "तुम्हाला खात्री आहे का?", + "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." + } + }, + "importData": { + "title": "डेटा आयात करा", + "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", + "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", + "action": "फाइल निवडा" + }, + "encryption": { + "title": "एनक्रिप्शन", + "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", + "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", + "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", + "action": "डेटा एनक्रिप्ट करा", + "dialog": { + "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", + "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" + } + }, + "cache": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "dialog": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "successHint": "कॅशे साफ झाली!" + } + }, + "data": { + "fixYourData": "तुमचा डेटा सुधारा", + "fixButton": "सुधारा", + "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." + } + }, + "shortcutsPage": { + "menuLabel": "शॉर्टकट्स", + "title": "शॉर्टकट्स", + "editBindingHint": "नवीन बाइंडिंग टाका", + "searchHint": "शोधा", + "actions": { + "resetDefault": "मूलभूत रीसेट करा" + }, + "errorPage": { + "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", + "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." + }, + "resetDialog": { + "title": "शॉर्टकट्स रीसेट करा", + "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", + "buttonLabel": "रीसेट करा" + }, + "conflictDialog": { + "title": "{} आधीच वापरले जात आहे", + "descriptionPrefix": "हे कीबाइंडिंग सध्या ", + "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", + "confirmLabel": "पुढे जा" + }, + "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", + "keybindings": { + "toggleToDoList": "टू-डू सूची चालू/बंद करा", + "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", + "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", + "selectAllCodeblock": "सर्व निवडा", + "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", + "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", + "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", + "copy": "निवड कॉपी करा", + "paste": "मजकुरात पेस्ट करा", + "cut": "निवड कट करा", + "alignLeft": "मजकूर डावीकडे संरेखित करा", + "alignCenter": "मजकूर मधोमध संरेखित करा", + "alignRight": "मजकूर उजवीकडे संरेखित करा", + "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", + "backspace": "हटवा", + "deleteLeftWord": "डावीकडील शब्द हटवा", + "deleteLeftSentence": "डावीकडील वाक्य हटवा", + "delete": "उजवीकडील अक्षर हटवा", + "deleteMacOS": "डावीकडील अक्षर हटवा", + "deleteRightWord": "उजवीकडील शब्द हटवा", + "moveCursorLeft": "कर्सर डावीकडे हलवा", + "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", + "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", + "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", + "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", + "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", + "moveCursorRight": "कर्सर उजवीकडे हलवा", + "moveCursorEnd": "कर्सर शेवटी हलवा", + "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", + "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", + "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorUp": "कर्सर वर हलवा", + "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorTop": "कर्सर वर हलवा", + "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", + "moveCursorBottom": "कर्सर खाली हलवा", + "moveCursorDown": "कर्सर खाली हलवा", + "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", + "home": "वर स्क्रोल करा", + "end": "खाली स्क्रोल करा", + "toggleBold": "बोल्ड चालू/बंद करा", + "toggleItalic": "इटालिक चालू/बंद करा", + "toggleUnderline": "अधोरेखित चालू/बंद करा", + "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", + "toggleCode": "इनलाइन कोड चालू/बंद करा", + "toggleHighlight": "हायलाईट चालू/बंद करा", + "showLinkMenu": "लिंक मेनू दाखवा", + "openInlineLink": "इनलाइन लिंक उघडा", + "openLinks": "सर्व निवडलेले लिंक उघडा", + "indent": "इंडेंट", + "outdent": "आउटडेंट", + "exit": "संपादनातून बाहेर पडा", + "pageUp": "एक पृष्ठ वर स्क्रोल करा", + "pageDown": "एक पृष्ठ खाली स्क्रोल करा", + "selectAll": "सर्व निवडा", + "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", + "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", + "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", + "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", + "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", + "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", + "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", + "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", + "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", + "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" + }, + "commands": { + "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", + "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", + "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", + "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", + "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", + "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", + "textAlignLeft": "मजकूर डावीकडे संरेखित करा", + "textAlignCenter": "मजकूर मधोमध संरेखित करा", + "textAlignRight": "मजकूर उजवीकडे संरेखित करा" + }, + "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", + "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" +}, + "aiPage": { + "title": "AI सेटिंग्ज", + "menuLabel": "AI सेटिंग्ज", + "keys": { + "enableAISearchTitle": "AI शोध", + "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", + "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", + "llmModel": "भाषा मॉडेल", + "llmModelType": "भाषा मॉडेल प्रकार", + "downloadLLMPrompt": "{} डाउनलोड करा", + "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", + "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", + "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", + "downloadAIModelButton": "डाउनलोड करा", + "downloadingModel": "डाउनलोड करत आहे", + "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", + "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", + "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", + "localAIStopped": "स्थानिक AI थांबले आहे", + "localAIRunning": "स्थानिक AI चालू आहे", + "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", + "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", + "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", + "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", + "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", + "restartLocalAI": "पुन्हा सुरू करा", + "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", + "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", + "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", + "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", + "offlineAIInstruction1": "हे अनुसरा", + "offlineAIInstruction2": "सूचना", + "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", + "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", + "offlineAIDownload2": "डाउनलोड", + "offlineAIDownload3": "करा", + "activeOfflineAI": "सक्रिय", + "downloadOfflineAI": "डाउनलोड करा", + "openModelDirectory": "फोल्डर उघडा", + "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", + "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", + "pleaseFollowThese": "कृपया हे अनुसरा", + "instructions": "सूचना", + "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", + "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", + "downloadModel": "त्यांना डाउनलोड करण्यासाठी." + } +}, + "planPage": { + "menuLabel": "योजना", + "title": "दर योजना", + "planUsage": { + "title": "योजनेचा वापर सारांश", + "storageLabel": "स्टोरेज", + "storageUsage": "{} पैकी {} GB", + "unlimitedStorageLabel": "अमर्यादित स्टोरेज", + "collaboratorsLabel": "सदस्य", + "collaboratorsUsage": "{} पैकी {}", + "aiResponseLabel": "AI प्रतिसाद", + "aiResponseUsage": "{} पैकी {}", + "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", + "proBadge": "प्रो", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", + "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", + "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", + "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", + "aiCredit": { + "title": "@:appName AI क्रेडिट जोडा", + "price": "{}", + "priceDescription": "1,000 क्रेडिट्ससाठी", + "purchase": "AI खरेदी करा", + "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", + "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", + "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" + }, + "currentPlan": { + "bannerLabel": "सद्य योजना", + "freeTitle": "फ्री", + "proTitle": "प्रो", + "teamTitle": "टीम", + "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", + "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", + "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", + "upgrade": "योजना बदला", + "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "activeLabel": "जोडले गेले", + "aiMax": { + "title": "AI Max", + "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" + }, + "aiOnDevice": { + "title": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", + "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" + } + }, + "deal": { + "bannerLabel": "नववर्षाचे विशेष ऑफर!", + "title": "तुमची टीम वाढवा!", + "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", + "viewPlans": "योजना पहा" + } + } +}, + "billingPage": { + "menuLabel": "बिलिंग", + "title": "बिलिंग", + "plan": { + "title": "योजना", + "freeLabel": "फ्री", + "proLabel": "प्रो", + "planButtonLabel": "योजना बदला", + "billingPeriod": "बिलिंग कालावधी", + "periodButtonLabel": "कालावधी संपादित करा" + }, + "paymentDetails": { + "title": "पेमेंट तपशील", + "methodLabel": "पेमेंट पद्धत", + "methodButtonLabel": "पद्धत संपादित करा" + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "removeLabel": "काढा", + "renewLabel": "नवीन करा", + "aiMax": { + "label": "AI Max", + "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" + }, + "aiOnDevice": { + "label": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" + }, + "removeDialog": { + "title": "{} काढा", + "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." + } + }, + "currentPeriodBadge": "सद्य कालावधी", + "changePeriod": "कालावधी बदला", + "planPeriod": "{} कालावधी", + "monthlyInterval": "मासिक", + "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", + "annualInterval": "वार्षिक", + "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" +}, + "comparePlanDialog": { + "title": "योजना तुलना आणि निवड", + "planFeatures": "योजनेची\nवैशिष्ट्ये", + "current": "सध्याची", + "actions": { + "upgrade": "अपग्रेड करा", + "downgrade": "डाऊनग्रेड करा", + "current": "सध्याची" + }, + "freePlan": { + "title": "फ्री", + "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", + "price": "{}", + "priceInfo": "सदैव फ्री" + }, + "proPlan": { + "title": "प्रो", + "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", + "price": "{}", + "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" + }, + "planLabels": { + "itemOne": "वर्कस्पेसेस", + "itemTwo": "सदस्य", + "itemThree": "स्टोरेज", + "itemFour": "रिअल-टाइम सहकार्य", + "itemFive": "मोबाईल अ‍ॅप", + "itemSix": "AI प्रतिसाद", + "itemSeven": "AI प्रतिमा", + "itemFileUpload": "फाइल अपलोड", + "customNamespace": "सानुकूल नेमस्पेस", + "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", + "intelligentSearch": "स्मार्ट शोध", + "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", + "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" + }, + "freeLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "२ पर्यंत", + "itemThree": "५ GB", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "१० कायमस्वरूपी", + "itemSeven": "२ कायमस्वरूपी", + "itemFileUpload": "७ MB पर्यंत", + "intelligentSearch": "स्मार्ट शोध" + }, + "proLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "१० पर्यंत", + "itemThree": "अमर्यादित", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "अमर्यादित", + "itemSeven": "दर महिन्याला १० प्रतिमा", + "itemFileUpload": "अमर्यादित", + "intelligentSearch": "स्मार्ट शोध" + }, + "paymentSuccess": { + "title": "तुम्ही आता {} योजनेवर आहात!", + "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." + }, + "downgradeDialog": { + "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", + "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", + "downgradeLabel": "योजना डाऊनग्रेड करा" + } +}, + "cancelSurveyDialog": { + "title": "तुम्ही जात आहात याचे दुःख आहे", + "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", + "commonOther": "इतर", + "otherHint": "तुमचे उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", + "answerThree": "यापेक्षा चांगला पर्याय सापडला", + "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता", + "answerFive": "एकदम कमी शक्यता" + }, + "questionThree": { + "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", + "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", + "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", + "answerOne": "खूप छान", + "answerTwo": "चांगला", + "answerThree": "सरासरी", + "answerFour": "सरासरीपेक्षा कमी", + "answerFive": "असंतोषजनक" + } +}, + "common": { + "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", + "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", + "reset": "रीसेट करा" +}, + "menu": { + "appearance": "दृश्यरूप", + "language": "भाषा", + "user": "वापरकर्ता", + "files": "फाईल्स", + "notifications": "सूचना", + "open": "सेटिंग्ज उघडा", + "logout": "लॉगआउट", + "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", + "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", + "syncSetting": "सिंक्रोनायझेशन सेटिंग", + "cloudSettings": "क्लाऊड सेटिंग्ज", + "enableSync": "सिंक्रोनायझेशन सक्षम करा", + "enableSyncLog": "सिंक लॉगिंग सक्षम करा", + "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", + "enableEncrypt": "डेटा एन्क्रिप्ट करा", + "cloudURL": "बेस URL", + "webURL": "वेब URL", + "invalidCloudURLScheme": "अवैध स्कीम", + "cloudServerType": "क्लाऊड सर्व्हर", + "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", + "cloudLocal": "स्थानिक", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", + "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", + "clickToCopy": "क्लिपबोर्डवर कॉपी करा", + "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", + "selfHostContent": "दस्तऐवज", + "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", + "pleaseInputValidURL": "कृपया वैध URL टाका", + "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", + "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", + "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", + "cloudWSURL": "वेबसॉकेट URL", + "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", + "restartApp": "अ‍ॅप रीस्टार्ट करा", + "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", + "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", + "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", + "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", + "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", + "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", + "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", + "inputTextFieldHint": "तुमची गुप्तकी", + "historicalUserList": "वापरकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", + "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", + "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", + "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", + "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", + "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", + "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", + "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", + "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" +}, + "notifications": { + "enableNotifications": { + "label": "सूचना सक्षम करा", + "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." + }, + "showNotificationsIcon": { + "label": "सूचना चिन्ह दाखवा", + "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." + }, + "archiveNotifications": { + "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", + "success": "सूचना यशस्वीरित्या संग्रहित केली" + }, + "markAsReadNotifications": { + "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", + "success": "वाचलेले म्हणून चिन्हांकित केले" + }, + "action": { + "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", + "multipleChoice": "अधिक निवडा", + "archive": "संग्रहित करा" + }, + "settings": { + "settings": "सेटिंग्ज", + "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", + "archiveAll": "सर्व संग्रहित करा" + }, + "emptyInbox": { + "title": "इनबॉक्स झिरो!", + "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." + }, + "emptyUnread": { + "title": "कोणतीही न वाचलेली सूचना नाही", + "description": "तुम्ही सर्व वाचले आहे!" + }, + "emptyArchived": { + "title": "कोणतीही संग्रहित सूचना नाही", + "description": "संग्रहित सूचना इथे दिसतील." + }, + "tabs": { + "inbox": "इनबॉक्स", + "unread": "न वाचलेले", + "archived": "संग्रहित" + }, + "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", + "titles": { + "notifications": "सूचना", + "reminder": "रिमाइंडर" + } +}, + "appearance": { + "resetSetting": "रीसेट", + "fontFamily": { + "label": "फॉन्ट फॅमिली", + "search": "शोध", + "defaultFont": "सिस्टम" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टमशी जुळवा" + }, + "fontScaleFactor": "फॉन्ट स्केल घटक", + "displaySize": "डिस्प्ले आकार", + "documentSettings": { + "cursorColor": "डॉक्युमेंट कर्सरचा रंग", + "selectionColor": "डॉक्युमेंट निवडीचा रंग", + "width": "डॉक्युमेंटची रुंदी", + "changeWidth": "बदला", + "pickColor": "रंग निवडा", + "colorShade": "रंगाची छटा", + "opacity": "अपारदर्शकता", + "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", + "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", + "hexInvalidError": "अवैध Hex व्हॅल्यू", + "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", + "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", + "app": "अ‍ॅप", + "flowy": "Flowy", + "apply": "लागू करा" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "मूलभूत मजकूर दिशा", + "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयं", + "fallback": "लेआउट दिशेशी जुळवा" + }, + "themeUpload": { + "button": "अपलोड", + "uploadTheme": "थीम अपलोड करा", + "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", + "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", + "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", + "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", + "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", + "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" + }, + "theme": "थीम", + "builtInsLabel": "अंतर्गत थीम्स", + "pluginsLabel": "प्लगइन्स", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "अनौपचारिक", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "वेळ फॉरमॅट", + "twelveHour": "१२ तास", + "twentyFourHour": "२४ तास" + }, + "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", + "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", + "members": { + "title": "सदस्य सेटिंग्ज", + "inviteMembers": "सदस्यांना आमंत्रण द्या", + "inviteHint": "ईमेलद्वारे आमंत्रण द्या", + "sendInvite": "आमंत्रण पाठवा", + "copyInviteLink": "आमंत्रण दुवा कॉपी करा", + "label": "सदस्य", + "user": "वापरकर्ता", + "role": "भूमिका", + "removeFromWorkspace": "वर्कस्पेसमधून काढा", + "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", + "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", + "owner": "मालक", + "guest": "अतिथी", + "member": "सदस्य", + "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", + "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", + "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", + "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", + "members": "सदस्य", + "membersCount": { + "zero": "{} सदस्य", + "one": "{} सदस्य", + "other": "{} सदस्य" + }, + "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", + "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", + "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", + "memberLimitExceededUpgrade": "अपग्रेड करा", + "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", + "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", + "removeMember": "सदस्य काढा", + "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", + "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", + "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", + "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", + "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" + } +}, + "files": { + "copy": "कॉपी करा", + "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", + "exportData": "तुमचा डेटा निर्यात करा", + "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", + "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", + "customizeLocation": "इतर फोल्डर उघडा", + "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", + "exportDatabase": "डेटाबेस निर्यात करा", + "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", + "selectAll": "सर्व निवडा", + "deselectAll": "सर्व निवड रद्द करा", + "createNewFolder": "नवीन फोल्डर तयार करा", + "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", + "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", + "open": "उघडा", + "openFolder": "आधीक फोल्डर उघडा", + "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", + "folderHintText": "फोल्डरचे नाव", + "location": "नवीन फोल्डर तयार करत आहे", + "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", + "browser": "ब्राउझ करा", + "create": "तयार करा", + "set": "सेट करा", + "folderPath": "फोल्डर साठवण्याचा मार्ग", + "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", + "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", + "changeLocationTooltips": "डेटा डिरेक्टरी बदला", + "change": "बदला", + "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", + "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", + "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", + "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", + "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", + "export": "निर्यात करा", + "clearCache": "कॅशे साफ करा", + "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", + "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", + "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" +}, + "user": { + "name": "नाव", + "email": "ईमेल", + "tooltipSelectIcon": "चिन्ह निवडा", + "selectAnIcon": "चिन्ह निवडा", + "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", + "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" +}, + "mobile": { + "personalInfo": "वैयक्तिक माहिती", + "username": "वापरकर्तानाव", + "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", + "about": "विषयी", + "pushNotifications": "पुश सूचना", + "support": "सपोर्ट", + "joinDiscord": "Discord मध्ये सहभागी व्हा", + "privacyPolicy": "गोपनीयता धोरण", + "userAgreement": "वापरकर्ता करार", + "termsAndConditions": "अटी व शर्ती", + "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", + "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", + "selectLayout": "लेआउट निवडा", + "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", + "version": "आवृत्ती" +}, + "grid": { + "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", + "createView": "नवीन", + "title": { + "placeholder": "नाव नाही" + }, + "settings": { + "filter": "फिल्टर", + "sort": "क्रमवारी", + "sortBy": "यावरून क्रमवारी लावा", + "properties": "गुणधर्म", + "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", + "group": "समूह", + "addFilter": "फिल्टर जोडा", + "deleteFilter": "फिल्टर हटवा", + "filterBy": "यावरून फिल्टर करा", + "typeAValue": "मूल्य लिहा...", + "layout": "लेआउट", + "compactMode": "कॉम्पॅक्ट मोड", + "databaseLayout": "लेआउट", + "viewList": { + "zero": "० दृश्ये", + "one": "{count} दृश्य", + "other": "{count} दृश्ये" + }, + "editView": "दृश्य संपादित करा", + "boardSettings": "बोर्ड सेटिंग", + "calendarSettings": "कॅलेंडर सेटिंग", + "createView": "नवीन दृश्य", + "duplicateView": "दृश्याची प्रत बनवा", + "deleteView": "दृश्य हटवा", + "numberOfVisibleFields": "{} दर्शविले" + }, + "filter": { + "empty": "कोणतेही सक्रिय फिल्टर नाहीत", + "addFilter": "फिल्टर जोडा", + "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", + "conditon": "अट", + "where": "जिथे" + }, + "textFilter": { + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "endsWith": "याने समाप्त होते", + "startWith": "याने सुरू होते", + "is": "आहे", + "isNot": "नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही", + "choicechipPrefix": { + "isNot": "नाही", + "startWith": "याने सुरू होते", + "endWith": "याने समाप्त होते", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } + }, + "checkboxFilter": { + "isChecked": "निवडलेले आहे", + "isUnchecked": "निवडलेले नाही", + "choicechipPrefix": { + "is": "आहे" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण झाले आहे", + "isIncomplted": "अपूर्ण आहे" + }, + "selectOptionFilter": { + "is": "आहे", + "isNot": "नाही", + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"dateFilter": { + "is": "या दिवशी आहे", + "before": "पूर्वी आहे", + "after": "नंतर आहे", + "onOrBefore": "या दिवशी किंवा त्याआधी आहे", + "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", + "between": "दरम्यान आहे", + "empty": "रिकामे आहे", + "notEmpty": "रिकामे नाही", + "startDate": "सुरुवातीची तारीख", + "endDate": "शेवटची तारीख", + "choicechipPrefix": { + "before": "पूर्वी", + "after": "नंतर", + "between": "दरम्यान", + "onOrBefore": "या दिवशी किंवा त्याआधी", + "onOrAfter": "या दिवशी किंवा त्यानंतर", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } +}, +"numberFilter": { + "equal": "बरोबर आहे", + "notEqual": "बरोबर नाही", + "lessThan": "पेक्षा कमी आहे", + "greaterThan": "पेक्षा जास्त आहे", + "lessThanOrEqualTo": "किंवा कमी आहे", + "greaterThanOrEqualTo": "किंवा जास्त आहे", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"field": { + "label": "गुणधर्म", + "hide": "गुणधर्म लपवा", + "show": "गुणधर्म दर्शवा", + "insertLeft": "डावीकडे जोडा", + "insertRight": "उजवीकडे जोडा", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "wrapCellContent": "पाठ लपेटा", + "clear": "सेल्स रिकामे करा", + "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", + "textFieldName": "मजकूर", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "तारीख", + "updatedAtFieldName": "शेवटचे अपडेट", + "createdAtFieldName": "तयार झाले", + "numberFieldName": "संख्या", + "singleSelectFieldName": "सिंगल सिलेक्ट", + "multiSelectFieldName": "मल्टीसिलेक्ट", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "relationFieldName": "संबंध", + "summaryFieldName": "AI सारांश", + "timeFieldName": "वेळ", + "mediaFieldName": "फाईल्स आणि मीडिया", + "translateFieldName": "AI भाषांतर", + "translateTo": "मध्ये भाषांतर करा", + "numberFormat": "संख्या स्वरूप", + "dateFormat": "तारीख स्वरूप", + "includeTime": "वेळ जोडा", + "isRange": "शेवटची तारीख", + "dateFormatFriendly": "महिना दिवस, वर्ष", + "dateFormatISO": "वर्ष-महिना-दिनांक", + "dateFormatLocal": "महिना/दिवस/वर्ष", + "dateFormatUS": "वर्ष/महिना/दिवस", + "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", + "timeFormat": "वेळ स्वरूप", + "invalidTimeFormat": "अवैध स्वरूप", + "timeFormatTwelveHour": "१२ तास", + "timeFormatTwentyFourHour": "२४ तास", + "clearDate": "तारीख हटवा", + "dateTime": "तारीख व वेळ", + "startDateTime": "सुरुवातीची तारीख व वेळ", + "endDateTime": "शेवटची तारीख व वेळ", + "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", + "selectTime": "वेळ निवडा", + "selectDate": "तारीख निवडा", + "visibility": "दृश्यता", + "propertyType": "गुणधर्माचा प्रकार", + "addSelectOption": "पर्याय जोडा", + "typeANewOption": "नवीन पर्याय लिहा", + "optionTitle": "पर्याय", + "addOption": "पर्याय जोडा", + "editProperty": "गुणधर्म संपादित करा", + "newProperty": "नवीन गुणधर्म", + "openRowDocument": "पृष्ठ म्हणून उघडा", + "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", + "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", + "newColumn": "नवीन कॉलम", + "format": "स्वरूप", + "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", + "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" +}, + "rowPage": { + "newField": "नवीन फील्ड जोडा", + "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", + "showHiddenFields": { + "one": "{count} लपलेले फील्ड दाखवा", + "many": "{count} लपलेली फील्ड दाखवा", + "other": "{count} लपलेली फील्ड दाखवा" + }, + "hideHiddenFields": { + "one": "{count} लपलेले फील्ड लपवा", + "many": "{count} लपलेली फील्ड लपवा", + "other": "{count} लपलेली फील्ड लपवा" + }, + "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", + "moreRowActions": "अधिक पंक्ती क्रिया" +}, +"sort": { + "ascending": "चढत्या क्रमाने", + "descending": "उतरत्या क्रमाने", + "by": "द्वारे", + "empty": "सक्रिय सॉर्ट्स नाहीत", + "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", + "deleteAllSorts": "सर्व सॉर्ट्स हटवा", + "addSort": "सॉर्ट जोडा", + "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", + "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", + "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" +}, +"row": { + "label": "पंक्ती", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "titlePlaceholder": "शीर्षक नाही", + "textPlaceholder": "रिक्त", + "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", + "count": "संख्या", + "newRow": "नवीन पंक्ती", + "loadMore": "अधिक लोड करा", + "action": "क्रिया", + "add": "खाली जोडा वर क्लिक करा", + "drag": "हलवण्यासाठी ड्रॅग करा", + "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", + "insertRecordAbove": "वर रेकॉर्ड जोडा", + "insertRecordBelow": "खाली रेकॉर्ड जोडा", + "noContent": "माहिती नाही", + "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", + "createRowAboveDescription": "वर पंक्ती तयार करा", + "createRowBelowDescription": "खाली पंक्ती जोडा" +}, +"selectOption": { + "create": "तयार करा", + "purpleColor": "जांभळा", + "pinkColor": "गुलाबी", + "lightPinkColor": "फिकट गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पिवळा", + "limeColor": "लिंबू", + "greenColor": "हिरवा", + "aquaColor": "आक्वा", + "blueColor": "निळा", + "deleteTag": "टॅग हटवा", + "colorPanelTitle": "रंग", + "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", + "searchOption": "पर्याय शोधा", + "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", + "createNew": "नवीन तयार करा", + "orSelectOne": "किंवा पर्याय निवडा", + "typeANewOption": "नवीन पर्याय टाइप करा", + "tagName": "टॅग नाव" +}, +"checklist": { + "taskHint": "कार्याचे वर्णन", + "addNew": "नवीन कार्य जोडा", + "submitNewTask": "तयार करा", + "hideComplete": "पूर्ण कार्ये लपवा", + "showComplete": "सर्व कार्ये दाखवा" +}, +"url": { + "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", + "copy": "लिंक क्लिपबोर्डवर कॉपी करा", + "textFieldHint": "URL टाका", + "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" +}, +"relation": { + "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", + "relatedDatabasePlaceholder": "काही नाही", + "inRelatedDatabase": "या मध्ये", + "rowSearchTextFieldPlaceholder": "शोध", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", + "emptySearchResult": "कोणतीही नोंद सापडली नाही", + "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", + "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" +}, +"menuName": "ग्रिड", +"referencedGridPrefix": "दृश्य", +"calculate": "गणना करा", +"calculationTypeLabel": { + "none": "काही नाही", + "average": "सरासरी", + "max": "कमाल", + "median": "मध्यम", + "min": "किमान", + "sum": "बेरीज", + "count": "मोजणी", + "countEmpty": "रिकाम्यांची मोजणी", + "countEmptyShort": "रिक्त", + "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", + "countNonEmptyShort": "भरलेले" +}, +"media": { + "rename": "पुन्हा नाव द्या", + "download": "डाउनलोड करा", + "expand": "मोठे करा", + "delete": "हटवा", + "moreFilesHint": "+{}", + "addFileOrImage": "फाईल किंवा लिंक जोडा", + "attachmentsHint": "{}", + "addFileMobile": "फाईल जोडा", + "extraCount": "+{}", + "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "showFileNames": "फाईलचे नाव दाखवा", + "downloadSuccess": "फाईल डाउनलोड झाली", + "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", + "setAsCover": "कव्हर म्हणून सेट करा", + "openInBrowser": "ब्राउझरमध्ये उघडा", + "embedLink": "फाईल लिंक एम्बेड करा" + } +}, + "document": { + "menuName": "दस्तऐवज", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "creating": "तयार करत आहे...", + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", + "createANewBoard": "नवीन बोर्ड तयार करा" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", + "createANewGrid": "नवीन ग्रिड तयार करा" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", + "createANewCalendar": "नवीन दिनदर्शिका तयार करा" + }, + "document": { + "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" + }, + "name": { + "textStyle": "मजकुराची शैली", + "list": "यादी", + "toggle": "टॉगल", + "fileAndMedia": "फाईल व मीडिया", + "simpleTable": "सोपे टेबल", + "visuals": "दृश्य घटक", + "document": "दस्तऐवज", + "advanced": "प्रगत", + "text": "मजकूर", + "heading1": "शीर्षक 1", + "heading2": "शीर्षक 2", + "heading3": "शीर्षक 3", + "image": "प्रतिमा", + "bulletedList": "बुलेट यादी", + "numberedList": "क्रमांकित यादी", + "todoList": "करण्याची यादी", + "doc": "दस्तऐवज", + "linkedDoc": "पृष्ठाशी लिंक करा", + "grid": "ग्रिड", + "linkedGrid": "लिंक केलेला ग्रिड", + "kanban": "कानबन", + "linkedKanban": "लिंक केलेला कानबन", + "calendar": "दिनदर्शिका", + "linkedCalendar": "लिंक केलेली दिनदर्शिका", + "quote": "उद्धरण", + "divider": "विभाजक", + "table": "टेबल", + "callout": "महत्त्वाचा मजकूर", + "outline": "रूपरेषा", + "mathEquation": "गणिती समीकरण", + "code": "कोड", + "toggleList": "टॉगल यादी", + "toggleHeading1": "टॉगल शीर्षक 1", + "toggleHeading2": "टॉगल शीर्षक 2", + "toggleHeading3": "टॉगल शीर्षक 3", + "emoji": "इमोजी", + "aiWriter": "AI ला काहीही विचारा", + "dateOrReminder": "दिनांक किंवा स्मरणपत्र", + "photoGallery": "फोटो गॅलरी", + "file": "फाईल", + "twoColumns": "२ स्तंभ", + "threeColumns": "३ स्तंभ", + "fourColumns": "४ स्तंभ" + }, + "subPage": { + "name": "दस्तऐवज", + "keyword1": "उपपृष्ठ", + "keyword2": "पृष्ठ", + "keyword3": "चाइल्ड पृष्ठ", + "keyword4": "पृष्ठ जोडा", + "keyword5": "एम्बेड पृष्ठ", + "keyword6": "नवीन पृष्ठ", + "keyword7": "पृष्ठ तयार करा", + "keyword8": "दस्तऐवज" + } + }, + "selectionMenu": { + "outline": "रूपरेषा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "संदर्भित बोर्ड", + "referencedGrid": "संदर्भित ग्रिड", + "referencedCalendar": "संदर्भित दिनदर्शिका", + "referencedDocument": "संदर्भित दस्तऐवज", + "aiWriter": { + "userQuestion": "AI ला काहीही विचारा", + "continueWriting": "लेखन सुरू ठेवा", + "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", + "improveWriting": "लेखन सुधारित करा", + "summarize": "सारांश द्या", + "explain": "स्पष्टीकरण द्या", + "makeShorter": "लहान करा", + "makeLonger": "मोठे करा" + }, + "autoGeneratorMenuItemName": "AI लेखक", +"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", +"autoGeneratorLearnMore": "अधिक जाणून घ्या", +"autoGeneratorGenerate": "उत्पन्न करा", +"autoGeneratorHintText": "AI ला विचारा...", +"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", +"autoGeneratorRewrite": "पुन्हा लिहा", +"smartEdit": "AI ला विचारा", +"aI": "AI", +"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", +"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", +"smartEditSummarize": "सारांश द्या", +"smartEditImproveWriting": "लेखन सुधारित करा", +"smartEditMakeLonger": "लांब करा", +"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", +"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", +"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", +"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", +"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", +"createInlineMathEquation": "समीकरण तयार करा", +"fonts": "फॉन्ट्स", +"insertDate": "तारीख जोडा", +"emoji": "इमोजी", +"toggleList": "टॉगल यादी", +"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", +"quoteList": "उद्धरण यादी", +"numberedList": "क्रमांकित यादी", +"bulletedList": "बुलेट यादी", +"todoList": "करण्याची यादी", +"callout": "ठळक मजकूर", +"simpleTable": { + "moreActions": { + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "insertLeft": "डावीकडे घाला", + "insertRight": "उजवीकडे घाला", + "insertAbove": "वर घाला", + "insertBelow": "खाली घाला", + "headerColumn": "हेडर स्तंभ", + "headerRow": "हेडर ओळ", + "clearContents": "सामग्री साफ करा", + "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", + "distributeColumnsWidth": "स्तंभ समान करा", + "duplicateRow": "ओळ डुप्लिकेट करा", + "duplicateColumn": "स्तंभ डुप्लिकेट करा", + "textColor": "मजकूराचा रंग", + "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", + "duplicateTable": "टेबल डुप्लिकेट करा" + }, + "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", + "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", + "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", + "headerName": { + "table": "टेबल", + "alignText": "मजकूर पंक्तिबद्ध करा" + } +}, +"cover": { + "changeCover": "कव्हर बदला", + "colors": "रंग", + "images": "प्रतिमा", + "clearAll": "सर्व साफ करा", + "abstract": "ऍबस्ट्रॅक्ट", + "addCover": "कव्हर जोडा", + "addLocalImage": "स्थानिक प्रतिमा जोडा", + "invalidImageUrl": "अवैध प्रतिमा URL", + "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", + "enterImageUrl": "प्रतिमा URL लिहा", + "add": "जोडा", + "back": "मागे", + "saveToGallery": "गॅलरीत जतन करा", + "removeIcon": "आयकॉन काढा", + "removeCover": "कव्हर काढा", + "pasteImageUrl": "प्रतिमा URL पेस्ट करा", + "or": "किंवा", + "pickFromFiles": "फाईल्समधून निवडा", + "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", + "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", + "addIcon": "आयकॉन जोडा", + "changeIcon": "आयकॉन बदला", + "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", + "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" +}, +"mathEquation": { + "name": "गणिती समीकरण", + "addMathEquation": "TeX समीकरण जोडा", + "editMathEquation": "गणिती समीकरण संपादित करा" +}, +"optionAction": { + "click": "क्लिक", + "toOpenMenu": "मेनू उघडण्यासाठी", + "drag": "ओढा", + "toMove": "हलवण्यासाठी", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "turnInto": "मध्ये बदला", + "moveUp": "वर हलवा", + "moveDown": "खाली हलवा", + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "left": "डावीकडे", + "center": "मध्यभागी", + "right": "उजवीकडे", + "defaultColor": "डिफॉल्ट", + "depth": "खोली", + "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" +}, + "image": { + "addAnImage": "प्रतिमा जोडा", + "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "addAnImageDesktop": "प्रतिमा जोडा", + "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", + "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", + "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", + "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "errorCode": "त्रुटी कोड" +}, +"photoGallery": { + "name": "फोटो गॅलरी", + "imageKeyword": "प्रतिमा", + "imageGalleryKeyword": "प्रतिमा गॅलरी", + "photoKeyword": "फोटो", + "photoBrowserKeyword": "फोटो ब्राउझर", + "galleryKeyword": "गॅलरी", + "addImageTooltip": "प्रतिमा जोडा", + "changeLayoutTooltip": "लेआउट बदला", + "browserLayout": "ब्राउझर", + "gridLayout": "ग्रिड", + "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" +}, +"math": { + "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" +}, +"urlPreview": { + "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" +}, +"outline": { + "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", + "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." +}, +"table": { + "addAfter": "नंतर जोडा", + "addBefore": "आधी जोडा", + "delete": "हटा", + "clear": "सामग्री साफ करा", + "duplicate": "डुप्लिकेट करा", + "bgColor": "पार्श्वभूमीचा रंग" +}, +"contextMenu": { + "copy": "कॉपी करा", + "cut": "कापा", + "paste": "पेस्ट करा", + "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" +}, +"action": "कृती", +"database": { + "selectDataSource": "डेटा स्रोत निवडा", + "noDataSource": "डेटा स्रोत नाही", + "selectADataSource": "डेटा स्रोत निवडा", + "toContinue": "पुढे जाण्यासाठी", + "newDatabase": "नवीन डेटाबेस", + "linkToDatabase": "डेटाबेसशी लिंक करा" +}, +"date": "तारीख", +"video": { + "label": "व्हिडिओ", + "emptyLabel": "व्हिडिओ जोडा", + "placeholder": "व्हिडिओ लिंक पेस्ट करा", + "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "insertVideo": "व्हिडिओ जोडा", + "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", + "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", + "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" +}, +"file": { + "name": "फाईल", + "uploadTab": "अपलोड", + "uploadMobile": "फाईल निवडा", + "uploadMobileGallery": "फोटो गॅलरीमधून", + "networkTab": "लिंक एम्बेड करा", + "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", + "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", + "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", + "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", + "fileUploadHintSuffix": "ब्राउझ करा", + "networkHint": "फाईल लिंक पेस्ट करा", + "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", + "networkAction": "एम्बेड", + "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", + "renameFile": { + "title": "फाईलचे नाव बदला", + "description": "या फाईलसाठी नवीन नाव लिहा", + "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." + }, + "uploadedAt": "{} रोजी अपलोड केले", + "linkedAt": "{} रोजी लिंक जोडली", + "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" +}, +"subPage": { + "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", + "errors": { + "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", + "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", + "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", + "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", + "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" + } +}, + "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" +}, +"outlineBlock": { + "placeholder": "सामग्री सूची" +}, +"textBlock": { + "placeholder": "कमांडसाठी '/' टाइप करा" +}, +"title": { + "placeholder": "शीर्षक नाही" +}, +"imageBlock": { + "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", + "upload": { + "label": "अपलोड", + "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" + }, + "url": { + "label": "प्रतिमेची URL", + "placeholder": "प्रतिमेची URL टाका" + }, + "ai": { + "label": "AI द्वारे प्रतिमा तयार करा", + "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "stability_ai": { + "label": "Stability AI द्वारे प्रतिमा तयार करा", + "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अवैध प्रतिमा", + "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", + "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "अवैध प्रतिमेची URL", + "noImage": "अशी फाईल किंवा निर्देशिका नाही", + "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" + }, + "embedLink": { + "label": "लिंक एम्बेड करा", + "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "प्रतिमा शोधा", + "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", + "saveImageToGallery": "प्रतिमा जतन करा", + "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", + "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", + "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", + "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", + "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", + "imageIsUploading": "प्रतिमा अपलोड होत आहे", + "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "मागील प्रतिमा", + "nextImageTooltip": "पुढील प्रतिमा", + "zoomOutTooltip": "लहान करा", + "zoomInTooltip": "मोठी करा", + "changeZoomLevelTooltip": "झूम पातळी बदला", + "openLocalImage": "प्रतिमा उघडा", + "downloadImage": "प्रतिमा डाउनलोड करा", + "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", + "scalePercentage": "{}%", + "deleteImageTooltip": "प्रतिमा हटवा" + } + } +}, + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "auto": "स्वयंचलित" + }, + "copyTooltip": "कॉपी करा", + "searchLanguageHint": "भाषा शोधा", + "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" +}, +"inlineLink": { + "placeholder": "लिंक पेस्ट करा किंवा टाका", + "openInNewTab": "नवीन टॅबमध्ये उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL टाका" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक टाका" + } +}, +"mention": { + "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", + "page": { + "label": "पृष्ठाला लिंक करा", + "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" + }, + "deleted": "हटवले गेले", + "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", + "noAccess": "प्रवेश नाही", + "deletedPage": "हटवलेले पृष्ठ", + "trashHint": " - ट्रॅशमध्ये", + "morePages": "अजून पृष्ठे" +}, +"toolbar": { + "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", + "textSize": "मजकूराचा आकार", + "textColor": "मजकूराचा रंग", + "h1": "मथळा 1", + "h2": "मथळा 2", + "h3": "मथळा 3", + "alignLeft": "डावीकडे संरेखित करा", + "alignRight": "उजवीकडे संरेखित करा", + "alignCenter": "मध्यभागी संरेखित करा", + "link": "लिंक", + "textAlign": "मजकूर संरेखन", + "moreOptions": "अधिक पर्याय", + "font": "फॉन्ट", + "inlineCode": "इनलाइन कोड", + "suggestions": "सूचना", + "turnInto": "मध्ये रूपांतरित करा", + "equation": "समीकरण", + "insert": "घाला", + "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", + "pageOrURL": "पृष्ठ किंवा URL", + "linkName": "लिंकचे नाव", + "linkNameHint": "लिंकचे नाव प्रविष्ट करा" +}, +"errorBlock": { + "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", + "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", + "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", + "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", + "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" +}, +"mobilePageSelector": { + "title": "पृष्ठ निवडा", + "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", + "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" +}, +"attachmentMenu": { + "choosePhoto": "फोटो निवडा", + "takePicture": "फोटो काढा", + "chooseFile": "फाईल निवडा" + } + }, + "board": { + "column": { + "label": "स्तंभ", + "createNewCard": "नवीन", + "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", + "createNewColumn": "नवीन गट जोडा", + "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", + "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", + "renameColumn": "स्तंभाचे नाव बदला", + "hideColumn": "लपवा", + "newGroup": "नवीन गट", + "deleteColumn": "हटवा", + "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" + }, + "hiddenGroupSection": { + "sectionTitle": "लपवलेले गट", + "collapseTooltip": "लपवलेले गट लपवा", + "expandTooltip": "लपवलेले गट पाहा" + }, + "cardDetail": "कार्ड तपशील", + "cardActions": "कार्ड क्रिया", + "cardDuplicated": "कार्डची प्रत तयार झाली", + "cardDeleted": "कार्ड हटवले गेले", + "showOnCard": "कार्ड तपशिलावर दाखवा", + "setting": "सेटिंग", + "propertyName": "गुणधर्माचे नाव", + "menuName": "बोर्ड", + "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", + "ungroupedButtonText": "गट नसलेली", + "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", + "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", + "groupBy": "या आधारावर गट करा", + "groupCondition": "गट स्थिती", + "referencedBoardPrefix": "याचे दृश्य", + "notesTooltip": "नोट्स आहेत", + "mobile": { + "editURL": "URL संपादित करा", + "showGroup": "गट दाखवा", + "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", + "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" + }, + "dateCondition": { + "weekOf": "{} - {} ची आठवडा", + "today": "आज", + "yesterday": "काल", + "tomorrow": "उद्या", + "lastSevenDays": "शेवटचे ७ दिवस", + "nextSevenDays": "पुढील ७ दिवस", + "lastThirtyDays": "शेवटचे ३० दिवस", + "nextThirtyDays": "पुढील ३० दिवस" + }, + "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", + "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", + "media": { + "cardText": "{} {}", + "fallbackName": "फायली" + } +}, + "calendar": { + "menuName": "कॅलेंडर", + "defaultNewCalendarTitle": "नाव नाही", + "newEventButtonTooltip": "नवीन इव्हेंट जोडा", + "navigation": { + "today": "आज", + "jumpToday": "आजवर जा", + "previousMonth": "मागील महिना", + "nextMonth": "पुढील महिना", + "views": { + "day": "दिवस", + "week": "आठवडा", + "month": "महिना", + "year": "वर्ष" + } + }, + "mobileEventScreen": { + "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", + "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." + }, + "settings": { + "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", + "showWeekends": "सप्ताहांत दाखवा", + "firstDayOfWeek": "आठवड्याची सुरुवात", + "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", + "changeLayoutDateField": "मांडणी फील्ड बदला", + "noDateTitle": "तारीख नाही", + "noDateHint": { + "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", + "one": "{count} नियोजित नसलेली इव्हेंट", + "other": "{count} नियोजित नसलेल्या इव्हेंट्स" + }, + "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", + "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", + "name": "कॅलेंडर सेटिंग्ज", + "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" + }, + "referencedCalendarPrefix": "याचे दृश्य", + "quickJumpYear": "या वर्षावर जा", + "duplicateEvent": "इव्हेंट डुप्लिकेट करा" +}, + "errorDialog": { + "title": "@:appName त्रुटी", + "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", + "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", + "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", + "github": "GitHub वर पहा" +}, +"search": { + "label": "शोध", + "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", + "placeholder": { + "actions": "कृती शोधा..." + } +}, +"message": { + "copy": { + "success": "कॉपी झाले!", + "fail": "कॉपी करू शकत नाही" + } +}, +"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", +"views": { + "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", + "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." +}, + "colors": { + "custom": "सानुकूल", + "default": "डीफॉल्ट", + "red": "लाल", + "orange": "संत्रा", + "yellow": "पिवळा", + "green": "हिरवा", + "blue": "निळा", + "purple": "जांभळा", + "pink": "गुलाबी", + "brown": "तपकिरी", + "gray": "करड्या रंगाचा" +}, + "emoji": { + "emojiTab": "इमोजी", + "search": "इमोजी शोधा", + "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", + "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", + "filter": "फिल्टर", + "random": "योगायोगाने", + "selectSkinTone": "त्वचेचा टोन निवडा", + "remove": "इमोजी काढा", + "categories": { + "smileys": "स्मायली आणि भावना", + "people": "लोक", + "animals": "प्राणी आणि निसर्ग", + "food": "अन्न", + "activities": "क्रिया", + "places": "स्थळे", + "objects": "वस्तू", + "symbols": "चिन्हे", + "flags": "ध्वज", + "nature": "निसर्ग", + "frequentlyUsed": "नेहमी वापरलेले" + }, + "skinTone": { + "default": "डीफॉल्ट", + "light": "हलका", + "mediumLight": "मध्यम-हलका", + "medium": "मध्यम", + "mediumDark": "मध्यम-गडद", + "dark": "गडद" + }, + "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" +}, + "inlineActions": { + "noResults": "निकाल नाही", + "recentPages": "अलीकडील पृष्ठे", + "pageReference": "पृष्ठ संदर्भ", + "docReference": "दस्तऐवज संदर्भ", + "boardReference": "बोर्ड संदर्भ", + "calReference": "कॅलेंडर संदर्भ", + "gridReference": "ग्रिड संदर्भ", + "date": "तारीख", + "reminder": { + "groupTitle": "स्मरणपत्र", + "shortKeyword": "remind" + }, + "createPage": "\"{}\" उप-पृष्ठ तयार करा" +}, + "datePicker": { + "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", + "dateFormat": "तारीख फॉरमॅट", + "includeTime": "वेळ समाविष्ट करा", + "isRange": "शेवटची तारीख", + "timeFormat": "वेळ फॉरमॅट", + "clearDate": "तारीख साफ करा", + "reminderLabel": "स्मरणपत्र", + "selectReminder": "स्मरणपत्र निवडा", + "reminderOptions": { + "none": "काहीही नाही", + "atTimeOfEvent": "इव्हेंटच्या वेळी", + "fiveMinsBefore": "५ मिनिटे आधी", + "tenMinsBefore": "१० मिनिटे आधी", + "fifteenMinsBefore": "१५ मिनिटे आधी", + "thirtyMinsBefore": "३० मिनिटे आधी", + "oneHourBefore": "१ तास आधी", + "twoHoursBefore": "२ तास आधी", + "onDayOfEvent": "इव्हेंटच्या दिवशी", + "oneDayBefore": "१ दिवस आधी", + "twoDaysBefore": "२ दिवस आधी", + "oneWeekBefore": "१ आठवडा आधी", + "custom": "सानुकूल" + } +}, + "relativeDates": { + "yesterday": "काल", + "today": "आज", + "tomorrow": "उद्या", + "oneWeek": "१ आठवडा" +}, + "notificationHub": { + "title": "सूचना", + "mobile": { + "title": "अपडेट्स" + }, + "emptyTitle": "सर्व पूर्ण झाले!", + "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", + "tabs": { + "inbox": "इनबॉक्स", + "upcoming": "आगामी" + }, + "actions": { + "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", + "showAll": "सर्व", + "showUnreads": "न वाचलेल्या" + }, + "filters": { + "ascending": "आरोही", + "descending": "अवरोही", + "groupByDate": "तारीखेनुसार गटबद्ध करा", + "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", + "resetToDefault": "डीफॉल्टवर रीसेट करा" + } +}, + "reminderNotification": { + "title": "स्मरणपत्र", + "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", + "tooltipDelete": "हटवा", + "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", + "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" +}, + "findAndReplace": { + "find": "शोधा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "close": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "noResult": "कोणतेही निकाल नाहीत", + "caseSensitive": "केस सेंसिटिव्ह", + "searchMore": "अधिक निकालांसाठी शोधा" +}, + "error": { + "weAreSorry": "आम्ही क्षमस्व आहोत", + "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", + "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", + "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", + "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" +}, + "editor": { + "bold": "जाड", + "bulletedList": "बुलेट यादी", + "bulletedListShortForm": "बुलेट", + "checkbox": "चेकबॉक्स", + "embedCode": "कोड एम्बेड करा", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "हायलाइट", + "color": "रंग", + "image": "प्रतिमा", + "date": "तारीख", + "page": "पृष्ठ", + "italic": "तिरका", + "link": "लिंक", + "numberedList": "क्रमांकित यादी", + "numberedListShortForm": "क्रमांकित", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", + "quote": "कोट", + "strikethrough": "ओढून टाका", + "text": "मजकूर", + "underline": "अधोरेखित", + "fontColorDefault": "डीफॉल्ट", + "fontColorGray": "धूसर", + "fontColorBrown": "तपकिरी", + "fontColorOrange": "केशरी", + "fontColorYellow": "पिवळा", + "fontColorGreen": "हिरवा", + "fontColorBlue": "निळा", + "fontColorPurple": "जांभळा", + "fontColorPink": "पिंग", + "fontColorRed": "लाल", + "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", + "backgroundColorGray": "धूसर पार्श्वभूमी", + "backgroundColorBrown": "तपकिरी पार्श्वभूमी", + "backgroundColorOrange": "केशरी पार्श्वभूमी", + "backgroundColorYellow": "पिवळी पार्श्वभूमी", + "backgroundColorGreen": "हिरवी पार्श्वभूमी", + "backgroundColorBlue": "निळी पार्श्वभूमी", + "backgroundColorPurple": "जांभळी पार्श्वभूमी", + "backgroundColorPink": "पिंग पार्श्वभूमी", + "backgroundColorRed": "लाल पार्श्वभूमी", + "backgroundColorLime": "लिंबू पार्श्वभूमी", + "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", + "done": "पूर्ण", + "cancel": "रद्द करा", + "tint1": "टिंट 1", + "tint2": "टिंट 2", + "tint3": "टिंट 3", + "tint4": "टिंट 4", + "tint5": "टिंट 5", + "tint6": "टिंट 6", + "tint7": "टिंट 7", + "tint8": "टिंट 8", + "tint9": "टिंट 9", + "lightLightTint1": "जांभळा", + "lightLightTint2": "पिंग", + "lightLightTint3": "फिकट पिंग", + "lightLightTint4": "केशरी", + "lightLightTint5": "पिवळा", + "lightLightTint6": "लिंबू", + "lightLightTint7": "हिरवा", + "lightLightTint8": "पाणी", + "lightLightTint9": "निळा", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", + "textColor": "मजकूराचा रंग", + "backgroundColor": "पार्श्वभूमीचा रंग", + "addYourLink": "तुमची लिंक जोडा", + "openLink": "लिंक उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "editLink": "लिंक संपादित करा", + "linkText": "मजकूर", + "linkTextHint": "कृपया मजकूर प्रविष्ट करा", + "linkAddressHint": "कृपया URL प्रविष्ट करा", + "highlightColor": "हायलाइट रंग", + "clearHighlightColor": "हायलाइट काढा", + "customColor": "स्वतःचा रंग", + "hexValue": "Hex मूल्य", + "opacity": "अपारदर्शकता", + "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयंचलित", + "cut": "कट", + "copy": "कॉपी", + "paste": "पेस्ट", + "find": "शोधा", + "select": "निवडा", + "selectAll": "सर्व निवडा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "closeFind": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "regex": "Regex", + "caseSensitive": "केस सेंसिटिव्ह", + "uploadImage": "प्रतिमा अपलोड करा", + "urlImage": "URL प्रतिमा", + "incorrectLink": "चुकीची लिंक", + "upload": "अपलोड", + "chooseImage": "प्रतिमा निवडा", + "loading": "लोड करत आहे", + "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", + "divider": "विभाजक", + "table": "तक्त्याचे स्वरूप", + "colAddBefore": "यापूर्वी स्तंभ जोडा", + "rowAddBefore": "यापूर्वी पंक्ती जोडा", + "colAddAfter": "यानंतर स्तंभ जोडा", + "rowAddAfter": "यानंतर पंक्ती जोडा", + "colRemove": "स्तंभ काढा", + "rowRemove": "पंक्ती काढा", + "colDuplicate": "स्तंभ डुप्लिकेट", + "rowDuplicate": "पंक्ती डुप्लिकेट", + "colClear": "सामग्री साफ करा", + "rowClear": "सामग्री साफ करा", + "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", + "typeSomething": "काहीतरी लिहा...", + "toggleListShortForm": "टॉगल", + "quoteListShortForm": "कोट", + "mathEquationShortForm": "सूत्र", + "codeBlockShortForm": "कोड" +}, + "favorite": { + "noFavorite": "कोणतेही आवडते पृष्ठ नाही", + "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", + "removeFromSidebar": "साइडबारमधून काढा", + "addToSidebar": "साइडबारमध्ये पिन करा" +}, +"cardDetails": { + "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" +}, +"blockPlaceholders": { + "todoList": "करण्याची यादी", + "bulletList": "यादी", + "numberList": "क्रमांकित यादी", + "quote": "कोट", + "heading": "मथळा {}" +}, +"titleBar": { + "pageIcon": "पृष्ठ चिन्ह", + "language": "भाषा", + "font": "फॉन्ट", + "actions": "क्रिया", + "date": "तारीख", + "addField": "फील्ड जोडा", + "userIcon": "वापरकर्त्याचे चिन्ह" +}, +"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", +"newSettings": { + "myAccount": { + "title": "माझे खाते", + "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", + "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", + "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", + "accountSecurity": "खाते सुरक्षा", + "2FA": "2-स्टेप प्रमाणीकरण", + "aiKeys": "AI कीज", + "accountLogin": "खाते लॉगिन", + "updateNameError": "नाव अपडेट करण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "aboutAppFlowy": "@:appName विषयी", + "deleteAccount": { + "title": "खाते हटवा", + "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", + "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", + "deleteMyAccount": "माझे खाते हटवा", + "dialogTitle": "खाते हटवा", + "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", + "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", + "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", + "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", + "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", + "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", + "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" + } + }, + "workplace": { + "name": "वर्कस्पेस", + "title": "वर्कस्पेस सेटिंग्स", + "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", + "workplaceName": "वर्कस्पेसचे नाव", + "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", + "workplaceIcon": "वर्कस्पेस चिन्ह", + "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", + "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "chooseAnIcon": "चिन्ह निवडा", + "appearance": { + "name": "दृश्यरूप", + "themeMode": { + "auto": "स्वयंचलित", + "light": "प्रकाश मोड", + "dark": "गडद मोड" + }, + "language": "भाषा" + } + }, + "syncState": { + "syncing": "सिंक्रोनायझ करत आहे", + "synced": "सिंक्रोनायझ झाले", + "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" + } +}, + "pageStyle": { + "title": "पृष्ठ शैली", + "layout": "लेआउट", + "coverImage": "मुखपृष्ठ प्रतिमा", + "pageIcon": "पृष्ठ चिन्ह", + "colors": "रंग", + "gradient": "ग्रेडियंट", + "backgroundImage": "पार्श्वभूमी प्रतिमा", + "presets": "पूर्वनियोजित", + "photo": "फोटो", + "unsplash": "Unsplash", + "pageCover": "पृष्ठ कव्हर", + "none": "काही नाही", + "openSettings": "सेटिंग्स उघडा", + "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", + "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", + "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", + "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", + "doNotAllow": "परवानगी देऊ नका", + "image": "प्रतिमा" +}, +"commandPalette": { + "placeholder": "शोधा किंवा प्रश्न विचारा...", + "bestMatches": "सर्वोत्तम जुळवणी", + "recentHistory": "अलीकडील इतिहास", + "navigateHint": "नेव्हिगेट करण्यासाठी", + "loadingTooltip": "आम्ही निकाल शोधत आहोत...", + "betaLabel": "बेटा", + "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", + "fromTrashHint": "कचरापेटीतून", + "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", + "clearSearchTooltip": "शोध फील्ड साफ करा" +}, +"space": { + "delete": "हटवा", + "deleteConfirmation": "हटवा: ", + "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", + "rename": "स्पेसचे नाव बदला", + "changeIcon": "चिन्ह बदला", + "manage": "स्पेस व्यवस्थापित करा", + "addNewSpace": "स्पेस तयार करा", + "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", + "createNewSpace": "नवीन स्पेस तयार करा", + "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", + "spaceName": "स्पेसचे नाव", + "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", + "permission": "स्पेस परवानगी", + "publicPermission": "सार्वजनिक", + "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", + "privatePermission": "खाजगी", + "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", + "spaceIconBackground": "पार्श्वभूमीचा रंग", + "spaceIcon": "चिन्ह", + "dangerZone": "धोकादायक क्षेत्र", + "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", + "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", + "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", + "title": "स्पेसेस", + "defaultSpaceName": "सामान्य", + "upgradeSpaceTitle": "स्पेस सक्षम करा", + "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", + "upgrade": "अपग्रेड", + "upgradeYourSpace": "अनेक स्पेस तयार करा", + "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", + "duplicate": "स्पेस डुप्लिकेट करा", + "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", + "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", + "switchSpace": "स्पेस स्विच करा", + "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", + "success": { + "deleteSpace": "स्पेस यशस्वीरित्या हटवली", + "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", + "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", + "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" + }, + "error": { + "deleteSpace": "स्पेस हटवण्यात अयशस्वी", + "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", + "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", + "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" + }, + "createSpace": "स्पेस तयार करा", + "manageSpace": "स्पेस व्यवस्थापित करा", + "renameSpace": "स्पेसचे नाव बदला", + "mSpaceIconColor": "स्पेस चिन्हाचा रंग", + "mSpaceIcon": "स्पेस चिन्ह" +}, + "publish": { + "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", + "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", + "reportPage": "पृष्ठाची तक्रार करा", + "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", + "createdWith": "यांनी तयार केले", + "downloadApp": "AppFlowy डाउनलोड करा", + "copy": { + "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", + "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", + "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" + }, + "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", + "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", + "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", + "publishFailed": "प्रकाशित करण्यात अयशस्वी", + "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", + "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", + "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", + "fastWithAI": "AI सह जलद आणि सोपे.", + "tryItNow": "आत्ताच वापरून पहा", + "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", + "database": { + "zero": "{} निवडलेले दृश्य प्रकाशित करा", + "one": "{} निवडलेली दृश्ये प्रकाशित करा", + "many": "{} निवडलेली दृश्ये प्रकाशित करा", + "other": "{} निवडलेली दृश्ये प्रकाशित करा" + }, + "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", + "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", + "saveThisPage": "या टेम्पलेटपासून सुरू करा", + "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", + "selectWorkspace": "वर्कस्पेस निवडा", + "addTo": "मध्ये जोडा", + "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", + "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", + "downloadIt": "डाउनलोड करा", + "openApp": "अ‍ॅपमध्ये उघडा", + "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", + "membersCount": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "useThisTemplate": "हा टेम्पलेट वापरा" +}, +"web": { + "continue": "पुढे जा", + "or": "किंवा", + "continueWithGoogle": "Google सह पुढे जा", + "continueWithGithub": "GitHub सह पुढे जा", + "continueWithDiscord": "Discord सह पुढे जा", + "continueWithApple": "Apple सह पुढे जा", + "moreOptions": "अधिक पर्याय", + "collapse": "आकुंचन", + "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "and": "आणि", + "termOfUse": "वापर अटी", + "privacyPolicy": "गोपनीयता धोरण", + "signInError": "साइन इन त्रुटी", + "login": "साइन अप किंवा लॉग इन करा", + "fileBlock": { + "uploadedAt": "{time} रोजी अपलोड केले", + "linkedAt": "{time} रोजी लिंक जोडली", + "empty": "फाईल अपलोड करा किंवा एम्बेड करा", + "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "retry": "पुन्हा प्रयत्न करा" + }, + "importNotion": "Notion वरून आयात करा", + "import": "आयात करा", + "importSuccess": "यशस्वीरित्या अपलोड केले", + "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", + "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", + "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", + "error": { + "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" + } +}, + "globalComment": { + "comments": "टिप्पण्या", + "addComment": "टिप्पणी जोडा", + "reactedBy": "यांनी प्रतिक्रिया दिली", + "addReaction": "प्रतिक्रिया जोडा", + "reactedByMore": "आणि {count} इतर", + "showSeconds": { + "one": "1 सेकंदापूर्वी", + "other": "{count} सेकंदांपूर्वी", + "zero": "आत्ताच", + "many": "{count} सेकंदांपूर्वी" + }, + "showMinutes": { + "one": "1 मिनिटापूर्वी", + "other": "{count} मिनिटांपूर्वी", + "many": "{count} मिनिटांपूर्वी" + }, + "showHours": { + "one": "1 तासापूर्वी", + "other": "{count} तासांपूर्वी", + "many": "{count} तासांपूर्वी" + }, + "showDays": { + "one": "1 दिवसापूर्वी", + "other": "{count} दिवसांपूर्वी", + "many": "{count} दिवसांपूर्वी" + }, + "showMonths": { + "one": "1 महिन्यापूर्वी", + "other": "{count} महिन्यांपूर्वी", + "many": "{count} महिन्यांपूर्वी" + }, + "showYears": { + "one": "1 वर्षापूर्वी", + "other": "{count} वर्षांपूर्वी", + "many": "{count} वर्षांपूर्वी" + }, + "reply": "उत्तर द्या", + "deleteComment": "टिप्पणी हटवा", + "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", + "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", + "hasBeenDeleted": "हटवले गेले", + "replyingTo": "याला उत्तर देत आहे", + "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", + "collapse": "संकुचित करा", + "readMore": "अधिक वाचा", + "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", + "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", + "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" +}, + "template": { + "asTemplate": "टेम्पलेट म्हणून जतन करा", + "name": "टेम्पलेट नाव", + "description": "टेम्पलेट वर्णन", + "about": "टेम्पलेट माहिती", + "deleteFromTemplate": "टेम्पलेटमधून हटवा", + "preview": "टेम्पलेट पूर्वदृश्य", + "categories": "टेम्पलेट श्रेणी", + "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", + "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", + "relatedTemplates": "संबंधित टेम्पलेट्स", + "requiredField": "{field} आवश्यक आहे", + "addCategory": "\"{category}\" जोडा", + "addNewCategory": "नवीन श्रेणी जोडा", + "addNewCreator": "नवीन निर्माता जोडा", + "deleteCategory": "श्रेणी हटवा", + "editCategory": "श्रेणी संपादित करा", + "editCreator": "निर्माता संपादित करा", + "category": { + "name": "श्रेणीचे नाव", + "icon": "श्रेणी चिन्ह", + "bgColor": "श्रेणी पार्श्वभूमीचा रंग", + "priority": "श्रेणी प्राधान्य", + "desc": "श्रेणीचे वर्णन", + "type": "श्रेणी प्रकार", + "icons": "श्रेणी चिन्हे", + "colors": "श्रेणी रंग", + "byUseCase": "वापराच्या आधारे", + "byFeature": "वैशिष्ट्यांनुसार", + "deleteCategory": "श्रेणी हटवा", + "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", + "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." + }, + "creator": { + "label": "टेम्पलेट निर्माता", + "name": "निर्मात्याचे नाव", + "avatar": "निर्मात्याचा अवतार", + "accountLinks": "निर्मात्याचे खाते दुवे", + "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", + "deleteCreator": "निर्माता हटवा", + "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", + "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." + }, + "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", + "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", + "viewTemplate": "टेम्पलेट पहा", + "deleteTemplate": "टेम्पलेट हटवा", + "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", + "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", + "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", + "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", + "uploadAvatar": "अवतार अपलोड करा", + "searchInCategory": "{category} मध्ये शोधा", + "label": "टेम्पलेट्स" +}, + "fileDropzone": { + "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", + "uploading": "अपलोड करत आहे...", + "uploadFailed": "अपलोड अयशस्वी", + "uploadSuccess": "अपलोड यशस्वी", + "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", + "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", + "uploadingDescription": "फाइल अपलोड होत आहे" +}, + "gallery": { + "preview": "पूर्ण स्क्रीनमध्ये उघडा", + "copy": "कॉपी करा", + "download": "डाउनलोड", + "prev": "मागील", + "next": "पुढील", + "resetZoom": "झूम रिसेट करा", + "zoomIn": "झूम इन", + "zoomOut": "झूम आउट" +}, + "invitation": { + "join": "सामील व्हा", + "on": "वर", + "invitedBy": "यांनी आमंत्रित केले", + "membersCount": { + "zero": "{count} सदस्य", + "one": "{count} सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", + "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", + "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", + "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", + "openWorkspace": "AppFlowy उघडा", + "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", + "errorModal": { + "title": "काहीतरी चुकले आहे", + "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", + "contactOwner": "मालकाशी संपर्क करा", + "close": "मुख्यपृष्ठावर परत जा", + "changeAccount": "खाते बदला" + } +}, + "requestAccess": { + "title": "या पृष्ठासाठी प्रवेश नाही", + "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", + "requestAccess": "प्रवेशाची विनंती करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", + "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", + "successful": "विनंती यशस्वीपणे पाठवली गेली", + "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", + "requestError": "प्रवेशाची विनंती अयशस्वी", + "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" +}, + "approveAccess": { + "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", + "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", + "upgrade": "अपग्रेड", + "downloadApp": "AppFlowy डाउनलोड करा", + "approveButton": "मंजूर करा", + "approveSuccess": "मंजूर यशस्वी", + "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", + "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", + "memberCount": { + "zero": "कोणतेही सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", + "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", + "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", + "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", + "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", + "asMember": "सदस्य म्हणून" +}, + "upgradePlanModal": { + "title": "Pro प्लॅनवर अपग्रेड करा", + "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", + "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", + "step1": "1. सेटिंग्जमध्ये जा", + "step2": "2. 'योजना' वर क्लिक करा", + "step3": "3. 'योजना बदला' निवडा", + "appNote": "नोंद:", + "actionButton": "अपग्रेड करा", + "downloadLink": "अ‍ॅप डाउनलोड करा", + "laterButton": "नंतर", + "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", + "refresh": "येथे" +}, + "breadcrumbs": { + "label": "ब्रेडक्रम्स" +}, + "time": { + "justNow": "आत्ताच", + "seconds": { + "one": "1 सेकंद", + "other": "{count} सेकंद" + }, + "minutes": { + "one": "1 मिनिट", + "other": "{count} मिनिटे" + }, + "hours": { + "one": "1 तास", + "other": "{count} तास" + }, + "days": { + "one": "1 दिवस", + "other": "{count} दिवस" + }, + "weeks": { + "one": "1 आठवडा", + "other": "{count} आठवडे" + }, + "months": { + "one": "1 महिना", + "other": "{count} महिने" + }, + "years": { + "one": "1 वर्ष", + "other": "{count} वर्षे" + }, + "ago": "पूर्वी", + "yesterday": "काल", + "today": "आज" +}, + "members": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" +}, + "tabMenu": { + "close": "बंद करा", + "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", + "closeOthers": "इतर टॅब बंद करा", + "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", + "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", + "favorite": "आवडते", + "unfavorite": "आवडते काढा", + "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", + "pinTab": "पिन करा", + "unpinTab": "अनपिन करा" +}, + "openFileMessage": { + "success": "फाइल यशस्वीरित्या उघडली", + "fileNotFound": "फाइल सापडली नाही", + "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", + "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", + "unknownError": "फाइल उघडण्यात अयशस्वी" +}, + "inviteMember": { + "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", + "upgrade": "अपग्रेड करा", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "आमंत्रण पाठवा", + "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", + "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", + "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", + "emails": "ईमेल" +}, + "quickNote": { + "label": "झटपट नोंद", + "quickNotes": "झटपट नोंदी", + "search": "झटपट नोंदी शोधा", + "collapseFullView": "पूर्ण दृश्य लपवा", + "expandFullView": "पूर्ण दृश्य उघडा", + "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", + "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", + "emptyNote": "रिकामी नोंद", + "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", + "addNote": "नवीन नोंद", + "noAdditionalText": "अधिक माहिती नाही" +}, + "subscribe": { + "upgradePlanTitle": "योजना तुलना करा आणि निवडा", + "yearly": "वार्षिक", + "save": "{discount}% बचत", + "monthly": "मासिक", + "priceIn": "किंमत येथे: ", + "free": "फ्री", + "pro": "प्रो", + "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", + "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", + "proDuration": { + "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", + "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" + }, + "cancel": "खालच्या योजनेवर जा", + "changePlan": "प्रो योजनेवर अपग्रेड करा", + "everythingInFree": "फ्री योजनेतील सर्व काही +", + "currentPlan": "सध्याची योजना", + "freeDuration": "कायम", + "freePoints": { + "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", + "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", + "three": "5 GB संचयन", + "four": "बुद्धिमान शोध", + "five": "20 AI प्रतिसाद", + "six": "मोबाईल अ‍ॅप", + "seven": "रिअल-टाइम सहकार्य" + }, + "proPoints": { + "first": "अमर्यादित संचयन", + "second": "10 वर्कस्पेस सदस्यांपर्यंत", + "three": "अमर्यादित AI प्रतिसाद", + "four": "अमर्यादित फाइल अपलोड्स", + "five": "कस्टम नेमस्पेस" + }, + "cancelPlan": { + "title": "आपल्याला जाताना पाहून वाईट वाटते", + "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", + "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", + "commonOther": "इतर", + "otherHint": "आपले उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", + "answerThree": "चांगला पर्याय सापडला", + "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता आहे", + "answerFive": "शक्यता नाही" + }, + "questionThree": { + "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", + "answerOne": "मल्टी-यूजर सहकार्य", + "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", + "answerOne": "छान", + "answerTwo": "चांगला", + "answerThree": "सामान्य", + "answerFour": "थोडासा वाईट", + "answerFive": "असंतोषजनक" + } + } +}, + "ai": { + "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", + "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", + "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", + "limitReachedAction": { + "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", + "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", + "upgrade": "अपग्रेड करा", + "toThe": "या योजनेवर", + "proPlan": "प्रो योजना", + "orPurchaseAn": "किंवा खरेदी करा", + "aiAddon": "AI अ‍ॅड-ऑन" + }, + "editing": "संपादन करत आहे", + "analyzing": "विश्लेषण करत आहे", + "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", + "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", + "more": "अधिक" +}, + "autoUpdate": { + "criticalUpdateTitle": "अद्यतन आवश्यक आहे", + "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", + "criticalUpdateButton": "अद्यतन करा", + "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", + "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", + "bannerUpdateButton": "अद्यतन करा", + "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", + "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", + "settingsUpdateButton": "अद्यतन करा", + "settingsUpdateWhatsNew": "काय नवीन आहे" +}, + "lockPage": { + "lockPage": "लॉक केलेले", + "reLockPage": "पुन्हा लॉक करा", + "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", + "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", + "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." +}, + "suggestion": { + "accept": "स्वीकारा", + "keep": "जसे आहे तसे ठेवा", + "discard": "रद्द करा", + "close": "बंद करा", + "tryAgain": "पुन्हा प्रयत्न करा", + "rewrite": "पुन्हा लिहा", + "insertBelow": "खाली टाका" +} +} From 8d2901cc5814f7921802351bd602826abce7437d Mon Sep 17 00:00:00 2001 From: FakhriAzzouz Date: Wed, 9 Apr 2025 02:38:00 +0100 Subject: [PATCH 109/207] chore: arabic translations update (#7701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update translations with Fink 🐦 * chore: update translations with Fink 🐦 --- frontend/resources/translations/ar-SA.json | 8 ++++++-- frontend/resources/translations/ca-ES.json | 4 ++-- frontend/resources/translations/ckb-KU.json | 4 ++-- frontend/resources/translations/cs-CZ.json | 4 ++-- frontend/resources/translations/de-DE.json | 4 ++-- frontend/resources/translations/es-VE.json | 4 ++-- frontend/resources/translations/eu-ES.json | 4 ++-- frontend/resources/translations/fa.json | 4 ++-- frontend/resources/translations/fr-CA.json | 4 ++-- frontend/resources/translations/fr-FR.json | 4 ++-- frontend/resources/translations/he.json | 4 ++-- frontend/resources/translations/hu-HU.json | 4 ++-- frontend/resources/translations/id-ID.json | 4 ++-- frontend/resources/translations/it-IT.json | 4 ++-- frontend/resources/translations/ja-JP.json | 4 ++-- frontend/resources/translations/ko-KR.json | 4 ++-- frontend/resources/translations/pl-PL.json | 4 ++-- frontend/resources/translations/pt-BR.json | 4 ++-- frontend/resources/translations/pt-PT.json | 4 ++-- frontend/resources/translations/ru-RU.json | 4 ++-- frontend/resources/translations/sv-SE.json | 4 ++-- frontend/resources/translations/th-TH.json | 4 ++-- frontend/resources/translations/tr-TR.json | 4 ++-- frontend/resources/translations/uk-UA.json | 4 ++-- frontend/resources/translations/vi-VN.json | 4 ++-- frontend/resources/translations/zh-CN.json | 4 ++-- frontend/resources/translations/zh-TW.json | 4 ++-- 27 files changed, 58 insertions(+), 54 deletions(-) diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 4afd35c40e..e8ca8c4ceb 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -311,14 +311,16 @@ "questionBubble": { "shortcuts": "الاختصارات", "whatsNew": "ما هو الجديد؟", - "help": "المساعدة والدعم", + "helpAndDocumentation": "المساعدة والتوثيق", + "getSupport": "احصل على الدعم", "markdown": "Markdown", "debug": { "name": "معلومات التصحيح", "success": "تم نسخ معلومات التصحيح إلى الحافظة!", "fail": "تعذر نسخ معلومات التصحيح إلى الحافظة" }, - "feedback": "تعليق" + "feedback": "تعليق", + "help": "المساعدة والدعم" }, "menuAppHeader": { "moreButtonToolTip": "إزالة وإعادة تسمية والمزيد...", @@ -513,6 +515,8 @@ "settings": "إعدادات", "members": "الأعضاء", "trash": "سلة المحذوفات", + "helpAndDocumentation": "المساعدة والتوثيق", + "getSupport": "احصل على الدعم", "helpAndSupport": "المساعدة والدعم" }, "sites": { diff --git a/frontend/resources/translations/ca-ES.json b/frontend/resources/translations/ca-ES.json index f388cf9bd5..8d98cb5cbc 100644 --- a/frontend/resources/translations/ca-ES.json +++ b/frontend/resources/translations/ca-ES.json @@ -133,14 +133,14 @@ "questionBubble": { "shortcuts": "Dreceres", "whatsNew": "Què hi ha de nou?", - "help": "Ajuda i Suport", "markdown": "Reducció", "debug": { "name": "Informació de depuració", "success": "S'ha copiat la informació de depuració!", "fail": "No es pot copiar la informació de depuració" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Ajuda i Suport" }, "menuAppHeader": { "moreButtonToolTip": "Suprimeix, canvia el nom i més...", diff --git a/frontend/resources/translations/ckb-KU.json b/frontend/resources/translations/ckb-KU.json index 4acb7a1765..acfc571536 100644 --- a/frontend/resources/translations/ckb-KU.json +++ b/frontend/resources/translations/ckb-KU.json @@ -170,14 +170,14 @@ "questionBubble": { "shortcuts": "کورتە ڕێگاکان", "whatsNew": "نوێترین", - "help": "پشتیوانی و یارمەتی", "markdown": "Markdown", "debug": { "name": "زانیاری دیباگ", "success": "زانیارییەکانی دیباگ کۆپی کراون بۆ کلیپبۆرد!", "fail": "ناتوانرێت زانیارییەکانی دیباگ کۆپی بکات بۆ کلیپبۆرد" }, - "feedback": "فیدباک" + "feedback": "فیدباک", + "help": "پشتیوانی و یارمەتی" }, "menuAppHeader": { "moreButtonToolTip": "سڕینەوە، گۆڕینی ناو، و زۆر شتی تر...", diff --git a/frontend/resources/translations/cs-CZ.json b/frontend/resources/translations/cs-CZ.json index 07e5a01bea..28750dd542 100644 --- a/frontend/resources/translations/cs-CZ.json +++ b/frontend/resources/translations/cs-CZ.json @@ -134,14 +134,14 @@ "questionBubble": { "shortcuts": "Klávesové zkratky", "whatsNew": "Co je nového?", - "help": "Pomoc a podpora", "markdown": "Markdown", "debug": { "name": "Debug informace", "success": "Debug informace zkopírovány do schránky!", "fail": "Nepodařilo se zkopáí" }, - "feedback": "Zpětná vazba" + "feedback": "Zpětná vazba", + "help": "Pomoc a podpora" }, "menuAppHeader": { "moreButtonToolTip": "Smazat, přejmenovat, a další...", diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index 60bb99b2fb..65a7fbea05 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -252,14 +252,14 @@ "questionBubble": { "shortcuts": "Tastenkürzel", "whatsNew": "Was gibt es Neues?", - "help": "Hilfe & Support", "markdown": "Markdown", "debug": { "name": "Debug-Informationen", "success": "Debug-Informationen in die Zwischenablage kopiert!", "fail": "Debug-Informationen konnten nicht in die Zwischenablage kopiert werden" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Hilfe & Support" }, "menuAppHeader": { "moreButtonToolTip": "Entfernen, umbenennen und mehr...", diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index e73165349a..5f947ea015 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -218,14 +218,14 @@ "questionBubble": { "shortcuts": "Atajos", "whatsNew": "¿Qué hay de nuevo?", - "help": "Ayuda y Soporte", "markdown": "Reducción", "debug": { "name": "Información de depuración", "success": "¡Información copiada!", "fail": "No fue posible copiar la información" }, - "feedback": "Comentario" + "feedback": "Comentario", + "help": "Ayuda y Soporte" }, "menuAppHeader": { "moreButtonToolTip": "Eliminar, renombrar y más...", diff --git a/frontend/resources/translations/eu-ES.json b/frontend/resources/translations/eu-ES.json index be987e7a53..2e52231f7c 100644 --- a/frontend/resources/translations/eu-ES.json +++ b/frontend/resources/translations/eu-ES.json @@ -99,14 +99,14 @@ "questionBubble": { "shortcuts": "Lasterbideak", "whatsNew": "Ze berri?", - "help": "Laguntza", "markdown": "Markdown", "debug": { "name": "Debug informazioa", "success": "Debug informazioa kopiatu da!", "fail": "Ezin izan da debug informazioa kopiatu" }, - "feedback": "Iritzia" + "feedback": "Iritzia", + "help": "Laguntza" }, "menuAppHeader": { "addPageTooltip": "Gehitu orri bat", diff --git a/frontend/resources/translations/fa.json b/frontend/resources/translations/fa.json index 80a100c3bc..cc93c17d64 100644 --- a/frontend/resources/translations/fa.json +++ b/frontend/resources/translations/fa.json @@ -139,14 +139,14 @@ "questionBubble": { "shortcuts": "میانبرها", "whatsNew": "تازه‌ترین‌ها", - "help": "پشتیبانی و مستندات", "markdown": "Markdown", "debug": { "name": "اطلاعات اشکال‌زدایی", "success": "طلاعات اشکال زدایی در کلیپ بورد کپی شد!", "fail": "نمی توان اطلاعات اشکال زدایی را در کلیپ بورد کپی کرد" }, - "feedback": "بازخورد" + "feedback": "بازخورد", + "help": "پشتیبانی و مستندات" }, "menuAppHeader": { "moreButtonToolTip": "حذف، تغییر نام، و موارد دیگر...", diff --git a/frontend/resources/translations/fr-CA.json b/frontend/resources/translations/fr-CA.json index 7f8cdbd6a3..589d2dfe18 100644 --- a/frontend/resources/translations/fr-CA.json +++ b/frontend/resources/translations/fr-CA.json @@ -196,14 +196,14 @@ "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", - "help": "Aide et Support Technique", "markdown": "Réduction", "debug": { "name": "Infos du système", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, - "feedback": "Retour" + "feedback": "Retour", + "help": "Aide et Support Technique" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index f2c3e1270d..989e21f349 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -269,14 +269,14 @@ "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", - "help": "Aide et Support", "markdown": "Rédaction", "debug": { "name": "Informations de Débogage", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, - "feedback": "Retour" + "feedback": "Retour", + "help": "Aide et Support" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", diff --git a/frontend/resources/translations/he.json b/frontend/resources/translations/he.json index eda0cd920f..6c40f88947 100644 --- a/frontend/resources/translations/he.json +++ b/frontend/resources/translations/he.json @@ -206,14 +206,14 @@ "questionBubble": { "shortcuts": "מקשי קיצור", "whatsNew": "מה חדש?", - "help": "עזרה ותמיכה", "markdown": "Markdown", "debug": { "name": "פרטי ניפוי שגיאות", "success": "פרטי ניפוי השגיאות הועתקו ללוח הגזירים!", "fail": "לא ניתן להעתיק את פרטי ניפוי השגיאות ללוח הגזירים" }, - "feedback": "משוב" + "feedback": "משוב", + "help": "עזרה ותמיכה" }, "menuAppHeader": { "moreButtonToolTip": "הסרה, שינוי שם ועוד…", diff --git a/frontend/resources/translations/hu-HU.json b/frontend/resources/translations/hu-HU.json index 40f05cccc6..1c10e40da4 100644 --- a/frontend/resources/translations/hu-HU.json +++ b/frontend/resources/translations/hu-HU.json @@ -103,14 +103,14 @@ "questionBubble": { "shortcuts": "Parancsikonok", "whatsNew": "Újdonságok", - "help": "Segítség & Támogatás", "markdown": "Markdown", "debug": { "name": "Debug Információ", "success": "Debug információ a vágólapra másolva", "fail": "A Debug információ nem másolható a vágólapra" }, - "feedback": "Visszacsatolás" + "feedback": "Visszacsatolás", + "help": "Segítség & Támogatás" }, "menuAppHeader": { "addPageTooltip": "Belső oldal hozzáadása", diff --git a/frontend/resources/translations/id-ID.json b/frontend/resources/translations/id-ID.json index 74d1e69d63..b900929966 100644 --- a/frontend/resources/translations/id-ID.json +++ b/frontend/resources/translations/id-ID.json @@ -160,14 +160,14 @@ "questionBubble": { "shortcuts": "Pintasan", "whatsNew": "Apa yang baru?", - "help": "Bantuan & Dukungan", "markdown": "Penurunan harga", "debug": { "name": "Info debug", "success": "Info debug disalin ke papan klip!", "fail": "Tidak dapat menyalin info debug ke papan klip" }, - "feedback": "Masukan" + "feedback": "Masukan", + "help": "Bantuan & Dukungan" }, "menuAppHeader": { "moreButtonToolTip": "Menghapus, merubah nama, dan banyak lagi...", diff --git a/frontend/resources/translations/it-IT.json b/frontend/resources/translations/it-IT.json index d6877ecd59..7fb463da20 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -221,14 +221,14 @@ "questionBubble": { "shortcuts": "Scorciatoie", "whatsNew": "Cosa c'è di nuovo?", - "help": "Aiuto & Supporto", "markdown": "Markdown", "debug": { "name": "Informazioni di debug", "success": "Informazioni di debug copiate negli appunti!", "fail": "Impossibile copiare le informazioni di debug negli appunti" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Aiuto & Supporto" }, "menuAppHeader": { "moreButtonToolTip": "Rimuovi, rinomina e altro...", diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index 15333ff3da..ebe679ad84 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -263,14 +263,14 @@ "questionBubble": { "shortcuts": "ショートカット", "whatsNew": "新着情報", - "help": "ヘルプ & サポート", "markdown": "Markdown", "debug": { "name": "デバッグ情報", "success": "デバッグ情報をクリップボードにコピーしました!", "fail": "デバッグ情報をクリップボードにコピーできませんでした" }, - "feedback": "フィードバック" + "feedback": "フィードバック", + "help": "ヘルプ & サポート" }, "menuAppHeader": { "moreButtonToolTip": "削除、名前の変更、その他...", diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 0b2aabe508..1246b65f30 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -301,14 +301,14 @@ "questionBubble": { "shortcuts": "단축키", "whatsNew": "새로운 기능", - "help": "도움말 및 지원", "markdown": "Markdown", "debug": { "name": "디버그 정보", "success": "디버그 정보를 클립보드에 복사했습니다!", "fail": "디버그 정보를 클립보드에 복사할 수 없습니다" }, - "feedback": "피드백" + "feedback": "피드백", + "help": "도움말 및 지원" }, "menuAppHeader": { "moreButtonToolTip": "제거, 이름 변경 등...", diff --git a/frontend/resources/translations/pl-PL.json b/frontend/resources/translations/pl-PL.json index e3ca580354..9473d7e2f0 100644 --- a/frontend/resources/translations/pl-PL.json +++ b/frontend/resources/translations/pl-PL.json @@ -164,14 +164,14 @@ "questionBubble": { "shortcuts": "Skróty", "whatsNew": "Co nowego?", - "help": "Pomoc & Wsparcie", "markdown": "Markdown", "debug": { "name": "Informacje Debugowania", "success": "Skopiowano informacje debugowania do schowka!", "fail": "Nie mozna skopiować informacji debugowania do schowka" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Pomoc & Wsparcie" }, "menuAppHeader": { "moreButtonToolTip": "Usuń, zmień nazwę i więcej...", diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index 51b585f14b..864d225095 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -225,14 +225,14 @@ "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", - "help": "Ajuda e Suporte", "markdown": "Remarcação", "debug": { "name": "Informação de depuração", "success": "Informação de depuração copiada para a área de transferência!", "fail": "Falha ao copiar a informação de depuração para a área de transferência" }, - "feedback": "Opinião" + "feedback": "Opinião", + "help": "Ajuda e Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", diff --git a/frontend/resources/translations/pt-PT.json b/frontend/resources/translations/pt-PT.json index 617e097f6b..c9892bf9df 100644 --- a/frontend/resources/translations/pt-PT.json +++ b/frontend/resources/translations/pt-PT.json @@ -128,14 +128,14 @@ "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", - "help": "Ajuda & Suporte", "markdown": "Remarcação", "debug": { "name": "Informação de depuração", "success": "Copiar informação de depuração para o clipboard!", "fail": "Falha em copiar a informação de depuração para o clipboard" }, - "feedback": "Opinião" + "feedback": "Opinião", + "help": "Ajuda & Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index 2bac18a879..c45010b8fc 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -251,14 +251,14 @@ "questionBubble": { "shortcuts": "Горячие клавиши", "whatsNew": "Что нового?", - "help": "Помощь и поддержка", "markdown": "Markdown", "debug": { "name": "Отладочная информация", "success": "Отладочная информация скопирована в буфер обмена!", "fail": "Не удалось скопировать отладочную информацию в буфер обмена" }, - "feedback": "Обратная связь" + "feedback": "Обратная связь", + "help": "Помощь и поддержка" }, "menuAppHeader": { "moreButtonToolTip": "Удалить, переименовать и другие действия...", diff --git a/frontend/resources/translations/sv-SE.json b/frontend/resources/translations/sv-SE.json index 42855011b2..3210aa1f15 100644 --- a/frontend/resources/translations/sv-SE.json +++ b/frontend/resources/translations/sv-SE.json @@ -107,14 +107,14 @@ "questionBubble": { "shortcuts": "Genvägar", "whatsNew": "Vad nytt?", - "help": "Hjälp & Support", "markdown": "Prissänkning", "debug": { "name": "Felsökningsinfo", "success": "Kopierade felsökningsinfo till urklipp!", "fail": "Kunde inte kopiera felsökningsinfo till urklipp" }, - "feedback": "Återkoppling" + "feedback": "Återkoppling", + "help": "Hjälp & Support" }, "menuAppHeader": { "addPageTooltip": "Lägg till en underliggande sida", diff --git a/frontend/resources/translations/th-TH.json b/frontend/resources/translations/th-TH.json index 0be97f517e..78e5462d7f 100644 --- a/frontend/resources/translations/th-TH.json +++ b/frontend/resources/translations/th-TH.json @@ -250,14 +250,14 @@ "questionBubble": { "shortcuts": "ทางลัด", "whatsNew": "มีอะไรใหม่?", - "help": "ช่วยเหลือและสนับสนุน", "markdown": "Markdown", "debug": { "name": "ข้อมูลดีบัก", "success": "คัดลอกข้อมูลดีบักไปยังคลิปบอร์ดแล้ว!", "fail": "ไม่สามารถคัดลอกข้อมูลดีบักไปยังคลิปบอร์ด" }, - "feedback": "ข้อเสนอแนะ" + "feedback": "ข้อเสนอแนะ", + "help": "ช่วยเหลือและสนับสนุน" }, "menuAppHeader": { "moreButtonToolTip": "ลบ เปลี่ยนชื่อ และอื่นๆ...", diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index 0e830b62c4..0eeac684c6 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -288,14 +288,14 @@ "questionBubble": { "shortcuts": "Kısayollar", "whatsNew": "Yenilikler", - "help": "Yardım ve Destek", "markdown": "Markdown", "debug": { "name": "Hata Ayıklama Bilgisi", "success": "Hata ayıklama bilgisi panoya kopyalandı!", "fail": "Hata ayıklama bilgisi panoya kopyalanamadı" }, - "feedback": "Geri Bildirim" + "feedback": "Geri Bildirim", + "help": "Yardım ve Destek" }, "menuAppHeader": { "moreButtonToolTip": "Kaldır, yeniden adlandır ve daha fazlası...", diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index 3262de34a7..394801ed21 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -225,14 +225,14 @@ "questionBubble": { "shortcuts": "Комбінації клавіш", "whatsNew": "Що нового?", - "help": "Довідка та підтримка", "markdown": "Markdown", "debug": { "name": "Інформація для налагодження", "success": "Інформацію для налагодження скопійовано в буфер обміну!", "fail": "Не вдалося скопіювати інформацію для налагодження в буфер обміну" }, - "feedback": "Зворотний зв'язок" + "feedback": "Зворотний зв'язок", + "help": "Довідка та підтримка" }, "menuAppHeader": { "moreButtonToolTip": "Видалити, перейменувати та інше...", diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index 957fdd6e03..e60648590d 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -226,14 +226,14 @@ "questionBubble": { "shortcuts": "Phím tắt", "whatsNew": "Có gì mới?", - "help": "Trợ giúp & Hỗ trợ", "markdown": "Markdown", "debug": { "name": "Thông tin gỡ lỗi", "success": "Đã sao chép thông tin gỡ lỗi vào khay nhớ tạm!", "fail": "Không thể sao chép thông tin gỡ lỗi vào khay nhớ tạm" }, - "feedback": "Nhận xét" + "feedback": "Nhận xét", + "help": "Trợ giúp & Hỗ trợ" }, "menuAppHeader": { "moreButtonToolTip": "Xóa, đổi tên và hơn thế nữa...", diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 44c46d8b5c..df65cbac4b 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -270,14 +270,14 @@ "questionBubble": { "shortcuts": "快捷键", "whatsNew": "新功能", - "help": "帮助和支持", "markdown": "Markdown", "debug": { "name": "调试信息", "success": "将调试信息复制到剪贴板!", "fail": "无法将调试信息复制到剪贴板" }, - "feedback": "反馈" + "feedback": "反馈", + "help": "帮助和支持" }, "menuAppHeader": { "moreButtonToolTip": "删除、重命名等等...", diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index acd121049e..b5f4ff3d5f 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -216,14 +216,14 @@ "questionBubble": { "shortcuts": "快捷鍵", "whatsNew": "有什麼新功能?", - "help": "幫助 & 支援", "markdown": "Markdown", "debug": { "name": "除錯資訊", "success": "已將除錯資訊複製到剪貼簿!", "fail": "無法將除錯資訊複製到剪貼簿" }, - "feedback": "意見回饋" + "feedback": "意見回饋", + "help": "幫助 & 支援" }, "menuAppHeader": { "moreButtonToolTip": "移除、重新命名等等...", From 7d0f6c9debd8dee526cf846e4ba276bd7361dc22 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:38:42 +0800 Subject: [PATCH 110/207] fix(mobile): edit mention page (#7698) * fix(mobile): edit mention page * chore: type check * chore: replace usages * chore: use MentionType instead --- .../base/insert_page_command.dart | 13 +++-- .../copy_and_paste/paste_from_block_link.dart | 12 ++--- .../child_page_transaction_handler.dart | 13 ++--- .../mention/date_transaction_handler.dart | 15 +++--- .../editor_plugins/mention/mention_block.dart | 47 ++++++++++++++++--- .../mention/mention_date_block.dart | 21 +++++---- .../mention/mention_page_block.dart | 37 +++++++-------- .../slash_menu_items/date_item.dart | 12 ++--- .../inline_actions/handlers/child_page.dart | 11 ++--- .../handlers/date_reference.dart | 12 ++--- .../handlers/inline_page_reference.dart | 11 ++--- .../handlers/reminder_reference.dart | 14 +++--- 12 files changed, 118 insertions(+), 100 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart index af605972de..11aed036d2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart @@ -70,13 +70,12 @@ extension InsertDatabase on EditorState { node, selection.end.offset, 0, - r'$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: view.id, + blockId: null, + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart index fbd9914c1d..c47c0c967d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart @@ -43,13 +43,11 @@ extension PasteFromBlockLink on EditorState { node, selection.startIndex, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.blockId: blockId, - MentionBlockKeys.pageId: pageId, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: pageId, + blockId: blockId, + ), ); await apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart index ef32ad1098..117e09e67f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart @@ -179,13 +179,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler { await duplicatedViewOrFailure.fold( (newView) async { - final newMentionAttributes = { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.childPage.name, - MentionBlockKeys.pageId: newView.id, - }, - }; - // The index is the index of the delta, to get the index of the mention character // in all the text, we need to calculate it based on the deltas before the current delta. int mentionIndex = 0; @@ -202,7 +195,11 @@ class ChildPageTransactionHandler extends MentionTransactionHandler { node, mentionIndex, MentionBlockKeys.mentionChar.length, - newMentionAttributes, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.childPage, + pageId: newView.id, + blockId: null, + ), ); await editorState.apply( transaction, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart index 972ed229dd..cb3196e9b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart @@ -192,15 +192,12 @@ class DateTransactionHandler extends MentionTransactionHandler { ), ); - final newMentionAttributes = { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: dateTime.toIso8601String(), - MentionBlockKeys.reminderId: reminderId, - MentionBlockKeys.includeTime: data.includeTime, - MentionBlockKeys.reminderOption: data.reminderOption.name, - }, - }; + final newMentionAttributes = MentionBlockKeys.buildMentionDateAttributes( + date: dateTime.toIso8601String(), + reminderId: reminderId, + reminderOption: data.reminderOption.name, + includeTime: data.includeTime, + ); // The index is the index of the delta, to get the index of the mention character // in all the text, we need to calculate it based on the deltas before the current delta. diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart index d65609f1a8..79c9b31d20 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -27,12 +27,12 @@ Node dateMentionNode() { operations: [ TextInsert( MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: DateTime.now().toIso8601String(), - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: DateTime.now().toIso8601String(), + reminderId: null, + reminderOption: null, + includeTime: false, + ), ), ], ), @@ -42,18 +42,51 @@ Node dateMentionNode() { class MentionBlockKeys { const MentionBlockKeys._(); - static const reminderId = 'reminder_id'; // ReminderID static const mention = 'mention'; static const type = 'type'; // MentionType, String + static const pageId = 'page_id'; static const blockId = 'block_id'; // Related to Reminder and Date blocks static const date = 'date'; // Start Date static const includeTime = 'include_time'; + static const reminderId = 'reminder_id'; // ReminderID static const reminderOption = 'reminder_option'; static const mentionChar = '\$'; + + static Map buildMentionPageAttributes({ + required MentionType mentionType, + required String pageId, + required String? blockId, + }) { + return { + MentionBlockKeys.mention: { + MentionBlockKeys.type: mentionType.name, + MentionBlockKeys.pageId: pageId, + if (blockId != null) MentionBlockKeys.blockId: blockId, + }, + }; + } + + static Map buildMentionDateAttributes({ + required String date, + required String? reminderId, + required String? reminderOption, + required bool includeTime, + }) { + return { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date, + MentionBlockKeys.includeTime: includeTime, + if (reminderId != null) MentionBlockKeys.reminderId: reminderId, + if (reminderOption != null) + MentionBlockKeys.reminderOption: reminderOption, + }, + }; + } } class MentionBlock extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart index 6c5ee7f527..20f60be23d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -201,16 +201,17 @@ class _MentionDateBlockState extends State { (reminderOption == ReminderOption.none ? null : widget.reminderId); final transaction = widget.editorState.transaction - ..formatText(widget.node, widget.index, 1, { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.reminderId: rId, - MentionBlockKeys.includeTime: includeTime, - MentionBlockKeys.reminderOption: - reminderOption?.name ?? widget.reminderOption.name, - }, - }); + ..formatText( + widget.node, + widget.index, + 1, + MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + reminderId: rId, + includeTime: includeTime, + reminderOption: reminderOption?.name ?? widget.reminderOption.name, + ), + ); widget.editorState.apply(transaction, withUpdateSelection: false); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index 23c05b89eb..ede690eb30 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -50,12 +50,11 @@ Node pageMentionNode(String viewId) { operations: [ TextInsert( MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: viewId, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: viewId, + blockId: null, + ), ), ], ), @@ -284,12 +283,11 @@ class _MentionSubPageBlockState extends State { widget.node, widget.index, MentionBlockKeys.mentionChar.length, - { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: widget.pageId, - }, - }, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: widget.pageId, + blockId: null, + ), ); widget.editorState.apply( @@ -383,25 +381,24 @@ Future _handleDoubleTap( } final currentViewId = context.read().documentId; - final newViewId = await showPageSelectorSheet( + final newView = await showPageSelectorSheet( context, currentViewId: currentViewId, selectedViewId: viewId, ); - if (newViewId != null) { + if (newView != null) { // Update this nodes pageId final transaction = editorState.transaction ..formatText( node, index, 1, - { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: newViewId, - }, - }, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: newView.id, + blockId: null, + ), ); await editorState.apply(transaction, withUpdateSelection: false); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart index 0bb09d0a7e..04a8ee1b7e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart @@ -46,12 +46,12 @@ extension on EditorState { selection.start.offset, 0, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: DateTime.now().toIso8601String(), - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: DateTime.now().toIso8601String(), + reminderId: null, + reminderOption: null, + includeTime: false, + ), ); await apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart index d29a1f86bf..665c284aea 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart @@ -71,12 +71,11 @@ class InlineChildPageService extends InlineActionsDelegate { replacement.$1, replacement.$2, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.childPage.name, - MentionBlockKeys.pageId: view.id, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.childPage, + pageId: view.id, + blockId: null, + ), ); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart index c7076bd255..747c8667f8 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart @@ -122,12 +122,12 @@ class DateReferenceService extends InlineActionsDelegate { start, end, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + includeTime: false, + reminderId: null, + reminderOption: null, + ), ); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart index 27a632e8ef..ebc684327b 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart @@ -221,12 +221,11 @@ class InlinePageReferenceService extends InlineActionsDelegate { replace.$1, replace.$2, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: view.id, + blockId: null, + ), ); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart index 1f479fa7c5..471f1c9211 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -148,14 +148,12 @@ class ReminderReferenceService extends InlineActionsDelegate { start, end, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.reminderId: reminder.id, - MentionBlockKeys.reminderOption: ReminderOption.atTimeOfEvent.name, - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + reminderId: reminder.id, + reminderOption: ReminderOption.atTimeOfEvent.name, + includeTime: false, + ), ); await editorState.apply(transaction); From 13028c6cfe3b105221ec4a88a22ad91c906ca64e Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 9 Apr 2025 09:39:35 +0800 Subject: [PATCH 111/207] fix: icon and emoji doesn't align in mention page menu (#7673) --- .../mobile_inline_actions_menu_group.dart | 4 ++-- .../header/emoji_icon_widget.dart | 16 +++++++--------- .../inline_actions/handlers/child_page.dart | 2 +- .../handlers/inline_page_reference.dart | 19 +++++++++++++------ .../inline_actions/inline_actions_result.dart | 4 ++-- .../widgets/inline_actions_menu_group.dart | 6 +++--- .../home/menu/view/view_item.dart | 6 +++++- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart index 862c9876f2..f340319254 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart @@ -102,7 +102,7 @@ class MobileInlineActionsWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final hasIcon = item.icon != null; + final hasIcon = item.iconBuilder != null; return Container( height: 36, decoration: BoxDecoration( @@ -119,7 +119,7 @@ class MobileInlineActionsWidget extends StatelessWidget { child: Row( children: [ if (hasIcon) ...[ - item.icon!.call(isSelected), + item.iconBuilder!.call(isSelected), SizedBox(width: 12), ], Flexible( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart index 91ae1af354..cda76233d6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart @@ -8,6 +8,7 @@ import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:string_validator/string_validator.dart'; @@ -113,24 +114,21 @@ class _RawEmojiIconWidgetState extends State { try { switch (widget.emoji.type) { case FlowyIconType.emoji: - return EmojiText( - emoji: widget.emoji.emoji, + return FlowyText.emoji( + widget.emoji.emoji, fontSize: widget.emojiSize, textAlign: TextAlign.justify, lineHeight: widget.lineHeight, ); case FlowyIconType.icon: - IconsData iconData = - IconsData.fromJson(jsonDecode(widget.emoji.emoji)); + IconsData iconData = IconsData.fromJson( + jsonDecode(widget.emoji.emoji), + ); if (!widget.enableColor) { iconData = iconData.noColor(); } - /// Under the same width conditions, icons on macOS seem to appear - /// larger than emojis, so 0.9 is used here to slightly reduce the - /// size of the icons - final iconSize = - Platform.isMacOS ? widget.emojiSize * 0.9 : widget.emojiSize; + final iconSize = widget.emojiSize; return IconWidget( iconsData: iconData, size: iconSize, diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart index 665c284aea..6dbd38affb 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart @@ -24,7 +24,7 @@ class InlineChildPageService extends InlineActionsDelegate { results.add( InlineActionsMenuItem( label: LocaleKeys.inlineActions_createPage.tr(args: [search]), - icon: (_) => const FlowySvg(FlowySvgs.add_s), + iconBuilder: (_) => const FlowySvg(FlowySvgs.add_s), onSelected: (context, editorState, service, replacement) => _onSelected(context, editorState, service, replacement, search), ), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart index ebc684327b..9853d6757c 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart @@ -234,12 +234,19 @@ class InlinePageReferenceService extends InlineActionsDelegate { InlineActionsMenuItem _fromView(ViewPB view) => InlineActionsMenuItem( keywords: [view.nameOrDefault.toLowerCase()], label: view.nameOrDefault, - icon: (onSelected) => view.icon.value.isNotEmpty - ? RawEmojiIconWidget( - emoji: view.icon.toEmojiIconData(), - emojiSize: 14, - ) - : view.defaultIcon(), + iconBuilder: (onSelected) { + final child = view.icon.value.isNotEmpty + ? RawEmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 16.0, + lineHeight: 18.0 / 16.0, + ) + : view.defaultIcon(size: const Size(16, 16)); + return SizedBox( + width: 16, + child: child, + ); + }, onSelected: (context, editorState, menu, replace) => insertPage ? _onInsertPageRef(view, context, editorState, replace) : _onInsertLinkRef(view, context, editorState, menu, replace), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart index 8da9647084..1fe2703870 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart @@ -12,13 +12,13 @@ typedef SelectItemHandler = void Function( class InlineActionsMenuItem { InlineActionsMenuItem({ required this.label, - this.icon, + this.iconBuilder, this.keywords, this.onSelected, }); final String label; - final Widget Function(bool onSelected)? icon; + final Widget Function(bool onSelected)? iconBuilder; final List? keywords; final SelectItemHandler? onSelected; } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart index f2d22138f7..123cfc1177 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart @@ -92,8 +92,8 @@ class InlineActionsWidget extends StatefulWidget { class _InlineActionsWidgetState extends State { @override Widget build(BuildContext context) { - final icon = widget.item.icon; - final hasIcon = icon != null; + final iconBuilder = widget.item.iconBuilder; + final hasIcon = iconBuilder != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( @@ -104,7 +104,7 @@ class _InlineActionsWidgetState extends State { text: Row( children: [ if (hasIcon) ...[ - icon.call(widget.isSelected), + iconBuilder.call(widget.isSelected), SizedBox(width: 12), ], Flexible( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index ae9059c623..22182f7429 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -632,7 +632,11 @@ class _SingleInnerViewItemState extends State { Widget _buildViewIconButton() { final iconData = widget.view.icon.toEmojiIconData(); final icon = iconData.isNotEmpty - ? RawEmojiIconWidget(emoji: iconData, emojiSize: 16.0) + ? RawEmojiIconWidget( + emoji: iconData, + emojiSize: 16.0, + lineHeight: 18.0 / 16.0, + ) : Opacity(opacity: 0.6, child: widget.view.defaultIcon()); final Widget child = AppFlowyPopover( From b3a4a9ba0452433578abc3c8d6d326805eefcda5 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:25:29 +0800 Subject: [PATCH 112/207] fix: use normal underline for AI suggested text style (#7712) --- .../plugins/document/presentation/editor_style.dart | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index c602ade2c4..bd205ffea9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -568,7 +568,6 @@ class EditorStyleCustomizer { if (style == null) { return null; } - final fontSize = style.fontSize ?? 14.0; final isLight = Theme.of(context).isLightMode; final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4); final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4); @@ -578,17 +577,10 @@ class EditorStyleCustomizer { decoration: TextDecoration.lineThrough, ), AiWriterBlockKeys.suggestionReplacement => style.copyWith( - color: Colors.transparent, + color: textColor, decoration: TextDecoration.underline, decorationColor: underlineColor, decorationThickness: 1.0, - // hack: https://jtmuller5.medium.com/the-ultimate-guide-to-underlining-text-in-flutter-57936f5c79bb - shadows: [ - Shadow( - color: textColor, - offset: Offset(0, -fontSize * 0.2), - ), - ], ), _ => style, }; From f4af47728fb59241a8c490db64f5f0be6b479b4f Mon Sep 17 00:00:00 2001 From: Morn Date: Wed, 9 Apr 2025 12:57:58 +0800 Subject: [PATCH 113/207] fix: overwriting a selected text removes more than expected (#7714) --- .../document/document_selection_test.dart | 37 +++++++++++++++++++ .../plugins/emoji/emoji_actions_command.dart | 6 +-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart index bd0fd18c50..de1cb880a5 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -47,5 +48,41 @@ void main() { expect(editorState.selection!.start.offset, 0); }); + + testWidgets('select and delete text', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create a new document + await tester.createNewPageWithNameUnderParent(); + + /// input text + final editor = tester.editor; + final editorState = editor.getCurrentEditorState(); + + const inputText = 'Test for text selection and deletion'; + final texts = inputText.split(' '); + await editor.tapLineOfEditorAt(0); + await tester.ime.insertText(inputText); + + /// selecte and delete + int index = 0; + while (texts.isNotEmpty) { + final text = texts.removeAt(0); + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: index), + end: Position(path: [0], offset: index + text.length), + ), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.delete); + index++; + } + + /// excpete the text value is correct + final node = editorState.getNodeAtPath([0])!; + final nodeText = node.delta?.toPlainText() ?? ''; + expect(nodeText, ' ' * (index - 1)); + }); }); } diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart index ffe25be8bd..9d386b36be 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart @@ -38,10 +38,6 @@ Future emojiCommandHandler( return false; } - if (!selection.isCollapsed) { - await editorState.deleteSelection(selection); - } - final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || @@ -58,6 +54,8 @@ Future emojiCommandHandler( if (previousCharacter != _emojiCharacter) return false; if (!context.mounted) return false; + if (!selection.isCollapsed) return false; + await editorState.insertTextAtPosition( character, position: selection.start, From 8b82d532e019efa7a29261b6cd1f93846dd1e675 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 9 Apr 2025 13:21:19 +0800 Subject: [PATCH 114/207] fix: replace the selected text with ai response in the same line (#7708) * fix: replace the selected text with ai response in the same line * fix: replace the selected text with ai response in the multiple lines * fix: integrate the replace function in the ai writer cubit * fix: unit test and integration test --- .../ai/operations/ai_writer_cubit.dart | 62 ++- .../operations/ai_writer_node_extension.dart | 21 +- .../base/markdown_text_robot.dart | 188 ++++++- .../lib/shared/markdown_to_document.dart | 2 + frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- .../ai_writer_test/ai_writer_bloc_test.dart | 2 +- .../text_robot/markdown_text_robot_test.dart | 508 ++++++++++++++++++ 8 files changed, 773 insertions(+), 16 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 076046624a..7a885a3fe8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -15,6 +16,11 @@ import 'ai_writer_block_operations.dart'; import 'ai_writer_entities.dart'; import 'ai_writer_node_extension.dart'; +/// Enable the debug log for the AiWriterCubit. +/// +/// This is useful for debugging the AI writer cubit. +const _aiWriterCubitDebugLog = false; + class AiWriterCubit extends Cubit { AiWriterCubit({ required this.documentId, @@ -95,6 +101,10 @@ class AiWriterCubit extends Cubit { final command = node.aiWriterCommand; final (run, prompt) = await _addSelectionTextToRecords(command); + _aiWriterCubitLog( + 'command: $command, run: $run, prompt: $prompt', + ); + if (!run) { await exit(); return; @@ -211,20 +221,26 @@ class AiWriterCubit extends Cubit { return; } + // Accept + // + // If the user clicks accept, we need to replace the selection with the AI's response if (action case SuggestionAction.accept) { - await _textRobot.persist(); - await formatSelection( - editorState, - selection, - ApplySuggestionFormatType.clear, + // trim the markdown text to avoid extra new lines + final trimmedMarkdownText = _textRobot.markdownText.trim(); + + _aiWriterCubitLog( + 'trigger accept action, markdown text: $trimmedMarkdownText', ); - final nodes = editorState.getNodesInSelection(selection); - final transaction = editorState.transaction..deleteNodes(nodes); - await editorState.apply( - transaction, - withUpdateSelection: false, + + await _textRobot.deleteAINodes(); + + await _textRobot.replace( + selection: selection, + markdownText: trimmedMarkdownText, ); + await exit(withDiscard: false, withUnformat: false); + return; } @@ -276,17 +292,24 @@ class AiWriterCubit extends Cubit { AiWriterCommand command, ) async { final node = aiWriterNode; + + // check the node is registered if (node == null) { return (false, ''); } + + // check the selection is valid final selection = node.aiWriterSelection?.normalized; if (selection == null) { return (false, ''); } + // if the command is continue writing, we don't need to get the selection text if (command == AiWriterCommand.continueWriting) { return (true, ''); } + + // if the selection is collapsed, we don't need to get the selection text if (selection.isCollapsed) { return (true, ''); } @@ -297,6 +320,7 @@ class AiWriterCubit extends Cubit { records.add( AiWriterRecord.user(content: selectionText, format: null), ); + return (true, ''); } else { return (true, selectionText); @@ -540,6 +564,10 @@ class AiWriterCubit extends Cubit { attributes: ApplySuggestionFormatType.replace.attributes, ); onAppendToDocument?.call(); + + _aiWriterCubitLog( + 'received message: $text', + ); }, processAssistMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { @@ -551,6 +579,10 @@ class AiWriterCubit extends Cubit { ), ); } + + _aiWriterCubitLog( + 'received assist message: $text', + ); }, onEnd: () async { if (state case final GeneratingAiWriterState generatingState) { @@ -567,6 +599,10 @@ class AiWriterCubit extends Cubit { records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); + + _aiWriterCubitLog( + 'returned response: ${_textRobot.markdownText}', + ); } }, onError: (error) async { @@ -658,6 +694,12 @@ class AiWriterCubit extends Cubit { ); } } + + void _aiWriterCubitLog(String message) { + if (_aiWriterCubitDebugLog) { + Log.debug('[AiWriterCubit] $message'); + } + } } mixin RegisteredAiWriter { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart index 168854a34d..6f4456f9ec 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart @@ -57,11 +57,30 @@ extension AiWriterNodeExtension on EditorState { slicedNodes.add(copiedNode); } + for (final (i, node) in slicedNodes.indexed) { + final childNodesShouldBeDeleted = []; + for (final child in node.children) { + if (!child.path.inSelection(selection)) { + childNodesShouldBeDeleted.add(child); + } + } + for (final child in childNodesShouldBeDeleted) { + slicedNodes[i] = node.copyWith( + children: node.children.where((e) => e.id != child.id).toList(), + type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type, + ); + } + } + + // use \n\n as line break to improve the ai response + // using \n will cause the ai response treat the text as a single line final markdown = await customDocumentToMarkdown( Document.blank()..insert([0], slicedNodes), + lineBreak: '\n\n', ); - return markdown; + // trim the last \n if it exists + return markdown.trimRight(); } List getPlainTextInSelection(Selection? selection) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index d6cfee929c..031e157b33 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -110,10 +110,13 @@ class MarkdownTextRobot { } /// Persist the text into the document - Future persist({String? markdownText}) async { + Future persist({ + String? markdownText, + }) async { if (markdownText != null) { _markdownText = markdownText; } + await _lock.synchronized(() async { await _refresh(inMemoryUpdate: false); }); @@ -124,6 +127,34 @@ class MarkdownTextRobot { } } + /// Replace the selected content with the AI's response + Future replace({ + required Selection selection, + required String markdownText, + }) async { + if (selection.isSingle) { + await _replaceInSameLine( + selection: selection, + markdownText: markdownText, + ); + } else { + await _replaceInMultiLines( + selection: selection, + markdownText: markdownText, + ); + } + } + + /// Delete the temporary inserted AI nodes + Future deleteAINodes() async { + final nodes = getInsertedNodes(); + final transaction = editorState.transaction..deleteNodes(nodes); + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); + } + /// Discard the inserted content Future discard() async { final start = _insertPosition; @@ -282,6 +313,161 @@ class MarkdownTextRobot { children: children, ); } + + /// If the selected content is in the same line, + /// keep the selected node and replace the delta. + Future _replaceInSameLine({ + required Selection selection, + required String markdownText, + }) async { + selection = selection.normalized; + + // If the selection is not a single node, do nothing. + if (!selection.isSingle) { + assert(false, 'Expected single node selection'); + Log.error('Expected single node selection'); + return; + } + + final startIndex = selection.startIndex; + final endIndex = selection.endIndex; + final length = endIndex - startIndex; + + // Get the selected node. + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + assert(false, 'Expected non-null node and delta'); + Log.error('Expected non-null node and delta'); + return; + } + + // Convert the markdown text to delta. + // Question: Why we need to convert the markdown to document first? + // Answer: Because the markdown text may contain the list item, + // if we convert the markdown to delta directly, the list item will be + // treated as a normal text node, and the delta will be incorrect. + // For example, the markdown text is: + // ``` + // 1. item1 + // ``` + // if we convert the markdown to delta directly, the delta will be: + // ``` + // [ + // { + // "insert": "1. item1" + // } + // ] + // ``` + // if we convert the markdown to document first, the document will be: + // ``` + // [ + // { + // "type": "numbered_list", + // "children": [ + // { + // "insert": "item1" + // } + // ] + // } + // ] + final document = customMarkdownToDocument(markdownText); + final decoder = DeltaMarkdownDecoder(); + final markdownDelta = + document.nodeAtPath([0])?.delta ?? decoder.convert(markdownText); + + // Replace the delta of the selected node. + final transaction = editorState.transaction; + transaction + ..deleteText(node, startIndex, length) + ..insertTextDelta(node, startIndex, markdownDelta); + await editorState.apply(transaction); + } + + /// If the selected content is in multiple lines + Future _replaceInMultiLines({ + required Selection selection, + required String markdownText, + }) async { + selection = selection.normalized; + + // If the selection is a single node, do nothing. + if (selection.isSingle) { + assert(false, 'Expected multi-line selection'); + Log.error('Expected multi-line selection'); + return; + } + + final markdownNodes = customMarkdownToDocument( + markdownText, + tableWidth: 250.0, + ).root.children; + + // Get the selected nodes. + final nodes = editorState.getNodesInSelection(selection); + + // Note: Don't change its order, otherwise the delta will be incorrect. + // step 1. merge the first selected node and the first node from the ai response + // step 2. merge the last selected node and the last node from the ai response + // step 3. insert the middle nodes from the ai response + // step 4. delete the middle nodes + final transaction = editorState.transaction; + + // step 1 + final firstNode = nodes.firstOrNull; + final delta = firstNode?.delta; + final firstMarkdownNode = markdownNodes.firstOrNull; + final firstMarkdownDelta = firstMarkdownNode?.delta; + if (firstNode != null && + delta != null && + firstMarkdownNode != null && + firstMarkdownDelta != null) { + final startIndex = selection.startIndex; + final length = delta.length - startIndex; + + transaction + ..deleteText(firstNode, startIndex, length) + ..insertTextDelta(firstNode, startIndex, firstMarkdownDelta); + } + + // step 2 + final lastNode = nodes.lastOrNull; + final lastDelta = lastNode?.delta; + final lastMarkdownNode = markdownNodes.lastOrNull; + final lastMarkdownDelta = lastMarkdownNode?.delta; + if (lastNode != null && + lastDelta != null && + lastMarkdownNode != null && + lastMarkdownDelta != null) { + final endIndex = selection.endIndex; + + transaction.deleteText(lastNode, 0, endIndex); + + // if the last node is same as the first node, it means we have replaced the + // selected text in the first node. + if (lastMarkdownNode.id != firstMarkdownNode?.id) { + transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta); + } + } + + // step 3 + final insertedPath = selection.start.path.nextNPath(1); + if (markdownNodes.length > 2) { + transaction.insertNodes( + insertedPath, + markdownNodes.skip(1).take(markdownNodes.length - 2).toList(), + ); + } + + // step 4 + final length = nodes.length - 2; + if (length > 0) { + final middleNodes = nodes.skip(1).take(length).toList(); + transaction.deleteNodes(middleNodes); + } + + await editorState.apply(transaction); + } } class AINodeExternalValues extends NodeExternalValues { diff --git a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart index 4be6fdbe11..912f96bd05 100644 --- a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart +++ b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart @@ -27,6 +27,7 @@ Future customDocumentToMarkdown( Document document, { String path = '', AsyncValueSetter? onArchive, + String lineBreak = '', }) async { final List> fileFutures = []; @@ -41,6 +42,7 @@ Future customDocumentToMarkdown( try { markdown = documentToMarkdown( document, + lineBreak: lineBreak, customParsers: [ const MathEquationNodeParser(), const CalloutNodeParser(), diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 083f945636..fecbc32b89 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: "552f95f" - resolved-ref: "552f95fd15627e10a138c6db2a6d0a8089bc9a25" + ref: "361b99c38370abeeb19656f89e8c31cb3666623b" + resolved-ref: "361b99c38370abeeb19656f89e8c31cb3666623b" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index e6e377fa24..0dc9ec673c 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -184,7 +184,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "552f95f" + ref: "361b99c38370abeeb19656f89e8c31cb3666623b" appflowy_editor_plugins: git: diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart index bcd8b13d39..46b8118087 100644 --- a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -375,7 +375,7 @@ void main() { await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); - expect(editorState.document.root.children.length, 1); + expect(editorState.document.root.children.length, 2); expect( editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'Hello World', diff --git a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart index 0b6289c784..f2fcf8cd65 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart @@ -294,6 +294,514 @@ void main() { ); }); }); + + group('markdown text robot - replace in same line:', () { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + Document buildTestDocument() { + return Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1 + text2 + text3)), + ], + ), + ); + } + + // 1. create a document with a paragraph node + // 2. use the text robot to replace the selected content in the same line + // 3. check the document + test('the selection is in the middle of the text', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: text1.length, + ), + end: Position( + path: [0], + offset: text1.length + text2.length, + ), + ); + + final markdownText = + '''Tim Berners-Lee's invention of the **World Wide Web** transformed the internet, making it accessible to _non-technical users_ and opening the floodgates for global mass adoption.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 5); + + final d1 = afterDelta[0] as TextInsert; + expect(d1.text, '${text1}Tim Berners-Lee\'s invention of the '); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'World Wide Web'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect(d3.text, ' transformed the internet, making it accessible to '); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'non-technical users'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect( + d5.text, + ' and opening the floodgates for global mass adoption.$text3', + ); + expect(d5.attributes, null); + }); + + test('replace markdown text with selection from start to middle', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + ), + end: Position( + path: [0], + offset: text1.length, + ), + ); + + final markdownText = + '''The **invention** of the _World Wide Web_ by Tim Berners-Lee transformed how we access information.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 5); + + final d1 = afterDelta[0] as TextInsert; + expect(d1.text, 'The '); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'invention'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect(d3.text, ' of the '); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'World Wide Web'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect( + d5.text, + ' by Tim Berners-Lee transformed how we access information.$text2$text3', + ); + expect(d5.attributes, null); + }); + + test('replace markdown text with selection from middle to end', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: text1.length + text2.length, + ), + end: Position( + path: [0], + offset: text1.length + text2.length + text3.length, + ), + ); + + final markdownText = + '''**Email** became widespread, and instant messaging services like *ICQ* and **AOL Instant Messenger** gained tremendous popularity, allowing for seamless real-time text communication across the globe.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 7); + + final d1 = afterDelta[0] as TextInsert; + expect( + d1.text, + text1 + text2, + ); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'Email'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect( + d3.text, + ' became widespread, and instant messaging services like ', + ); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'ICQ'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect(d5.text, ' and '); + expect(d5.attributes, null); + + final d6 = afterDelta[5] as TextInsert; + expect( + d6.text, + 'AOL Instant Messenger', + ); + expect(d6.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d7 = afterDelta[6] as TextInsert; + expect( + d7.text, + ' gained tremendous popularity, allowing for seamless real-time text communication across the globe.', + ); + expect(d7.attributes, null); + }); + }); + + group('markdown text robot - replace in multiple lines:', () { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + Document buildTestDocument() { + return Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + } + + // 1. create a document with 3 paragraph nodes + // 2. use the text robot to replace the selected content in the multiple lines + // 3. check the document + test( + 'the selection starts with the first paragraph and ends with the middle of second paragraph', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + ), + end: Position( + path: [1], + offset: text2.length - + ', opening the floodgates for mass adoption. '.length, + ), + ); + + final markdownText = + '''The **introduction** of the World Wide Web in the *early 1990s* marked a significant turning point. + +Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 3); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, 'The '); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, 'introduction'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, ' of the World Wide Web in the '); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, 'early 1990s'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, ' marked a significant turning point.'); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 3); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, "Tim Berners-Lee's "); + expect(d1.attributes, null); + + final d2 = delta2[1] as TextInsert; + expect(d2.text, "revolutionary invention"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta2[2] as TextInsert; + expect( + d3.text, + " made the internet accessible to non-technical users, opening the floodgates for mass adoption. ", + ); + expect(d3.attributes, null); + } + + { + // third paragraph + final delta3 = afterNodes[2].delta!.toList(); + expect(delta3.length, 1); + + final d1 = delta3[0] as TextInsert; + expect(d1.text, text3); + expect(d1.attributes, null); + } + }); + + test( + 'the selection starts with the middle of the first paragraph and ends with the middle of last paragraph', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: 'The introduction of the World Wide Web'.length, + ), + end: Position( + path: [2], + offset: + 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' + .length, + ), + ); + + final markdownText = + ''' in the **early 1990s** marked a *significant turning point* in technological history. + +Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users, opening the floodgates for *unprecedented mass adoption*. + +Email became **widely prevalent**, and instant messaging services like *ICQ* and *AOL Instant Messenger* gained tremendous popularity + '''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 3); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, 'The introduction of the World Wide Web in the '); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, 'early 1990s'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, ' marked a '); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, 'significant turning point'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, ' in technological history.'); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 5); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, "Tim Berners-Lee's "); + expect(d1.attributes, null); + + final d2 = delta2[1] as TextInsert; + expect(d2.text, "revolutionary invention"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta2[2] as TextInsert; + expect( + d3.text, + " made the internet accessible to non-technical users, opening the floodgates for ", + ); + expect(d3.attributes, null); + + final d4 = delta2[3] as TextInsert; + expect(d4.text, "unprecedented mass adoption"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta2[4] as TextInsert; + expect(d5.text, "."); + expect(d5.attributes, null); + } + + { + // third paragraph + // third paragraph + final delta3 = afterNodes[2].delta!.toList(); + expect(delta3.length, 7); + + final d1 = delta3[0] as TextInsert; + expect(d1.text, "Email became "); + expect(d1.attributes, null); + + final d2 = delta3[1] as TextInsert; + expect(d2.text, "widely prevalent"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta3[2] as TextInsert; + expect(d3.text, ", and instant messaging services like "); + expect(d3.attributes, null); + + final d4 = delta3[3] as TextInsert; + expect(d4.text, "ICQ"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta3[4] as TextInsert; + expect(d5.text, " and "); + expect(d5.attributes, null); + + final d6 = delta3[5] as TextInsert; + expect(d6.text, "AOL Instant Messenger"); + expect(d6.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d7 = delta3[6] as TextInsert; + expect( + d7.text, + " gained tremendous popularity, allowing for real-time text communication.", + ); + expect(d7.attributes, null); + } + }); + + test( + 'the length of the returned response less than the length of the selected text', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: 'The introduction of the World Wide Web'.length, + ), + end: Position( + path: [2], + offset: + 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' + .length, + ), + ); + + final markdownText = + ''' in the **early 1990s** marked a *significant turning point* in technological history.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 2); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, "The introduction of the World Wide Web in the "); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, "early 1990s"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, " marked a "); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, "significant turning point"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, " in technological history."); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 1); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, ", allowing for real-time text communication."); + expect(d1.attributes, null); + } + }); + }); } const _sample1 = '''# The Curious Cat From ad227bcf79b973fcc994d3d4c0fa507e823dd088 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:45:07 +0800 Subject: [PATCH 115/207] chore: toast message when theme folder doesn't have permission (#7715) --- .../lib/core/helpers/url_launcher.dart | 1 - .../presentation/base/mobile_view_page.dart | 2 -- .../base/view_page/more_bottom_sheet.dart | 11 ----------- .../bottom_sheet/bottom_sheet_view_item.dart | 3 --- .../default_mobile_action_pane.dart | 2 -- .../card_detail/widgets/row_page_button.dart | 2 +- .../presentation/home/mobile_home_page.dart | 2 +- .../home/space/mobile_space_menu.dart | 4 ---- .../mobile_bottom_navigation_bar.dart | 2 -- .../widgets/settings_popup_menu.dart | 3 --- .../notifications/widgets/slide_actions.dart | 4 ---- .../presentation/notifications/widgets/tab.dart | 1 - .../setting/support_setting_group.dart | 1 - .../workspace/invite_members_screen.dart | 7 ------- .../message/ai_message_action_bar.dart | 1 - .../presentation/message/ai_message_bubble.dart | 1 - .../presentation/message/message_util.dart | 2 -- .../document/application/document_bloc.dart | 1 - .../ai/ai_writer_toolbar_item.dart | 2 -- .../code_block/code_block_copy_button.dart | 1 - .../copy_and_paste/paste_from_image.dart | 3 --- .../desktop_toolbar/link/link_hover_menu.dart | 1 - .../error/error_block_component_builder.dart | 1 - .../editor_plugins/file/file_util.dart | 5 ----- .../custom_image_block_component.dart | 2 -- .../image_menu.dart | 2 -- .../multi_image_menu.dart | 1 - .../link_preview/link_preview_menu.dart | 1 - .../mention/child_page_transaction_handler.dart | 1 - .../lib/plugins/shared/share/export_tab.dart | 3 +-- .../lib/plugins/shared/share/publish_tab.dart | 9 --------- .../lib/plugins/shared/share/share_button.dart | 2 -- .../lib/plugins/shared/share/share_tab.dart | 1 - .../lib/shared/flowy_error_page.dart | 3 +-- .../lib/startup/tasks/appflowy_cloud_task.dart | 2 +- .../helpers/handle_open_workspace_error.dart | 4 ++-- .../widgets/magic_link_sign_in_buttons.dart | 2 +- .../lib/util/share_log_files.dart | 6 +++--- .../settings/appearance/appearance_cubit.dart | 17 ++++++++++++++++- .../sidebar/workspace/sidebar_workspace.dart | 2 +- .../pages/account/account_deletion.dart | 8 ++++---- .../pages/settings_manage_data_view.dart | 2 +- .../settings/pages/sites/constants.dart | 1 - .../pages/sites/domain/domain_item.dart | 2 -- .../sites/domain/domain_settings_dialog.dart | 2 -- .../published_view_settings_dialog.dart | 2 -- .../pages/sites/settings_sites_view.dart | 7 ------- .../presentation/settings/settings_dialog.dart | 4 ---- .../workspace/presentation/widgets/dialogs.dart | 4 +--- .../widgets/float_bubble/version_section.dart | 1 - .../presentation/widgets/view_title_bar.dart | 1 - frontend/resources/translations/en.json | 3 ++- 52 files changed, 36 insertions(+), 122 deletions(-) diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index a27ab07e9d..fa61e7f5b8 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -133,7 +133,6 @@ Future _afLaunchLocalUri( }; if (context != null && context.mounted) { showToastNotification( - context, message: message, type: result.type == ResultType.done ? ToastificationType.success diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 792679daf1..318b06394a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -336,7 +336,6 @@ class _MobileViewPageState extends State { listener: (context, state) { if (state.isLocked) { showToastNotification( - context, message: LocaleKeys.lockPage_pageLockedToast.tr(), ); @@ -366,7 +365,6 @@ class _MobileViewPageState extends State { listener: (context, state) { if (state.isLocked) { showToastNotification( - context, message: LocaleKeys.lockPage_pageLockedToast.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index 080d83e83c..6609b10136 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { context.pop(); showToastNotification( - context, message: LocaleKeys.button_duplicateSuccessfully.tr(), ); } @@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { _toggleFavorite(context); showToastNotification( - context, message: LocaleKeys.button_favoriteSuccessfully.tr(), ); } @@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { _toggleFavorite(context); showToastNotification( - context, message: LocaleKeys.button_unfavoriteSuccessfully.tr(), ); } @@ -202,7 +199,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { ), ); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } @@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { ), ); showToastNotification( - context, message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } else { showToastNotification( - context, message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(), type: ToastificationType.error, ); @@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { if (state.publishResult != null) { state.publishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_publishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', type: ToastificationType.error, ), @@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { } else if (state.unpublishResult != null) { state.unpublishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_unpublishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: LocaleKeys.publish_unpublishFailed.tr(), description: error.msg, type: ToastificationType.error, @@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { state.updatePathNameResult!.onSuccess( (value) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart index c1129af79d..86021ea938 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart @@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State { Navigator.pop(context); context.read().add(const ViewEvent.duplicate()); showToastNotification( - context, message: LocaleKeys.button_duplicateSuccessfully.tr(), ); break; @@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State { .read() .add(FavoriteEvent.toggle(widget.view)); showToastNotification( - context, message: !widget.view.isFavorite ? LocaleKeys.button_favoriteSuccessfully.tr() : LocaleKeys.button_unfavoriteSuccessfully.tr(), @@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State { Navigator.pop(context); showToastNotification( - context, message: LocaleKeys.sideBar_removeSuccess.tr(), ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart index cb840b0f40..d4b4292443 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart @@ -45,7 +45,6 @@ enum MobilePaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: LocaleKeys.button_unfavoriteSuccessfully.tr(), ); @@ -61,7 +60,6 @@ enum MobilePaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: LocaleKeys.button_favoriteSuccessfully.tr(), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart index fa3494002d..b0f21188cd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart @@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State { Log.info('Open row page(${widget.documentId})'); if (view == null) { - showToastNotification(context, message: 'Failed to open row page'); + showToastNotification(message: 'Failed to open row page'); // reload the view again unawaited(_preloadView(context)); Log.error('Failed to open row page(${widget.documentId})'); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index 2d409f58b6..1ae5d881ce 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> { } if (message != null) { - showToastNotification(context, message: message, type: toastType); + showToastNotification(message: message, type: toastType); } } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart index 0197f34940..485e07a28c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State { context.read().add(const SpaceEvent.duplicate()); showToastNotification( - context, message: LocaleKeys.space_success_duplicateSpace.tr(), ); @@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State { .add(SpaceEvent.rename(space: widget.space, name: name)); showToastNotification( - context, message: LocaleKeys.space_success_renameSpace.tr(), ); }, @@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State { ); showToastNotification( - context, message: LocaleKeys.space_success_updateSpace.tr(), ); @@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State { context.read().add(SpaceEvent.delete(widget.space)); showToastNotification( - context, message: LocaleKeys.space_success_deleteSpace.tr(), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart index 170ef46ac2..3c6adb8627 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget { } showToastNotification( - context, message: LocaleKeys .settings_notifications_markAsReadNotifications_allSuccess .tr(), @@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget { } showToastNotification( - context, message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess .tr(), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart index dfa277f2ef..e694f9932d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget { void _onMarkAllAsRead(BuildContext context) { showToastNotification( - context, message: LocaleKeys .settings_notifications_markAsReadNotifications_allSuccess .tr(), @@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget { void _onArchiveAll(BuildContext context) { showToastNotification( - context, message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess .tr(), ); @@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget { } showToastNotification( - context, message: 'Unarchive all success (Debug Mode)', ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart index 85f468c76c..d1216eed98 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart @@ -31,7 +31,6 @@ enum NotificationPaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: LocaleKeys .settings_notifications_markAsReadNotifications_success .tr(), @@ -55,7 +54,6 @@ enum NotificationPaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: 'Unarchive notification success', ); @@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget { Navigator.of(context).pop(); showToastNotification( - context, message: LocaleKeys.settings_notifications_markAsReadNotifications_success .tr(), ); @@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget { void _onArchive(BuildContext context) { showToastNotification( - context, message: LocaleKeys.settings_notifications_archiveNotifications_success .tr() .tr(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart index 7dda8f0a14..45e801e07c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart @@ -74,7 +74,6 @@ class _NotificationTabState extends State if (context.mounted) { showToastNotification( - context, message: LocaleKeys.settings_notifications_refreshSuccess.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart index 584b867736..e5e4efef77 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart @@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget { ); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.settings_files_clearCacheSuccess.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart index 2e805c5c5a..4f16aabffd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), bottomPadding: keyboardHeight, @@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( - context, type: ToastificationType.error, bottomPadding: keyboardHeight, message: message, @@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), bottomPadding: keyboardHeight, @@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( - context, type: ToastificationType.error, message: message, bottomPadding: keyboardHeight, @@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { result.fold( (s) { showToastNotification( - context, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceSuccess .tr(), @@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { }, (f) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceFailed @@ -283,7 +277,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { final email = emailController.text; if (!isEmail(email)) { return showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), ); 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 945613490a..08fd82188d 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 @@ -184,7 +184,6 @@ class CopyButton extends StatelessWidget { ); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } 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 3ce80e2919..2786799520 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 @@ -376,7 +376,6 @@ class ChatAIMessagePopup extends StatelessWidget { } if (context.mounted) { showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } 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 1b0084c77c..652fe3791b 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 @@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart'; void openPageFromMessage(BuildContext context, ViewPB? view) { if (view == null) { showToastNotification( - context, message: LocaleKeys.chat_openPagePreviewFailedToast.tr(), type: ToastificationType.error, ); @@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) { return; } showToastNotification( - context, richMessage: TextSpan( children: [ TextSpan( diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index 0a54ec5753..010dae1f12 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -442,7 +442,6 @@ class DocumentBloc extends Bloc { final context = AppGlobals.rootNavKey.currentContext; if (context != null && context.mounted) { showToastNotification( - context, message: 'document integrity check failed', type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index 25502fd975..35575fe85a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -150,7 +150,6 @@ class _AiWriterToolbarActionListState extends State { }); } else { showToastNotification( - context, message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), ); } @@ -196,7 +195,6 @@ class ImproveWritingButton extends StatelessWidget { _insertAiNode(editorState, AiWriterCommand.improveWriting); } else { showToastNotification( - context, message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart index cc80119cb9..645de3b2f8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart @@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget { if (context.mounted) { showToastNotification( - context, message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart index 6e6c9b1772..d086f36bed 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart @@ -73,7 +73,6 @@ extension PasteFromImage on EditorState { Log.info('unsupported format: $format'); if (UniversalPlatform.isMobile) { showToastNotification( - context, message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), ); } @@ -112,7 +111,6 @@ extension PasteFromImage on EditorState { if (errorMessage != null && context.mounted) { showToastNotification( - context, message: errorMessage, ); return false; @@ -131,7 +129,6 @@ extension PasteFromImage on EditorState { Log.error('cannot copy image file', e); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart index 0d71611516..1abf70028f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -241,7 +241,6 @@ class _LinkHoverTriggerState extends State { .setData(ClipboardServiceData(plainText: href)); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart index 4394ff57c6..6c09ca6a28 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart @@ -154,7 +154,6 @@ class _ErrorBlockComponentWidgetState extends State void _copyBlockContent() { showToastNotification( - context, message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index 3ab93b4c95..d7ae7c5ccf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -105,7 +105,6 @@ Future downloadMediaFile( } else { if (userProfile == null) { return showToastNotification( - context, message: LocaleKeys.grid_media_downloadFailedToken.tr(), ); } @@ -128,14 +127,12 @@ Future downloadMediaFile( if (result != null && context.mounted) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.grid_media_downloadSuccess.tr(), ); } } else if (context.mounted) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); @@ -159,13 +156,11 @@ Future downloadMediaFile( if (context.mounted) { showToastNotification( - context, message: LocaleKeys.grid_media_downloadSuccess.tr(), ); } } else if (context.mounted) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart index bbb9dc2abc..7f0105134d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart @@ -378,7 +378,6 @@ class CustomImageBlockComponentState extends State onTap: () async { context.pop(); showToastNotification( - context, message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); await getIt().setPlainText(url); @@ -431,7 +430,6 @@ class CustomImageBlockComponentState extends State ); if (mounted) { showToastNotification( - context, message: result.isSuccess ? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr() : LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart index 4a6260d8b8..d11d943066 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart @@ -117,14 +117,12 @@ class _ImageMenuState extends State { if (mounted) { showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } } catch (e) { if (mounted) { showToastNotification( - context, message: LocaleKeys.message_copy_fail.tr(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart index b98e05f231..dc95054e81 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -218,7 +218,6 @@ class _MultiImageMenuState extends State { ClipboardData(text: images[widget.indexNotifier.value].url), ); showToastNotification( - context, message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index cf7d72cc2a..2ba67a87da 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -78,7 +78,6 @@ class _LinkPreviewMenuState extends State { if (url != null) { Clipboard.setData(ClipboardData(text: url)); showToastNotification( - context, message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart index 117e09e67f..77f8c8d0a1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart @@ -100,7 +100,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler { Log.error(error); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage .tr(), ); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart index e4a23b6459..9d6adee7df 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart @@ -174,11 +174,10 @@ class ExportTab extends StatelessWidget { ClipboardServiceData(plainText: markdown), ); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); }, - (error) => showToastNotification(context, message: error.msg), + (error) => showToastNotification(message: error.msg), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart index 0ffa22a43b..244ded0bf6 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -85,11 +85,9 @@ class PublishTab extends StatelessWidget { if (state.publishResult != null) { state.publishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_publishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', type: ToastificationType.error, ), @@ -97,11 +95,9 @@ class PublishTab extends StatelessWidget { } else if (state.unpublishResult != null) { state.unpublishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_unpublishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: LocaleKeys.publish_unpublishFailed.tr(), description: error.msg, type: ToastificationType.error, @@ -110,14 +106,12 @@ class PublishTab extends StatelessWidget { } else if (state.updatePathNameResult != null) { state.updatePathNameResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ), (error) { Log.error('update path name failed: $error'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), type: ToastificationType.error, description: error.code.publishErrorMessage, @@ -182,7 +176,6 @@ class _PublishedWidgetState extends State<_PublishedWidget> { ); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); }, @@ -292,7 +285,6 @@ class _PublishWidgetState extends State<_PublishWidget> { // check if any database is selected if (_selectedViews.isEmpty) { showToastNotification( - context, message: LocaleKeys.publish_noDatabaseSelected.tr(), ); return; @@ -611,7 +603,6 @@ class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> { // unable to deselect the primary database if (isPrimaryDatabase) { showToastNotification( - context, message: LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(), ); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart index 59ee55b980..9020441b4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart @@ -70,7 +70,6 @@ class ShareButton extends StatelessWidget { case ShareType.html: case ShareType.csv: showToastNotification( - context, message: LocaleKeys.settings_files_exportFileSuccess.tr(), ); break; @@ -81,7 +80,6 @@ class ShareButton extends StatelessWidget { void _handleExportError(BuildContext context, FlowyError error) { showToastNotification( - context, message: '${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}', ); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart index 1b925cfec6..190fe9ddd8 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart @@ -117,7 +117,6 @@ class _ShareTabContent extends StatelessWidget { ); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart index da9f679f56..5c5e788ddb 100644 --- a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -45,7 +45,6 @@ class _MobileSyncErrorPage extends StatelessWidget { onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); @@ -101,7 +100,7 @@ class _DesktopSyncErrorPage extends StatelessWidget { onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( - context, + message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 2c22b8a01e..18baef4cb8 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -129,7 +129,7 @@ class AppFlowyCloudDeepLink { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( - context, + message: err.msg, ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart index 2e8e4feeae..51c693843f 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart @@ -17,14 +17,14 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) { case ErrorCode.InvalidEncryptSecret: case ErrorCode.NetworkError: showToastNotification( - context, + message: error.msg, type: ToastificationType.error, ); break; default: showToastNotification( - context, + message: error.msg, type: ToastificationType.error, callbacks: ToastificationCallbacks( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart index 0486d67838..69c9689823 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart @@ -65,7 +65,7 @@ class _SignInWithMagicLinkButtonsState void _sendMagicLink(BuildContext context, String email) { if (!isEmail(email)) { return showToastNotification( - context, + message: LocaleKeys.signIn_invalidEmail.tr(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart index d7e7b6ce87..0435c5fe52 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -25,7 +25,7 @@ Future shareLogFiles(BuildContext? context) async { if (archiveLogFiles.isEmpty) { if (context != null && context.mounted) { showToastNotification( - context, + message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); @@ -42,7 +42,7 @@ Future shareLogFiles(BuildContext? context) async { if (zip == null) { if (context != null && context.mounted) { showToastNotification( - context, + message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); @@ -72,7 +72,7 @@ Future shareLogFiles(BuildContext? context) async { } catch (e) { if (context != null && context.mounted) { showToastNotification( - context, + message: e.toString(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart index b64ef7d5b8..99b9eaa2c9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; @@ -17,6 +19,7 @@ import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:universal_platform/universal_platform.dart'; part 'appearance_cubit.freezed.dart'; @@ -97,7 +100,19 @@ class AppearanceSettingsCubit extends Cubit { Future setTheme(String themeName) async { _appearanceSettings.theme = themeName; unawaited(_saveAppearanceSettings()); - emit(state.copyWith(appTheme: await AppTheme.fromName(themeName))); + try { + final theme = await AppTheme.fromName(themeName); + emit(state.copyWith(appTheme: theme)); + } catch (e) { + Log.error("Error setting theme: $e"); + if (UniversalPlatform.isMacOS) { + showToastNotification( + message: + LocaleKeys.settings_workspacePage_theme_failedToLoadThemes.tr(), + type: ToastificationType.error, + ); + } + } } /// Reset the current user selected theme back to the default diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 038ec9f5a6..20160d32ec 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -169,7 +169,7 @@ class _SidebarWorkspaceState extends State { if (message != null) { showToastNotification( - context, + message: message, type: result.fold( (_) => ToastificationType.success, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart index 31139492ad..8cabffc6e3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -193,7 +193,7 @@ Future deleteMyAccount( if (!isChecked) { showToastNotification( - context, + type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -208,7 +208,7 @@ Future deleteMyAccount( if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { showToastNotification( - context, + type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -226,7 +226,7 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - context, + message: LocaleKeys .newSettings_myAccount_deleteAccount_deleteAccountSuccess .tr(), @@ -245,7 +245,7 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - context, + type: ToastificationType.error, bottomPadding: bottomPadding, message: f.msg, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 7c096b4b2f..194ad02beb 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -157,7 +157,7 @@ class SettingsManageDataView extends StatelessWidget { if (context.mounted) { showToastNotification( - context, + message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart index 1a3b305c0b..2f03fc052c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart @@ -53,7 +53,6 @@ class SettingsPageSitesEvent { ); getIt().setData(ClipboardServiceData(plainText: url)); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart index 8f9df5f1b6..b1d9b9cdae 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart @@ -253,7 +253,6 @@ class _FreePlanUpgradeButton extends StatelessWidget { onTap: () { if (isOwner) { showToastNotification( - context, message: LocaleKeys.settings_sites_namespace_redirectToPayment.tr(), type: ToastificationType.info, @@ -264,7 +263,6 @@ class _FreePlanUpgradeButton extends StatelessWidget { ); } else { showToastNotification( - context, message: LocaleKeys .settings_sites_namespace_pleaseAskOwnerToSetHomePage .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart index 6555494144..9617f2c8d6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart @@ -216,7 +216,6 @@ class _DomainSettingsDialogState extends State { result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(), ); @@ -234,7 +233,6 @@ class _DomainSettingsDialogState extends State { Log.error('Failed to update namespace: $f'); showToastNotification( - context, message: basicErrorMessage, type: ToastificationType.error, description: errorMessage, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart index b7f3cecebf..ad37bae866 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart @@ -203,7 +203,6 @@ class _PublishedViewSettingsDialogState result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); Navigator.of(context).pop(); @@ -212,7 +211,6 @@ class _PublishedViewSettingsDialogState Log.error('update path name failed: $f'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), type: ToastificationType.error, description: f.code.publishErrorMessage, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart index 7b00e652ed..f3845b0896 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart @@ -178,7 +178,6 @@ class _SettingsSitesPageView extends StatelessWidget { Log.error('Failed to generate payment link for Pro Plan: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(), type: ToastificationType.error, @@ -188,14 +187,12 @@ class _SettingsSitesPageView extends StatelessWidget { result != null) { result.fold((_) { showToastNotification( - context, message: LocaleKeys.publish_unpublishSuccessfully.tr(), ); }, (f) { Log.error('Failed to unpublish view: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.publish_unpublishFailed.tr(), type: ToastificationType.error, description: f.msg, @@ -204,14 +201,12 @@ class _SettingsSitesPageView extends StatelessWidget { } else if (type == SettingsSitesActionType.setHomePage && result != null) { result.fold((s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(), ); }, (f) { Log.error('Failed to set homepage: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(), type: ToastificationType.error, ); @@ -220,14 +215,12 @@ class _SettingsSitesPageView extends StatelessWidget { result != null) { result.fold((s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(), ); }, (f) { Log.error('Failed to remove homepage: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 2cf83276b4..08747b95da 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -363,7 +363,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> { }) async { if (cloudUrl.isEmpty || webUrl.isEmpty) { showToastNotification( - context, message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), type: ToastificationType.error, ); @@ -375,7 +374,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> { if (mounted) { if (isValid) { showToastNotification( - context, message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]), ); @@ -387,7 +385,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> { await runAppFlowy(); } else { showToastNotification( - context, message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), type: ToastificationType.error, ); @@ -522,7 +519,6 @@ class _SupportSettings extends StatelessWidget { await getIt().clearAllCache(); if (context.mounted) { showToastNotification( - context, message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index aa541b902c..3bcc840582 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -157,7 +157,6 @@ class _NavigatorTextFieldDialogState extends State { onOkPressed: () { if (newValue.isEmpty) { showToastNotification( - context, message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(), ); return; @@ -363,8 +362,7 @@ class OkCancelButton extends StatelessWidget { } } -void showToastNotification( - BuildContext context, { +void showToastNotification({ String? message, TextSpan? richMessage, String? description, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart index 923f695188..f6a2caa5a2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart @@ -51,7 +51,6 @@ class FlowyVersionSection extends CustomActionCell { } enableDocumentInternalLog = !enableDocumentInternalLog; showToastNotification( - context, message: enableDocumentInternalLog ? 'Enabled Internal Log' : 'Disabled Internal Log', diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart index 04ae9b30ad..3be0973123 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -74,7 +74,6 @@ class ViewTitleBar extends StatelessWidget { listener: (context, state) { if (state.isLocked) { showToastNotification( - context, message: LocaleKeys.lockPage_pageLockedToast.tr(), ); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 47a2bec391..855a715032 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -628,7 +628,8 @@ "theme": { "title": "Theme", "description": "Select a preset theme, or upload your own custom theme.", - "uploadCustomThemeTooltip": "Upload a custom theme" + "uploadCustomThemeTooltip": "Upload a custom theme", + "failedToLoadThemes": "Failed to load themes, please check your permission settings in System Settings > Privacy and Security > Files and Folders > @:appName" }, "workspaceFont": { "title": "Workspace font", From 89525b7f7b4e5e7b4b21bd99af3f776015242158 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:11:33 +0800 Subject: [PATCH 116/207] chore: bump version (#7716) --- frontend/Makefile.toml | 2 +- frontend/appflowy_flutter/pubspec.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 0b23c5df26..41fdffb1af 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.8.8" +APPFLOWY_VERSION = "0.8.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 0dc9ec673c..9b0987d0b2 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -2,13 +2,13 @@ name: appflowy description: Bring projects, wikis, and teams together with AI. AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data. The best open source alternative to Notion. -publish_to: "none" +publish_to: 'none' -version: 0.8.8 +version: 0.8.9 environment: - flutter: ">=3.27.4" - sdk: ">=3.3.0 <4.0.0" + flutter: '>=3.27.4' + sdk: '>=3.3.0 <4.0.0' dependencies: any_date: ^1.0.4 @@ -38,7 +38,7 @@ dependencies: calendar_view: git: url: https://github.com/Xazin/flutter_calendar_view - ref: "6fe0c98" + ref: '6fe0c98' collection: ^1.17.1 connectivity_plus: ^5.0.2 cross_file: ^0.3.4+1 @@ -74,7 +74,7 @@ dependencies: flutter_emoji_mart: git: url: https://github.com/LucasXu0/emoji_mart.git - ref: "355aa56" + ref: '355aa56' flutter_math_fork: ^0.7.3 flutter_slidable: ^3.0.0 @@ -184,13 +184,13 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "361b99c38370abeeb19656f89e8c31cb3666623b" + ref: '361b99c38370abeeb19656f89e8c31cb3666623b' appflowy_editor_plugins: git: url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git - path: "packages/appflowy_editor_plugins" - ref: "4efcff7" + path: 'packages/appflowy_editor_plugins' + ref: '4efcff7' sheet: git: From 9ec04376734cdf225f3cde02b9507b92fa6039c9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 9 Apr 2025 14:15:37 +0800 Subject: [PATCH 117/207] chore: remove enable sync log toggle --- .../presentation/settings/widgets/setting_appflowy_cloud.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index f423156025..5f158f4ae1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget { const VSpace(8), const AppFlowyCloudEnableSync(), const VSpace(6), - const AppFlowyCloudSyncLogEnabled(), + // const AppFlowyCloudSyncLogEnabled(), const VSpace(12), RestartButton( onClick: () { From 2371d4691df487f65b2dda0bd33402448275fbd9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 9 Apr 2025 20:07:06 +0800 Subject: [PATCH 118/207] chore: fix local ai setting --- .../settings/pages/setting_ai_view/local_ai_setting.dart | 6 ++---- .../pages/setting_ai_view/plugin_status_indicator.dart | 4 ++++ frontend/resources/translations/en.json | 4 ++-- frontend/rust-lib/flowy-ai/src/local_ai/resource.rs | 9 +++++++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 751e7a6180..b836f15b03 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -140,10 +140,8 @@ class LocalAISettingPanel extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const LocalAIStatusIndicator(), - if (state.showSettings) ...[ - const VSpace(10), - OllamaSettingPage(), - ], + const VSpace(10), + OllamaSettingPage(), ], ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart index fdf03bb9ca..a280cf0644 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart @@ -288,6 +288,10 @@ class _LackOfResource extends StatelessWidget { text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), style: textStyle, ), + TextSpan( + text: modelNames.join(', '), + style: textStyle, + ), TextSpan( text: ' ', style: textStyle, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 855a715032..43a59974fd 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -885,7 +885,7 @@ "pleaseFollowThese": "Please follow these", "instructions": "instructions", "installOllamaLai": "to set up Ollama and AppFlowy Local AI.", - "modelsMissing": "Cannot find the required models.", + "modelsMissing": "Cannot find the required models: ", "downloadModel": "to download them." } }, @@ -3209,4 +3209,4 @@ "rewrite": "Rewrite", "insertBelow": "Insert below" } -} +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index b384b30f4a..f90c360ec7 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -195,9 +195,14 @@ impl LocalAIResourceController { let tags: TagsResponse = resp.json().await.inspect_err(|e| { log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") })?; - // Check each required model is present in the response. + // Check if each of our required models exists in the list of available models + trace!("[LLM Resource] ollama available models: {:?}", tags.models); for required in &required_models { - if !tags.models.iter().any(|m| m.name.contains(required)) { + if !tags + .models + .iter() + .any(|m| m.name == *required || m.name == format!("{}:latest", required)) + { log::trace!( "[LLM Resource] required model '{}' not found in API response", required From a7adeeadb17863630933490e119a5fe8ef08bd88 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 9 Apr 2025 20:47:03 +0800 Subject: [PATCH 119/207] chore: remove local ai related path to lai repo --- frontend/rust-lib/Cargo.lock | 10 +- frontend/rust-lib/Cargo.toml | 6 +- frontend/rust-lib/flowy-ai/Cargo.toml | 5 - .../flowy-ai/src/local_ai/controller.rs | 2 +- .../flowy-ai/src/local_ai/resource.rs | 2 +- .../rust-lib/flowy-ai/src/local_ai/watch.rs | 133 +----------------- 6 files changed, 9 insertions(+), 149 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index a72f4596f6..a251594ef9 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,12 +345,11 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" dependencies = [ "af-plugin", "anyhow", "bytes", - "futures", "reqwest 0.11.27", "serde", "serde_json", @@ -358,14 +357,12 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "zip 2.2.0", - "zip-extensions", ] [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" dependencies = [ "anyhow", "futures-util", @@ -379,7 +376,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" dependencies = [ "anyhow", "cfg-if", @@ -2504,7 +2501,6 @@ dependencies = [ "tracing-subscriber", "uuid", "validator 0.18.1", - "winreg 0.55.0", "zip 2.2.0", "zip-extensions", ] diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 78f67f8f90..4df29d1fe4 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index ee056021c4..a08eae7e92 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -53,11 +53,6 @@ collab-integrate.workspace = true notify = "6.1.1" af-mcp = { version = "0.1.0" } -[target.'cfg(target_os = "windows")'.dependencies] -winreg = "0.55" - -#cmd_lib = { version = "1.9.5" } - [dev-dependencies] dotenv = "0.15.0" uuid.workspace = true diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 6fbe7ce2fc..ac44e9ad55 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -13,9 +13,9 @@ use futures::Sink; use lib_infra::async_trait::async_trait; use std::collections::HashMap; -use crate::local_ai::watch::is_plugin_ready; use crate::stream_message::StreamMessage; use af_local_ai::ollama_plugin::OllamaAIPlugin; +use af_plugin::core::path::is_plugin_ready; use af_plugin::core::plugin::RunningState; use arc_swap::ArcSwapOption; use futures_util::SinkExt; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index f90c360ec7..2fc6fad2cc 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -5,13 +5,13 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_infra::async_trait::async_trait; use crate::entities::LackOfAIResourcePB; -use crate::local_ai::watch::{is_plugin_ready, ollama_plugin_path}; #[cfg(target_os = "macos")] use crate::local_ai::watch::{watch_offline_app, WatchContext}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; use af_local_ai::ollama_plugin::OllamaPluginConfig; +use af_plugin::core::path::{is_plugin_ready, ollama_plugin_path}; use lib_infra::util::{get_operating_system, OperatingSystem}; use reqwest::Client; use serde::Deserialize; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs index 1d7be0daf9..2baed3f0a5 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs @@ -1,13 +1,10 @@ use crate::local_ai::resource::WatchDiskEvent; +use af_plugin::core::path::{install_path, ollama_plugin_path}; use flowy_error::{FlowyError, FlowyResult}; use std::path::PathBuf; -use std::process::Command; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; use tracing::{error, trace}; -#[cfg(windows)] -use winreg::{enums::*, RegKey}; - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] #[allow(dead_code)] pub struct WatchContext { @@ -61,131 +58,3 @@ pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver Option { - None -} - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub(crate) fn install_path() -> Option { - #[cfg(target_os = "windows")] - return None; - - #[cfg(target_os = "macos")] - return Some(PathBuf::from("/usr/local/bin")); - - #[cfg(target_os = "linux")] - return None; -} - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub fn is_plugin_ready() -> bool { - ollama_plugin_path().exists() || ollama_plugin_command_available() -} - -#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] -pub fn is_plugin_ready() -> bool { - false -} - -#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] -pub(crate) fn ollama_plugin_path() -> PathBuf { - PathBuf::new() -} - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub(crate) fn ollama_plugin_path() -> std::path::PathBuf { - #[cfg(target_os = "windows")] - { - // Use LOCALAPPDATA for a user-specific installation path on Windows. - let local_appdata = - std::env::var("LOCALAPPDATA").unwrap_or_else(|_| "C:\\Program Files".to_string()); - std::path::PathBuf::from(local_appdata).join("Programs\\appflowy_plugin\\af_ollama_plugin.exe") - } - - #[cfg(target_os = "macos")] - { - let offline_app = "af_ollama_plugin"; - std::path::PathBuf::from(format!("/usr/local/bin/{}", offline_app)) - } - - #[cfg(target_os = "linux")] - { - let offline_app = "af_ollama_plugin"; - std::path::PathBuf::from(format!("/usr/local/bin/{}", offline_app)) - } -} - -pub(crate) fn ollama_plugin_command_available() -> bool { - if cfg!(windows) { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let output = Command::new("cmd") - .args(&["/C", "where", "af_ollama_plugin"]) - .creation_flags(CREATE_NO_WINDOW) - .output(); - if let Ok(output) = output { - if !output.stdout.is_empty() { - return true; - } - } - - // 2. Fallback: Check registry PATH for the executable - let path_dirs = get_windows_path_dirs(); - let plugin_exe = "af_ollama_plugin.exe"; // Adjust name if needed - - path_dirs.iter().any(|dir| { - let full_path = std::path::Path::new(dir).join(plugin_exe); - full_path.exists() - }) - } - - #[cfg(not(windows))] - false - } else { - let output = Command::new("command") - .args(["-v", "af_ollama_plugin"]) - .output(); - match output { - Ok(o) => !o.stdout.is_empty(), - _ => false, - } - } -} - -#[cfg(windows)] -fn get_windows_path_dirs() -> Vec { - let mut paths = Vec::new(); - - // Check HKEY_CURRENT_USER\Environment - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(env) = hkcu.open_subkey("Environment") { - if let Ok(path) = env.get_value::("Path") { - paths.extend(path.split(';').map(|s| s.trim().to_string())); - } - } - - // Check HKEY_LOCAL_MACHINE\SYSTEM\...\Environment - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - if let Ok(env) = hklm.open_subkey(r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment") - { - if let Ok(path) = env.get_value::("Path") { - paths.extend(path.split(';').map(|s| s.trim().to_string())); - } - } - paths -} - -#[cfg(test)] -mod tests { - use crate::local_ai::watch::ollama_plugin_command_available; - - #[test] - fn test_command_import() { - let result = ollama_plugin_command_available(); - println!("ollama plugin exist: {:?}", result); - } -} From 403c558f9bcc4fcc86db8122e7183ba5e44ec133 Mon Sep 17 00:00:00 2001 From: Morn Date: Thu, 10 Apr 2025 13:45:22 +0800 Subject: [PATCH 120/207] chore: update translation (#7675) --- frontend/resources/translations/zh-CN.json | 76 ++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index df65cbac4b..2731a0fb2e 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -1326,6 +1326,62 @@ }, "document": { "selectADocumentToLinkTo": "选择要链接到的文档" + }, + "name": { + "textStyle": "文本样式", + "list": "列表", + "toggle": "切换", + "fileAndMedia": "文件与媒体", + "simpleTable": "简单表格", + "visuals": "视觉元素", + "document": "文档", + "advanced": "高级", + "text": "文本", + "heading1": "一级标题", + "heading2": "二级标题", + "heading3": "三级标题", + "image": "图片", + "bulletedList": "项目符号列表", + "numberedList": "编号列表", + "todoList": "待办事项列表", + "doc": "文档", + "linkedDoc": "链接到页面", + "grid": "网格", + "linkedGrid": "链接网格", + "kanban": "看板", + "linkedKanban": "链接看板", + "calendar": "日历", + "linkedCalendar": "链接日历", + "quote": "引用", + "divider": "分隔符", + "table": "表格", + "callout": "提示框", + "outline": "大纲", + "mathEquation": "数学公式", + "code": "代码", + "toggleList": "切换列表", + "toggleHeading1": "切换标题1", + "toggleHeading2": "切换标题2", + "toggleHeading3": "切换标题3", + "emoji": "表情符号", + "aiWriter": "向AI提问", + "dateOrReminder": "日期或提醒", + "photoGallery": "图片库", + "file": "文件", + "twoColumns": "两列", + "threeColumns": "三列", + "fourColumns": "四列" + }, + "subPage": { + "name": "文档", + "keyword1": "子页面", + "keyword2": "页面", + "keyword3": "子页面", + "keyword4": "插入页面", + "keyword5": "嵌入页面", + "keyword6": "新页面", + "keyword7": "创建页面", + "keyword8": "文档" } }, "selectionMenu": { @@ -1337,6 +1393,16 @@ "referencedGrid": "引用的网格", "referencedCalendar": "引用的日历", "referencedDocument": "参考文档", + "aiWriter": { + "userQuestion": "向AI提问", + "continueWriting": "继续写作", + "fixSpelling": "修正拼写和语法", + "improveWriting": "优化写作", + "summarize": "总结", + "explain": "解释", + "makeShorter": "缩短", + "makeLonger": "扩展" + }, "autoGeneratorMenuItemName": "AI 创作", "autoGeneratorTitleName": "AI: 让 AI 写些什么...", "autoGeneratorLearnMore": "学习更多", @@ -1397,7 +1463,7 @@ }, "optionAction": { "click": "点击", - "toOpenMenu": " 来打开菜单", + "toOpenMenu": "打开菜单", "delete": "删除", "duplicate": "复制", "turnInto": "变成", @@ -1407,8 +1473,10 @@ "align": "对齐", "left": "左", "center": "中心", - "right": "又", - "defaultColor": "默认" + "right": "右", + "defaultColor": "默认", + "depth": "深度", + "copyLinkToBlock": "粘贴块链接" }, "image": { "addAnImage": "添加图像", @@ -2003,4 +2071,4 @@ "yesterday": "昨天", "today": "今天" } -} +} \ No newline at end of file From e3bbabd63ede2c0df0c575bf26f73aa13884d450 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 10 Apr 2025 14:06:08 +0800 Subject: [PATCH 121/207] feat: sign in with passcode (#7685) * feat: sign in with password or passcode * feat: add loading dialog * chore: update translations * feat: support otp on mobile --- .../anon_user_data_migration_test.dart | 1 - .../setting/about/about_setting_group.dart | 4 +- .../workspace/invite_members_screen.dart | 3 +- .../editor_plugins/file/file_util.dart | 3 +- .../lib/startup/tasks/app_widget.dart | 54 ++--- .../startup/tasks/appflowy_cloud_task.dart | 58 ++++++ .../auth/af_cloud_auth_service.dart | 20 +- .../auth/af_cloud_mock_auth_service.dart | 11 +- .../user/application/auth/auth_service.dart | 16 +- .../auth/backend_auth_service.dart | 14 +- .../lib/user/application/sign_in_bloc.dart | 141 ++++++++++--- .../lib/user/application/user_service.dart | 9 + .../desktop_sign_in_screen.dart | 72 ++++--- .../sign_in_screen/mobile_sign_in_screen.dart | 32 +-- .../widgets/anonymous_sign_in_button.dart | 2 +- .../anonymous_sign_in_button.dart | 16 ++ .../continue_with/continue_with_email.dart | 23 +++ .../continue_with_email_and_password.dart | 133 +++++++++++++ ...inue_with_magic_link_or_passcode_page.dart | 187 ++++++++++++++++++ .../continue_with/continue_with_password.dart | 21 ++ .../continue_with_password_page.dart | 147 ++++++++++++++ .../sign_in_screen/widgets/logo/logo.dart | 20 ++ .../widgets/magic_link_sign_in_buttons.dart | 8 +- .../widgets/sign_in_agreement.dart | 32 +-- .../widgets/sign_in_anonymous_button.dart | 109 ++-------- .../widgets/sign_in_or_logout_button.dart | 57 ++---- .../third_party_sign_in_button.dart | 124 +++--------- .../third_party_sign_in_buttons.dart | 86 ++++---- .../sign_in_screen/widgets/widgets.dart | 8 +- .../widgets/auth_form_container.dart | 2 +- .../widgets/flowy_logo_title.dart | 24 +-- .../sidebar/billing/sidebar_plan_bloc.dart | 3 + .../pages/account/account_sign_in_out.dart | 4 +- .../presentation/widgets/dialogs.dart | 4 +- .../packages/appflowy_ui/README.md | 2 +- .../packages/appflowy_ui/lib/appflowy_ui.dart | 2 - .../lib/src/theme/data/builder.dart | 2 +- frontend/appflowy_flutter/pubspec.lock | 9 +- frontend/appflowy_flutter/pubspec.yaml | 3 +- frontend/resources/translations/en.json | 12 +- frontend/rust-lib/.vscode/launch.json | 17 ++ frontend/rust-lib/Cargo.lock | 24 +-- frontend/rust-lib/Cargo.toml | 4 +- .../af_cloud/impls/user/cloud_service_impl.rs | 24 ++- .../src/local_server/impls/user.rs | 11 +- .../flowy-server/src/supabase/api/user.rs | 10 + frontend/rust-lib/flowy-user-pub/src/cloud.rs | 9 +- .../rust-lib/flowy-user/src/entities/auth.rs | 48 +++++ .../rust-lib/flowy-user/src/event_handler.rs | 23 ++- frontend/rust-lib/flowy-user/src/event_map.rs | 7 +- .../flowy-user/src/user_manager/manager.rs | 27 +++ 51 files changed, 1227 insertions(+), 455 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart create mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart rename frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/{ => third_party_sign_in_button}/third_party_sign_in_button.dart (53%) rename frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/{ => third_party_sign_in_button}/third_party_sign_in_buttons.dart (68%) create mode 100644 frontend/rust-lib/.vscode/launch.json diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart index 230ee59495..e34ac02aab 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart @@ -15,7 +15,6 @@ void main() { cloudType: AuthenticatorType.appflowyCloudSelfHost, ); - await tester.tapContinousAnotherWay(); await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart index d4f0766626..2d5a3176cd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart @@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget { trailing: const Icon( Icons.chevron_right, ), - onTap: () => afLaunchUrlString('https://appflowy.io/privacy'), + onTap: () => afLaunchUrlString('https://appflowy.com/privacy'), ), MobileSettingItem( name: LocaleKeys.settings_mobile_termsAndConditions.tr(), trailing: const Icon( Icons.chevron_right, ), - onTap: () => afLaunchUrlString('https://appflowy.io/terms'), + onTap: () => afLaunchUrlString('https://appflowy.com/terms'), ), if (kDebugMode) MobileSettingItem( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart index 4f16aabffd..62aa114ef3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -276,10 +276,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { void _inviteMember(BuildContext context) { final email = emailController.text; if (!isEmail(email)) { - return showToastNotification( + showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), ); + return; } context .read() diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index d7ae7c5ccf..83debdd71b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -104,9 +104,10 @@ Future downloadMediaFile( await afLaunchUrlString(file.url); } else { if (userProfile == null) { - return showToastNotification( + showToastNotification( message: LocaleKeys.grid_media_downloadFailedToken.tr(), ); + return; } final uri = Uri.parse(file.url); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index ce28ea68b0..ed4c4d1623 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -19,6 +19,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -98,7 +99,7 @@ class InitAppWidgetTask extends LaunchTask { Locale('zh', 'TW'), Locale('fa'), Locale('hin'), - Locale('mr','IN'), + Locale('mr', 'IN'), ], path: 'assets/translations', fallbackLocale: const Locale('en'), @@ -224,31 +225,36 @@ class _ApplicationWidgetState extends State { Tooltip.dismissAllToolTips(); } }, - child: MaterialApp.router( - builder: (context, child) => MediaQuery( - // use the 1.0 as the textScaleFactor to avoid the text size - // affected by the system setting. - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(state.textScaleFactor), - ), - child: overlayManagerBuilder( - context, - !UniversalPlatform.isMobile && FeatureFlag.search.isOn - ? CommandPalette( - notifier: _commandPaletteNotifier, - child: child, - ) - : child, + child: AppFlowyTheme( + data: Theme.of(context).brightness == Brightness.light + ? AppFlowyThemeData.light() + : AppFlowyThemeData.dark(), + child: MaterialApp.router( + builder: (context, child) => MediaQuery( + // use the 1.0 as the textScaleFactor to avoid the text size + // affected by the system setting. + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(state.textScaleFactor), + ), + child: overlayManagerBuilder( + context, + !UniversalPlatform.isMobile && FeatureFlag.search.isOn + ? CommandPalette( + notifier: _commandPaletteNotifier, + child: child, + ) + : child, + ), ), + debugShowCheckedModeBanner: false, + theme: state.lightTheme, + darkTheme: state.darkTheme, + themeMode: state.themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: state.locale, + routerConfig: routerConfig, ), - debugShowCheckedModeBanner: false, - theme: state.lightTheme, - darkTheme: state.darkTheme, - themeMode: state.themeMode, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: state.locale, - routerConfig: routerConfig, ), ), ), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 18baef4cb8..d8dc4dd121 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -83,6 +83,13 @@ class AppFlowyCloudDeepLink { void unsubscribeDeepLinkLoadingState(VoidCallback listener) => _stateNotifier?.removeListener(listener); + Future passGotrueTokenResponse( + GotrueTokenResponsePB gotrueTokenResponse, + ) async { + final uri = _buildDeepLinkUri(gotrueTokenResponse); + await _handleUri(uri); + } + Future _handleUri( Uri? uri, ) async { @@ -173,6 +180,57 @@ class AppFlowyCloudDeepLink { bool _isPaymentSuccessUri(Uri uri) { return uri.host == 'payment-success'; } + + Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) { + final params = {}; + + if (gotrueTokenResponse.hasAccessToken() && + gotrueTokenResponse.accessToken.isNotEmpty) { + params['access_token'] = gotrueTokenResponse.accessToken; + } + + if (gotrueTokenResponse.hasExpiresAt()) { + params['expires_at'] = gotrueTokenResponse.expiresAt.toString(); + } + + if (gotrueTokenResponse.hasExpiresIn()) { + params['expires_in'] = gotrueTokenResponse.expiresIn.toString(); + } + + if (gotrueTokenResponse.hasProviderRefreshToken() && + gotrueTokenResponse.providerRefreshToken.isNotEmpty) { + params['provider_refresh_token'] = + gotrueTokenResponse.providerRefreshToken; + } + + if (gotrueTokenResponse.hasProviderAccessToken() && + gotrueTokenResponse.providerAccessToken.isNotEmpty) { + params['provider_token'] = gotrueTokenResponse.providerAccessToken; + } + + if (gotrueTokenResponse.hasRefreshToken() && + gotrueTokenResponse.refreshToken.isNotEmpty) { + params['refresh_token'] = gotrueTokenResponse.refreshToken; + } + + if (gotrueTokenResponse.hasTokenType() && + gotrueTokenResponse.tokenType.isNotEmpty) { + params['token_type'] = gotrueTokenResponse.tokenType; + } + + if (params.isEmpty) { + return null; + } + + final fragment = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return Uri.parse('appflowy-flutter://login-callback#$fragment'); + } } class InitAppFlowyCloudTask extends LaunchTask { diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index 149bddc951..6d02f188c8 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -32,12 +32,17 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, }) async { - throw UnimplementedError(); + return _backendAuthService.signInWithEmailPassword( + email: email, + password: password, + params: params, + ); } @override @@ -106,6 +111,17 @@ class AppFlowyCloudAuthService implements AuthService { ); } + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + return _backendAuthService.signInWithPasscode( + email: email, + passcode: passcode, + ); + } + @override Future> getUser() async { return UserBackendService.getCurrentUserProfile(); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart index 5f8ea7cac6..d8cee89b59 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart @@ -33,7 +33,8 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -106,4 +107,12 @@ class AppFlowyCloudMockAuthService implements AuthService { Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } + + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + throw UnimplementedError(); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart index 90c6954afe..9879b9a18e 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart @@ -1,5 +1,5 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class AuthServiceMapKeys { @@ -23,7 +23,8 @@ abstract class AuthService { /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params, @@ -75,6 +76,17 @@ abstract class AuthService { Map params, }); + /// Authenticates a user with a passcode sent to their email. + /// + /// - `email`: The email address of the user. + /// - `passcode`: The passcode of the user. + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + Future> signInWithPasscode({ + required String email, + required String passcode, + }); + /// Signs out the currently authenticated user. Future signOut(); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart index 4d03788c8c..f47fd5a4a6 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -18,7 +18,8 @@ class BackendAuthService implements AuthService { final AuthenticatorPB authType; @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -28,8 +29,7 @@ class BackendAuthService implements AuthService { ..password = password ..authType = authType ..deviceId = await getDeviceId(); - final response = UserEventSignInWithEmailPassword(request).send(); - return response.then((value) => value); + return UserEventSignInWithEmailPassword(request).send(); } @override @@ -105,4 +105,12 @@ class BackendAuthService implements AuthService { // No need to pass the redirect URL. return UserBackendService.signInWithMagicLink(email, ''); } + + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + return UserBackendService.signInWithPasscode(email, passcode); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 6fda156567..54a60702c8 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -30,12 +30,26 @@ class SignInBloc extends Bloc { on( (event, emit) async { await event.when( - signedInWithUserEmailAndPassword: () async => _onSignIn(emit), - signedInWithOAuth: (platform) async => - _onSignInWithOAuth(emit, platform), - signedInAsGuest: () async => _onSignInAsGuest(emit), - signedWithMagicLink: (email) async => - _onSignInWithMagicLink(emit, email), + signInWithEmailAndPassword: (email, password) async => + _onSignInWithEmailAndPassword( + emit, + email: email, + password: password, + ), + signInWithOAuth: (platform) async => _onSignInWithOAuth( + emit, + platform: platform, + ), + signInAsGuest: () async => _onSignInAsGuest(emit), + signInWithMagicLink: (email) async => _onSignInWithMagicLink( + emit, + email: email, + ), + signInWithPasscode: (email, passcode) async => _onSignInWithPasscode( + emit, + email: email, + passcode: passcode, + ), deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result), cancel: () { emit( @@ -119,26 +133,34 @@ class SignInBloc extends Bloc { } } - Future _onSignIn(Emitter emit) async { + Future _onSignInWithEmailAndPassword( + Emitter emit, { + required String email, + required String password, + }) async { final result = await authService.signInWithEmailPassword( - email: state.email ?? '', - password: state.password ?? '', + email: email, + password: password, ); emit( result.fold( - (userProfile) => state.copyWith( - isSubmitting: false, - successOrFail: FlowyResult.success(userProfile), - ), + (gotrueTokenResponse) { + getIt().passGotrueTokenResponse( + gotrueTokenResponse, + ); + return state.copyWith( + isSubmitting: false, + ); + }, (error) => _stateFromCode(error), ), ); } Future _onSignInWithOAuth( - Emitter emit, - String platform, - ) async { + Emitter emit, { + required String platform, + }) async { emit( state.copyWith( isSubmitting: true, @@ -161,9 +183,9 @@ class SignInBloc extends Bloc { } Future _onSignInWithMagicLink( - Emitter emit, - String email, - ) async { + Emitter emit, { + required String email, + }) async { emit( state.copyWith( isSubmitting: true, @@ -183,6 +205,40 @@ class SignInBloc extends Bloc { ); } + Future _onSignInWithPasscode( + Emitter emit, { + required String email, + required String passcode, + }) async { + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + + final result = await authService.signInWithPasscode( + email: email, + passcode: passcode, + ); + + emit( + result.fold( + (gotrueTokenResponse) { + getIt().passGotrueTokenResponse( + gotrueTokenResponse, + ); + return state.copyWith( + isSubmitting: false, + ); + }, + (error) => _stateFromCode(error), + ), + ); + } + Future _onSignInAsGuest( Emitter emit, ) async { @@ -224,10 +280,17 @@ class SignInBloc extends Bloc { emailError: null, ); case ErrorCode.UserUnauthorized: + final errorMsg = error.msg; + String msg = LocaleKeys.signIn_generalError.tr(); + if (errorMsg.contains('rate limit')) { + msg = LocaleKeys.signIn_limitRateError.tr(); + } else if (errorMsg.contains('invalid')) { + msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(); + } return state.copyWith( isSubmitting: false, successOrFail: FlowyResult.failure( - FlowyError(msg: LocaleKeys.signIn_limitRateError.tr()), + FlowyError(msg: msg), ), ); default: @@ -243,19 +306,35 @@ class SignInBloc extends Bloc { @freezed class SignInEvent with _$SignInEvent { - const factory SignInEvent.signedInWithUserEmailAndPassword() = - SignedInWithUserEmailAndPassword; - const factory SignInEvent.signedInWithOAuth(String platform) = - SignedInWithOAuth; - const factory SignInEvent.signedInAsGuest() = SignedInAsGuest; - const factory SignInEvent.signedWithMagicLink(String email) = - SignedWithMagicLink; - const factory SignInEvent.emailChanged(String email) = EmailChanged; - const factory SignInEvent.passwordChanged(String password) = PasswordChanged; + // Sign in methods + const factory SignInEvent.signInWithEmailAndPassword({ + required String email, + required String password, + }) = SignInWithEmailAndPassword; + const factory SignInEvent.signInWithOAuth({ + required String platform, + }) = SignInWithOAuth; + const factory SignInEvent.signInAsGuest() = SignInAsGuest; + const factory SignInEvent.signInWithMagicLink({ + required String email, + }) = SignInWithMagicLink; + const factory SignInEvent.signInWithPasscode({ + required String email, + required String passcode, + }) = SignInWithPasscode; + + // Event handlers + const factory SignInEvent.emailChanged({ + required String email, + }) = EmailChanged; + const factory SignInEvent.passwordChanged({ + required String password, + }) = PasswordChanged; const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) = DeepLinkStateChange; - const factory SignInEvent.cancel() = _Cancel; - const factory SignInEvent.switchLoginType(LoginType type) = _SwitchLoginType; + + const factory SignInEvent.cancel() = Cancel; + const factory SignInEvent.switchLoginType(LoginType type) = SwitchLoginType; } // we support sign in directly without sign up, but we want to allow the users to sign up if they want to diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 5a75a4df3e..644a115641 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -86,6 +86,15 @@ class UserBackendService implements IUserBackendService { return UserEventMagicLinkSignIn(payload).send(); } + static Future> + signInWithPasscode( + String email, + String passcode, + ) async { + final payload = PasscodeSignInPB(email: email, passcode: passcode); + return UserEventPasscodeSignIn(payload).send(); + } + static Future> signOut() { return UserEventSignOut().send(); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 94b4347869..3a906ee0c4 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -1,11 +1,13 @@ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/settings/show_settings.dart'; import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -19,6 +21,8 @@ class DesktopSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + const indicatorMinHeight = 4.0; return BlocBuilder( builder: (context, state) { @@ -29,25 +33,23 @@ class DesktopSignInScreen extends StatelessWidget { children: [ const Spacer(), - const VSpace(20), - // logo and title FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), - logoSize: const Size(60, 60), + logoSize: Size.square(36), ), - const VSpace(20), + VSpace(theme.spacing.xxl), - // magic link sign in - const SignInWithMagicLinkButtons(), - const VSpace(20), + // continue with email and password + const ContinueWithEmailAndPassword(), + VSpace(theme.spacing.xxl), // third-party sign in. if (isAuthEnabled) ...[ const _OrDivider(), - const VSpace(20), + VSpace(theme.spacing.xxl), const ThirdPartySignInButtons(), - const VSpace(20), + VSpace(theme.spacing.xxl), ], // sign in agreement @@ -69,7 +71,7 @@ class DesktopSignInScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ DesktopSignInSettingsButton(), - HSpace(42), + HSpace(20), SignInAnonymousButtonV2(), ], ), @@ -99,18 +101,24 @@ class DesktopSignInSettingsButton extends StatelessWidget { @override Widget build(BuildContext context) { - return FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.signIn_settings.tr(), - textAlign: TextAlign.center, - fontSize: 12.0, - // fontWeight: FontWeight.w500, - color: Colors.grey, - decoration: TextDecoration.underline, + final theme = AppFlowyTheme.of(context); + return AFGhostIconTextButton( + text: LocaleKeys.signIn_settings.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), - onTap: () { - showSimpleSettingsDialog(context); + onTap: () => showSimpleSettingsDialog(context), + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); }, ); } @@ -121,14 +129,30 @@ class _OrDivider extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Row( children: [ - const Flexible(child: Divider(thickness: 1)), + Flexible( + child: Divider( + thickness: 1, + color: theme.borderColorScheme.greyTertiary, + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + child: Text( + LocaleKeys.signIn_or.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.secondary, + ), + ), + ), + Flexible( + child: Divider( + thickness: 1, + color: theme.borderColorScheme.greyTertiary, + ), ), - const Flexible(child: Divider(thickness: 1)), ], ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index 2a9a6fe798..6326e1a811 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -7,6 +7,7 @@ import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -37,7 +38,7 @@ class MobileSignInScreen extends StatelessWidget { const VSpace(spacing * 2), isLocalAuthEnabled ? const SignInAnonymousButtonV3() - : const SignInWithMagicLinkButtons(), + : const ContinueWithEmailAndPassword(), const VSpace(spacing), if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), const VSpace(spacing * 1.5), @@ -103,21 +104,28 @@ class MobileSignInScreen extends StatelessWidget { } Widget _buildSettingsButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( mainAxisSize: MainAxisSize.min, children: [ - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.signIn_settings.tr(), - textAlign: TextAlign.center, - fontSize: 12.0, - // fontWeight: FontWeight.w500, - color: Colors.grey, - decoration: TextDecoration.underline, + AFGhostIconTextButton( + text: LocaleKeys.signIn_settings.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), - onTap: () { - context.push(MobileLaunchSettingsPage.routeName); + onTap: () => context.push(MobileLaunchSettingsPage.routeName), + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); }, ), const HSpace(24), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart index 1e5d7fa531..c6b2d5401c 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart @@ -34,7 +34,7 @@ class SignInAnonymousButtonV3 extends StatelessWidget { ? () { context .read() - .add(const SignInEvent.signedInAsGuest()); + .add(const SignInEvent.signInAsGuest()); } : () { final bloc = context.read(); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart new file mode 100644 index 0000000000..351527137f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart @@ -0,0 +1,16 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AnonymousSignInButton extends StatelessWidget { + const AnonymousSignInButton({super.key}); + + @override + Widget build(BuildContext context) { + return AFGhostButton.normal( + onTap: () {}, + builder: (context, isHovering, disabled) { + return const Placeholder(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart new file mode 100644 index 0000000000..c4cf504ef5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart @@ -0,0 +1,23 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class ContinueWithEmail extends StatelessWidget { + const ContinueWithEmail({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFFilledTextButton.primary( + text: LocaleKeys.signIn_continueWithEmail.tr(), + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart new file mode 100644 index 0000000000..e918c3f4f1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -0,0 +1,133 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_ui/appflowy_ui.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:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ContinueWithEmailAndPassword extends StatefulWidget { + const ContinueWithEmailAndPassword({super.key}); + + @override + State createState() => + _ContinueWithEmailAndPasswordState(); +} + +class _ContinueWithEmailAndPasswordState + extends State { + final controller = TextEditingController(); + final focusNode = FocusNode(); + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Column( + children: [ + SizedBox( + height: UniversalPlatform.isMobile ? 38.0 : 40.0, + child: AFTextField( + controller: controller, + hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), + radius: 10, + onSubmitted: (value) => _pushContinueWithMagicLinkOrPasscodePage( + context, + value, + ), + ), + ), + VSpace(theme.spacing.l), + ContinueWithEmail( + onTap: () => _pushContinueWithMagicLinkOrPasscodePage( + context, + controller.text, + ), + ), + // Hide password sign in until we implement the reset password / forgot password + // VSpace(theme.spacing.l), + // ContinueWithPassword( + // onTap: () => _pushContinueWithPasswordPage( + // context, + // controller.text, + // ), + // ), + ], + ); + } + + void _pushContinueWithMagicLinkOrPasscodePage( + BuildContext context, + String email, + ) { + if (!isEmail(email)) { + showToastNotification( + message: LocaleKeys.signIn_invalidEmail.tr(), + type: ToastificationType.error, + ); + return; + } + + final signInBloc = context.read(); + + signInBloc.add(SignInEvent.signInWithMagicLink(email: email)); + + // push the a continue with magic link or passcode screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: signInBloc, + child: ContinueWithMagicLinkOrPasscodePage( + email: email, + backToLogin: () => Navigator.pop(context), + onEnterPasscode: (passcode) => signInBloc.add( + SignInEvent.signInWithPasscode( + email: email, + passcode: passcode, + ), + ), + ), + ), + ), + ); + } + + // void _pushContinueWithPasswordPage( + // BuildContext context, + // String email, + // ) { + // final signInBloc = context.read(); + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => ContinueWithPasswordPage( + // email: email, + // backToLogin: () => Navigator.pop(context), + // onEnterPassword: (password) => signInBloc.add( + // SignInEvent.signInWithEmailAndPassword( + // email: email, + // password: password, + // ), + // ), + // onForgotPassword: () { + // // todo: implement forgot password + // }, + // ), + // ), + // ); + // } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart new file mode 100644 index 0000000000..ea7ff087ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ContinueWithMagicLinkOrPasscodePage extends StatefulWidget { + const ContinueWithMagicLinkOrPasscodePage({ + super.key, + required this.backToLogin, + required this.email, + required this.onEnterPasscode, + }); + + final String email; + final VoidCallback backToLogin; + final ValueChanged onEnterPasscode; + + @override + State createState() => + _ContinueWithMagicLinkOrPasscodePageState(); +} + +class _ContinueWithMagicLinkOrPasscodePageState + extends State { + final passcodeController = TextEditingController(); + + bool isEnteringPasscode = false; + + ToastificationItem? toastificationItem; + + @override + void dispose() { + passcodeController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.isSubmitting) { + _showLoadingDialog(); + } else { + _dismissLoadingDialog(); + } + }, + child: Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo, title and description + ..._buildLogoTitleAndDescription(), + + // Enter code manually + ..._buildEnterCodeManually(), + + // Back to login + ..._buildBackToLogin(), + ], + ), + ), + ), + ), + ); + } + + List _buildEnterCodeManually() { + // todo: ask designer to provide the spacing + final spacing = VSpace(20); + + if (!isEnteringPasscode) { + return [ + AFFilledTextButton.primary( + text: LocaleKeys.signIn_enterCodeManually.tr(), + onTap: () => setState(() => isEnteringPasscode = true), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + spacing, + ]; + } + + return [ + // Enter code manually + SizedBox( + height: 40, + child: AFTextField( + controller: passcodeController, + hintText: LocaleKeys.signIn_enterCode.tr(), + keyboardType: TextInputType.number, + radius: 10, + autoFocus: true, + onSubmitted: widget.onEnterPasscode, + ), + ), + // todo: ask designer to provide the spacing + VSpace(12), + + // continue to login + AFFilledTextButton.primary( + text: 'Continue to sign in', + onTap: () => widget.onEnterPasscode(passcodeController.text), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + + spacing, + ]; + } + + List _buildBackToLogin() { + return [ + AFGhostTextButton( + text: LocaleKeys.signIn_backToLogin.tr(), + size: AFButtonSize.s, + onTap: widget.backToLogin, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ]; + } + + List _buildLogoTitleAndDescription() { + final theme = AppFlowyTheme.of(context); + final spacing = VSpace(theme.spacing.xxl); + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_checkYourEmail.tr(), + style: theme.textStyle.heading.h3( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // description + Text( + LocaleKeys.signIn_temporaryVerificationSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + spacing, + ]; + } + + void _showLoadingDialog() { + _dismissLoadingDialog(); + + toastificationItem = showToastNotification( + message: LocaleKeys.signIn_signingIn.tr(), + ); + } + + void _dismissLoadingDialog() { + final toastificationItem = this.toastificationItem; + if (toastificationItem != null) { + toastification.dismiss(toastificationItem); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart new file mode 100644 index 0000000000..5bfd191e22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart @@ -0,0 +1,21 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ContinueWithPassword extends StatelessWidget { + const ContinueWithPassword({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFOutlinedTextButton.normal( + text: 'Continue with password', + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart new file mode 100644 index 0000000000..3a281889ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -0,0 +1,147 @@ +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class ContinueWithPasswordPage extends StatefulWidget { + const ContinueWithPasswordPage({ + super.key, + required this.backToLogin, + required this.email, + required this.onEnterPassword, + required this.onForgotPassword, + }); + + final String email; + final VoidCallback backToLogin; + final ValueChanged onEnterPassword; + final VoidCallback onForgotPassword; + + @override + State createState() => + _ContinueWithPasswordPageState(); +} + +class _ContinueWithPasswordPageState extends State { + final passwordController = TextEditingController(); + + @override + void dispose() { + passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo and title + ..._buildLogoAndTitle(), + + // Password input and buttons + ..._buildPasswordSection(), + + // Back to login + ..._buildBackToLogin(), + ], + ), + ), + ), + ); + } + + List _buildLogoAndTitle() { + final theme = AppFlowyTheme.of(context); + final spacing = VSpace(theme.spacing.xxl); + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + 'Enter password', + style: theme.textStyle.heading.h3( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // email display + RichText( + text: TextSpan( + children: [ + TextSpan( + text: 'Login as ', + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + ), + TextSpan( + text: widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + ], + ), + ), + spacing, + ]; + } + + List _buildPasswordSection() { + return [ + // Password input + AFTextField( + controller: passwordController, + hintText: 'Enter password', + autoFocus: true, + onSubmitted: widget.onEnterPassword, + ), + // todo: ask designer to provide the spacing + VSpace(12), + + // todo: forgot password is not implemented yet + // Forgot password button + // AFGhostTextButton( + // text: 'Forget password?', + // size: AFButtonSize.s, + // onTap: widget.onForgotPassword, + // textColor: (context, isHovering, disabled) { + // return theme.textColorScheme.theme; + // }, + // ), + VSpace(12), + + // Continue button + AFFilledTextButton.primary( + text: 'Continue', + onTap: () => widget.onEnterPassword(passwordController.text), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + VSpace(20), + ]; + } + + List _buildBackToLogin() { + return [ + AFGhostTextButton( + text: 'Back to Login', + size: AFButtonSize.s, + onTap: widget.backToLogin, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.theme; + }, + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart new file mode 100644 index 0000000000..eb29807b5b --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/material.dart'; + +class AFLogo extends StatelessWidget { + const AFLogo({ + super.key, + this.size = const Size.square(36), + }); + + final Size size; + + @override + Widget build(BuildContext context) { + return FlowySvg( + FlowySvgs.flowy_logo_xl, + blendMode: null, + size: size, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart index 69c9689823..45e4fe7273 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart @@ -64,14 +64,16 @@ class _SignInWithMagicLinkButtonsState void _sendMagicLink(BuildContext context, String email) { if (!isEmail(email)) { - return showToastNotification( - + showToastNotification( message: LocaleKeys.signIn_invalidEmail.tr(), type: ToastificationType.error, ); + return; } - context.read().add(SignInEvent.signedWithMagicLink(email)); + context + .read() + .add(SignInEvent.signInWithMagicLink(email: email)); showConfirmDialog( context: context, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart index a5e0d9784d..4ea819d997 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -1,6 +1,7 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -12,41 +13,40 @@ class SignInAgreement extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final textStyle = theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ); + final underlinedTextStyle = theme.textStyle.caption.underline( + color: theme.textColorScheme.secondary, + ); return RichText( textAlign: TextAlign.center, text: TextSpan( children: [ TextSpan( text: isLocalAuthEnabled - ? '${LocaleKeys.web_signInLocalAgreement.tr()} ' - : '${LocaleKeys.web_signInAgreement.tr()} ', - style: const TextStyle(color: Colors.grey, fontSize: 12), + ? '${LocaleKeys.web_signInLocalAgreement.tr()} \n' + : '${LocaleKeys.web_signInAgreement.tr()} \n', + style: textStyle, ), TextSpan( text: '${LocaleKeys.web_termOfUse.tr()} ', - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - decoration: TextDecoration.underline, - ), + style: underlinedTextStyle, mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString('https://appflowy.io/terms'), + ..onTap = () => afLaunchUrlString('https://appflowy.com/terms'), ), TextSpan( text: '${LocaleKeys.web_and.tr()} ', - style: const TextStyle(color: Colors.grey, fontSize: 12), + style: textStyle, ), TextSpan( text: LocaleKeys.web_privacyPolicy.tr(), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - decoration: TextDecoration.underline, - ), + style: underlinedTextStyle, mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'), + ..onTap = () => afLaunchUrlString('https://appflowy.com/privacy'), ), ], ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index 7fe4584e97..a0b31c2fe5 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -1,91 +1,14 @@ import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:universal_platform/universal_platform.dart'; - -/// Used in DesktopSignInScreen and MobileSignInScreen -class SignInAnonymousButton extends StatelessWidget { - const SignInAnonymousButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final isMobile = UniversalPlatform.isMobile; - - return BlocBuilder( - builder: (context, signInState) { - return BlocProvider( - create: (context) => AnonUserBloc() - ..add( - const AnonUserEvent.initial(), - ), - child: BlocListener( - listener: (context, state) async { - if (state.openedAnonUser != null) { - await runAppFlowy(); - } - }, - child: BlocBuilder( - builder: (context, state) { - final text = state.anonUsers.isEmpty - ? LocaleKeys.signIn_loginStartWithAnonymous.tr() - : LocaleKeys.signIn_continueAnonymousUser.tr(); - final onTap = state.anonUsers.isEmpty - ? () { - context - .read() - .add(const SignInEvent.signedInAsGuest()); - } - : () { - final bloc = context.read(); - final user = bloc.state.anonUsers.first; - bloc.add(AnonUserEvent.openAnonUser(user)); - }; - // SignInAnonymousButton in mobile - if (isMobile) { - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 56), - ), - onPressed: onTap, - child: FlowyText( - LocaleKeys.signIn_loginStartWithAnonymous.tr(), - fontSize: 14, - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.w500, - ), - ); - } - // SignInAnonymousButton in desktop - return SizedBox( - height: 48, - child: FlowyButton( - isSelected: true, - disable: signInState.isSubmitting, - text: FlowyText.medium( - text, - textAlign: TextAlign.center, - ), - radius: Corners.s6Border, - onTap: onTap, - ), - ); - }, - ), - ), - ); - }, - ); - } -} class SignInAnonymousButtonV2 extends StatelessWidget { const SignInAnonymousButtonV2({ @@ -109,27 +32,35 @@ class SignInAnonymousButtonV2 extends StatelessWidget { }, child: BlocBuilder( builder: (context, state) { - final text = LocaleKeys.signIn_anonymous.tr(); + final theme = AppFlowyTheme.of(context); final onTap = state.anonUsers.isEmpty ? () { context .read() - .add(const SignInEvent.signedInAsGuest()); + .add(const SignInEvent.signInAsGuest()); } : () { final bloc = context.read(); final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; - return FlowyButton( - useIntrinsicWidth: true, - onTap: onTap, - text: FlowyText( - text, - color: Colors.grey, - decoration: TextDecoration.underline, - fontSize: 12, + return AFGhostIconTextButton( + text: LocaleKeys.signIn_anonymousMode.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), + size: AFButtonSize.s, + onTap: onTap, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.anonymous_mode_m, + color: theme.textColorScheme.secondary, + ); + }, ); }, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart index 5146e29962..7067844500 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class MobileLogoutButton extends StatelessWidget { @@ -18,50 +18,19 @@ class MobileLogoutButton extends StatelessWidget { @override Widget build(BuildContext context) { - final style = Theme.of(context); - return GestureDetector( + return AFOutlinedIconTextButton.normal( + text: text, onTap: onPressed, - child: Container( - height: 38, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(4), - ), - border: Border.all( - color: textColor ?? style.colorScheme.outline, - width: 0.5, - ), - ), - alignment: Alignment.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icon != null) ...[ - SizedBox( - // The icon could be in different height as original aspect ratio, we use a fixed sizebox to wrap it to make sure they all occupy the same space. - width: 30, - height: 30, - child: Center( - child: SizedBox( - width: 24, - child: FlowySvg( - icon!, - blendMode: null, - ), - ), - ), - ), - const HSpace(8), - ], - FlowyText( - text, - fontSize: 14.0, - fontWeight: FontWeight.w400, - color: textColor, - ), - ], - ), - ), + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + if (icon == null) { + return const SizedBox.shrink(); + } + return FlowySvg( + icon!, + size: Size.square(18), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart similarity index 53% rename from frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart rename to frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart index 35d16b031f..9a7234ab6b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart @@ -1,10 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; -import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum ThirdPartySignInButtonType { @@ -102,118 +99,55 @@ class MobileThirdPartySignInButton extends StatelessWidget { super.key, this.height = 38, this.fontSize = 14.0, - required this.onPressed, + required this.onTap, required this.type, }); - final VoidCallback onPressed; + final VoidCallback onTap; final double height; final double fontSize; final ThirdPartySignInButtonType type; @override Widget build(BuildContext context) { - final style = Theme.of(context); - - return AnimatedGestureDetector( - scaleFactor: 1.0, - onTapUp: onPressed, - child: Container( - height: height, - decoration: BoxDecoration( - color: type.backgroundColor(context), - borderRadius: const BorderRadius.all( - Radius.circular(4), - ), - border: Border.all( - color: style.colorScheme.outline, - width: 0.5, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (type != ThirdPartySignInButtonType.anonymous) - FlowySvg( - type.icon, - size: Size.square(fontSize), - blendMode: type.blendMode, - color: type.textColor(context), - ), - const HSpace(8.0), - FlowyText( - type.labelText, - fontSize: fontSize, - color: type.textColor(context), - ), - ], - ), - ), + return AFOutlinedIconTextButton.normal( + text: type.labelText, + onTap: onTap, + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + type.icon, + size: Size.square(16), + blendMode: type.blendMode, + ); + }, ); } } -class DesktopSignInButton extends StatelessWidget { - const DesktopSignInButton({ +class DesktopThirdPartySignInButton extends StatelessWidget { + const DesktopThirdPartySignInButton({ super.key, required this.type, - required this.onPressed, + required this.onTap, }); final ThirdPartySignInButtonType type; - final VoidCallback onPressed; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final style = Theme.of(context); - // In desktop, the width of button is limited by [AuthFormContainer] - return SizedBox( - height: 48, - width: AuthFormContainer.width, - child: OutlinedButton.icon( - // In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left. - icon: Container( - width: AuthFormContainer.width / 4, - alignment: Alignment.centerRight, - child: SizedBox( - // Some icons are not square, so we just use a fixed width here. - width: 24, - child: FlowySvg( - type.icon, - blendMode: type.blendMode, - ), - ), - ), - label: Container( - padding: const EdgeInsets.only(left: 8), - alignment: Alignment.centerLeft, - child: FlowyText( - type.labelText, - fontSize: 14, - ), - ), - style: ButtonStyle( - overlayColor: WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.hovered)) { - return style.colorScheme.onSecondaryContainer; - } - return null; - }, - ), - shape: WidgetStateProperty.all( - const RoundedRectangleBorder( - borderRadius: Corners.s6Border, - ), - ), - side: WidgetStateProperty.all( - BorderSide( - color: style.dividerColor, - ), - ), - ), - onPressed: onPressed, - ), + return AFOutlinedIconTextButton.normal( + text: type.labelText, + onTap: onTap, + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + type.icon, + size: Size.square(18), + blendMode: type.blendMode, + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart similarity index 68% rename from frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart rename to frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart index 7baa243e5f..ed2e060c49 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart @@ -1,8 +1,7 @@ import 'dart:io'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -40,7 +39,7 @@ class ThirdPartySignInButtons extends StatelessWidget { void _signIn(BuildContext context, String provider) { context.read().add( - SignInEvent.signedInWithOAuth(provider), + SignInEvent.signInWithOAuth(platform: provider), ); } } @@ -58,23 +57,22 @@ class _DesktopThirdPartySignIn extends StatefulWidget { } class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { - static const padding = 12.0; - bool isExpanded = false; @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( children: [ - DesktopSignInButton( + DesktopThirdPartySignInButton( key: signInWithGoogleButtonKey, type: ThirdPartySignInButtonType.google, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), - const VSpace(padding), - DesktopSignInButton( + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.apple, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), ], @@ -82,38 +80,38 @@ class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { } List _buildExpandedButtons() { + final theme = AppFlowyTheme.of(context); return [ - const VSpace(padding * 1.5), - DesktopSignInButton( + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.github, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), ), - const VSpace(padding), - DesktopSignInButton( + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.discord, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), ), ]; } List _buildCollapsedButtons() { + final theme = AppFlowyTheme.of(context); return [ - const VSpace(padding), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - setState(() { - isExpanded = !isExpanded; - }); - }, - child: FlowyText( - LocaleKeys.signIn_continueAnotherWay.tr(), - color: Theme.of(context).colorScheme.onSurface, - decoration: TextDecoration.underline, - fontSize: 14, - ), - ), + VSpace(theme.spacing.l), + AFGhostTextButton( + text: 'More options', + textColor: (context, isHovering, disabled) { + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, ), ]; } @@ -153,14 +151,14 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { if (Platform.isIOS) ...[ MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.apple, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), const VSpace(padding), ], MobileThirdPartySignInButton( key: signInWithGoogleButtonKey, type: ThirdPartySignInButtonType.google, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), ], @@ -172,31 +170,33 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { const VSpace(padding), MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.github, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), ), const VSpace(padding), MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.discord, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), ), ]; } List _buildCollapsedButtons() { + final theme = AppFlowyTheme.of(context); return [ const VSpace(padding * 2), - GestureDetector( + AFGhostTextButton( + text: 'More options', + textColor: (context, isHovering, disabled) { + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, onTap: () { setState(() { isExpanded = !isExpanded; }); }, - child: FlowyText( - LocaleKeys.signIn_continueAnotherWay.tr(), - color: Theme.of(context).colorScheme.onSurface, - decoration: TextDecoration.underline, - fontSize: 14, - ), ), ]; } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart index 18e260a472..6d79b896c1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart @@ -1,7 +1,7 @@ -export 'magic_link_sign_in_buttons.dart'; +export 'continue_with/continue_with_email_and_password.dart'; +export 'sign_in_agreement.dart'; export 'sign_in_anonymous_button.dart'; export 'sign_in_or_logout_button.dart'; -export 'third_party_sign_in_button.dart'; +export 'third_party_sign_in_button/third_party_sign_in_button.dart'; // export 'switch_sign_in_sign_up_button.dart'; -export 'third_party_sign_in_buttons.dart'; -export 'sign_in_agreement.dart'; +export 'third_party_sign_in_button/third_party_sign_in_buttons.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart index 8ce09a5b7f..c0b8e7e5ae 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart @@ -8,7 +8,7 @@ class AuthFormContainer extends StatelessWidget { final List children; - static const double width = 340; + static const double width = 320; @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart index c2a13eac82..93ccea25d0 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart @@ -1,8 +1,7 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra/size.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; class FlowyLogoTitle extends StatelessWidget { const FlowyLogoTitle({ @@ -16,24 +15,19 @@ class FlowyLogoTitle extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return SizedBox( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox.fromSize( - size: logoSize, - child: const FlowySvg( - FlowySvgs.flowy_logo_xl, - blendMode: null, - ), - ), + AFLogo(size: logoSize), const VSpace(20), - FlowyText.regular( + Text( title, - fontSize: FontSizes.s24, - fontFamily: - GoogleFonts.poppins(fontWeight: FontWeight.w500).fontFamily, - color: Theme.of(context).colorScheme.tertiary, + style: theme.textStyle.heading.h3( + color: theme.textColorScheme.primary, + ), ), ], ), diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart index 1737494530..2fae0e4872 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -188,6 +188,9 @@ class SidebarPlanBloc extends Bloc { UserEventGetWorkspaceUsage(payload).send().then((result) { result.onSuccess( (usage) { + if (isClosed) { + return; + } add(SidebarPlanEvent.updateWorkspaceUsage(usage)); }, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart index 13f5d832d0..984598f29c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -4,7 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/prelude.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -111,7 +111,7 @@ class _SignInDialogContent extends StatelessWidget { const _DialogHeader(), const _DialogTitle(), const VSpace(16), - const SignInWithMagicLinkButtons(), + const ContinueWithEmailAndPassword(), if (isAuthEnabled) ...[ const VSpace(20), const _OrDivider(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 3bcc840582..7e30c4fa55 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -362,7 +362,7 @@ class OkCancelButton extends StatelessWidget { } } -void showToastNotification({ +ToastificationItem showToastNotification({ String? message, TextSpan? richMessage, String? description, @@ -374,7 +374,7 @@ void showToastNotification({ (message == null) != (richMessage == null), "Exactly one of message or richMessage must be non-null.", ); - toastification.showCustom( + return toastification.showCustom( alignment: Alignment.bottomCenter, autoCloseDuration: const Duration(milliseconds: 3000), callbacks: callbacks ?? const ToastificationCallbacks(), diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/README.md index 75218b1842..953d3545f1 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/README.md +++ b/frontend/appflowy_flutter/packages/appflowy_ui/README.md @@ -9,7 +9,7 @@ AppFlowy UI is a Flutter package that provides a collection of reusable UI compo ## Installation -Add the following to your *app's* `pubspec.yaml` file: +Add the following to your `pubspec.yaml` file: ```yaml dependencies: diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart index 92d4484c0c..974907f940 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart @@ -1,4 +1,2 @@ -library; - export 'src/component/component.dart'; export 'src/theme/theme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index 668644f196..d48c061c71 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -206,7 +206,7 @@ class AppFlowyThemeBuilder { quaternary: colorScheme.neutral.neutral100, quaternaryHover: colorScheme.neutral.neutral200, transparent: colorScheme.neutral.alphaWhite0, - primaryAlpha5: colorScheme.neutral.alphaGrey100005, + primaryAlpha5: colorScheme.neutral.alphaGrey10005, primaryAlpha5Hover: colorScheme.neutral.alphaGrey100010, primaryAlpha80: colorScheme.neutral.alphaGrey100080, primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index fecbc32b89..1c6d66431d 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -126,6 +126,13 @@ packages: relative: true source: path version: "0.0.1" + appflowy_ui: + dependency: "direct main" + description: + path: "packages/appflowy_ui" + relative: true + source: path + version: "1.0.0" archive: dependency: "direct main" description: @@ -2597,5 +2604,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.6.0 <4.0.0" + dart: ">=3.6.2 <4.0.0" flutter: ">=3.27.4" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 9b0987d0b2..0cc553d1a7 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -25,7 +25,8 @@ dependencies: path: packages/appflowy_popover appflowy_result: path: packages/appflowy_result - + appflowy_ui: + path: packages/appflowy_ui archive: ^3.4.10 auto_size_text_field: ^2.2.3 auto_updater: ^1.0.0 diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 43a59974fd..e42660436c 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -37,6 +37,7 @@ "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", "anonymous": "Anonymous", + "anonymousMode": "Anonymous mode", "buttonText": "Sign In", "signingInText": "Signing in...", "forgotPassword": "Forgot Password?", @@ -68,7 +69,16 @@ "logIn": "Log in", "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", - "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes." + "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", + "tokenHasExpiredOrInvalid": "The token has expired or is invalid. Please try again.", + "signingIn": "Signing in...", + "checkYourEmail": "Check your email", + "temporaryVerificationSent": "A temporary verification link has been sent. Please check your inbox at", + "continueToSignIn": "Continue to sign in", + "backToLogin": "Back to login", + "enterCode": "Enter code", + "enterCodeManually": "Enter code manually", + "continueWithEmail": "Continue with email" }, "workspace": { "chooseWorkspace": "Choose your workspace", diff --git a/frontend/rust-lib/.vscode/launch.json b/frontend/rust-lib/.vscode/launch.json new file mode 100644 index 0000000000..3b1e6e62a7 --- /dev/null +++ b/frontend/rust-lib/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "AF-desktop: Debug Rust", + "type": "lldb", + // "request": "attach", + // "pid": "${command:pickMyProcess}" + // To launch the application directly, use the following configuration: + "request": "launch", + "program": "/Users/lucas.xu/Desktop/appflowy_backup/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app", + }, + ] + } diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index a251594ef9..fdf8c8348e 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "bytes", @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "again", "anyhow", @@ -1189,7 +1189,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "futures-channel", "futures-util", @@ -1474,7 +1474,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "bincode", @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "async-trait", @@ -1944,7 +1944,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "bincode", "bytes", @@ -3418,7 +3418,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3433,7 +3433,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "app-error", "jsonwebtoken", @@ -4054,7 +4054,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "bytes", @@ -6754,7 +6754,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 4df29d1fe4..d3427ef99c 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -103,8 +103,8 @@ dashmap = "6.0.1" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f300884" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f300884" } [profile.dev] opt-level = 0 diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 6b2917cc62..3c79cd38ca 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -14,7 +14,7 @@ use client_api::entity::workspace_dto::{ }; use client_api::entity::{ AFRole, AFWorkspace, AFWorkspaceInvitation, AFWorkspaceSettings, AFWorkspaceSettingsChange, - AuthProvider, CollabParams, CreateCollabParams, QueryWorkspaceMember, + AuthProvider, CollabParams, CreateCollabParams, GotrueTokenResponse, QueryWorkspaceMember, }; use client_api::entity::{QueryCollab, QueryCollabParams}; use client_api::{Client, ClientConfiguration}; @@ -121,16 +121,13 @@ where &self, email: &str, password: &str, - ) -> Result { + ) -> Result { let password = password.to_string(); let email = email.to_string(); let try_get_client = self.server.try_get_client(); let client = try_get_client?; - client.sign_in_password(&email, &password).await?; - let profile = client.get_profile().await?; - let token = client.get_token()?; - let profile = user_profile_from_af_profile(token, profile)?; - Ok(profile) + let response = client.sign_in_password(&email, &password).await?; + Ok(response) } async fn sign_in_with_magic_link( @@ -148,6 +145,19 @@ where Ok(()) } + async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result { + let email = email.to_owned(); + let passcode = passcode.to_owned(); + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let response = client.sign_in_with_passcode(&email, &passcode).await?; + Ok(response) + } + async fn generate_oauth_url_with_provider(&self, provider: &str) -> Result { let provider = AuthProvider::from(provider); let try_get_client = self.server.try_get_client(); diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 962c0506bf..8d9e342e85 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -1,4 +1,5 @@ #![allow(unused_variables)] +use client_api::entity::GotrueTokenResponse; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_entity::CollabObject; @@ -98,7 +99,7 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { &self, _email: &str, _password: &str, - ) -> Result { + ) -> Result { Err(FlowyError::local_version_not_support().with_context("Not support")) } @@ -110,6 +111,14 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { Err(FlowyError::local_version_not_support().with_context("Not support")) } + async fn sign_in_with_passcode( + &self, + _email: &str, + _passcode: &str, + ) -> Result { + Err(FlowyError::local_version_not_support().with_context("Not support")) + } + async fn generate_oauth_url_with_provider(&self, _provider: &str) -> Result { Err(FlowyError::internal().with_context("Can't oauth url when using offline mode")) } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs index 65d02c704a..3712307af4 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs @@ -199,6 +199,16 @@ where }) } + fn sign_in_with_passcode( + &self, + _email: &str, + _passcode: &str, + ) -> FutureResult { + FutureResult::new(async { + Err(FlowyError::not_support().with_context("Can't sign in with passcode when using supabase")) + }) + } + fn generate_oauth_url_with_provider(&self, _provider: &str) -> FutureResult { FutureResult::new(async { Err(FlowyError::internal().with_context("Can't generate oauth url when using supabase")) diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index b549c549fc..3f7f39910b 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -4,6 +4,7 @@ use client_api::entity::billing_dto::SubscriptionPlanDetail; pub use client_api::entity::billing_dto::SubscriptionStatus; use client_api::entity::billing_dto::WorkspaceSubscriptionStatus; use client_api::entity::billing_dto::WorkspaceUsageAndLimit; +use client_api::entity::GotrueTokenResponse; pub use client_api::entity::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use collab_entity::{CollabObject, CollabType}; use flowy_error::{internal_error, ErrorCode, FlowyError}; @@ -148,11 +149,17 @@ pub trait UserCloudService: Send + Sync + 'static { &self, email: &str, password: &str, - ) -> Result; + ) -> Result; async fn sign_in_with_magic_link(&self, email: &str, redirect_to: &str) -> Result<(), FlowyError>; + async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result; + /// When the user opens the OAuth URL, it redirects to the corresponding provider's OAuth web page. /// After the user is authenticated, the browser will open a deep link to the AppFlowy app (iOS, macOS, etc.), /// which will call [Client::sign_in_with_url]generate_sign_in_url_with_email to sign in. diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index dbfd9b811a..02ae333aa0 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::convert::TryInto; +use client_api::entity::GotrueTokenResponse; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; @@ -86,6 +87,53 @@ pub struct MagicLinkSignInPB { pub redirect_to: String, } +#[derive(ProtoBuf, Default)] +pub struct PasscodeSignInPB { + #[pb(index = 1)] + pub email: String, + + #[pb(index = 2)] + pub passcode: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct GotrueTokenResponsePB { + #[pb(index = 1)] + pub access_token: String, + + #[pb(index = 2)] + pub token_type: String, + + #[pb(index = 3)] + pub expires_in: i64, + + #[pb(index = 4)] + pub expires_at: i64, + + #[pb(index = 5)] + pub refresh_token: String, + + #[pb(index = 6, one_of)] + pub provider_access_token: Option, + + #[pb(index = 7, one_of)] + pub provider_refresh_token: Option, +} + +impl From for GotrueTokenResponsePB { + fn from(response: GotrueTokenResponse) -> Self { + Self { + access_token: response.access_token, + token_type: response.token_type, + expires_in: response.expires_in, + expires_at: response.expires_at, + refresh_token: response.refresh_token, + provider_access_token: response.provider_access_token, + provider_refresh_token: response.provider_refresh_token, + } + } +} + #[derive(ProtoBuf, Default)] pub struct OauthSignInPB { /// Use this field to store the third party auth information. diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 4d2ee57ce8..0e42525d04 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -41,14 +41,16 @@ fn upgrade_store_preferences( pub async fn sign_in_with_email_password_handler( data: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let auth_type = params.auth_type.clone(); let old_authenticator = manager.cloud_services.get_user_authenticator(); - match manager.sign_in(params, auth_type).await { - Ok(profile) => data_result_ok(UserProfilePB::from(profile)), + match manager + .sign_in_with_password(¶ms.email, ¶ms.password) + .await + { + Ok(token) => data_result_ok(token.into()), Err(err) => { manager .cloud_services @@ -319,6 +321,19 @@ pub async fn sign_in_with_magic_link_handler( Ok(()) } +#[tracing::instrument(level = "debug", skip(data, manager), err)] +pub async fn sign_in_with_passcode_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params = data.into_inner(); + let response = manager + .sign_in_with_passcode(¶ms.email, ¶ms.passcode) + .await?; + data_result_ok(response.into()) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn oauth_sign_in_handler( data: AFPluginData, diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 0807b46170..2de1fdfbdc 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -81,7 +81,7 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting) .event(UserEvent::GetWorkspaceSetting, get_workspace_setting) .event(UserEvent::NotifyDidSwitchPlan, notify_did_switch_plan_handler) - + .event(UserEvent::PasscodeSignIn, sign_in_with_passcode_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -89,7 +89,7 @@ pub fn init(user_manager: Weak) -> AFPlugin { pub enum UserEvent { /// Only use when the [Authenticator] is Local or SelfHosted /// Logging into an account using a register email and password - #[event(input = "SignInPayloadPB", output = "UserProfilePB")] + #[event(input = "SignInPayloadPB", output = "GotrueTokenResponsePB")] SignInWithEmailPassword = 0, /// Only use when the [Authenticator] is Local or SelfHosted @@ -278,6 +278,9 @@ pub enum UserEvent { #[event()] DeleteAccount = 64, + + #[event(input = "PasscodeSignInPB", output = "GotrueTokenResponsePB")] + PasscodeSignIn = 65, } #[async_trait] diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 864629d169..b4bc8911e1 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -1,3 +1,4 @@ +use client_api::entity::GotrueTokenResponse; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; use flowy_error::{internal_error, ErrorCode, FlowyResult}; @@ -719,6 +720,19 @@ impl UserManager { Ok(url) } + pub(crate) async fn sign_in_with_password( + &self, + email: &str, + password: &str, + ) -> Result { + self + .cloud_services + .set_user_authenticator(&Authenticator::AppFlowyCloud); + let auth_service = self.cloud_services.get_user_service()?; + let response = auth_service.sign_in_with_password(email, password).await?; + Ok(response) + } + pub(crate) async fn sign_in_with_magic_link( &self, email: &str, @@ -734,6 +748,19 @@ impl UserManager { Ok(()) } + pub(crate) async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result { + self + .cloud_services + .set_user_authenticator(&Authenticator::AppFlowyCloud); + let auth_service = self.cloud_services.get_user_service()?; + let response = auth_service.sign_in_with_passcode(email, passcode).await?; + Ok(response) + } + pub(crate) async fn generate_oauth_url( &self, oauth_provider: &str, From 4997ac99cf4c6445fc730ab2d19fb8b952299728 Mon Sep 17 00:00:00 2001 From: Morn Date: Thu, 10 Apr 2025 14:45:13 +0800 Subject: [PATCH 122/207] feat: revamp link preivew (#7692) * feat: revamp link preivew * feat: add convert to menu for link hover menu * feat: add mention link * feat: support convert preview to mention * feat: add embed link preview * fix: some test erros * fix: test errors * fix: some UI issues * chore: add test for url * chore: add test for mention * chore: add test for bookmark * chore: add test for embed * chore: remove unuse import * fix: some UI issues * fix: remove text span overlay on mobile * fix: code lint error --------- Co-authored-by: Lucas --- .../document_copy_and_paste_test.dart | 44 +- .../document/document_link_preview_test.dart | 452 ++++++++++++++++++ .../document/document_test_runner_4.dart | 2 + .../uncategorized/emoji_shortcut_test.dart | 5 +- .../presentation/editor_configuration.dart | 19 +- .../copy_and_paste/custom_paste_command.dart | 5 + .../copy_and_paste/paste_from_html.dart | 2 + .../copy_and_paste/paste_from_plain_text.dart | 26 + .../link/link_create_menu.dart | 7 +- .../desktop_toolbar/link/link_hover_menu.dart | 160 ++++++- .../link/link_replace_menu.dart | 184 +++++++ .../link_embed_block_component.dart | 286 +++++++++++ .../link_embed/link_embed_menu.dart | 332 +++++++++++++ .../link_preview/custom_link_parser.dart | 135 ++++++ .../link_preview/custom_link_preview.dart | 116 +++-- .../custom_link_preview_block_component.dart | 204 ++++++++ .../link_preview/link_preview_menu.dart | 262 ++++++---- .../link_preview/paste_as/paste_as_menu.dart | 253 ++++++++++ .../editor_plugins/link_preview/shared.dart | 173 ++++++- .../editor_plugins/mention/mention_block.dart | 16 + .../mention/mention_link_block.dart | 337 +++++++++++++ .../mention/mention_link_error_preview.dart | 232 +++++++++ .../mention/mention_link_preview.dart | 271 +++++++++++ .../editor_plugins/menu/menu_extension.dart | 116 +++++ .../custom_hightlight_color_toolbar_item.dart | 3 + .../document/presentation/editor_style.dart | 5 +- .../lib/plugins/emoji/emoji_handler.dart | 3 +- .../lib/startup/tasks/app_widget.dart | 28 +- .../lib/util/default_extensions.dart | 8 + .../lib/src/theme/data/builder.dart | 4 +- .../flowy_infra/lib/theme_extension_v2.dart | 8 + .../lib/widget/flowy_tooltip.dart | 1 - frontend/appflowy_flutter/pubspec.lock | 8 + frontend/appflowy_flutter/pubspec.yaml | 1 + .../flowy_icons/20x/embed_fullscreen.svg | 3 + .../resources/flowy_icons/20x/turninto.svg | 3 + .../resources/flowy_icons/40x/embed_error.svg | 7 + frontend/resources/translations/en.json | 24 +- 38 files changed, 3571 insertions(+), 174 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart create mode 100644 frontend/resources/flowy_icons/20x/embed_fullscreen.svg create mode 100644 frontend/resources/flowy_icons/20x/turninto.svg create mode 100644 frontend/resources/flowy_icons/40x/embed_error.svg diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart index c18b42939c..ee82f01d3f 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -1,10 +1,12 @@ +import 'dart:async'; import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -320,8 +322,14 @@ void main() { (tester) async { const url = 'https://appflowy.io'; await tester.pasteContent(plainText: url, (editorState) async { + final pasteAsMenu = find.byType(PasteAsMenu); + expect(pasteAsMenu, findsOneWidget); + final bookmarkButton = find.text( + LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), + ); + await tester.tapButton(bookmarkButton); // the second one is the paragraph node - expect(editorState.document.root.children.length, 2); + expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], url); @@ -333,19 +341,19 @@ void main() { await tester.hoverOnWidget( find.byType(CustomLinkPreviewWidget), onHover: () async { - final convertToLinkButton = find.byWidgetPredicate((widget) { - return widget is MenuBlockButton && - widget.tooltip == - LocaleKeys.document_plugins_urlPreview_convertToLink.tr(); - }); + /// show menu + final menu = find.byType(CustomLinkPreviewMenu); + expect(menu, findsOneWidget); + await tester.tapButton(menu); + + final convertToLinkButton = find.text( + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl.tr(), + ); expect(convertToLinkButton, findsOneWidget); - await tester.tap(convertToLinkButton); - await tester.pumpAndSettle(); + await tester.tapButton(convertToLinkButton); }, ); - await tester.pumpAndSettle(); - final editorState = tester.editor.getCurrentEditorState(); final textNode = editorState.getNodeAtPath([0])!; expect(textNode.type, ParagraphBlockKeys.type); @@ -363,14 +371,20 @@ void main() { (tester) async { const url = 'https://appflowy.io'; await tester.pasteContent(plainText: url, (editorState) async { + final pasteAsMenu = find.byType(PasteAsMenu); + expect(pasteAsMenu, findsOneWidget); + final bookmarkButton = find.text( + LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), + ); + await tester.tapButton(bookmarkButton); // the second one is the paragraph node - expect(editorState.document.root.children.length, 2); + expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], url); }); - await tester.editor.tapLineOfEditorAt(0); + await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: @@ -521,7 +535,7 @@ void main() { extension on WidgetTester { Future pasteContent( - void Function(EditorState editorState) test, { + FutureOr Function(EditorState editorState) test, { Future Function(EditorState editorState)? beforeTest, String? plainText, String? html, @@ -558,6 +572,6 @@ extension on WidgetTester { ); await pumpAndSettle(const Duration(milliseconds: 1000)); - test(editor.getCurrentEditorState()); + await test(editor.getCurrentEditorState()); } } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart new file mode 100644 index 0000000000..f74011ee2b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart @@ -0,0 +1,452 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const avaliableLink = 'https://appflowy.io/', + unavailableLink = 'www.thereIsNoting.com'; + + Future preparePage(WidgetTester tester, {String? pageName}) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: pageName); + await tester.editor.tapLineOfEditorAt(0); + } + + Future pasteLink(WidgetTester tester, String link) async { + await getIt() + .setData(ClipboardServiceData(plainText: link)); + + /// paste the link + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(Duration(seconds: 1)); + } + + Future pasteAs( + WidgetTester tester, + String link, + PasteMenuType type, { + Duration waitTime = const Duration(milliseconds: 500), + }) async { + await pasteLink(tester, link); + final convertToMentionButton = find.text(type.title); + await tester.tapButton(convertToMentionButton); + await tester.pumpAndSettle(waitTime); + } + + void checkUrl(Node node, String link) { + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': link, + 'attributes': {'href': link}, + } + ]); + } + + void checkMention(Node node, String link) { + final delta = node.delta!; + final insert = (delta.first as TextInsert).text; + final attributes = delta.first.attributes; + expect(insert, MentionBlockKeys.mentionChar); + final mention = + attributes?[MentionBlockKeys.mention] as Map; + expect(mention[MentionBlockKeys.type], MentionType.externalLink.name); + expect(mention[MentionBlockKeys.url], avaliableLink); + } + + void checkBookmark(Node node, String link) { + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], link); + } + + void checkEmbed(Node node, String link) { + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed); + expect(node.attributes[LinkPreviewBlockKeys.url], link); + } + + group('Paste as URL', () { + Future pasteAndTurnInto( + WidgetTester tester, + String link, + String title, + ) async { + await pasteLink(tester, link); + final convertToLinkButton = find + .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); + await tester.tapButton(convertToLinkButton); + + /// hover link and turn into mention + await tester.hoverOnWidget( + find.byType(LinkHoverTrigger), + onHover: () async { + final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(turnintoButton); + final convertToButton = find.text(title); + await tester.tapButton(convertToButton); + await tester.pumpAndSettle(Duration(seconds: 1)); + }, + ); + } + + testWidgets('paste a link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteLink(tester, link); + final convertToLinkButton = find + .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); + await tester.tapButton(convertToLinkButton); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link and turn into mention', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toMention.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link and turn into bookmark', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toBookmark.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste a link and turn into embed', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toEmbed.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + }); + + group('Paste as Mention', () { + Future pasteAsMention(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.mention); + + String getMentionLink(Node node) { + final insert = node.delta?.first as TextInsert?; + final mention = insert?.attributes?[MentionBlockKeys.mention] + as Map?; + return mention?[MentionBlockKeys.url] ?? ''; + } + + Future hoverMentionAndClick( + WidgetTester tester, + String command, + ) async { + final mentionLink = find.byType(MentionLinkBlock); + expect(mentionLink, findsOneWidget); + await tester.hoverOnWidget( + mentionLink, + onHover: () async { + final errorPreview = find.byType(MentionLinkErrorPreview); + expect(errorPreview, findsOneWidget); + final convertButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(convertButton); + final menuButton = find.text(command); + await tester.tapButton(menuButton); + }, + ); + } + + testWidgets('paste a link as mention', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste as mention and copy link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + final mentionLink = find.byType(MentionLinkBlock); + expect(mentionLink, findsOneWidget); + await tester.hoverOnWidget( + mentionLink, + onHover: () async { + final preview = find.byType(MentionLinkPreview); + if (!preview.hasFound) { + final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(copyButton); + } else { + final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); + await tester.tapButton(moreOptionButton); + final copyButton = + find.text(MentionLinktMenuCommand.copyLink.title); + await tester.tapButton(copyButton); + } + }, + ); + final clipboardContent = await getIt().getData(); + expect(clipboardContent.plainText, link); + }); + + testWidgets('paste as error mention and turninto url', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toURL.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste as error mention and turninto embed', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toEmbed.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste as error mention and turninto bookmark', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toBookmark.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste as error mention and remove link', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.removeLink.title, + ); + node = tester.editor.getNodeAtPath([0]); + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + {'insert': link}, + ]); + }); + }); + + group('Paste as Bookmark', () { + Future pasteAsBookmark(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.bookmark); + + Future hoverAndClick( + WidgetTester tester, + LinkPreviewMenuCommand command, + ) async { + final bookmark = find.byType(CustomLinkPreviewBlockComponent); + expect(bookmark, findsOneWidget); + await tester.hoverOnWidget( + bookmark, + onHover: () async { + final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); + await tester.tapButton(menuButton); + final commandButton = find.text(command.title); + await tester.tapButton(commandButton); + }, + ); + } + + testWidgets('paste a link as bookmark', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste a link as bookmark and convert to mention', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link as bookmark and convert to url', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link as bookmark and convert to embed', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed); + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste a link as bookmark and copy link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink); + final clipboardContent = await getIt().getData(); + expect(clipboardContent.plainText, link); + }); + + testWidgets('paste a link as bookmark and replace link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.replace); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.delete); + await tester.enterText(find.byType(TextFormField), unavailableLink); + await tester.tapButton(find.text(LocaleKeys.button_replace.tr())); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, unavailableLink); + }); + + testWidgets('paste a link as bookmark and remove link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink); + final node = tester.editor.getNodeAtPath([0]); + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + {'insert': link}, + ]); + }); + }); + group('Paste as Embed', () { + Future pasteAsEmbed(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.embed); + + Future hoverAndConvert( + WidgetTester tester, + PasteMenuType command, + ) async { + final embed = find.byType(LinkEmbedBlockComponent); + expect(embed, findsOneWidget); + await tester.hoverOnWidget( + embed, + onHover: () async { + final menuButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(menuButton); + final commandButton = find.text(command.title); + await tester.tapButton(commandButton); + }, + ); + } + + testWidgets('paste a link as embed', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste a link as bookmark and convert to mention', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, PasteMenuType.mention); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link as bookmark and convert to url', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, PasteMenuType.url); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link as bookmark and convert to bookmark', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, PasteMenuType.bookmark); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart index a05545753e..bc0671834b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart @@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart' as document_with_multi_image_block_test; import 'document_with_simple_table_test.dart' as document_with_simple_table_test; +import 'document_link_preview_test.dart' as document_link_preview_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -28,4 +29,5 @@ void main() { document_find_menu_test.main(); document_toolbar_test.main(); document_with_simple_table_test.main(); + document_link_preview_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart index aba3a7be06..1a4e57078f 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -43,7 +43,10 @@ void main() { }); group('insert emoji by colon', () { - Future createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async { + Future createNewDocumentAndShowEmojiList( + WidgetTester tester, { + String? search, + }) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 67ab383eba..b3d99ab84b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -16,6 +16,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'editor_plugins/link_preview/custom_link_preview_block_component.dart'; import 'editor_plugins/page_block/custom_page_block_component.dart'; /// A global configuration for the editor. @@ -969,11 +970,11 @@ OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder( ); } -LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( +CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { - return LinkPreviewBlockComponentBuilder( + return CustomLinkPreviewBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (UniversalPlatform.isMobile) { @@ -983,20 +984,6 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( }, ), cache: LinkPreviewDataCache(), - showMenu: true, - menuBuilder: (context, node, state) => Positioned( - top: 10, - right: 0, - child: LinkPreviewMenu(node: node, state: state), - ), - builder: (_, node, url, title, description, imageUrl) => - CustomLinkPreviewWidget( - node: node, - url: url, - title: title, - description: description, - imageUrl: imageUrl, - ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart index 5c25d470d5..dd3f7362a9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:http/http.dart' as http; import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; /// - support /// - desktop @@ -162,6 +163,7 @@ Future _pasteAsLinkPreview( EditorState editorState, String? text, ) async { + final isMobile = UniversalPlatform.isMobile; // the url should contain a protocol if (text == null || !isURL(text, {'require_protocol': true})) { return false; @@ -193,6 +195,8 @@ Future _pasteAsLinkPreview( return false; } + if (!isMobile && !isImageUrl) return false; + // insert the text with link format final textTransaction = editorState.transaction ..insertText( @@ -250,6 +254,7 @@ Future doPlainPaste(EditorState editorState) async { } Future _isImageUrl(String text) async { + if (isNotImageUrl(text)) return false; final response = await http.head(Uri.parse(text)); if (response.statusCode == 200) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart index 6eff666991..3f11759545 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; @@ -13,6 +14,7 @@ extension PasteFromHtml on EditorState { } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); + checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart index 4728eb7cf6..fcb12cefa5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart @@ -1,6 +1,8 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; extension PasteFromPlainText on EditorState { Future pastePlainText(String plainText) async { @@ -41,6 +43,7 @@ extension PasteFromPlainText on EditorState { } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); + checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } @@ -65,6 +68,29 @@ extension PasteFromPlainText on EditorState { AppFlowyRichTextKeys.href: plainText, }); await apply(transaction); + checkToShowPasteAsMenu(node); return true; } + + void checkToShowPasteAsMenu(Node node) { + if (selection == null || !selection!.isCollapsed) return; + if (UniversalPlatform.isMobile) return; + final href = _getLinkFromNode(node); + if (href != null) { + final context = document.root.context; + if (context != null && context.mounted) { + PasteAsMenuService(context: context, editorState: this).show(href); + } + } + } + + String? _getLinkFromNode(Node node) { + final delta = node.delta; + if (delta == null) return null; + final inserts = delta.whereType(); + if (inserts.isEmpty || inserts.length > 1) return null; + final link = inserts.first.attributes?.href; + if (link != null) return inserts.first.text; + return null; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart index bc14073193..a6e1a78299 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -304,11 +304,14 @@ void showLinkCreateMenu( return (left, top, right, bottom, alignment); } -ShapeDecoration buildToolbarLinkDecoration(BuildContext context) => +ShapeDecoration buildToolbarLinkDecoration( + BuildContext context, { + double radius = 12.0, +}) => ShapeDecoration( color: Theme.of(context).cardColor, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(radius), ), shadows: [ const BoxShadow( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart index 1abf70028f..c992e40c61 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -6,6 +6,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/startup/startup.dart'; @@ -14,6 +17,7 @@ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -139,6 +143,7 @@ class _LinkHoverTriggerState extends State { isHoverMenuHovering = false; tryToDismissLinkHoverMenu(); }, + onConvertTo: (type) => convertLinkTo(editorState, selection, type), onOpenLink: openLink, onCopyLink: () => copyLink(context), onEditLink: showLinkEditMenu, @@ -236,14 +241,7 @@ class _LinkHoverTriggerState extends State { Future copyLink(BuildContext context) async { final href = widget.attribute.href ?? ''; - if (href.isEmpty) return; - await getIt() - .setData(ClipboardServiceData(plainText: href)); - if (context.mounted) { - showToastNotification( - message: LocaleKeys.shareAction_copyLinkSuccess.tr(), - ); - } + await context.copyLink(href); hoverMenuController.close(); } @@ -270,6 +268,26 @@ class _LinkHoverTriggerState extends State { editorState.apply(transaction); } + Future convertLinkTo( + EditorState editorState, + Selection selection, + LinkConvertMenuCommand type, + ) async { + final url = widget.attribute.href ?? ''; + if (type == LinkConvertMenuCommand.toBookmark) { + await convertUrlToLinkPreview(editorState, selection, url); + } else if (type == LinkConvertMenuCommand.toMention) { + await convertUrlToMention(editorState, selection); + } else if (type == LinkConvertMenuCommand.toEmbed) { + await convertUrlToLinkPreview( + editorState, + selection, + url, + previewType: LinkEmbedKeys.embed, + ); + } + } + void onRemoveAndReplaceLink( EditorState editorState, Selection selection, @@ -307,6 +325,7 @@ class LinkHoverMenu extends StatefulWidget { required this.onOpenLink, required this.onEditLink, required this.onRemoveLink, + required this.onConvertTo, }); final Attributes attribute; @@ -317,6 +336,7 @@ class LinkHoverMenu extends StatefulWidget { final VoidCallback onOpenLink; final VoidCallback onEditLink; final VoidCallback onRemoveLink; + final ValueChanged onConvertTo; @override State createState() => _LinkHoverMenuState(); @@ -326,6 +346,8 @@ class _LinkHoverMenuState extends State { ViewPB? currentView; late bool isPage = widget.attribute.isPage; late String href = widget.attribute.href ?? ''; + final popoverController = PopoverController(); + bool isConvertButtonSelected = false; @override void initState() { @@ -333,6 +355,12 @@ class _LinkHoverMenuState extends State { if (isPage) getPageView(); } + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + @override Widget build(BuildContext context) { return Column( @@ -364,6 +392,7 @@ class _LinkHoverMenuState extends State { FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_m), tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, width: 36, height: 32, onPressed: widget.onCopyLink, @@ -371,13 +400,16 @@ class _LinkHoverMenuState extends State { FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_edit_m), tooltipText: LocaleKeys.editor_editLink.tr(), + preferBelow: false, width: 36, height: 32, onPressed: widget.onEditLink, ), + buildConvertButton(), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), tooltipText: LocaleKeys.editor_removeLink.tr(), + preferBelow: false, width: 36, height: 32, onPressed: widget.onRemoveLink, @@ -444,6 +476,73 @@ class _LinkHoverMenuState extends State { ), ); } + + Widget buildConvertButton() { + return AppFlowyPopover( + offset: Offset(44, 10.0), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg(FlowySvgs.turninto_m), + isSelected: isConvertButtonSelected, + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: () { + setState(() { + isConvertButtonSelected = true; + }); + showConvertMenu(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(LinkConvertMenuCommand.values.length, (index) { + final command = LinkConvertMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () { + widget.onConvertTo(command); + closeConvertMenu(); + }, + ), + ); + }), + ), + ), + ); + } + + void showConvertMenu() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void closeConvertMenu() { + popoverController.close(); + } } class HoverTriggerKey { @@ -489,3 +588,48 @@ class LinkHoverTriggers { callbacks.first.call(); } } + +enum LinkConvertMenuCommand { + toMention, + toBookmark, + toEmbed; + + String get title { + switch (this) { + case toMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + } + } + + String get type { + switch (this) { + case toMention: + return MentionBlockKeys.type; + case toBookmark: + return LinkPreviewBlockKeys.type; + case toEmbed: + return LinkPreviewBlockKeys.type; + } + } +} + +extension LinkExtension on BuildContext { + Future copyLink(String link) async { + if (link.isEmpty) return; + await getIt() + .setData(ClipboardServiceData(plainText: link)); + if (mounted) { + showToastNotification( + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart new file mode 100644 index 0000000000..d08442d779 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart @@ -0,0 +1,184 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:flutter/services.dart'; + +import 'link_create_menu.dart'; +import 'link_styles.dart'; + +void showReplaceMenu({ + required BuildContext context, + required EditorState editorState, + required Node node, + String? url, + required LTRB ltrb, + required ValueChanged onReplace, +}) { + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: ltrb.top, + bottom: ltrb.bottom, + left: ltrb.left, + right: ltrb.right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkReplaceMenu( + link: url ?? '', + onSubmitted: (link) async { + onReplace.call(link); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +class LinkReplaceMenu extends StatefulWidget { + const LinkReplaceMenu({ + super.key, + required this.onSubmitted, + required this.link, + required this.onDismiss, + }); + + final ValueChanged onSubmitted; + final VoidCallback onDismiss; + final String link; + + @override + State createState() => _LinkReplaceMenuState(); +} + +class _LinkReplaceMenuState extends State { + bool showErrorText = false; + late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); + late TextEditingController textEditingController = + TextEditingController(text: widget.link); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + @override + void dispose() { + focusNode.dispose(); + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 330, + padding: EdgeInsets.all(8), + decoration: buildToolbarLinkDecoration(context), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: buildLinkField()), + HSpace(8), + buildReplaceButton(), + ], + ), + ); + } + + Widget buildLinkField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 32, + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: true, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: textEditingController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_pasteHint + .tr(), + context, + showErrorBorder: showErrorText, + ), + ), + ), + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + } + + Widget buildReplaceButton() { + return FlowyTextButton( + LocaleKeys.button_replace.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + fontWeight: FontWeight.w400, + onPressed: onSubmit, + ); + } + + void onSubmit() { + final link = textEditingController.text.trim(); + if (link.isEmpty || !isUri(link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onSubmitted.call(link); + } + + KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + if (key.logicalKey == LogicalKeyboardKey.escape) { + widget.onDismiss.call(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.enter) { + onSubmit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart new file mode 100644 index 0000000000..6dc6501c03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart @@ -0,0 +1,286 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:appflowy_ui/appflowy_ui.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 'link_embed_menu.dart'; + +class LinkEmbedKeys { + const LinkEmbedKeys._(); + static const String previewType = 'preview_type'; + static const String embed = 'embed'; + static const String align = 'align'; +} + +Node linkEmbedNode({required String url}) => Node( + type: LinkPreviewBlockKeys.type, + attributes: { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: LinkEmbedKeys.embed, + }, + ); + +class LinkEmbedBlockComponent extends BlockComponentStatefulWidget { + const LinkEmbedBlockComponent({ + super.key, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + required super.node, + }); + + @override + State createState() => + LinkEmbedBlockComponentState(); +} + +class LinkEmbedBlockComponentState extends State + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; + + EmbedLoadingStatus status = EmbedLoadingStatus.loading; + final parser = LinkParser(); + LinkInfo linkInfo = LinkInfo(); + + final showActionsNotifier = ValueNotifier(false); + bool isMenuShowing = false, isHovering = false; + + @override + void initState() { + super.initState(); + parser.addLinkInfoListener((v) { + if (mounted) { + setState(() { + if (v.isEmpty() && linkInfo.isEmpty()) { + status = EmbedLoadingStatus.error; + } else { + linkInfo = v; + status = EmbedLoadingStatus.idle; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + parser.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget result = MouseRegion( + onEnter: (_) { + isHovering = true; + showActionsNotifier.value = true; + }, + onExit: (_) { + isHovering = false; + Future.delayed(const Duration(milliseconds: 200), () { + if (isMenuShowing || isHovering) return; + if (mounted) showActionsNotifier.value = false; + }); + }, + child: buildChild(context), + ); + result = Padding(padding: padding, child: result); + + if (widget.showActions && widget.actionBuilder != null) { + result = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: result, + ); + } + return result; + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + fillSceme = theme.fillColorScheme, + borderScheme = theme.borderColorScheme; + Widget child; + final isIdle = status == EmbedLoadingStatus.idle; + if (isIdle) { + child = buildContent(context); + } else { + child = buildErrorLoadingWidget(context); + } + return Container( + height: 450, + decoration: BoxDecoration( + color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover, + borderRadius: BorderRadius.all(Radius.circular(16)), + border: Border.all(color: borderScheme.greyTertiary), + ), + child: Stack( + children: [ + child, + buildMenu(context), + ], + ), + ); + } + + Widget buildMenu(BuildContext context) { + return Positioned( + top: 12, + right: 12, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + if (!showActions) return SizedBox.shrink(); + return LinkEmbedMenu( + editorState: context.read(), + node: node, + onReload: () { + setState(() { + status = EmbedLoadingStatus.loading; + }); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) parser.start(url); + }); + }, + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + ); + }, + ), + ); + } + + Widget buildContent(BuildContext context) { + final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: MediaQuery.of(context).size.width, + ), + ), + ), + Container( + height: 64, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: Center( + child: linkInfo.buildIconWidget(size: Size.square(32)), + ), + ), + HSpace(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + linkInfo.siteName ?? '', + color: textScheme.primary, + fontSize: 14, + figmaLineHeight: 20, + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + ), + VSpace(4), + FlowyText.regular( + url, + color: textScheme.secondary, + fontSize: 12, + figmaLineHeight: 16, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget buildErrorLoadingWidget(BuildContext context) { + final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme; + final isLoading = status == EmbedLoadingStatus.loading; + return isLoading + ? Center( + child: SizedBox.square( + dimension: 64, + child: CircularProgressIndicator.adaptive(), + ), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + FlowySvgs.embed_error_xl.path, + ), + VSpace(4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + TextSpan( + text: '$url ', + style: TextStyle( + color: textSceme.primary, + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w700, + ), + ), + TextSpan( + text: LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_refuseConnect + .tr(), + style: TextStyle( + color: textSceme.primary, + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +enum EmbedLoadingStatus { loading, idle, error } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart new file mode 100644 index 0000000000..8b9ac122a8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -0,0 +1,332 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +import 'link_embed_block_component.dart'; + +class LinkEmbedMenu extends StatefulWidget { + const LinkEmbedMenu({ + super.key, + required this.node, + required this.editorState, + required this.onMenuShowed, + required this.onMenuHided, + required this.onReload, + }); + + final Node node; + final EditorState editorState; + final VoidCallback onMenuShowed; + final VoidCallback onMenuHided; + final VoidCallback onReload; + + @override + State createState() => _LinkEmbedMenuState(); +} + +class _LinkEmbedMenuState extends State { + final turnintoController = PopoverController(); + final moreOptionController = PopoverController(); + int turnintoMenuNum = 0, moreOptionNum = 0, alignMenuNum = 0; + final moreOptionButtonKey = GlobalKey(); + bool get isTurnIntoShowing => turnintoMenuNum > 0; + bool get isMoreOptionShowing => moreOptionNum > 0; + bool get isAlignMenuShowing => alignMenuNum > 0; + + Node get node => widget.node; + EditorState get editorState => widget.editorState; + + String get url => node.attributes[LinkPreviewBlockKeys.url] ?? ''; + + @override + void dispose() { + super.dispose(); + turnintoController.close(); + moreOptionController.close(); + widget.onMenuHided.call(); + } + + @override + Widget build(BuildContext context) { + return buildChild(); + } + + Widget buildChild() { + final theme = AppFlowyTheme.of(context), + iconScheme = theme.iconColorTheme, + fillScheme = theme.fillColorScheme; + + return Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: fillScheme.primaryAlpha80, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // FlowyIconButton( + // icon: FlowySvg( + // FlowySvgs.embed_fullscreen_m, + // color: iconScheme.tertiary, + // ), + // tooltipText: LocaleKeys.document_imageBlock_openFullScreen.tr(), + // preferBelow: false, + // onPressed: () {}, + // ), + FlowyIconButton( + icon: FlowySvg( + FlowySvgs.toolbar_link_m, + color: iconScheme.tertiary, + ), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + onPressed: () => copyLink(context), + ), + buildTurnIntoBotton(), + buildMoreOptionBotton(), + ], + ), + ); + } + + Widget buildTurnIntoBotton() { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; + return AppFlowyPopover( + offset: Offset(0, 6), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: turnintoController, + onOpen: () { + keepEditorFocusNotifier.increase(); + turnintoMenuNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + turnintoMenuNum--; + checkToHideMenu(); + }, + popupBuilder: (context) => buildTurnIntoMenu(), + child: FlowyIconButton( + icon: FlowySvg( + FlowySvgs.turninto_m, + color: iconScheme.tertiary, + ), + tooltipText: LocaleKeys.document_toolbar_turnInto.tr(), + preferBelow: false, + onPressed: showTurnIntoMenu, + ), + ); + } + + Widget buildTurnIntoMenu() { + final types = + PasteMenuType.values.where((e) => e != PasteMenuType.embed).toList(); + return Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(types.length, (index) { + final command = types[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () { + if (command == PasteMenuType.bookmark) { + final transaction = editorState.transaction; + transaction.updateNode(node, { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: '', + }); + editorState.apply(transaction); + } else if (command == PasteMenuType.mention) { + convertUrlPreviewNodeToMention(editorState, node); + } else if (command == PasteMenuType.url) { + convertUrlPreviewNodeToLink(editorState, node); + } + }, + ), + ); + }), + ), + ); + } + + Widget buildMoreOptionBotton() { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; + return AppFlowyPopover( + offset: Offset(0, 6), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: moreOptionController, + onOpen: () { + keepEditorFocusNotifier.increase(); + moreOptionNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + moreOptionNum--; + checkToHideMenu(); + }, + popupBuilder: (context) => buildMoreOptionMenu(), + child: FlowyIconButton( + key: moreOptionButtonKey, + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + color: iconScheme.tertiary, + ), + tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(), + preferBelow: false, + onPressed: showMoreOptionMenu, + ), + ); + } + + Widget buildMoreOptionMenu() { + final types = LinkEmbedMenuCommand.values; + return Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(types.length, (index) { + final command = types[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onEmbedMenuCommand(command), + ), + ); + }), + ), + ); + } + + void showTurnIntoMenu() { + keepEditorFocusNotifier.increase(); + turnintoController.show(); + checkToShowMenu(); + turnintoMenuNum++; + if (isMoreOptionShowing) closeMoreOptionMenu(); + } + + void closeTurnIntoMenu() { + turnintoController.close(); + checkToHideMenu(); + } + + void showMoreOptionMenu() { + keepEditorFocusNotifier.increase(); + moreOptionController.show(); + checkToShowMenu(); + moreOptionNum++; + if (isTurnIntoShowing) closeTurnIntoMenu(); + } + + void closeMoreOptionMenu() { + moreOptionController.close(); + checkToHideMenu(); + } + + void checkToHideMenu() { + Future.delayed(Duration(milliseconds: 200), () { + if (!mounted) return; + if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { + widget.onMenuHided.call(); + } + }); + } + + void checkToShowMenu() { + if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { + widget.onMenuShowed.call(); + } + } + + Future copyLink(BuildContext context) async { + await context.copyLink(url); + widget.onMenuHided.call(); + } + + void onEmbedMenuCommand(LinkEmbedMenuCommand command) { + switch (command) { + case LinkEmbedMenuCommand.openLink: + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + break; + case LinkEmbedMenuCommand.replace: + final box = moreOptionButtonKey.currentContext?.findRenderObject() + as RenderBox?; + if (box == null) return; + final p = box.localToGlobal(Offset.zero); + showReplaceMenu( + context: context, + editorState: editorState, + node: node, + url: url, + ltrb: LTRB(left: p.dx - 330, top: p.dy), + onReplace: (url) async { + await convertLinkBlockToOtherLinkBlock( + editorState, + node, + node.type, + url: url, + ); + }, + ); + break; + case LinkEmbedMenuCommand.reload: + widget.onReload.call(); + break; + case LinkEmbedMenuCommand.removeLink: + removeUrlPreviewLink(editorState, node); + break; + } + closeMoreOptionMenu(); + } +} + +enum LinkEmbedMenuCommand { + openLink, + replace, + reload, + removeLink; + + String get title { + switch (this) { + case openLink: + return LocaleKeys.editor_openLink.tr(); + case replace: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace + .tr(); + case reload: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart new file mode 100644 index 0000000000..0032d9cd33 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:favicon/favicon.dart'; +import 'package:flutter/material.dart'; +// ignore: depend_on_referenced_packages +import 'package:flutter_link_previewer/flutter_link_previewer.dart' hide Size; + +class LinkParser { + static final LinkInfoCache _cache = LinkInfoCache(); + final Set> _listeners = >{}; + + Future start(String url) async { + final data = await _cache.get(url); + if (data != null) { + refreshLinkInfo(data); + } + await _getLinkInfo(url); + } + + Future _getLinkInfo(String url) async { + try { + final previewData = await getPreviewData(url); + final favicon = await FaviconFinder.getBest(url); + final linkInfo = LinkInfo( + siteName: previewData.title, + description: previewData.description, + imageUrl: previewData.image?.url, + faviconUrl: favicon?.url, + ); + if (!linkInfo.isEmpty()) await _cache.set(url, linkInfo); + refreshLinkInfo(linkInfo); + return linkInfo; + } catch (e, s) { + Log.error('get link info error: ', e, s); + refreshLinkInfo(LinkInfo()); + return null; + } + } + + void refreshLinkInfo(LinkInfo info) { + for (final listener in _listeners) { + listener(info); + } + } + + void addLinkInfoListener(ValueChanged listener) { + _listeners.add(listener); + } + + void dispose() { + _listeners.clear(); + } +} + +class LinkInfo { + factory LinkInfo.fromJson(Map json) => LinkInfo( + siteName: json['siteName'], + description: json['description'], + imageUrl: json['imageUrl'], + faviconUrl: json['faviconUrl'], + ); + + LinkInfo({ + this.siteName, + this.description, + this.imageUrl, + this.faviconUrl, + }); + + final String? siteName; + final String? description; + final String? imageUrl; + final String? faviconUrl; + + Map toJson() => { + 'siteName': siteName, + 'description': description, + 'imageUrl': imageUrl, + 'faviconUrl': faviconUrl, + }; + + bool isEmpty() { + return siteName == null || + description == null || + imageUrl == null || + faviconUrl == null; + } + + Widget buildIconWidget({Size size = const Size.square(20.0)}) { + final iconUrl = faviconUrl; + if (iconUrl == null) { + return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size); + } + if (iconUrl.endsWith('.svg')) { + return FlowyNetworkSvg( + iconUrl, + height: size.height, + width: size.width, + errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m), + ); + } + return FlowyNetworkImage( + url: iconUrl, + fit: BoxFit.contain, + height: size.height, + width: size.width, + errorWidgetBuilder: (context, error, stackTrace) => + const FlowySvg(FlowySvgs.toolbar_link_earth_m), + ); + } +} + +class LinkInfoCache { + final _linkInfoPrefix = 'link_info'; + + Future get(String url) async { + final option = await getIt().getWithFormat( + _linkInfoPrefix + url, + (value) => LinkInfo.fromJson(jsonDecode(value)), + ); + return option; + } + + Future set(String url, LinkInfo data) async { + await getIt().set( + _linkInfoPrefix + url, + jsonEncode(data.toJson()), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index 879a71f008..0b6ad9fd7b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; class CustomLinkPreviewWidget extends StatelessWidget { const CustomLinkPreviewWidget({ @@ -21,6 +22,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { this.title, this.description, this.imageUrl, + this.isHovering = false, + this.status = LinkPreviewStatus.loading, }); final Node node; @@ -28,9 +31,14 @@ class CustomLinkPreviewWidget extends StatelessWidget { final String? description; final String? imageUrl; final String url; + final bool isHovering; + final LinkPreviewStatus status; @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + borderScheme = theme.borderColorScheme, + textScheme = theme.textColorScheme; final documentFontSize = context .read() .editorStyle @@ -39,69 +47,60 @@ class CustomLinkPreviewWidget extends StatelessWidget { .fontSize ?? 16.0; final (fontSize, width) = UniversalPlatform.isDesktopOrWeb - ? (documentFontSize, 180.0) + ? (documentFontSize, 160.0) : (documentFontSize - 2, 120.0); final Widget child = Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.onSurface, - ), - borderRadius: BorderRadius.circular( - 6.0, + color: isHovering + ? borderScheme.greyTertiaryHover + : borderScheme.greyTertiary, ), + borderRadius: BorderRadius.circular(16.0), ), - child: IntrinsicHeight( + child: SizedBox( + height: 96, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (imageUrl != null) - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6.0), - bottomLeft: Radius.circular(6.0), - ), - child: FlowyNetworkImage( - url: imageUrl!, - width: width, - ), - ), + buildImage(context), Expanded( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.fromLTRB(20, 12, 60, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + buildLoadingOrErrorWidget(), if (title != null) Padding( - padding: const EdgeInsets.only( - bottom: 4.0, - right: 10.0, - ), + padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText.medium( title!, - maxLines: 2, overflow: TextOverflow.ellipsis, fontSize: fontSize, + color: textScheme.primary, + figmaLineHeight: 20, ), ), if (description != null) Padding( - padding: const EdgeInsets.only(bottom: 4.0), + padding: const EdgeInsets.only(bottom: 16.0), child: FlowyText( description!, - maxLines: 2, overflow: TextOverflow.ellipsis, fontSize: fontSize - 4, + figmaLineHeight: 16, + color: textScheme.primary, ), ), FlowyText( url.toString(), overflow: TextOverflow.ellipsis, - maxLines: 2, - color: Theme.of(context).hintColor, + color: textScheme.secondary, fontSize: fontSize - 4, + figmaLineHeight: 16, ), ], ), @@ -150,4 +149,67 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), ]; } + + Widget buildImage(BuildContext context) { + final theme = AppFlowyTheme.of(context), + fillScheme = theme.fillColorScheme, + iconScheme = theme.iconColorTheme; + final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0; + Widget child; + if (imageUrl != null) { + child = FlowyNetworkImage( + url: imageUrl!, + width: width, + ); + } else { + child = Center( + child: FlowySvg( + FlowySvgs.toolbar_link_earth_m, + color: iconScheme.secondary, + size: Size.square(30), + ), + ); + } + return ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), + child: Container( + width: width, + color: fillScheme.quaternary, + child: child, + ), + ); + } + + Widget buildLoadingOrErrorWidget() { + if (status == LinkPreviewStatus.loading) { + return Expanded( + child: const Center( + child: SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + } else if (status == LinkPreviewStatus.error) { + return Expanded( + child: const Center( + child: SizedBox( + height: 16, + width: 16, + child: Icon( + Icons.error_outline, + color: Colors.red, + ), + ), + ), + ); + } + return SizedBox.shrink(); + } } + +enum LinkPreviewStatus { loading, error, idle } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart new file mode 100644 index 0000000000..3772169331 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart @@ -0,0 +1,204 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; + +import 'custom_link_preview.dart'; +import 'link_preview_menu.dart'; + +class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { + CustomLinkPreviewBlockComponentBuilder({ + super.configuration, + this.cache, + }); + + final LinkPreviewDataCacheInterface? cache; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + final isEmbed = + node.attributes[LinkEmbedKeys.previewType] == LinkEmbedKeys.embed; + if (isEmbed) { + return LinkEmbedBlockComponent( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (_, state) => + actionBuilder(blockComponentContext, state), + ); + } + return CustomLinkPreviewBlockComponent( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + cache: cache, + ); + } + + @override + BlockComponentValidate get validate => + (node) => node.attributes[LinkPreviewBlockKeys.url]!.isNotEmpty; +} + +class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget { + const CustomLinkPreviewBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + this.cache, + }); + + final LinkPreviewDataCacheInterface? cache; + + @override + State createState() => + CustomLinkPreviewBlockComponentState(); +} + +class CustomLinkPreviewBlockComponentState + extends State + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; + + late final LinkPreviewParser parser; + late Future future; + + final showActionsNotifier = ValueNotifier(false); + bool isMenuShowing = false, isHovering = false; + + @override + void initState() { + super.initState(); + parser = LinkPreviewParser(url: url, cache: widget.cache); + future = parser.start(); + } + + @override + void didUpdateWidget(CustomLinkPreviewBlockComponent oldWidget) { + super.didUpdateWidget(oldWidget); + final url = widget.node.attributes[LinkPreviewBlockKeys.url]!; + final oldUrl = oldWidget.node.attributes[LinkPreviewBlockKeys.url]!; + if (url != oldUrl) { + parser = LinkPreviewParser(url: url, cache: widget.cache); + setState(() { + future = parser.start(); + }); + } + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) { + isHovering = true; + showActionsNotifier.value = true; + }, + onExit: (_) { + isHovering = false; + Future.delayed(const Duration(milliseconds: 200), () { + if (isMenuShowing || isHovering) return; + if (mounted) showActionsNotifier.value = false; + }); + }, + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + return FutureBuilder( + future: future, + builder: (context, snapshot) { + Widget child; + + if (snapshot.connectionState != ConnectionState.done) { + child = CustomLinkPreviewWidget( + node: node, + url: url, + isHovering: showActions, + ); + } else { + final title = parser.getContent(LinkPreviewRegex.title); + final description = + parser.getContent(LinkPreviewRegex.description); + final image = parser.getContent(LinkPreviewRegex.image); + + if (title == null && description == null && image == null) { + child = CustomLinkPreviewWidget( + node: node, + url: url, + isHovering: showActions, + status: LinkPreviewStatus.error, + ); + } else { + child = CustomLinkPreviewWidget( + node: node, + url: url, + title: title, + description: description, + imageUrl: image, + isHovering: showActions, + status: LinkPreviewStatus.idle, + ); + } + } + + child = Padding(padding: padding, child: child); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + child = Stack( + children: [ + child, + if (showActions) + Positioned( + top: 16, + right: 16, + child: CustomLinkPreviewMenu( + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + onReload: () { + if (mounted) { + setState(() { + future = parser.start(); + }); + } + }, + node: node, + ), + ), + ], + ); + + return child; + }, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index 2ba67a87da..2fb493dda3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -1,109 +1,207 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.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/services.dart'; import 'package:provider/provider.dart'; -import '../image/custom_image_block_component/custom_image_block_component.dart'; - -class LinkPreviewMenu extends StatefulWidget { - const LinkPreviewMenu({ +class CustomLinkPreviewMenu extends StatefulWidget { + const CustomLinkPreviewMenu({ super.key, + required this.onMenuShowed, + required this.onMenuHided, + required this.onReload, required this.node, - required this.state, }); - + final VoidCallback onMenuShowed; + final VoidCallback onMenuHided; + final VoidCallback onReload; final Node node; - final LinkPreviewBlockComponentState state; @override - State createState() => _LinkPreviewMenuState(); + State createState() => _CustomLinkPreviewMenuState(); } -class _LinkPreviewMenuState extends State { +class _CustomLinkPreviewMenuState extends State { + final popoverController = PopoverController(); + final buttonKey = GlobalKey(); + bool closed = false; + bool selected = false; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + widget.onMenuHided.call(); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - height: 32, - decoration: BoxDecoration( - color: theme.cardColor, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withValues(alpha: 0.1), - ), - ], - borderRadius: BorderRadius.circular(4.0), - ), - child: Row( - children: [ - const HSpace(4), - MenuBlockButton( - tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), - iconData: FlowySvgs.m_toolbar_link_m, - onTap: () async => convertUrlPreviewNodeToLink( - context.read(), - widget.node, - ), - ), - const HSpace(4), - MenuBlockButton( - tooltip: LocaleKeys.editor_copyLink.tr(), - iconData: FlowySvgs.copy_s, - onTap: copyImageLink, - ), - const _Divider(), - MenuBlockButton( - tooltip: LocaleKeys.button_delete.tr(), - iconData: FlowySvgs.trash_s, - onTap: deleteLinkPreviewNode, - ), - const HSpace(4), - ], + return AppFlowyPopover( + offset: Offset(0, 0.0), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + keepEditorFocusNotifier.decrease(); + if (!closed) { + closed = true; + return; + } else { + closed = false; + widget.onMenuHided.call(); + } + setState(() { + selected = false; + }); + }, + popupBuilder: (context) => buildMenu(), + child: FlowyIconButton( + key: buttonKey, + isSelected: selected, + icon: FlowySvg(FlowySvgs.toolbar_more_m), + onPressed: showPopover, ), ); } - void copyImageLink() { - final url = widget.node.attributes[CustomImageBlockKeys.url]; - if (url != null) { - Clipboard.setData(ClipboardData(text: url)); - showToastNotification( - message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), - ); + Widget buildMenu() { + return MouseRegion( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(LinkPreviewMenuCommand.values.length, (index) { + final command = LinkPreviewMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + Future onTap(LinkPreviewMenuCommand command) async { + final editorState = context.read(); + final node = widget.node; + final url = node.attributes[LinkPreviewBlockKeys.url]; + switch (command) { + case LinkPreviewMenuCommand.convertToMention: + await convertUrlPreviewNodeToMention(editorState, node); + break; + case LinkPreviewMenuCommand.convertToUrl: + await convertUrlPreviewNodeToLink(editorState, node); + break; + case LinkPreviewMenuCommand.convertToEmbed: + final transaction = editorState.transaction; + transaction.updateNode(node, { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: LinkEmbedKeys.embed, + }); + await editorState.apply(transaction); + break; + case LinkPreviewMenuCommand.copyLink: + if (url != null) { + await context.copyLink(url); + } + break; + case LinkPreviewMenuCommand.replace: + final box = buttonKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + final p = box.localToGlobal(Offset.zero); + showReplaceMenu( + context: context, + editorState: editorState, + node: node, + url: url, + ltrb: LTRB(left: p.dx - 330, top: p.dy), + onReplace: (url) async { + await convertLinkBlockToOtherLinkBlock( + editorState, + node, + node.type, + url: url, + ); + }, + ); + break; + case LinkPreviewMenuCommand.reload: + widget.onReload.call(); + break; + case LinkPreviewMenuCommand.removeLink: + await removeUrlPreviewLink(editorState, node); + break; + } + closePopover(); + } + + void showPopover() { + widget.onMenuShowed.call(); + keepEditorFocusNotifier.increase(); + popoverController.show(); + setState(() { + selected = true; + }); + } + + void closePopover() { + popoverController.close(); + widget.onMenuHided.call(); + } +} + +enum LinkPreviewMenuCommand { + convertToMention, + convertToUrl, + convertToEmbed, + copyLink, + replace, + reload, + removeLink; + + String get title { + switch (this) { + case convertToMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case LinkPreviewMenuCommand.convertToUrl: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case LinkPreviewMenuCommand.convertToEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case LinkPreviewMenuCommand.copyLink: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink + .tr(); + case LinkPreviewMenuCommand.replace: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace + .tr(); + case LinkPreviewMenuCommand.reload: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload + .tr(); + case LinkPreviewMenuCommand.removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); } } - - Future deleteLinkPreviewNode() async { - final node = widget.node; - final editorState = context.read(); - final transaction = editorState.transaction; - transaction.deleteNode(node); - transaction.afterSelection = null; - await editorState.apply(transaction); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Container( - width: 1, - color: Colors.grey, - ), - ); - } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart new file mode 100644 index 0000000000..16febe54cb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -0,0 +1,253 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension_v2.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/services.dart'; + +const _menuHeighgt = 188.0, _menuWidth = 288.0; + +class PasteAsMenuService { + PasteAsMenuService({ + required this.context, + required this.editorState, + }); + + final BuildContext context; + final EditorState editorState; + OverlayEntry? _menuEntry; + + void show(String href) { + WidgetsBinding.instance.addPostFrameCallback((_) => _show(href)); + } + + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); + } + + _menuEntry?.remove(); + _menuEntry = null; + } + + void _show(String href) { + final Size editorSize = editorState.renderBox?.size ?? Size.zero; + if (editorSize == Size.zero) return; + final menuPosition = editorState.calculateMenuOffset( + menuWidth: _menuWidth, + menuHeight: _menuHeighgt, + ); + if (menuPosition == null) return; + final ltrb = menuPosition.ltrb; + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + ltrb.buildPositioned( + child: PasteAsMenu( + onSelect: (t) { + final selection = editorState.selection; + if (selection == null) return; + final end = selection.end; + final urlSelection = Selection( + start: end.copyWith(offset: end.offset - href.length), + end: end, + ); + if (t == PasteMenuType.bookmark) { + convertUrlToLinkPreview(editorState, urlSelection, href); + } else if (t == PasteMenuType.mention) { + convertUrlToMention(editorState, urlSelection); + } else if (t == PasteMenuType.embed) { + convertUrlToLinkPreview( + editorState, + urlSelection, + href, + previewType: LinkEmbedKeys.embed, + ); + } + dismiss(); + }, + onDismiss: dismiss, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } +} + +class PasteAsMenu extends StatefulWidget { + const PasteAsMenu({ + super.key, + required this.onSelect, + required this.onDismiss, + }); + final ValueChanged onSelect; + final VoidCallback onDismiss; + + @override + State createState() => _PasteAsMenuState(); +} + +class _PasteAsMenuState extends State { + final focusNode = FocusNode(debugLabel: 'paste_as_menu'); + final ValueNotifier selectedIndexNotifier = ValueNotifier(0); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + } + + @override + void dispose() { + focusNode.dispose(); + selectedIndexNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeV2 = AFThemeExtensionV2.of(context); + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: Container( + width: _menuWidth, + height: _menuHeighgt, + padding: EdgeInsets.all(6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 16, + color: themeV2.shadow_medium, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 32, + padding: EdgeInsets.all(8), + child: FlowyText.semibold( + color: themeV2.text_tertiary, + LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs + .tr(), + ), + ), + ...List.generate( + PasteMenuType.values.length, + (i) => buildItem(PasteMenuType.values[i], i), + ), + ], + ), + ), + ); + } + + Widget buildItem(PasteMenuType type, int i) { + return ValueListenableBuilder( + valueListenable: selectedIndexNotifier, + builder: (context, value, child) { + final isSelected = i == value; + return SizedBox( + height: 36, + child: FlowyButton( + isSelected: isSelected, + text: FlowyText( + type.title, + ), + onTap: () => onSelect(type), + ), + ); + }, + ); + } + + void changeIndex(int index) => selectedIndexNotifier.value = index; + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + int index = selectedIndexNotifier.value, + length = PasteMenuType.values.length; + if (event.logicalKey == LogicalKeyboardKey.enter) { + onSelect(PasteMenuType.values[index]); + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + dismiss(); + } else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft] + .contains(event.logicalKey)) { + if (index == 0) { + index = length - 1; + } else { + index--; + } + changeIndex(index); + } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight] + .contains(event.logicalKey)) { + if (index == length - 1) { + index = 0; + } else { + index++; + } + changeIndex(index); + } + return KeyEventResult.handled; + } + + void onSelect(PasteMenuType type) => widget.onSelect.call(type); + + void dismiss() => widget.onDismiss.call(); +} + +enum PasteMenuType { + mention, + url, + bookmark, + embed, +} + +extension PasteMenuTypeExtension on PasteMenuType { + String get title { + switch (this) { + case PasteMenuType.mention: + return LocaleKeys.document_plugins_linkPreview_typeSelection_mention + .tr(); + case PasteMenuType.url: + return LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr(); + case PasteMenuType.bookmark: + return LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark + .tr(); + case PasteMenuType.embed: + return LocaleKeys.document_plugins_linkPreview_typeSelection_embed.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart index 57564c4722..8b193c70fb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart @@ -1,3 +1,5 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -9,7 +11,7 @@ Future convertUrlPreviewNodeToLink( return; } - final url = node.attributes[ImageBlockKeys.url]; + final url = node.attributes[LinkPreviewBlockKeys.url]; final delta = Delta() ..insert( url, @@ -29,3 +31,172 @@ Future convertUrlPreviewNodeToLink( ); return editorState.apply(transaction); } + +Future convertUrlPreviewNodeToMention( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta() + ..insert( + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.externalLink.name, + MentionBlockKeys.url: url, + }, + }, + ); + final transaction = editorState.transaction; + transaction + ..insertNode(node.path, paragraphNode(delta: delta)) + ..deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: url.length, + ), + ); + return editorState.apply(transaction); +} + +Future removeUrlPreviewLink( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta()..insert(url); + final transaction = editorState.transaction; + transaction + ..insertNode(node.path, paragraphNode(delta: delta)) + ..deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: url.length, + ), + ); + return editorState.apply(transaction); +} + +Future convertUrlToLinkPreview( + EditorState editorState, + Selection selection, + String url, { + String? previewType, +}) async { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final delta = node.delta; + if (delta == null) return; + final List beforeOperations = [], afterOperations = []; + int index = 0; + for (final insert in delta.whereType()) { + if (index < selection.startIndex) { + beforeOperations.add(insert); + } else if (index >= selection.endIndex) { + afterOperations.add(insert); + } + index += insert.length; + } + final transaction = editorState.transaction; + transaction + ..deleteNode(node) + ..insertNodes(node.path.next, [ + if (beforeOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: beforeOperations)), + if (previewType == LinkEmbedKeys.embed) + linkEmbedNode(url: url) + else + linkPreviewNode(url: url), + if (afterOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: afterOperations)), + ]); + await editorState.apply(transaction); +} + +Future convertUrlToMention( + EditorState editorState, + Selection selection, +) async { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final delta = node.delta; + if (delta == null) return; + String url = ''; + int index = 0; + for (final insert in delta.whereType()) { + if (index >= selection.startIndex && index < selection.endIndex) { + final href = insert.attributes?.href ?? ''; + if (href.isNotEmpty) { + url = href; + break; + } + } + index += insert.length; + } + final transaction = editorState.transaction; + transaction.replaceText( + node, + selection.startIndex, + selection.length, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.externalLink.name, + MentionBlockKeys.url: url, + }, + }, + ); + await editorState.apply(transaction); +} + +Future convertLinkBlockToOtherLinkBlock( + EditorState editorState, + Node node, + String toType, { + String? url, +}) async { + final nodeType = node.type; + if (nodeType != LinkPreviewBlockKeys.type || + (nodeType == toType && url == null)) { + return; + } + final insertedNode = []; + + final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? ''; + final previewType = node.attributes[LinkEmbedKeys.previewType]; + Node afterNode = node.copyWith( + type: toType, + attributes: { + LinkPreviewBlockKeys.url: afterUrl, + LinkEmbedKeys.previewType: previewType, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: node.attributes[blockComponentTextDirection], + blockComponentDelta: (node.delta ?? Delta()).toJson(), + }, + ); + afterNode = afterNode.copyWith(children: []); + insertedNode.add(afterNode); + insertedNode.addAll(node.children.map((e) => e.deepCopy())); + final transaction = editorState.transaction; + transaction.insertNodes( + node.path, + insertedNode, + ); + transaction.deleteNodes([node]); + await editorState.apply(transaction); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart index 79c9b31d20..0060d65bb7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -6,14 +6,18 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'mention_link_block.dart'; + enum MentionType { page, date, + externalLink, childPage; static MentionType fromString(String value) => switch (value) { 'page' => page, 'date' => date, + 'externalLink' => externalLink, 'childPage' => childPage, // Backwards compatibility 'reminder' => date, @@ -47,6 +51,7 @@ class MentionBlockKeys { static const pageId = 'page_id'; static const blockId = 'block_id'; + static const url = 'url'; // Related to Reminder and Date blocks static const date = 'date'; // Start Date @@ -157,6 +162,17 @@ class MentionBlock extends StatelessWidget { reminderOption: reminderOption ?? ReminderOption.none, includeTime: mention[MentionBlockKeys.includeTime] ?? false, ); + case MentionType.externalLink: + final String? url = mention[MentionBlockKeys.url] as String?; + if (url == null) { + return const SizedBox.shrink(); + } + return MentionLinkBlock( + url: url, + editorState: editorState, + node: node, + index: index, + ); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart new file mode 100644 index 0000000000..7a807fef64 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart @@ -0,0 +1,337 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'mention_link_error_preview.dart'; +import 'mention_link_preview.dart'; + +class MentionLinkBlock extends StatefulWidget { + const MentionLinkBlock({ + super.key, + required this.url, + required this.editorState, + required this.node, + required this.index, + this.delayToShow = const Duration(milliseconds: 50), + this.delayToHide = const Duration(milliseconds: 300), + }); + + final String url; + final Duration delayToShow; + final Duration delayToHide; + final EditorState editorState; + final Node node; + final int index; + + @override + State createState() => _MentionLinkBlockState(); +} + +class _MentionLinkBlockState extends State { + final parser = LinkParser(); + _LoadingStatus status = _LoadingStatus.loading; + final previewController = PopoverController(); + LinkInfo linkInfo = LinkInfo(); + bool isHovering = false; + int previewFocusNum = 0; + bool isPreviewHovering = false; + bool showAtBottom = false; + final key = GlobalKey(); + + bool get isPreviewShowing => previewFocusNum > 0; + String get url => widget.url; + + EditorState get editorState => widget.editorState; + + Node get node => widget.node; + + int get index => widget.index; + + bool get readyForPreview => + status == _LoadingStatus.idle && !linkInfo.isEmpty(); + + @override + void initState() { + super.initState(); + + parser.addLinkInfoListener((v) { + if (mounted) { + setState(() { + if (v.isEmpty() && linkInfo.isEmpty()) { + status = _LoadingStatus.error; + } else { + linkInfo = v; + status = _LoadingStatus.idle; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + super.dispose(); + parser.dispose(); + previewController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: ValueKey(showAtBottom), + controller: previewController, + direction: showAtBottom + ? PopoverDirection.bottomWithLeftAligned + : PopoverDirection.topWithLeftAligned, + offset: Offset(0, showAtBottom ? -20 : 20), + onOpen: () { + keepEditorFocusNotifier.increase(); + previewFocusNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + previewFocusNum--; + }, + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + margin: EdgeInsets.zero, + constraints: getConstraints(), + borderRadius: BorderRadius.circular(16), + popupBuilder: (context) => readyForPreview + ? MentionLinkPreview( + linkInfo: linkInfo, + showAtBottom: showAtBottom, + triggerSize: getSizeFromKey(), + onEnter: (e) { + isPreviewHovering = true; + }, + onExit: (e) { + isPreviewHovering = false; + tryToDismissPreview(); + }, + onCopyLink: () => copyLink(context), + onConvertTo: (s) => convertTo(s), + onRemoveLink: removeLink, + onOpenLink: openLink, + ) + : MentionLinkErrorPreview( + url: url, + triggerSize: getSizeFromKey(), + onEnter: (e) { + isPreviewHovering = true; + }, + onExit: (e) { + isPreviewHovering = false; + tryToDismissPreview(); + }, + onCopyLink: () => copyLink(context), + onConvertTo: (s) => convertTo(s), + onRemoveLink: removeLink, + onOpenLink: openLink, + ), + child: buildIconWithTitle(context), + ); + } + + Widget buildIconWithTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: onEnter, + onExit: onExit, + child: GestureDetector( + onTap: () async { + await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + }, + child: FlowyHoverContainer( + style: + HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + applyStyle: isHovering, + child: Row( + mainAxisSize: MainAxisSize.min, + key: key, + children: [ + HSpace(2), + buildIcon(), + HSpace(4), + Flexible( + child: FlowyText( + linkInfo.siteName ?? url, + color: theme.textColorScheme.primary, + fontSize: 14, + figmaLineHeight: 20, + overflow: TextOverflow.ellipsis, + ), + ), + HSpace(2), + ], + ), + ), + ), + ); + } + + Widget buildIcon() { + const defaultWidget = FlowySvg(FlowySvgs.toolbar_link_earth_m); + Widget icon = defaultWidget; + if (status == _LoadingStatus.loading) { + icon = Padding( + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator(strokeWidth: 1), + ); + } else { + icon = linkInfo.buildIconWidget(); + } + return SizedBox( + height: 20, + width: 20, + child: icon, + ); + } + + RenderBox? get box => key.currentContext?.findRenderObject() as RenderBox?; + + Size getSizeFromKey() => box?.size ?? Size.zero; + + Future copyLink(BuildContext context) async { + await context.copyLink(url); + previewController.close(); + } + + Future openLink() async { + await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + } + + Future removeLink() async { + final transaction = editorState.transaction + ..replaceText(widget.node, widget.index, 1, url, attributes: {}); + await editorState.apply(transaction); + } + + Future convertTo(PasteMenuType type) async { + if (type == PasteMenuType.url) { + await toUrl(); + } else if (type == PasteMenuType.bookmark) { + await toLinkPreview(); + } else if (type == PasteMenuType.embed) { + await toLinkPreview(previewType: LinkEmbedKeys.embed); + } + } + + Future toUrl() async { + final transaction = editorState.transaction + ..replaceText( + widget.node, + widget.index, + 1, + url, + attributes: { + AppFlowyRichTextKeys.href: url, + }, + ); + await editorState.apply(transaction); + } + + Future toLinkPreview({String? previewType}) async { + final selection = Selection( + start: Position(path: node.path, offset: index), + end: Position(path: node.path, offset: index + 1), + ); + await convertUrlToLinkPreview( + editorState, + selection, + url, + previewType: previewType, + ); + } + + void changeHovering(bool hovering) { + if (isHovering == hovering) return; + if (mounted) { + setState(() { + isHovering = hovering; + }); + } + } + + void changeShowAtBottom(bool bottom) { + if (showAtBottom == bottom) return; + if (mounted) { + setState(() { + showAtBottom = bottom; + }); + } + } + + void tryToDismissPreview() { + Future.delayed(widget.delayToHide, () { + if (isHovering || isPreviewHovering) { + return; + } + previewController.close(); + }); + } + + void onEnter(PointerEnterEvent e) { + changeHovering(true); + final location = box?.localToGlobal(Offset.zero) ?? Offset.zero; + if (readyForPreview) { + if (location.dy < 300) { + changeShowAtBottom(true); + } else { + changeShowAtBottom(false); + } + } + Future.delayed(widget.delayToShow, () { + if (isHovering && !isPreviewShowing && status != _LoadingStatus.loading) { + showPreview(); + } + }); + } + + void onExit(PointerExitEvent e) { + changeHovering(false); + tryToDismissPreview(); + } + + void showPreview() { + if (!mounted) return; + keepEditorFocusNotifier.increase(); + previewController.show(); + previewFocusNum++; + } + + BoxConstraints getConstraints() { + final size = getSizeFromKey(); + if (!readyForPreview) { + return BoxConstraints( + maxWidth: max(320, size.width), + maxHeight: 48 + size.height, + ); + } + return BoxConstraints( + maxWidth: max(300, size.width), + maxHeight: 300, + ); + } +} + +enum _LoadingStatus { + loading, + idle, + error, +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart new file mode 100644 index 0000000000..df396108e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart @@ -0,0 +1,232 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.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/services.dart'; + +class MentionLinkErrorPreview extends StatefulWidget { + const MentionLinkErrorPreview({ + super.key, + required this.url, + required this.onEnter, + required this.onExit, + required this.onCopyLink, + required this.onRemoveLink, + required this.onConvertTo, + required this.onOpenLink, + required this.triggerSize, + }); + + final String url; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final VoidCallback onOpenLink; + final ValueChanged onConvertTo; + final Size triggerSize; + + @override + State createState() => + _MentionLinkErrorPreviewState(); +} + +class _MentionLinkErrorPreviewState extends State { + final menuController = PopoverController(); + bool isConvertButtonSelected = false; + + @override + void dispose() { + super.dispose(); + menuController.close(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: SizedBox( + width: max(320, widget.triggerSize.width), + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded(child: buildLinkWidget()), + Container( + height: 20, + width: 1, + color: Color(0xffE8ECF3) + .withAlpha(Theme.of(context).isLightMode ? 255 : 40), + margin: EdgeInsets.symmetric(horizontal: 6), + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_m), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onCopyLink, + ), + buildConvertButton(), + ], + ), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + onTap: widget.onOpenLink, + child: Container( + width: widget.triggerSize.width, + height: widget.triggerSize.height, + color: Colors.black.withAlpha(1), + ), + ), + ), + ], + ); + } + + Widget buildLinkWidget() { + final url = widget.url; + return FlowyTooltip( + message: url, + preferBelow: false, + child: FlowyText.regular( + url, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ); + } + + Widget buildConvertButton() { + return AppFlowyPopover( + offset: Offset(8, 10), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: menuController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg(FlowySvgs.turninto_m), + isSelected: isConvertButtonSelected, + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: () { + setState(() { + isConvertButtonSelected = true; + }); + showPopover(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(MentionLinktErrorMenuCommand.values.length, + (index) { + final command = MentionLinktErrorMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + menuController.show(); + } + + void closePopover() { + menuController.close(); + } + + void onTap(MentionLinktErrorMenuCommand command) { + switch (command) { + case MentionLinktErrorMenuCommand.toURL: + widget.onConvertTo(PasteMenuType.url); + break; + case MentionLinktErrorMenuCommand.toBookmark: + widget.onConvertTo(PasteMenuType.bookmark); + break; + case MentionLinktErrorMenuCommand.toEmbed: + widget.onConvertTo(PasteMenuType.embed); + break; + case MentionLinktErrorMenuCommand.removeLink: + widget.onRemoveLink(); + break; + } + closePopover(); + } +} + +enum MentionLinktErrorMenuCommand { + toURL, + toBookmark, + toEmbed, + removeLink; + + String get title { + switch (this) { + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart new file mode 100644 index 0000000000..00082f127a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart @@ -0,0 +1,271 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.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/services.dart'; + +class MentionLinkPreview extends StatefulWidget { + const MentionLinkPreview({ + super.key, + required this.linkInfo, + required this.onEnter, + required this.onExit, + required this.onCopyLink, + required this.onRemoveLink, + required this.onConvertTo, + required this.onOpenLink, + required this.triggerSize, + required this.showAtBottom, + }); + + final LinkInfo linkInfo; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final VoidCallback onOpenLink; + final ValueChanged onConvertTo; + final Size triggerSize; + final bool showAtBottom; + + @override + State createState() => _MentionLinkPreviewState(); +} + +class _MentionLinkPreviewState extends State { + final menuController = PopoverController(); + bool isSelected = false; + + LinkInfo get linkInfo => widget.linkInfo; + + @override + void dispose() { + super.dispose(); + menuController.close(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + textColorScheme = theme.textColorScheme; + + final card = MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Container( + decoration: buildToolbarLinkDecoration(context, radius: 16), + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: 280, + height: 120, + ), + ), + VSpace(12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText.semibold( + linkInfo.siteName ?? '', + fontSize: 14, + figmaLineHeight: 20, + color: textColorScheme.primary, + overflow: TextOverflow.ellipsis, + ), + ), + VSpace(4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText( + linkInfo.description ?? '', + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.secondary, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + VSpace(36), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 28, + child: Row( + children: [ + linkInfo.buildIconWidget(size: Size.square(16)), + HSpace(6), + Expanded( + child: FlowyText( + linkInfo.description ?? '', + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.primary, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.w700, + ), + ), + buildMoreOptionButton(), + ], + ), + ), + VSpace(12), + ], + ), + ), + ); + + final clickPlaceHolder = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + child: Container( + height: 20, + width: widget.triggerSize.width, + color: Colors.white.withAlpha(1), + ), + onTap: () { + widget.onOpenLink.call(); + closePopover(); + }, + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: widget.showAtBottom + ? [clickPlaceHolder, card] + : [card, clickPlaceHolder], + ); + } + + Widget buildMoreOptionButton() { + return AppFlowyPopover( + controller: menuController, + direction: PopoverDirection.topWithLeftAligned, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + margin: EdgeInsets.zero, + borderRadius: BorderRadius.circular(12), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + width: 28, + height: 28, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + size: Size.square(20), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(MentionLinktMenuCommand.values.length, (index) { + final command = MentionLinktMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + menuController.show(); + } + + void closePopover() { + menuController.close(); + } + + void onTap(MentionLinktMenuCommand command) { + switch (command) { + case MentionLinktMenuCommand.toURL: + widget.onConvertTo(PasteMenuType.url); + break; + case MentionLinktMenuCommand.toBookmark: + widget.onConvertTo(PasteMenuType.bookmark); + break; + case MentionLinktMenuCommand.toEmbed: + widget.onConvertTo(PasteMenuType.embed); + break; + case MentionLinktMenuCommand.copyLink: + widget.onCopyLink(); + break; + case MentionLinktMenuCommand.removeLink: + widget.onRemoveLink(); + break; + } + closePopover(); + } +} + +enum MentionLinktMenuCommand { + toURL, + toBookmark, + toEmbed, + copyLink, + removeLink; + + String get title { + switch (this) { + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case copyLink: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart new file mode 100644 index 0000000000..7dcd21f423 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +extension MenuExtension on EditorState { + MenuPosition? calculateMenuOffset({ + Rect? rect, + required double menuWidth, + required double menuHeight, + Offset menuOffset = const Offset(0, 10), + }) { + final selectionService = service.selectionService; + final selectionRects = selectionService.selectionRects; + late Rect startRect; + if (rect != null) { + startRect = rect; + } else { + if (selectionRects.isEmpty) return null; + startRect = selectionRects.first; + } + + final editorOffset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = renderBox!.size.height; + final editorWidth = renderBox!.size.width; + + // show below default + Alignment alignment = Alignment.topLeft; + final bottomRight = startRect.bottomRight; + final topRight = startRect.topRight; + var startOffset = bottomRight + menuOffset; + Offset offset = Offset( + startOffset.dx, + startOffset.dy, + ); + + // show above + if (startOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { + startOffset = topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + startOffset.dx, + editorHeight + editorOffset.dy - startOffset.dy, + ); + } + + // show on right + if (offset.dx + menuWidth < editorOffset.dx + editorWidth) { + offset = Offset( + offset.dx, + offset.dy, + ); + } else if (startOffset.dx - editorOffset.dx > menuWidth) { + // show on left + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorWidth - offset.dx + editorOffset.dx, + offset.dy, + ); + } + return MenuPosition(align: alignment, offset: offset); + } +} + +class MenuPosition { + MenuPosition({ + required this.align, + required this.offset, + }); + + final Alignment align; + final Offset offset; + + LTRB get ltrb { + double? left, top, right, bottom; + switch (align) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return LTRB(left: left, top: top, right: right, bottom: bottom); + } +} + +class LTRB { + LTRB({this.left, this.top, this.right, this.bottom}); + + final double? left; + final double? top; + final double? right; + final double? bottom; + + Positioned buildPositioned({required Widget child}) => Positioned( + left: left, + top: top, + right: right, + bottom: bottom, + child: child, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart index 8b32ebdde9..cd332e14ef 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -58,6 +58,9 @@ class _HighlightColorPickerWidgetState @override Widget build(BuildContext context) { + if (editorState.selection == null) { + return const SizedBox.shrink(); + } final selectionRectList = editorState.selectionRects(); final top = selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index bd205ffea9..3664c9aee7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -591,10 +591,9 @@ class EditorStyleCustomizer { Node node, SelectableMixin delegate, ) { + if (UniversalPlatform.isMobile) return []; final delta = node.delta; - if (delta == null) { - return []; - } + if (delta == null) return []; final widgets = []; final textInserts = delta.whereType(); int index = 0; diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart index 26769ba7d3..3ab578b961 100644 --- a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart @@ -299,7 +299,8 @@ class _EmojiHandlerState extends State { .getTextInSelection( selection.copyWith( start: selection.start.copyWith(offset: startOffset), - end: selection.start.copyWith(offset: startOffset + _search.length + 1), + end: selection.start + .copyWith(offset: startOffset + _search.length + 1), ), ) .join(); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index ed4c4d1623..0ef21267aa 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -225,12 +225,20 @@ class _ApplicationWidgetState extends State { Tooltip.dismissAllToolTips(); } }, - child: AppFlowyTheme( - data: Theme.of(context).brightness == Brightness.light - ? AppFlowyThemeData.light() - : AppFlowyThemeData.dark(), - child: MaterialApp.router( - builder: (context, child) => MediaQuery( + child: MaterialApp.router( + debugShowCheckedModeBanner: false, + theme: state.lightTheme, + darkTheme: state.darkTheme, + themeMode: state.themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: state.locale, + routerConfig: routerConfig, + builder: (context, child) => AppFlowyTheme( + data: Theme.of(context).brightness == Brightness.light + ? AppFlowyThemeData.light() + : AppFlowyThemeData.dark(), + child: MediaQuery( // use the 1.0 as the textScaleFactor to avoid the text size // affected by the system setting. data: MediaQuery.of(context).copyWith( @@ -246,14 +254,6 @@ class _ApplicationWidgetState extends State { : child, ), ), - debugShowCheckedModeBanner: false, - theme: state.lightTheme, - darkTheme: state.darkTheme, - themeMode: state.themeMode, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: state.locale, - routerConfig: routerConfig, ), ), ), diff --git a/frontend/appflowy_flutter/lib/util/default_extensions.dart b/frontend/appflowy_flutter/lib/util/default_extensions.dart index 36bbdcb6b4..603a66d6cf 100644 --- a/frontend/appflowy_flutter/lib/util/default_extensions.dart +++ b/frontend/appflowy_flutter/lib/util/default_extensions.dart @@ -13,3 +13,11 @@ const List defaultImageExtensions = [ 'webp', 'bmp', ]; + +bool isNotImageUrl(String url) { + final nonImageSuffixRegex = RegExp( + r'(\.(io|html|php|json|txt|js|css|xml|md|log)(\?.*)?(#.*)?$)|/$', + caseSensitive: false, + ); + return nonImageSuffixRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index d48c061c71..ed7e9aec29 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -151,7 +151,7 @@ class AppFlowyThemeBuilder { Brightness brightness, ) { return switch (brightness) { - Brightness.light => AppFlowyFillColorScheme( + Brightness.dark => AppFlowyFillColorScheme( primary: colorScheme.neutral.neutral100, primaryHover: colorScheme.neutral.neutral200, secondary: colorScheme.neutral.neutral300, @@ -196,7 +196,7 @@ class AppFlowyThemeBuilder { purpleThick: colorScheme.purple.purple500, purpleThickHover: colorScheme.purple.purple600, ), - Brightness.dark => AppFlowyFillColorScheme( + Brightness.light => AppFlowyFillColorScheme( primary: colorScheme.neutral.neutral1000, primaryHover: colorScheme.neutral.neutral900, secondary: colorScheme.neutral.neutral600, diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart index 7fac9e07d0..b9136a00bc 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart @@ -13,6 +13,7 @@ class AFThemeExtensionV2 extends ThemeExtension { const AFThemeExtensionV2({ required this.icon_primary, required this.icon_tertiary, + required this.text_tertiary, required this.border_grey_quaternary, required this.fill_theme_select, required this.fill_grey_thick_alpha_1, @@ -21,6 +22,7 @@ class AFThemeExtensionV2 extends ThemeExtension { final Color icon_primary; final Color icon_tertiary; + final Color text_tertiary; final Color border_grey_quaternary; final Color fill_theme_select; final Color fill_grey_thick_alpha_1; @@ -30,6 +32,7 @@ class AFThemeExtensionV2 extends ThemeExtension { AFThemeExtensionV2 copyWith({ Color? icon_primary, Color? icon_tertiary, + Color? text_tertiary, Color? border_grey_quaternary, Color? fill_theme_select, Color? fill_grey_thick_alpha_1, @@ -38,6 +41,7 @@ class AFThemeExtensionV2 extends ThemeExtension { AFThemeExtensionV2( icon_primary: icon_primary ?? this.icon_primary, icon_tertiary: icon_tertiary ?? this.icon_tertiary, + text_tertiary: text_tertiary ?? this.text_tertiary, border_grey_quaternary: border_grey_quaternary ?? this.border_grey_quaternary, fill_theme_select: fill_theme_select ?? this.fill_theme_select, @@ -57,6 +61,8 @@ class AFThemeExtensionV2 extends ThemeExtension { Color.lerp(icon_primary, other.icon_primary, t) ?? icon_primary, icon_tertiary: Color.lerp(icon_tertiary, other.icon_tertiary, t) ?? icon_tertiary, + text_tertiary: + Color.lerp(text_tertiary, other.text_tertiary, t) ?? text_tertiary, border_grey_quaternary: Color.lerp(border_grey_quaternary, other.border_grey_quaternary, t) ?? border_grey_quaternary, @@ -75,6 +81,7 @@ class AFThemeExtensionV2 extends ThemeExtension { const AFThemeExtensionV2 darkAFThemeV2 = AFThemeExtensionV2( icon_primary: Color(0xFF1F2329), icon_tertiary: Color(0xFF99A1A8), + text_tertiary: Color(0xFFB5BBD3), border_grey_quaternary: Color(0xFFE8ECF3), fill_theme_select: Color(0x00BCF01F), fill_grey_thick_alpha_1: Color(0x1F23290F), @@ -84,6 +91,7 @@ const AFThemeExtensionV2 darkAFThemeV2 = AFThemeExtensionV2( const AFThemeExtensionV2 lightAFThemeV2 = AFThemeExtensionV2( icon_primary: Color(0xFF1F2329), icon_tertiary: Color(0xFF99A1A8), + text_tertiary: Color(0xFFB5BBD3), border_grey_quaternary: Color(0xFFE8ECF3), fill_theme_select: Color(0x00BCF01F), fill_grey_thick_alpha_1: Color(0x1F23290F), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart index 8813cad09e..5b0b791c6c 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart @@ -89,7 +89,6 @@ class _ManualTooltipState extends State { super.initState(); } - @override Widget build(BuildContext context) { return Tooltip( diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 1c6d66431d..ae44b95eb3 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -673,6 +673,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + favicon: + dependency: "direct main" + description: + name: favicon + sha256: ebb7423ba7ccc87d3cce9b60de1b5f72004de3cddeedd88d98463fc7f66faf0c + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 0cc553d1a7..de26c31c1e 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: auto_size_text_field: ^2.2.3 auto_updater: ^1.0.0 avatar_stack: ^3.0.0 + favicon: ^1.1.2 # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 diff --git a/frontend/resources/flowy_icons/20x/embed_fullscreen.svg b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg new file mode 100644 index 0000000000..b8b197fb13 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/turninto.svg b/frontend/resources/flowy_icons/20x/turninto.svg new file mode 100644 index 0000000000..598b870ec7 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/turninto.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/40x/embed_error.svg b/frontend/resources/flowy_icons/40x/embed_error.svg new file mode 100644 index 0000000000..68196c7b7e --- /dev/null +++ b/frontend/resources/flowy_icons/40x/embed_error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index e42660436c..29473d2e00 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2019,7 +2019,28 @@ "failedDuplicateFindView": "Failed to duplicate page - original view not found" } }, - "cannotMoveToItsChildren": "Cannot move to its children" + "cannotMoveToItsChildren": "Cannot move to its children", + "linkPreview": { + "typeSelection": { + "pasteAs": "Paste as", + "mention": "Mention", + "URL": "URL", + "bookmark": "Bookmark", + "embed": "Embed" + }, + "linkPreviewMenu": { + "toMetion": "Convert to Mention", + "toUrl": "Convert to URL", + "toEmbed": "Convert to Embed", + "toBookmark": "Convert to Bookmark", + "copyLink": "Copy Link", + "replace": "Replace", + "reload": "Reload", + "removeLink": "Remove Link", + "pasteHint": "Paste in https://...", + "refuseConnect": "refued to connect." + } + } }, "outlineBlock": { "placeholder": "Table of Contents" @@ -2513,6 +2534,7 @@ "copyLink": "Copy link", "removeLink": "Remove link", "editLink": "Edit link", + "convertTo": "Convert to", "linkText": "Text", "linkTextHint": "Please enter text", "linkAddressHint": "Please enter URL", From bcb1e8e4f5695f28bd22ff94278c267eaa33b8aa Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 11 Apr 2025 09:57:41 +0800 Subject: [PATCH 123/207] chore: adjust replace, insert below, discard selection behavior (#7719) --- .../ai/operations/ai_writer_cubit.dart | 13 ++++++++++++- .../ai/widgets/ai_writer_scroll_wrapper.dart | 1 + .../editor_plugins/base/markdown_text_robot.dart | 10 +++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 7a885a3fe8..7b9da81d44 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -57,8 +57,13 @@ class AiWriterCubit extends Cubit { bool withDiscard = true, bool withUnformat = true, }) async { + if (aiWriterNode == null) { + return; + } if (withDiscard) { - await _textRobot.discard(); + await _textRobot.discard( + afterSelection: aiWriterNode!.aiWriterSelection, + ); } _textRobot.clear(); _textRobot.reset(); @@ -232,6 +237,12 @@ class AiWriterCubit extends Cubit { 'trigger accept action, markdown text: $trimmedMarkdownText', ); + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + await _textRobot.deleteAINodes(); await _textRobot.replace( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart index 6b5f27e028..ef8ee81219 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -40,6 +40,7 @@ class _AiWriterScrollWrapperState extends State { onRemoveNode: () { aiWriterRegistered = false; widget.editorState.service.keyboardService?.enableShortcuts(); + widget.editorState.service.keyboardService?.enable(); }, onAppendToDocument: onAppendToDocument, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 031e157b33..429cd51221 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -118,7 +118,7 @@ class MarkdownTextRobot { } await _lock.synchronized(() async { - await _refresh(inMemoryUpdate: false); + await _refresh(inMemoryUpdate: false, updateSelection: true); }); if (_enableDebug) { @@ -156,7 +156,9 @@ class MarkdownTextRobot { } /// Discard the inserted content - Future discard() async { + Future discard({ + Selection? afterSelection, + }) async { final start = _insertPosition; if (start == null) { return; @@ -165,6 +167,8 @@ class MarkdownTextRobot { return; } + afterSelection ??= Selection.collapsed(start); + // fallback to the calculated position if the selection is null. final end = Position( path: start.path.nextNPath(_insertedNodes.length - 1), @@ -174,7 +178,7 @@ class MarkdownTextRobot { ); final transaction = editorState.transaction ..deleteNodes(deletedNodes) - ..afterSelection = Selection.collapsed(start); + ..afterSelection = afterSelection; await editorState.apply( transaction, From 351c891a5a82c73c1a2ac4fc5932f2b645971cea Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:21:02 +0800 Subject: [PATCH 124/207] fix: adjust ghost icon hover color (#7723) --- .../packages/appflowy_ui/lib/src/theme/data/builder.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index ed7e9aec29..c17dd14578 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -206,7 +206,7 @@ class AppFlowyThemeBuilder { quaternary: colorScheme.neutral.neutral100, quaternaryHover: colorScheme.neutral.neutral200, transparent: colorScheme.neutral.alphaWhite0, - primaryAlpha5: colorScheme.neutral.alphaGrey10005, + primaryAlpha5: colorScheme.neutral.alphaGrey100005, primaryAlpha5Hover: colorScheme.neutral.alphaGrey100010, primaryAlpha80: colorScheme.neutral.alphaGrey100080, primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, From 2e295e6891723aa087e44f06dddccb08e5336bfa Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 11 Apr 2025 14:25:19 +0800 Subject: [PATCH 125/207] fix: unable to accept the response from 'mark longer' (#7725) * fix: unable to accpet 'make it longer' * fix: markdown text robot test --- .../base/markdown_text_robot.dart | 41 ++++++++++-- .../text_robot/markdown_text_robot_test.dart | 64 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 429cd51221..0dea3d2864 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -324,6 +324,12 @@ class MarkdownTextRobot { required Selection selection, required String markdownText, }) async { + if (markdownText.isEmpty) { + assert(false, 'Expected non-empty markdown text'); + Log.error('Expected non-empty markdown text'); + return; + } + selection = selection.normalized; // If the selection is not a single node, do nothing. @@ -376,15 +382,42 @@ class MarkdownTextRobot { // } // ] final document = customMarkdownToDocument(markdownText); + final nodes = document.root.children; final decoder = DeltaMarkdownDecoder(); final markdownDelta = - document.nodeAtPath([0])?.delta ?? decoder.convert(markdownText); + nodes.firstOrNull?.delta ?? decoder.convert(markdownText); + + if (markdownDelta.isEmpty) { + assert(false, 'Expected non-empty markdown delta'); + Log.error('Expected non-empty markdown delta'); + return; + } // Replace the delta of the selected node. final transaction = editorState.transaction; - transaction - ..deleteText(node, startIndex, length) - ..insertTextDelta(node, startIndex, markdownDelta); + + // it means the user selected the entire sentence, we just replace the node + if (startIndex == 0 && length == node.delta?.length) { + transaction + ..insertNodes(node.path.next, nodes) + ..deleteNode(node); + } else { + // it means the user selected a part of the sentence, we need to delete the + // selected part and insert the new delta. + transaction + ..deleteText(node, startIndex, length) + ..insertTextDelta(node, startIndex, markdownDelta); + + // Add the remaining nodes to the document. + final remainingNodes = nodes.skip(1); + if (remainingNodes.isNotEmpty) { + transaction.insertNodes( + node.path.next, + remainingNodes, + ); + } + } + await editorState.apply(transaction); } diff --git a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart index f2fcf8cd65..8b1b710f4e 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart @@ -487,6 +487,70 @@ void main() { ); expect(d7.attributes, null); }); + + test('replace markdown text with selection from start to end', () async { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point.'''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption.'''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + final document = Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: text1.length), + ); + + final markdownText = '''1. $text1 + +2. $text1 + +3. $text1'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final nodes = editorState.document.root.children; + expect(nodes.length, 5); + + final d1 = nodes[0].delta!.toList()[0] as TextInsert; + expect(d1.text, text1); + expect(d1.attributes, null); + expect(nodes[0].type, NumberedListBlockKeys.type); + + final d2 = nodes[1].delta!.toList()[0] as TextInsert; + expect(d2.text, text1); + expect(d2.attributes, null); + expect(nodes[1].type, NumberedListBlockKeys.type); + + final d3 = nodes[2].delta!.toList()[0] as TextInsert; + expect(d3.text, text1); + expect(d3.attributes, null); + expect(nodes[2].type, NumberedListBlockKeys.type); + + final d4 = nodes[3].delta!.toList()[0] as TextInsert; + expect(d4.text, text2); + expect(d4.attributes, null); + + final d5 = nodes[4].delta!.toList()[0] as TextInsert; + expect(d5.text, text3); + expect(d5.attributes, null); + }); }); group('markdown text robot - replace in multiple lines:', () { From 8c4324ee9d60b8a0fef7ed5cb5026cf881e0e7d6 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 14 Apr 2025 10:29:46 +0800 Subject: [PATCH 126/207] fix: otp launch review issues (#7730) * fix: add error text under text field * chore: update translation --- .../shared/common_operations.dart | 6 +- .../lib/user/application/sign_in_bloc.dart | 9 +- .../desktop_sign_in_screen.dart | 10 -- .../sign_in_screen/sign_in_screen.dart | 16 +- .../continue_with_email_and_password.dart | 109 +++++++------ ...inue_with_magic_link_or_passcode_page.dart | 148 +++++++++++------- .../continue_with_password_page.dart | 68 +++++--- .../widgets/sign_in_agreement.dart | 4 +- .../third_party_sign_in_buttons.dart | 1 + .../src/component/textfield/textfield.dart | 32 +++- frontend/resources/translations/en.json | 15 +- 11 files changed, 269 insertions(+), 149 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 4a117a71ff..d7a505d152 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -67,12 +67,10 @@ extension CommonOperations on WidgetTester { } else { // cloud version final anonymousButton = find.byType(SignInAnonymousButtonV2); - await tapButton(anonymousButton); + await tapButton(anonymousButton, warnIfMissed: true); } - if (Platform.isWindows) { - await pumpAndSettle(const Duration(milliseconds: 200)); - } + await pumpAndSettle(const Duration(milliseconds: 200)); } Future tapContinousAnotherWay() async { diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 54a60702c8..82269d1f1f 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -199,7 +199,9 @@ class SignInBloc extends Bloc { emit( result.fold( - (userProfile) => state.copyWith(isSubmitting: true), + (userProfile) => state.copyWith( + isSubmitting: false, + ), (error) => _stateFromCode(error), ), ); @@ -282,8 +284,9 @@ class SignInBloc extends Bloc { case ErrorCode.UserUnauthorized: final errorMsg = error.msg; String msg = LocaleKeys.signIn_generalError.tr(); - if (errorMsg.contains('rate limit')) { - msg = LocaleKeys.signIn_limitRateError.tr(); + if (errorMsg.contains('rate limit') || + errorMsg.contains('For security purposes')) { + msg = LocaleKeys.signIn_tooFrequentVerificationCodeRequest.tr(); } else if (errorMsg.contains('invalid')) { msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 3a906ee0c4..65e8ee550b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -23,7 +23,6 @@ class DesktopSignInScreen extends StatelessWidget { Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); - const indicatorMinHeight = 4.0; return BlocBuilder( builder: (context, state) { return Scaffold( @@ -55,15 +54,6 @@ class DesktopSignInScreen extends StatelessWidget { // sign in agreement const SignInAgreement(), - // loading status - const VSpace(indicatorMinHeight), - state.isSubmitting - ? const LinearProgressIndicator( - minHeight: indicatorMinHeight, - ) - : const VSpace(indicatorMinHeight), - const VSpace(20), - const Spacer(), // anonymous sign in and settings diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index 5b99ad83f3..a428545d8d 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -4,6 +4,7 @@ import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -37,10 +38,17 @@ class SignInScreen extends StatelessWidget { void _showSignInError(BuildContext context, SignInState state) { final successOrFail = state.successOrFail; if (successOrFail != null) { - handleUserProfileResult( - successOrFail, - context, - getIt(), + successOrFail.fold( + (userProfile) { + if (userProfile.encryptionType == EncryptionTypePB.Symmetric) { + getIt().pushEncryptionScreen(context, userProfile); + } else { + getIt().goHomeScreen(context, userProfile); + } + }, + (error) { + handleOpenWorkspaceError(context, error); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart index e918c3f4f1..a0dda40a36 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -2,14 +2,12 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.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:string_validator/string_validator.dart'; -import 'package:universal_platform/universal_platform.dart'; class ContinueWithEmailAndPassword extends StatefulWidget { const ContinueWithEmailAndPassword({super.key}); @@ -23,6 +21,7 @@ class _ContinueWithEmailAndPasswordState extends State { final controller = TextEditingController(); final focusNode = FocusNode(); + final emailKey = GlobalKey(); @override void dispose() { @@ -36,55 +35,74 @@ class _ContinueWithEmailAndPasswordState Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); - return Column( - children: [ - SizedBox( - height: UniversalPlatform.isMobile ? 38.0 : 40.0, - child: AFTextField( + return BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + // only push the continue with magic link or passcode page if the magic link is sent successfully + if (successOrFail != null) { + successOrFail.fold( + (_) => emailKey.currentState?.clearError(), + (error) => emailKey.currentState?.syncError( + errorText: error.msg, + ), + ); + } else if (successOrFail == null && !state.isSubmitting) { + _pushContinueWithMagicLinkOrPasscodePage( + context, + controller.text, + ); + } + }, + child: Column( + children: [ + AFTextField( + key: emailKey, controller: controller, hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), radius: 10, - onSubmitted: (value) => _pushContinueWithMagicLinkOrPasscodePage( + onSubmitted: (value) => _signInWithEmail( context, value, ), ), - ), - VSpace(theme.spacing.l), - ContinueWithEmail( - onTap: () => _pushContinueWithMagicLinkOrPasscodePage( - context, - controller.text, + VSpace(theme.spacing.l), + ContinueWithEmail( + onTap: () => _signInWithEmail( + context, + controller.text, + ), ), - ), - // Hide password sign in until we implement the reset password / forgot password - // VSpace(theme.spacing.l), - // ContinueWithPassword( - // onTap: () => _pushContinueWithPasswordPage( - // context, - // controller.text, - // ), - // ), - ], + // VSpace(theme.spacing.l), + // ContinueWithPassword( + // onTap: () => _pushContinueWithPasswordPage( + // context, + // controller.text, + // ), + // ), + ], + ), ); } + void _signInWithEmail(BuildContext context, String email) { + if (!isEmail(email)) { + emailKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidEmail.tr(), + ); + return; + } + + context + .read() + .add(SignInEvent.signInWithMagicLink(email: email)); + } + void _pushContinueWithMagicLinkOrPasscodePage( BuildContext context, String email, ) { - if (!isEmail(email)) { - showToastNotification( - message: LocaleKeys.signIn_invalidEmail.tr(), - type: ToastificationType.error, - ); - return; - } - final signInBloc = context.read(); - signInBloc.add(SignInEvent.signInWithMagicLink(email: email)); - // push the a continue with magic link or passcode screen Navigator.push( context, @@ -114,18 +132,21 @@ class _ContinueWithEmailAndPasswordState // Navigator.push( // context, // MaterialPageRoute( - // builder: (context) => ContinueWithPasswordPage( - // email: email, - // backToLogin: () => Navigator.pop(context), - // onEnterPassword: (password) => signInBloc.add( - // SignInEvent.signInWithEmailAndPassword( - // email: email, - // password: password, + // builder: (context) => BlocProvider.value( + // value: signInBloc, + // child: ContinueWithPasswordPage( + // email: email, + // backToLogin: () => Navigator.pop(context), + // onEnterPassword: (password) => signInBloc.add( + // SignInEvent.signInWithEmailAndPassword( + // email: email, + // password: password, + // ), // ), + // onForgotPassword: () { + // // todo: implement forgot password + // }, // ), - // onForgotPassword: () { - // // todo: implement forgot password - // }, // ), // ), // ); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart index ea7ff087ff..5be30ef84c 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -33,6 +33,8 @@ class _ContinueWithMagicLinkOrPasscodePageState ToastificationItem? toastificationItem; + final inputPasscodeKey = GlobalKey(); + @override void dispose() { passcodeController.dispose(); @@ -44,10 +46,13 @@ class _ContinueWithMagicLinkOrPasscodePageState Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - if (state.isSubmitting) { - _showLoadingDialog(); - } else { - _dismissLoadingDialog(); + final successOrFail = state.successOrFail; + if (successOrFail != null && successOrFail.isFailure) { + successOrFail.onFailure((error) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(), + ); + }); } }, child: Scaffold( @@ -91,24 +96,39 @@ class _ContinueWithMagicLinkOrPasscodePageState return [ // Enter code manually - SizedBox( - height: 40, - child: AFTextField( - controller: passcodeController, - hintText: LocaleKeys.signIn_enterCode.tr(), - keyboardType: TextInputType.number, - radius: 10, - autoFocus: true, - onSubmitted: widget.onEnterPasscode, - ), + AFTextField( + key: inputPasscodeKey, + controller: passcodeController, + hintText: LocaleKeys.signIn_enterCode.tr(), + keyboardType: TextInputType.number, + radius: 10, + autoFocus: true, + onSubmitted: (passcode) { + if (passcode.isEmpty) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), + ); + } else { + widget.onEnterPasscode(passcode); + } + }, ), // todo: ask designer to provide the spacing VSpace(12), // continue to login AFFilledTextButton.primary( - text: 'Continue to sign in', - onTap: () => widget.onEnterPasscode(passcodeController.text), + text: LocaleKeys.signIn_continueToSignIn.tr(), + onTap: () { + final passcode = passcodeController.text; + if (passcode.isEmpty) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), + ); + } else { + widget.onEnterPasscode(passcode); + } + }, size: AFButtonSize.l, alignment: Alignment.center, ), @@ -123,6 +143,7 @@ class _ContinueWithMagicLinkOrPasscodePageState text: LocaleKeys.signIn_backToLogin.tr(), size: AFButtonSize.s, onTap: widget.backToLogin, + padding: EdgeInsets.zero, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (isHovering) { @@ -137,51 +158,70 @@ class _ContinueWithMagicLinkOrPasscodePageState List _buildLogoTitleAndDescription() { final theme = AppFlowyTheme.of(context); final spacing = VSpace(theme.spacing.xxl); - return [ - // logo - const AFLogo(), - spacing, + if (!isEnteringPasscode) { + return [ + // logo + const AFLogo(), + spacing, - // title - Text( - LocaleKeys.signIn_checkYourEmail.tr(), - style: theme.textStyle.heading.h3( - color: theme.textColorScheme.primary, + // title + Text( + LocaleKeys.signIn_checkYourEmail.tr(), + style: theme.textStyle.heading.h3( + color: theme.textColorScheme.primary, + ), ), - ), - spacing, + spacing, - // description - Text( - LocaleKeys.signIn_temporaryVerificationSent.tr(), - style: theme.textStyle.body.standard( - color: theme.textColorScheme.primary, + // description + Text( + LocaleKeys.signIn_temporaryVerificationLinkSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - Text( - widget.email, - style: theme.textStyle.body.enhanced( - color: theme.textColorScheme.primary, + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - spacing, - ]; - } + spacing, + ]; + } else { + return [ + // logo + const AFLogo(), + spacing, - void _showLoadingDialog() { - _dismissLoadingDialog(); + // title + Text( + LocaleKeys.signIn_enterCode.tr(), + style: theme.textStyle.heading.h3( + color: theme.textColorScheme.primary, + ), + ), + spacing, - toastificationItem = showToastNotification( - message: LocaleKeys.signIn_signingIn.tr(), - ); - } - - void _dismissLoadingDialog() { - final toastificationItem = this.toastificationItem; - if (toastificationItem != null) { - toastification.dismiss(toastificationItem); + // description + Text( + LocaleKeys.signIn_temporaryVerificationCodeSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + spacing, + ]; } } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart index 3a281889ad..4ab40011d2 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -1,8 +1,10 @@ +import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class ContinueWithPasswordPage extends StatefulWidget { const ContinueWithPasswordPage({ @@ -25,6 +27,7 @@ class ContinueWithPasswordPage extends StatefulWidget { class _ContinueWithPasswordPageState extends State { final passwordController = TextEditingController(); + final inputPasswordKey = GlobalKey(); @override void dispose() { @@ -38,18 +41,29 @@ class _ContinueWithPasswordPageState extends State { body: Center( child: SizedBox( width: 320, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Logo and title - ..._buildLogoAndTitle(), + child: BlocListener( + listener: (context, state) { + if (state.passwordError != null) { + inputPasswordKey.currentState?.syncError( + errorText: 'Incorrect password. Please try again.', + ); + } else { + inputPasswordKey.currentState?.clearError(); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo and title + ..._buildLogoAndTitle(), - // Password input and buttons - ..._buildPasswordSection(), + // Password input and buttons + ..._buildPasswordSection(), - // Back to login - ..._buildBackToLogin(), - ], + // Back to login + ..._buildBackToLogin(), + ], + ), ), ), ), @@ -100,25 +114,33 @@ class _ContinueWithPasswordPageState extends State { return [ // Password input AFTextField( + key: inputPasswordKey, controller: passwordController, hintText: 'Enter password', autoFocus: true, onSubmitted: widget.onEnterPassword, ), // todo: ask designer to provide the spacing - VSpace(12), + VSpace(8), - // todo: forgot password is not implemented yet // Forgot password button - // AFGhostTextButton( - // text: 'Forget password?', - // size: AFButtonSize.s, - // onTap: widget.onForgotPassword, - // textColor: (context, isHovering, disabled) { - // return theme.textColorScheme.theme; - // }, - // ), - VSpace(12), + Align( + alignment: Alignment.centerLeft, + child: AFGhostTextButton( + text: 'Forget password?', + size: AFButtonSize.s, + padding: EdgeInsets.zero, + onTap: widget.onForgotPassword, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ), + VSpace(20), // Continue button AFFilledTextButton.primary( @@ -137,8 +159,12 @@ class _ContinueWithPasswordPageState extends State { text: 'Back to Login', size: AFButtonSize.s, onTap: widget.backToLogin, + padding: EdgeInsets.zero, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } return theme.textColorScheme.theme; }, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart index 4ea819d997..19a5ab9cf6 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -26,8 +26,8 @@ class SignInAgreement extends StatelessWidget { children: [ TextSpan( text: isLocalAuthEnabled - ? '${LocaleKeys.web_signInLocalAgreement.tr()} \n' - : '${LocaleKeys.web_signInAgreement.tr()} \n', + ? LocaleKeys.web_signInLocalAgreement.tr() + : LocaleKeys.web_signInAgreement.tr(), style: textStyle, ), TextSpan( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart index ed2e060c49..8d27846c46 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart @@ -101,6 +101,7 @@ class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { VSpace(theme.spacing.l), AFGhostTextButton( text: 'More options', + padding: EdgeInsets.zero, textColor: (context, isHovering, disabled) { if (isHovering) { return theme.fillColorScheme.themeThickHover; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart index 9fed31027f..595d4bb859 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -5,6 +5,11 @@ typedef AFTextFieldValidator = (bool result, String errorText) Function( TextEditingController controller, ); +abstract class AFTextFieldState extends State { + void syncError({required String errorText}) {} + void clearError() {} +} + class AFTextField extends StatefulWidget { const AFTextField({ super.key, @@ -17,8 +22,12 @@ class AFTextField extends StatefulWidget { this.onChanged, this.onSubmitted, this.autoFocus, + this.height = 40.0, }); + /// The height of the text field. + final double height; + /// The hint text to display when the text field is empty. final String? hintText; @@ -52,7 +61,7 @@ class AFTextField extends StatefulWidget { State createState() => _AFTextFieldState(); } -class _AFTextFieldState extends State { +class _AFTextFieldState extends AFTextFieldState { late final TextEditingController effectiveController; bool hasError = false; @@ -146,8 +155,11 @@ class _AFTextFieldState extends State { ), ); + child = SizedBox(height: widget.height, child: child); + if (hasError && errorText.isNotEmpty) { child = Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ child, SizedBox(height: theme.spacing.xs), @@ -174,4 +186,22 @@ class _AFTextFieldState extends State { }); } } + + @override + void syncError({ + required String errorText, + }) { + setState(() { + hasError = true; + this.errorText = errorText; + }); + } + + @override + void clearError() { + setState(() { + hasError = false; + errorText = ''; + }); + } } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 29473d2e00..91f9ce7743 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -48,7 +48,7 @@ "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "syncPromptMessage": "Syncing the data might take a while. Please don't close this page", - "or": "OR", + "or": "or", "signInWithGoogle": "Continue with Google", "signInWithGithub": "Continue with GitHub", "signInWithDiscord": "Continue with Discord", @@ -70,15 +70,18 @@ "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", - "tokenHasExpiredOrInvalid": "The token has expired or is invalid. Please try again.", + "tokenHasExpiredOrInvalid": "The code has expired or is invalid. Please try again.", "signingIn": "Signing in...", "checkYourEmail": "Check your email", - "temporaryVerificationSent": "A temporary verification link has been sent. Please check your inbox at", + "temporaryVerificationLinkSent": "A temporary verification link has been sent.\nPlease check your inbox at", + "temporaryVerificationCodeSent": "A temporary verification code has been sent.\nPlease check your inbox at", "continueToSignIn": "Continue to sign in", "backToLogin": "Back to login", "enterCode": "Enter code", "enterCodeManually": "Enter code manually", - "continueWithEmail": "Continue with email" + "continueWithEmail": "Continue with email", + "invalidVerificationCode": "Please enter a valid verification code", + "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later." }, "workspace": { "chooseWorkspace": "Choose your workspace", @@ -2814,8 +2817,8 @@ "continueWithApple": "Continue with Apple ", "moreOptions": "More options", "collapse": "Collapse", - "signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's", - "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to AppFlowy's", + "signInAgreement": "By clicking \"Continue\" above, you agreed to \nAppFlowy's ", + "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to \nAppFlowy's ", "and": "and", "termOfUse": "Terms", "privacyPolicy": "Privacy Policy", From 4d172761ce76cd50805196093c4874ef68c0f1e1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Apr 2025 10:36:42 +0800 Subject: [PATCH 127/207] chore: search with ai summary --- .../command_palette/folder_search_test.dart | 4 +- .../command_palette/command_palette_bloc.dart | 337 ++++++---- .../command_palette/search_listener.dart | 74 --- .../command_palette/search_result_ext.dart | 2 +- .../command_palette/search_service.dart | 93 ++- .../command_palette/command_palette.dart | 81 +-- .../command_palette/widgets/search_field.dart | 193 +++--- .../widgets/search_result_tile.dart | 175 +++--- .../widgets/search_results_list.dart | 114 +++- frontend/resources/translations/en.json | 1 + frontend/rust-lib/Cargo.lock | 334 +++++----- frontend/rust-lib/Cargo.toml | 12 +- frontend/rust-lib/flowy-ai/Cargo.toml | 3 - .../src/deps_resolve/cloud_service_impl.rs | 24 +- .../flowy-core/src/deps_resolve/user_deps.rs | 3 +- frontend/rust-lib/flowy-core/src/lib.rs | 4 +- frontend/rust-lib/flowy-error/Cargo.toml | 2 +- frontend/rust-lib/flowy-folder/src/manager.rs | 5 +- .../rust-lib/flowy-folder/src/manager_init.rs | 16 +- .../rust-lib/flowy-search-pub/src/cloud.rs | 11 +- .../rust-lib/flowy-search-pub/src/entities.rs | 21 +- frontend/rust-lib/flowy-search/Cargo.toml | 15 +- .../flowy-search/src/document/handler.rs | 187 ++++-- .../flowy-search/src/entities/notification.rs | 18 +- .../flowy-search/src/entities/query.rs | 14 +- .../flowy-search/src/entities/result.rs | 53 +- .../src/entities/search_filter.rs | 4 +- .../flowy-search/src/event_handler.rs | 9 +- .../flowy-search/src/folder/entities.rs | 4 +- .../flowy-search/src/folder/handler.rs | 43 +- .../flowy-search/src/folder/indexer.rs | 590 ++++++++---------- .../flowy-search/src/services/manager.rs | 125 ++-- .../rust-lib/flowy-search/src/services/mod.rs | 1 - .../flowy-search/src/services/notifier.rs | 61 -- .../flowy-server/src/af_cloud/impls/search.rs | 31 +- .../af_cloud/impls/user/cloud_service_impl.rs | 2 +- .../flowy-user-pub/src/workspace_service.rs | 2 +- .../src/services/authenticate_user.rs | 6 +- .../user_manager/manager_user_workspace.rs | 4 +- .../rust-lib/lib-infra/src/isolate_stream.rs | 1 + 40 files changed, 1445 insertions(+), 1234 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart delete mode 100644 frontend/rust-lib/flowy-search/src/services/notifier.rs diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart index 3271070c74..265d313320 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart @@ -49,7 +49,7 @@ void main() { // The score should be higher for "ViewOna" thus it should be shown first final secondDocumentWidget = tester .widget(find.byType(SearchResultTile).first) as SearchResultTile; - expect(secondDocumentWidget.result.data, secondDocument); + expect(secondDocumentWidget.item.data, secondDocument); // Change search to "ViewOne" await tester.enterText(searchFieldFinder, firstDocument); @@ -59,7 +59,7 @@ void main() { final firstDocumentWidget = tester.widget( find.byType(SearchResultTile).first, ) as SearchResultTile; - expect(firstDocumentWidget.result.data, firstDocument); + expect(firstDocumentWidget.item.data, firstDocument); }); testWidgets('Displaying icons in search results', (tester) async { diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart index d952c09221..b01402c868 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -2,10 +2,8 @@ import 'dart:async'; import 'package:appflowy/plugins/trash/application/trash_listener.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; -import 'package:appflowy/workspace/application/command_palette/search_listener.dart'; import 'package:appflowy/workspace/application/command_palette/search_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; @@ -13,184 +11,285 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'command_palette_bloc.freezed.dart'; -const _searchChannel = 'CommandPalette'; - class CommandPaletteBloc extends Bloc { CommandPaletteBloc() : super(CommandPaletteState.initial()) { - _searchListener.start( - onResultsChanged: _onResultsChanged, - ); + // Register event handlers + on<_SearchChanged>(_onSearchChanged); + on<_PerformSearch>(_onPerformSearch); + on<_NewSearchStream>(_onNewSearchStream); + on<_ResultsChanged>(_onResultsChanged); + on<_TrashChanged>(_onTrashChanged); + on<_WorkspaceChanged>(_onWorkspaceChanged); + on<_ClearSearch>(_onClearSearch); _initTrash(); - _dispatch(); } Timer? _debounceOnChanged; final TrashService _trashService = TrashService(); - final SearchListener _searchListener = SearchListener( - channel: _searchChannel, - ); final TrashListener _trashListener = TrashListener(); String? _oldQuery; String? _workspaceId; - int _messagesReceived = 0; @override Future close() { _trashListener.close(); - _searchListener.stop(); _debounceOnChanged?.cancel(); + state.searchResponseStream?.dispose(); return super.close(); } - void _dispatch() { - on((event, emit) async { - event.when( - searchChanged: _debounceOnSearchChanged, - trashChanged: (trash) async { - if (trash != null) { - return emit(state.copyWith(trash: trash)); - } - - final trashOrFailure = await _trashService.readTrash(); - final trashRes = trashOrFailure.fold( - (trash) => trash, - (error) => null, - ); - - if (trashRes != null) { - emit(state.copyWith(trash: trashRes.items)); - } - }, - performSearch: (search) async { - if (search.isNotEmpty && search != state.query) { - _oldQuery = state.query; - emit(state.copyWith(query: search, isLoading: true)); - await SearchBackendService.performSearch( - search, - workspaceId: _workspaceId, - channel: _searchChannel, - ); - } else { - emit(state.copyWith(query: null, isLoading: false, results: [])); - } - }, - resultsChanged: (results) { - if (state.query != _oldQuery) { - emit(state.copyWith(results: [], isLoading: true)); - _oldQuery = state.query; - _messagesReceived = 0; - } - - if (state.query != results.query) { - return; - } - - _messagesReceived++; - - emit( - state.copyWith( - results: _filterDuplicates(results.items), - isLoading: _messagesReceived != results.sends.toInt(), - ), - ); - }, - workspaceChanged: (workspaceId) { - _workspaceId = workspaceId; - emit(state.copyWith(results: [], query: '', isLoading: false)); - }, - clearSearch: () { - emit(state.copyWith(results: [], query: '', isLoading: false)); - }, - ); - }); - } - Future _initTrash() async { + // Start listening for trash updates _trashListener.start( trashUpdated: (trashOrFailed) { - final trash = trashOrFailed.toNullable(); - add(CommandPaletteEvent.trashChanged(trash: trash)); + add( + CommandPaletteEvent.trashChanged( + trash: trashOrFailed.toNullable(), + ), + ); }, ); + // Read initial trash state and forward results final trashOrFailure = await _trashService.readTrash(); - final trash = trashOrFailure.toNullable(); - - add(CommandPaletteEvent.trashChanged(trash: trash?.items)); - } - - void _debounceOnSearchChanged(String value) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer( - const Duration(milliseconds: 300), - () => _performSearch(value), + add( + CommandPaletteEvent.trashChanged( + trash: trashOrFailure.toNullable()?.items, + ), ); } - List _filterDuplicates(List results) { - final currentItems = [...state.results]; - final res = [...results]; - - for (final item in results) { - final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id); - if (duplicateIndex == -1) { - continue; - } - - final duplicate = currentItems[duplicateIndex]; - if (item.score < duplicate.score) { - res.remove(item); - } else { - currentItems.remove(duplicate); - } - } - - return res..addAll(currentItems); + FutureOr _onSearchChanged( + _SearchChanged event, + Emitter emit, + ) { + _debounceOnChanged?.cancel(); + _debounceOnChanged = Timer( + const Duration(milliseconds: 300), + () { + if (!isClosed) { + add(CommandPaletteEvent.performSearch(search: event.search)); + } + }, + ); } - void _performSearch(String value) => - add(CommandPaletteEvent.performSearch(search: value)); + FutureOr _onPerformSearch( + _PerformSearch event, + Emitter emit, + ) async { + if (event.search.isNotEmpty && event.search != state.query) { + _oldQuery = state.query; + emit(state.copyWith(query: event.search, isLoading: true)); - void _onResultsChanged(SearchResultNotificationPB results) => - add(CommandPaletteEvent.resultsChanged(results: results)); + // Fire off search asynchronously (fire and forget) + unawaited( + SearchBackendService.performSearch( + event.search, + workspaceId: _workspaceId, + ).then( + (result) => result.onSuccess((stream) { + if (!isClosed) { + add(CommandPaletteEvent.newSearchStream(stream: stream)); + } + }), + ), + ); + } else { + // Clear state if search is empty or unchanged + emit( + state.copyWith( + query: null, + isLoading: false, + resultItems: [], + resultSummaries: [], + ), + ); + } + } + + FutureOr _onNewSearchStream( + _NewSearchStream event, + Emitter emit, + ) { + state.searchResponseStream?.dispose(); + emit( + state.copyWith( + searchId: event.stream.searchId, + searchResponseStream: event.stream, + ), + ); + + event.stream.listen( + onItems: ( + List items, + String searchId, + bool isLoading, + ) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + items: items, + searchId: searchId, + isLoading: isLoading, + ), + ); + } + }, + onSummaries: ( + List summaries, + String searchId, + bool isLoading, + ) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + summaries: summaries, + searchId: searchId, + isLoading: isLoading, + ), + ); + } + }, + onFinished: (String searchId) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + searchId: searchId, + isLoading: false, + ), + ); + } + }, + ); + } + + FutureOr _onResultsChanged( + _ResultsChanged event, + Emitter emit, + ) async { + // If query was updated since last emission, clear previous results. + if (state.query != _oldQuery) { + emit( + state.copyWith( + resultItems: [], + resultSummaries: [], + isLoading: event.isLoading, + ), + ); + _oldQuery = state.query; + } + + // Check for outdated search streams + if (state.searchId != event.searchId) return; + + final updatedItems = + event.items ?? List.from(state.resultItems); + final updatedSummaries = + event.summaries ?? List.from(state.resultSummaries); + + emit( + state.copyWith( + resultItems: updatedItems, + resultSummaries: updatedSummaries, + isLoading: event.isLoading, + ), + ); + } + + // Update trash state and, in case of null, retry reading trash from the service + FutureOr _onTrashChanged( + _TrashChanged event, + Emitter emit, + ) async { + if (event.trash != null) { + emit(state.copyWith(trash: event.trash!)); + } else { + final trashOrFailure = await _trashService.readTrash(); + trashOrFailure.fold((trash) { + emit(state.copyWith(trash: trash.items)); + }, (error) { + // Optionally handle error; otherwise, we simply do nothing. + }); + } + } + + // Update the workspace and clear current search results and query + FutureOr _onWorkspaceChanged( + _WorkspaceChanged event, + Emitter emit, + ) { + _workspaceId = event.workspaceId; + emit( + state.copyWith( + query: '', + resultItems: [], + resultSummaries: [], + isLoading: false, + ), + ); + } + + // Clear search state + FutureOr _onClearSearch( + _ClearSearch event, + Emitter emit, + ) { + emit( + state.copyWith( + query: '', + resultItems: [], + resultSummaries: [], + isLoading: false, + searchId: null, + ), + ); + } + + bool _isActiveSearch(String searchId) => + !isClosed && state.searchId == searchId; } @freezed class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.searchChanged({required String search}) = _SearchChanged; - const factory CommandPaletteEvent.performSearch({required String search}) = _PerformSearch; - + const factory CommandPaletteEvent.newSearchStream({ + required SearchResponseStream stream, + }) = _NewSearchStream; const factory CommandPaletteEvent.resultsChanged({ - required SearchResultNotificationPB results, + required String searchId, + required bool isLoading, + List? items, + List? summaries, }) = _ResultsChanged; const factory CommandPaletteEvent.trashChanged({ @Default(null) List? trash, }) = _TrashChanged; - const factory CommandPaletteEvent.workspaceChanged({ @Default(null) String? workspaceId, }) = _WorkspaceChanged; - const factory CommandPaletteEvent.clearSearch() = _ClearSearch; } @freezed class CommandPaletteState with _$CommandPaletteState { const CommandPaletteState._(); - const factory CommandPaletteState({ @Default(null) String? query, - required List results, + @Default([]) List resultItems, + @Default([]) List resultSummaries, + @Default(null) SearchResponseStream? searchResponseStream, required bool isLoading, @Default([]) List trash, + @Default(null) String? searchId, }) = _CommandPaletteState; factory CommandPaletteState.initial() => - const CommandPaletteState(results: [], isLoading: false); + const CommandPaletteState(isLoading: false); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart deleted file mode 100644 index b22630eb74..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:appflowy/core/notification/search_notification.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flowy_infra/notifier.dart'; - -// Do not modify! -const _searchObjectId = "SEARCH_IDENTIFIER"; - -class SearchListener { - SearchListener({this.channel}); - - /// Use this to filter out search results from other channels. - /// - /// If null, it will receive search results from all - /// channels, otherwise it will only receive search results from the specified - /// channel. - /// - final String? channel; - - PublishNotifier? _updateNotifier = - PublishNotifier(); - PublishNotifier? _updateDidCloseNotifier = - PublishNotifier(); - SearchNotificationListener? _listener; - - void start({ - void Function(SearchResultNotificationPB)? onResultsChanged, - void Function(SearchResultNotificationPB)? onResultsClosed, - }) { - if (onResultsChanged != null) { - _updateNotifier?.addPublishListener(onResultsChanged); - } - - if (onResultsClosed != null) { - _updateDidCloseNotifier?.addPublishListener(onResultsClosed); - } - - _listener = SearchNotificationListener( - objectId: _searchObjectId, - handler: _handler, - channel: channel, - ); - } - - void _handler( - SearchNotification ty, - FlowyResult result, - ) { - switch (ty) { - case SearchNotification.DidUpdateResults: - result.fold( - (payload) => _updateNotifier?.value = - SearchResultNotificationPB.fromBuffer(payload), - (err) => Log.error(err), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _updateNotifier?.dispose(); - _updateNotifier = null; - _updateDidCloseNotifier?.dispose(); - _updateDidCloseNotifier = null; - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart index 610c666667..563eac8a0a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart @@ -5,7 +5,7 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; -extension GetIcon on SearchResultPB { +extension GetIcon on SearchResponseItemPB { Widget? getIcon() { final iconValue = icon.value, iconType = icon.ty; if (iconType == ResultIconTypePB.Emoji) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart index 53a229ae66..6b862bc9ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -1,22 +1,107 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:nanoid/nanoid.dart'; +import 'package:fixnum/fixnum.dart'; class SearchBackendService { - static Future> performSearch( + static Future> performSearch( String keyword, { String? workspaceId, - String? channel, }) async { + final searchId = nanoid(6); + final stream = SearchResponseStream(searchId: searchId); + final filter = SearchFilterPB(workspaceId: workspaceId); final request = SearchQueryPB( search: keyword, filter: filter, - channel: channel, + searchId: searchId, + streamPort: Int64(stream.nativePort), ); - return SearchEventSearch(request).send(); + unawaited(SearchEventSearch(request).send()); + return FlowyResult.success(stream); + } +} + +class SearchResponseStream { + SearchResponseStream({required this.searchId}) { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (Uint8List data) => _onResultsChanged(data), + ); + } + + final String searchId; + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + void Function( + List items, + String searchId, + bool isLoading, + )? _onItems; + void Function( + List summaries, + String searchId, + bool isLoading, + )? _onSummaries; + void Function(String searchId)? _onFinished; + int get nativePort => _port.sendPort.nativePort; + + Future dispose() async { + await _subscription.cancel(); + _port.close(); + } + + void _onResultsChanged(Uint8List data) { + final response = SearchResponsePB.fromBuffer(data); + + if (response.hasResult()) { + if (response.result.hasSearchResult()) { + _onItems?.call( + response.result.searchResult.items, + searchId, + response.isLoading, + ); + } + if (response.result.hasSearchSummary()) { + _onSummaries?.call( + response.result.searchSummary.items, + searchId, + response.isLoading, + ); + } + } else { + _onFinished?.call(searchId); + } + } + + void listen({ + required void Function( + List items, + String searchId, + bool isLoading, + )? onItems, + required void Function( + List summaries, + String searchId, + bool isLoading, + )? onSummaries, + required void Function(String searchId)? onFinished, + }) { + _onItems = onItems; + _onSummaries = onSummaries; + _onFinished = onFinished; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart index 8eb7765c3a..9a507decc6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -4,7 +4,6 @@ import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_v import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart'; 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/services.dart'; @@ -135,11 +134,15 @@ class CommandPaletteModal extends StatelessWidget { builder: (context, state) => FlowyDialog( alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 100), - constraints: const BoxConstraints(maxHeight: 420, maxWidth: 510), + constraints: const BoxConstraints( + maxHeight: 520, + maxWidth: 510, + minHeight: 420, + ), expandHeight: false, child: shortcutBuilder( + // Change mainAxisSize to max so Expanded works correctly. Column( - mainAxisSize: MainAxisSize.min, children: [ SearchField(query: state.query, isLoading: state.isLoading), if (state.query?.isEmpty ?? true) ...[ @@ -150,23 +153,26 @@ class CommandPaletteModal extends StatelessWidget { ), ), ], - if (state.results.isNotEmpty && + if (state.resultItems.isNotEmpty && (state.query?.isNotEmpty ?? false)) ...[ const Divider(height: 0), Flexible( - child: SearchResultsList( + child: SearchResultList( trash: state.trash, - results: state.results, + resultItems: state.resultItems, + resultSummaries: state.resultSummaries, ), ), - ] else if ((state.query?.isNotEmpty ?? false) && + ] + // When there are no results and the query is not empty and not loading, + // show the no results message, centered in the available space. + else if ((state.query?.isNotEmpty ?? false) && !state.isLoading) ...[ - const _NoResultsHint(), + const Divider(height: 0), + Expanded( + child: const _NoResultsHint(), + ), ], - _CommandPaletteFooter( - shouldShow: state.results.isNotEmpty && - (state.query?.isNotEmpty ?? false), - ), ], ), ), @@ -175,57 +181,16 @@ class CommandPaletteModal extends StatelessWidget { } } +/// Updated _NoResultsHint now centers its content. class _NoResultsHint extends StatelessWidget { const _NoResultsHint(); @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(height: 0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: FlowyText.regular( - LocaleKeys.commandPalette_noResultsHint.tr(), - textAlign: TextAlign.left, - ), - ), - ], - ); - } -} - -class _CommandPaletteFooter extends StatelessWidget { - const _CommandPaletteFooter({required this.shouldShow}); - - final bool shouldShow; - - @override - Widget build(BuildContext context) { - if (!shouldShow) { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4), - ), - child: const FlowyText.semibold('TAB', fontSize: 10), - ), - const HSpace(4), - FlowyText(LocaleKeys.commandPalette_navigateHint.tr(), fontSize: 11), - ], + return Center( + child: FlowyText.regular( + LocaleKeys.commandPalette_noResultsHint.tr(), + textAlign: TextAlign.center, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart index c18024a909..1586ab0a7e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart @@ -7,7 +7,6 @@ import 'package:appflowy/workspace/application/command_palette/command_palette_b 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/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -25,28 +24,31 @@ class SearchField extends StatefulWidget { class _SearchFieldState extends State { late final FocusNode focusNode; - late final controller = TextEditingController(text: widget.query); + late final TextEditingController controller; @override void initState() { super.initState(); - focusNode = FocusNode( - onKeyEvent: (node, event) { - if (node.hasFocus && - event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.arrowDown) { - node.nextFocus(); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); + controller = TextEditingController(text: widget.query); + focusNode = FocusNode(onKeyEvent: _handleKeyEvent); focusNode.requestFocus(); - controller.selection = TextSelection( - baseOffset: 0, - extentOffset: controller.text.length, - ); + // Update the text selection after the first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + }); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (node.hasFocus && + event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + node.nextFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } @override @@ -56,21 +58,83 @@ class _SearchFieldState extends State { super.dispose(); } + Widget _buildSuffixIcon(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + final clearIcon = Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AFThemeExtension.of(context).lightGreyHover, + ), + child: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(16), + ), + ); + return AnimatedOpacity( + opacity: hasText ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: hasText + ? FlowyTooltip( + message: LocaleKeys.commandPalette_clearSearchTooltip.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: _clearSearch, + child: clearIcon, + ), + ), + ) + : clearIcon, + ); + }, + ); + } + @override Widget build(BuildContext context) { + // Cache theme and text styles + final theme = Theme.of(context); + final textStyle = theme.textTheme.bodySmall?.copyWith(fontSize: 14); + final hintStyle = theme.textTheme.bodySmall?.copyWith( + fontSize: 14, + color: theme.hintColor, + ); + + // Choose the leading icon based on loading state + final Widget leadingIcon = widget.isLoading + ? FlowyTooltip( + message: LocaleKeys.commandPalette_loadingTooltip.tr(), + child: const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(3.0), + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + ), + ) + : SizedBox( + width: 20, + height: 20, + child: FlowySvg( + FlowySvgs.search_m, + color: theme.hintColor, + ), + ); + return Row( children: [ const HSpace(12), - FlowySvg( - FlowySvgs.search_m, - color: Theme.of(context).hintColor, - ), + leadingIcon, Expanded( child: FlowyTextField( focusNode: focusNode, controller: controller, - textStyle: - Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 14), + textStyle: textStyle, decoration: InputDecoration( constraints: const BoxConstraints(maxHeight: 48), contentPadding: const EdgeInsets.symmetric(horizontal: 12), @@ -80,72 +144,14 @@ class _SearchFieldState extends State { ), isDense: false, hintText: LocaleKeys.commandPalette_placeholder.tr(), - hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 14, - color: Theme.of(context).hintColor, - ), - errorStyle: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(color: Theme.of(context).colorScheme.error), + hintStyle: hintStyle, + errorStyle: theme.textTheme.bodySmall! + .copyWith(color: theme.colorScheme.error), suffix: Row( mainAxisSize: MainAxisSize.min, children: [ - AnimatedOpacity( - opacity: controller.text.trim().isNotEmpty ? 1 : 0, - duration: const Duration(milliseconds: 200), - child: Builder( - builder: (context) { - final icon = Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AFThemeExtension.of(context).lightGreyHover, - ), - child: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(16), - ), - ); - if (controller.text.isEmpty) { - return icon; - } - - return FlowyTooltip( - message: - LocaleKeys.commandPalette_clearSearchTooltip.tr(), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: controller.text.trim().isNotEmpty - ? _clearSearch - : null, - child: icon, - ), - ), - ); - }, - ), - ), + _buildSuffixIcon(context), const HSpace(8), - FlowyTooltip( - message: LocaleKeys.commandPalette_betaTooltip.tr(), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 2, - ), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4), - ), - child: FlowyText.semibold( - LocaleKeys.commandPalette_betaLabel.tr(), - fontSize: 11, - lineHeight: 1.2, - ), - ), - ), ], ), counterText: "", @@ -155,9 +161,7 @@ class _SearchFieldState extends State { ), errorBorder: OutlineInputBorder( borderRadius: Corners.s8Border, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.error, - ), + borderSide: BorderSide(color: theme.colorScheme.error), ), ), onChanged: (value) => context @@ -165,17 +169,6 @@ class _SearchFieldState extends State { .add(CommandPaletteEvent.searchChanged(search: value)), ), ), - if (widget.isLoading) ...[ - FlowyTooltip( - message: LocaleKeys.commandPalette_loadingTooltip.tr(), - child: const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2.5), - ), - ), - const HSpace(12), - ], ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart index 5f26f07597..a11721d1c3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart @@ -16,12 +16,12 @@ import 'package:flutter/services.dart'; class SearchResultTile extends StatefulWidget { const SearchResultTile({ super.key, - required this.result, + required this.item, required this.onSelected, this.isTrashed = false, }); - final SearchResultPB result; + final SearchResponseItemPB item; final VoidCallback onSelected; final bool isTrashed; @@ -31,7 +31,6 @@ class SearchResultTile extends StatefulWidget { class _SearchResultTileState extends State { bool _hasFocus = false; - final focusNode = FocusNode(); @override @@ -40,104 +39,132 @@ class _SearchResultTileState extends State { super.dispose(); } + /// Helper to handle the selection action. + void _handleSelection() { + widget.onSelected(); + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: widget.item.viewId), + ), + ); + } + + /// Helper to clean up preview text. + String _cleanPreview(String preview) { + return preview.replaceAll('\n', ' ').trim(); + } + @override Widget build(BuildContext context) { - final title = widget.result.data.orDefault( + final title = widget.item.data.orDefault( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); - final icon = widget.result.getIcon(); - final cleanedPreview = _cleanPreview(widget.result.preview); + final icon = widget.item.getIcon(); + final cleanedPreview = _cleanPreview(widget.item.preview); + final hasPreview = cleanedPreview.isNotEmpty; + final trashHintText = + widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null; + + // Build the tile content based on preview availability. + Widget tileContent; + if (hasPreview) { + tileContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.isTrashed) + FlowyText( + trashHintText!, + color: AFThemeExtension.of(context) + .textColor + .withAlpha(175), + fontSize: 10, + ), + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const VSpace(4), + _DocumentPreview(preview: cleanedPreview), + ], + ); + } else { + tileContent = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isTrashed) + FlowyText( + trashHintText!, + color: + AFThemeExtension.of(context).textColor.withAlpha(175), + fontSize: 10, + ), + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () { - widget.onSelected(); - - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.result.viewId), - ), - ); - }, + onTap: _handleSelection, child: Focus( + focusNode: focusNode, onKeyEvent: (node, event) { - if (event is! KeyDownEvent) { - return KeyEventResult.ignored; - } - + if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey == LogicalKeyboardKey.enter) { - widget.onSelected(); - - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.result.viewId), - ), - ); + _handleSelection(); return KeyEventResult.handled; } - return KeyEventResult.ignored; }, onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), child: FlowyHover( isSelected: () => _hasFocus, style: HoverStyle( + borderRadius: BorderRadius.circular(8), hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), foregroundColorOnHover: AFThemeExtension.of(context).textColor, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - // page icon - if (icon != null) ...[ - SizedBox(width: 24, child: icon), - const HSpace(6), - ], - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // if the result is trashed, show a hint - if (widget.isTrashed) ...[ - FlowyText( - LocaleKeys.commandPalette_fromTrashHint.tr(), - color: AFThemeExtension.of(context) - .textColor - .withAlpha(175), - fontSize: 10, - ), - ], - // page title - FlowyText( - title, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - // content preview - if (cleanedPreview.isNotEmpty) ...[ - const VSpace(4), - _DocumentPreview(preview: cleanedPreview), - ], - ], + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: tileContent, ), ), ), ), ); } - - String _cleanPreview(String preview) { - return preview.replaceAll('\n', ' ').trim(); - } } class _DocumentPreview extends StatelessWidget { @@ -147,9 +174,9 @@ class _DocumentPreview extends StatelessWidget { @override Widget build(BuildContext context) { + // Combine the horizontal padding for clarity: return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16) + - const EdgeInsets.only(left: 14), + padding: const EdgeInsets.fromLTRB(30, 0, 16, 0), child: FlowyText.regular( preview, color: Theme.of(context).hintColor, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index ed9becf29e..b2ca24b68f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -7,41 +7,99 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -class SearchResultsList extends StatelessWidget { - const SearchResultsList({ - super.key, +class SearchResultList extends StatelessWidget { + const SearchResultList({ required this.trash, - required this.results, + required this.resultItems, + required this.resultSummaries, + super.key, }); final List trash; - final List results; + final List resultItems; + final List resultSummaries; + + Widget _buildSectionHeader(String title) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8) + + const EdgeInsets.only(left: 8), + child: Opacity( + opacity: 0.6, + child: FlowyText(title, fontSize: 12), + ), + ); + + Widget _buildSummariesSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiSummary.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: resultSummaries.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) => SearchSummaryTile( + summary: resultSummaries[index], + ), + ), + ], + ); + } + + Widget _buildResultsSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _buildSectionHeader(LocaleKeys.commandPalette_bestMatches.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: resultItems.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) { + final item = resultItems[index]; + return SearchResultTile( + item: item, + onSelected: () => FlowyOverlay.pop(context), + isTrashed: trash.any((t) => t.id == item.viewId), + ); + }, + ), + ], + ); + } @override Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - separatorBuilder: (_, __) => const Divider(height: 0), - itemCount: results.length + 1, - itemBuilder: (_, index) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8) + - const EdgeInsets.only(left: 16), - child: FlowyText( - LocaleKeys.commandPalette_bestMatches.tr(), - ), - ); - } - - final result = results[index - 1]; - return SearchResultTile( - result: result, - onSelected: () => FlowyOverlay.pop(context), - isTrashed: trash.any((t) => t.id == result.viewId), - ); - }, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + if (resultSummaries.isNotEmpty) _buildSummariesSection(), + const VSpace(10), + if (resultItems.isNotEmpty) _buildResultsSection(context), + ], + ), + ); + } +} + +class SearchSummaryTile extends StatelessWidget { + const SearchSummaryTile({required this.summary, super.key}); + + final SearchSummaryPB summary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: FlowyText( + summary.content, + maxLines: 10, + ), ); } } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 29473d2e00..147902d799 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2693,6 +2693,7 @@ "commandPalette": { "placeholder": "Search or ask a question...", "bestMatches": "Best matches", + "aiSummary": "AI summary", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index fdf8c8348e..fd3ae1a6b9 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "af-plugin", "anyhow", @@ -362,7 +362,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "anyhow", "futures-util", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "anyhow", "cfg-if", @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "bytes", @@ -604,7 +604,7 @@ dependencies = [ "backoff", "base64 0.21.5", "bytes", - "derive_builder", + "derive_builder 0.12.0", "futures", "rand 0.8.5", "reqwest 0.11.27", @@ -632,9 +632,9 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -643,9 +643,9 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", @@ -887,6 +887,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c5f8abc69af414cbd6f2103bb668b91e584072f2105e4b38bed79b6ad0975f" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b69edf39b6f321cb2699a93fc20c256adb839719c42676d03f7aa975e4e5581d" +dependencies = [ + "darling 0.20.11", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.94", +] + [[package]] name = "borsh" version = "1.5.1" @@ -1134,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "again", "anyhow", @@ -1189,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1202,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "futures-channel", "futures-util", @@ -1474,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "bincode", @@ -1496,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "async-trait", @@ -1761,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1817,12 +1842,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.8" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.8", - "darling_macro 0.20.8", + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] @@ -1841,15 +1866,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.8" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", - "strsim 0.10.0", + "strsim 0.11.1", "syn 2.0.94", ] @@ -1866,11 +1891,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.8" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.8", + "darling_core 0.20.11", "quote", "syn 2.0.94", ] @@ -1944,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "bincode", "bytes", @@ -2027,7 +2052,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", ] [[package]] @@ -2042,16 +2076,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "derive_builder_macro" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.12.0", "syn 1.0.109", ] +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.94", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -2162,9 +2218,9 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "downcast-rs" -version = "1.2.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" [[package]] name = "dtoa" @@ -2484,7 +2540,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "log", - "md5", "notify", "pin-project", "protobuf", @@ -2501,8 +2556,6 @@ dependencies = [ "tracing-subscriber", "uuid", "validator 0.18.1", - "zip 2.2.0", - "zip-extensions", ] [[package]] @@ -2857,15 +2910,16 @@ dependencies = [ name = "flowy-search" version = "0.1.0" dependencies = [ + "allo-isolate", "async-stream", "bytes", "collab", "collab-folder", + "derive_builder 0.20.2", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-folder", - "flowy-notification", "flowy-search-pub", "flowy-user", "futures", @@ -2874,11 +2928,11 @@ dependencies = [ "protobuf", "serde", "serde_json", - "strsim 0.11.0", + "strsim 0.11.1", "strum_macros 0.26.1", "tantivy", - "tempfile", "tokio", + "tokio-stream", "tracing", "uuid", ] @@ -3290,19 +3344,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "generator" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "windows 0.48.0", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -3418,7 +3459,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3433,7 +3474,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "app-error", "jsonwebtoken", @@ -3527,12 +3568,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" - [[package]] name = "hermit-abi" version = "0.5.0" @@ -3802,6 +3837,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperloglogplus" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" +dependencies = [ + "serde", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -4054,7 +4098,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "bytes", @@ -4104,9 +4148,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -4121,7 +4162,7 @@ version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ - "hermit-abi 0.5.0", + "hermit-abi", "libc", "windows-sys 0.59.0", ] @@ -4144,6 +4185,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -4416,20 +4466,6 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - [[package]] name = "lru" version = "0.12.3" @@ -4608,11 +4644,10 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "measure_time" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56220900f1a0923789ecd6bf25fbae8af3b2f1ff3e9e297fc9b6b8674dd4d852" +checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" dependencies = [ - "instant", "log", ] @@ -4876,16 +4911,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi 0.3.2", - "libc", -] - [[package]] name = "object" version = "0.32.1" @@ -4903,12 +4928,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "oneshot" -version = "0.1.6" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" -dependencies = [ - "loom", -] +checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" [[package]] name = "opaque-debug" @@ -4988,9 +5010,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "ownedbytes" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" dependencies = [ "stable_deref_trait", ] @@ -5167,7 +5189,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros", + "phf_macros 0.8.0", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5187,6 +5209,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5254,6 +5277,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "phf_shared" version = "0.8.0" @@ -6482,12 +6518,6 @@ dependencies = [ "parking_lot 0.12.1", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -6606,18 +6636,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.210" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -6754,7 +6784,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f300884#f300884dde348ce855712b8e5a3ff3066682d160" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" dependencies = [ "anyhow", "app-error", @@ -6844,9 +6874,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "sketches-ddsketch" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" dependencies = [ "serde", ] @@ -6967,9 +6997,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -7093,7 +7123,7 @@ dependencies = [ "ntapi", "once_cell", "rayon", - "windows 0.52.0", + "windows", ] [[package]] @@ -7146,14 +7176,15 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tantivy" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d0582f186c0a6d55655d24543f15e43607299425c5ad8352c242b914b31856" +checksum = "b21ad8b222d71c57aa979353ed702f0bc6d97e66d368962cbded57fbd19eedd7" dependencies = [ "aho-corasick", "arc-swap", "base64 0.22.1", "bitpacking", + "bon", "byteorder", "census", "crc32fast", @@ -7163,20 +7194,20 @@ dependencies = [ "fnv", "fs4", "htmlescape", - "itertools 0.12.1", + "hyperloglogplus", + "itertools 0.14.0", "levenshtein_automata", "log", "lru", "lz4_flex", "measure_time", "memmap2", - "num_cpus", "once_cell", "oneshot", "rayon", "regex", "rust-stemmers", - "rustc-hash 1.1.0", + "rustc-hash 2.1.0", "serde", "serde_json", "sketches-ddsketch", @@ -7189,7 +7220,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror 1.0.64", + "thiserror 2.0.9", "time", "uuid", "winapi", @@ -7197,22 +7228,22 @@ dependencies = [ [[package]] name = "tantivy-bitpacker" -version = "0.6.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" +checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" +checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" dependencies = [ "downcast-rs", "fastdivide", - "itertools 0.12.1", + "itertools 0.14.0", "serde", "tantivy-bitpacker", "tantivy-common", @@ -7222,9 +7253,9 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" +checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" dependencies = [ "async-trait", "byteorder", @@ -7246,19 +7277,23 @@ dependencies = [ [[package]] name = "tantivy-query-grammar" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" +checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" dependencies = [ "nom", + "serde", + "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" +checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" dependencies = [ + "futures-util", + "itertools 0.14.0", "tantivy-bitpacker", "tantivy-common", "tantivy-fst", @@ -7267,9 +7302,9 @@ dependencies = [ [[package]] name = "tantivy-stacker" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" +checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" dependencies = [ "murmurhash32", "rand_distr", @@ -7278,9 +7313,9 @@ dependencies = [ [[package]] name = "tantivy-tokenizer-api" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" +checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" dependencies = [ "serde", ] @@ -7303,14 +7338,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" dependencies = [ "cfg-if", "fastrand", + "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8213,7 +8249,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" dependencies = [ - "darling 0.20.8", + "darling 0.20.11", "once_cell", "proc-macro-error", "proc-macro2", @@ -8227,7 +8263,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" dependencies = [ - "darling 0.20.8", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", @@ -8489,15 +8525,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows" version = "0.52.0" @@ -9008,15 +9035,6 @@ dependencies = [ "zstd 0.13.2", ] -[[package]] -name = "zip-extensions" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb0a99499b3497d765525c5d05e3ade9ca4a731c184365c19472c3fd6ba86341" -dependencies = [ - "zip 2.2.0", -] - [[package]] name = "zopfli" version = "0.8.1" diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index d3427ef99c..0a3cf409bb 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -97,14 +97,16 @@ validator = { version = "0.18", features = ["derive"] } tokio-util = "0.7.11" zip = "2.2.0" dashmap = "6.0.1" +derive_builder = "0.20.2" +tantivy = { version = "0.24.0" } # Please using the following command to update the revision id # Current directory: frontend # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f300884" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f300884" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" } [profile.dev] opt-level = 0 @@ -152,6 +154,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index a08eae7e92..a31d42da61 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -41,9 +41,6 @@ reqwest = { version = "0.11.27", features = ["json"] } sha2 = "0.10.7" base64 = "0.21.5" futures-util = "0.3.30" -md5 = "0.7.0" -zip = { workspace = true, features = ["deflate"] } -zip-extensions = "0.8.0" pin-project = "1.1.5" flowy-storage-pub = { workspace = true } collab-integrate.workspace = true diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 32cf633a6f..836bd6b32b 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -1,6 +1,6 @@ +use crate::server_layer::{Server, ServerProvider}; use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; -use client_api::entity::search_dto::SearchDocumentResponseItem; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; use collab::core::origin::{CollabClient, CollabOrigin}; @@ -10,6 +10,9 @@ use collab_entity::CollabType; use collab_integrate::collab_builder::{ CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, }; +use flowy_ai_pub::cloud::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, LocalAIConfig, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, @@ -46,8 +49,6 @@ use tracing::log::error; use tracing::{debug, info}; use uuid::Uuid; -use crate::server_layer::{Server, ServerProvider}; - #[async_trait] impl StorageCloudService for ServerProvider { async fn get_object_url(&self, object_id: ObjectIdentity) -> Result { @@ -874,4 +875,21 @@ impl SearchCloudService for ServerProvider { None => Err(FlowyError::internal().with_context("SearchCloudService not found")), } } + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result { + let server = self.get_server()?; + match server.search_service() { + Some(search_service) => { + search_service + .generate_search_summary(workspace_id, query, search_results) + .await + }, + None => Err(FlowyError::internal().with_context("SearchCloudService not found")), + } + } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs index 1fd1d5211c..73c2844a23 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs @@ -82,12 +82,13 @@ impl UserWorkspaceService for UserWorkspaceServiceImpl { Ok(()) } - fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { + async fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { // The remove_indices_for_workspace should not block the deletion of the workspace // Log the error and continue if let Err(err) = self .folder_manager .remove_indices_for_workspace(workspace_id) + .await { info!("Error removing indices for workspace: {}", err); } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 79a889f86f..31b98da5e6 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -165,9 +165,9 @@ impl AppFlowyCore { collab_builder .set_snapshot_persistence(Arc::new(SnapshotDBImpl(Arc::downgrade(&authenticate_user)))); - let folder_indexer = Arc::new(FolderIndexManagerImpl::new(Some(Arc::downgrade( + let folder_indexer = Arc::new(FolderIndexManagerImpl::new(Arc::downgrade( &authenticate_user, - )))); + ))); let folder_manager = FolderDepsResolver::resolve( Arc::downgrade(&authenticate_user), diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index 69897435e9..61a7422f17 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -33,7 +33,7 @@ collab-document = { workspace = true, optional = true } collab-plugins = { workspace = true, optional = true } collab-folder = { workspace = true, optional = true } client-api = { workspace = true, optional = true } -tantivy = { version = "0.22.0", optional = true } +tantivy = { workspace = true, optional = true } uuid.workspace = true [features] diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 180a5dc1f3..ea89def872 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -2051,10 +2051,11 @@ impl FolderManager { .collect() } - pub fn remove_indices_for_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { + pub async fn remove_indices_for_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { self .folder_indexer - .remove_indices_for_workspace(*workspace_id)?; + .remove_indices_for_workspace(*workspace_id) + .await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index d61de1f237..59eb3e21ee 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -115,8 +115,9 @@ impl FolderManager { let index_content_rx = folder.subscribe_index_content(); self .folder_indexer - .set_index_content_receiver(index_content_rx, *workspace_id); - self.handle_index_folder(*workspace_id, &folder); + .set_index_content_receiver(index_content_rx, *workspace_id) + .await; + self.handle_index_folder(*workspace_id, &folder).await; folder_state_rx }; @@ -137,6 +138,8 @@ impl FolderManager { Arc::downgrade(&self.user), ); + self.folder_indexer.initialize().await; + Ok(()) } @@ -166,7 +169,7 @@ impl FolderManager { Ok(folder) } - fn handle_index_folder(&self, workspace_id: Uuid, folder: &Folder) { + async fn handle_index_folder(&self, workspace_id: Uuid, folder: &Folder) { let mut index_all = true; let encoded_collab = self @@ -191,12 +194,11 @@ impl FolderManager { if index_all { let views = folder.get_all_views(); let folder_indexer = self.folder_indexer.clone(); + let _ = folder_indexer + .remove_indices_for_workspace(workspace_id) + .await; // We spawn a blocking task to index all views in the folder spawn_blocking(move || { - // We remove old indexes just in case - let _ = folder_indexer.remove_indices_for_workspace(workspace_id); - - // We index all views from the workspace folder_indexer.index_all_views(views, workspace_id); }); } diff --git a/frontend/rust-lib/flowy-search-pub/src/cloud.rs b/frontend/rust-lib/flowy-search-pub/src/cloud.rs index b62e226b92..8108cbed9a 100644 --- a/frontend/rust-lib/flowy-search-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-search-pub/src/cloud.rs @@ -1,4 +1,6 @@ -use client_api::entity::search_dto::SearchDocumentResponseItem; +pub use client_api::entity::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; use uuid::Uuid; @@ -10,4 +12,11 @@ pub trait SearchCloudService: Send + Sync + 'static { workspace_id: &Uuid, query: String, ) -> Result, FlowyError>; + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result; } diff --git a/frontend/rust-lib/flowy-search-pub/src/entities.rs b/frontend/rust-lib/flowy-search-pub/src/entities.rs index b7145e993f..fc4c19359c 100644 --- a/frontend/rust-lib/flowy-search-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-search-pub/src/entities.rs @@ -1,9 +1,9 @@ -use std::any::Any; use std::sync::Arc; use collab::core::collab::IndexContentReceiver; use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewLayout}; use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; use uuid::Uuid; pub struct IndexableData { @@ -26,19 +26,22 @@ impl IndexableData { } } +#[async_trait] pub trait IndexManager: Send + Sync { - fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: Uuid); - fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; - fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; - fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; - fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError>; - fn is_indexed(&self) -> bool; - - fn as_any(&self) -> &dyn Any; + async fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: Uuid); + async fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; + async fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; + async fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; + async fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError>; + async fn is_indexed(&self) -> bool; } +#[async_trait] pub trait FolderIndexManager: IndexManager { + async fn initialize(&self); + fn index_all_views(&self, views: Vec>, workspace_id: Uuid); + fn index_view_changes( &self, views: Vec>, diff --git a/frontend/rust-lib/flowy-search/Cargo.toml b/frontend/rust-lib/flowy-search/Cargo.toml index dd313d1aea..a803ad894f 100644 --- a/frontend/rust-lib/flowy-search/Cargo.toml +++ b/frontend/rust-lib/flowy-search/Cargo.toml @@ -17,13 +17,10 @@ flowy-error = { workspace = true, features = [ "impl_from_tantivy", "impl_from_serde", ] } -flowy-notification.workspace = true flowy-user.workspace = true flowy-search-pub.workspace = true flowy-folder = { workspace = true } - bytes.workspace = true -futures.workspace = true lib-dispatch.workspace = true lib-infra = { workspace = true } protobuf.workspace = true @@ -31,18 +28,18 @@ serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["full", "rt-multi-thread", "tracing"] } tracing.workspace = true - -async-stream = "0.3.4" +derive_builder.workspace = true strsim = "0.11.0" strum_macros = "0.26.1" -tantivy = { version = "0.22.0" } +tantivy.workspace = true uuid.workspace = true +allo-isolate = { version = "^0.1", features = ["catch-unwind"] } +futures.workspace = true +tokio-stream.workspace = true +async-stream = "0.3.6" [build-dependencies] flowy-codegen.workspace = true -[dev-dependencies] -tempfile = "3.10.0" - [features] dart = ["flowy-codegen/dart"] diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index d447d27dfb..45bf5864c7 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,16 +1,22 @@ -use flowy_error::FlowyResult; -use flowy_folder::{manager::FolderManager, ViewLayout}; -use flowy_search_pub::cloud::SearchCloudService; -use lib_infra::async_trait::async_trait; -use std::str::FromStr; -use std::sync::Arc; -use tracing::{trace, warn}; -use uuid::Uuid; - +use crate::entities::{ + CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, SearchResultPB, + SearchSourcePB, SearchSummaryPB, +}; use crate::{ - entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResultPB}, + entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, services::manager::{SearchHandler, SearchType}, }; +use async_stream::stream; +use flowy_error::FlowyResult; +use flowy_folder::{manager::FolderManager, ViewLayout}; +use flowy_search_pub::cloud::{SearchCloudService, SearchResult}; +use lib_infra::async_trait::async_trait; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::Arc; +use tokio_stream::{self, Stream}; +use tracing::{trace, warn}; +use uuid::Uuid; pub struct DocumentSearchHandler { pub cloud_service: Arc, @@ -39,65 +45,122 @@ impl SearchHandler for DocumentSearchHandler { &self, query: String, filter: Option, - ) -> FlowyResult> { - let filter = match filter { - Some(filter) => filter, - None => return Ok(vec![]), - }; + ) -> Pin> + Send + 'static>> { + let cloud_service = self.cloud_service.clone(); + let folder_manager = self.folder_manager.clone(); - let workspace_id = match filter.workspace_id { - Some(workspace_id) => workspace_id, - None => return Ok(vec![]), - }; + Box::pin(stream! { + let filter = match filter { + Some(f) => f, + None => { + yield Ok(CreateSearchResultPBArgs::default().build().unwrap()); + return; + }, + }; - let workspace_id = Uuid::from_str(&workspace_id)?; - let results = self - .cloud_service - .document_search(&workspace_id, query) - .await?; - trace!("[Search] remote search results: {:?}", results); + let workspace_id = match Uuid::from_str(&filter.workspace_id) { + Ok(id) => id, + Err(e) => { + yield Err(e.into()); + return; + } + }; - // Grab all views from folder cache - // Notice that `get_all_view_pb` returns Views that don't include trashed and private views - let views = self.folder_manager.get_all_views_pb().await?; - let mut search_results: Vec = vec![]; + let views = match folder_manager.get_all_views_pb().await { + Ok(views) => views, + Err(e) => { + yield Err(e); + return; + }, + }; - for result in results { - if let Some(view) = views.iter().find(|v| v.id == result.object_id.to_string()) { - // If there is no View for the result, we don't add it to the results - // If possible we will extract the icon to display for the result - let icon: Option = match view.icon.clone() { - Some(view_icon) => Some(ResultIconPB::from(view_icon)), - None => { - let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); - Some(ResultIconPB { - ty: ResultIconTypePB::Icon, - value: view_layout_ty.to_string(), - }) - }, - }; + let result_items = match cloud_service.document_search(&workspace_id, query.clone()).await { + Ok(items) => items, + Err(e) => { + yield Err(e); + return; + }, + }; - search_results.push(SearchResultPB { - index_type: IndexTypePB::Document, - view_id: result.object_id.to_string(), - id: result.object_id.to_string(), - data: view.name.clone(), - icon, - score: result.score, - workspace_id: result.workspace_id.to_string(), - preview: result.preview, - }); - } else { - warn!("No view found for search result: {:?}", result); + trace!("[Search] search results: {:?}", result_items); + let summary_input = result_items + .iter() + .map(|v| SearchResult { + object_id: v.object_id, + content: v.content.clone(), + }) + .collect::>(); + + let mut items: Vec = Vec::new(); + for item in result_items.iter() { + if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { + let icon: Option = match view.icon.clone() { + Some(view_icon) => Some(ResultIconPB::from(view_icon)), + None => { + let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); + Some(ResultIconPB { + ty: ResultIconTypePB::Icon, + value: view_layout_ty.to_string(), + }) + }, + }; + + items.push(SearchResponseItemPB { + index_type: IndexTypePB::Document, + view_id: item.object_id.to_string(), + id: item.object_id.to_string(), + data: view.name.clone(), + icon, + score: item.score, + workspace_id: item.workspace_id.to_string(), + preview: item.preview.clone(), + }); + } else { + warn!("No view found for search result: {:?}", item); + } } - } - trace!("[Search] showing results: {:?}", search_results); - Ok(search_results) - } + let search_result = RepeatedSearchResponseItemPB { + items, + }; + yield Ok( + CreateSearchResultPBArgs::default() + .search_result(Some(search_result)) + .build() + .unwrap(), + ); - /// Ignore for [DocumentSearchHandler] - fn index_count(&self) -> u64 { - 0 + // Search summary generation. + match cloud_service.generate_search_summary(&workspace_id, query.clone(), summary_input).await { + Ok(summary_result) => { + trace!("[Search] search summary: {:?}", summary_result); + let summaries: Vec = summary_result + .summaries + .into_iter() + .filter_map(|v| { + v.metadata.as_object().and_then(|object| { + let id = object.get("id")?.as_str()?.to_string(); + let source = object.get("source")?.as_str()?.to_string(); + let metadata = SearchSourcePB {id, source }; + Some(SearchSummaryPB { content: v.content, metadata: Some(metadata) }) + }) + }) + .collect(); + + let summary_result = RepeatedSearchSummaryPB { + items: summaries, + }; + yield Ok( + CreateSearchResultPBArgs::default() + .search_summary(Some(summary_result)) + .build() + .unwrap(), + ); + } + Err(e) => { + warn!("Failed to generate search summary: {:?}", e); + } + } + }) } } diff --git a/frontend/rust-lib/flowy-search/src/entities/notification.rs b/frontend/rust-lib/flowy-search/src/entities/notification.rs index a28ed2b5d8..0a51eb85d3 100644 --- a/frontend/rust-lib/flowy-search/src/entities/notification.rs +++ b/frontend/rust-lib/flowy-search/src/entities/notification.rs @@ -1,20 +1,16 @@ +use super::SearchResultPB; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use super::SearchResultPB; - #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResultNotificationPB { - #[pb(index = 1)] - pub items: Vec, +pub struct SearchResponsePB { + #[pb(index = 1, one_of)] + pub result: Option, #[pb(index = 2)] - pub sends: u64, + pub search_id: String, - #[pb(index = 3, one_of)] - pub channel: Option, - - #[pb(index = 4)] - pub query: String, + #[pb(index = 3)] + pub is_loading: bool, } #[derive(ProtoBuf_Enum, Debug, Default)] diff --git a/frontend/rust-lib/flowy-search/src/entities/query.rs b/frontend/rust-lib/flowy-search/src/entities/query.rs index 8ffbcf3d46..65c92ebed0 100644 --- a/frontend/rust-lib/flowy-search/src/entities/query.rs +++ b/frontend/rust-lib/flowy-search/src/entities/query.rs @@ -13,13 +13,9 @@ pub struct SearchQueryPB { #[pb(index = 3, one_of)] pub filter: Option, - /// Used to identify the channel of the search - /// - /// This can be used to have multiple search notification listeners in place. - /// It is up to the client to decide how to handle this. - /// - /// If not set, then no channel is used. - /// - #[pb(index = 4, one_of)] - pub channel: Option, + #[pb(index = 4)] + pub search_id: String, + + #[pb(index = 5)] + pub stream_port: i64, } diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 0f5ea4dc23..d7d6db0820 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -1,17 +1,54 @@ +use super::IndexTypePB; use collab_folder::{IconType, ViewIcon}; +use derive_builder::Builder; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_folder::entities::ViewIconPB; -use super::IndexTypePB; +#[derive(Debug, Default, ProtoBuf, Builder, Clone)] +#[builder(name = "CreateSearchResultPBArgs")] +#[builder(pattern = "mutable")] +pub struct SearchResultPB { + #[pb(index = 1, one_of)] + #[builder(default)] + pub search_result: Option, -#[derive(Debug, Default, ProtoBuf, Clone)] -pub struct RepeatedSearchResultPB { - #[pb(index = 1)] - pub items: Vec, + #[pb(index = 2, one_of)] + #[builder(default)] + pub search_summary: Option, } #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResultPB { +pub struct RepeatedSearchSummaryPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchSummaryPB { + #[pb(index = 1)] + pub content: String, + + #[pb(index = 2, one_of)] + pub metadata: Option, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchSourcePB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub source: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchResponseItemPB { #[pb(index = 1)] pub index_type: IndexTypePB, @@ -37,9 +74,9 @@ pub struct SearchResultPB { pub preview: Option, } -impl SearchResultPB { +impl SearchResponseItemPB { pub fn with_score(&self, score: f64) -> Self { - SearchResultPB { + SearchResponseItemPB { index_type: self.index_type.clone(), view_id: self.view_id.clone(), id: self.id.clone(), diff --git a/frontend/rust-lib/flowy-search/src/entities/search_filter.rs b/frontend/rust-lib/flowy-search/src/entities/search_filter.rs index 33031b3b2c..2059971a0d 100644 --- a/frontend/rust-lib/flowy-search/src/entities/search_filter.rs +++ b/frontend/rust-lib/flowy-search/src/entities/search_filter.rs @@ -2,6 +2,6 @@ use flowy_derive::ProtoBuf; #[derive(Eq, PartialEq, ProtoBuf, Default, Debug, Clone)] pub struct SearchFilterPB { - #[pb(index = 1, one_of)] - pub workspace_id: Option, + #[pb(index = 1)] + pub workspace_id: String, } diff --git a/frontend/rust-lib/flowy-search/src/event_handler.rs b/frontend/rust-lib/flowy-search/src/event_handler.rs index de611a078f..d79a719f6f 100644 --- a/frontend/rust-lib/flowy-search/src/event_handler.rs +++ b/frontend/rust-lib/flowy-search/src/event_handler.rs @@ -21,7 +21,14 @@ pub(crate) async fn search_handler( ) -> Result<(), FlowyError> { let query = data.into_inner(); let manager = upgrade_manager(manager)?; - manager.perform_search(query.search, query.filter, query.channel); + manager + .perform_search( + query.search, + query.stream_port, + query.filter, + query.search_id, + ) + .await; Ok(()) } diff --git a/frontend/rust-lib/flowy-search/src/folder/entities.rs b/frontend/rust-lib/flowy-search/src/folder/entities.rs index b3837668b8..98f6c469ca 100644 --- a/frontend/rust-lib/flowy-search/src/folder/entities.rs +++ b/frontend/rust-lib/flowy-search/src/folder/entities.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::entities::{IndexTypePB, ResultIconPB, SearchResultPB}; +use crate::entities::{IndexTypePB, ResultIconPB, SearchResponseItemPB}; #[derive(Debug, Serialize, Deserialize)] pub struct FolderIndexData { @@ -11,7 +11,7 @@ pub struct FolderIndexData { pub workspace_id: String, } -impl From for SearchResultPB { +impl From for SearchResponseItemPB { fn from(data: FolderIndexData) -> Self { let icon = if data.icon.is_empty() { None diff --git a/frontend/rust-lib/flowy-search/src/folder/handler.rs b/frontend/rust-lib/flowy-search/src/folder/handler.rs index f92e17cda1..975609a227 100644 --- a/frontend/rust-lib/flowy-search/src/folder/handler.rs +++ b/frontend/rust-lib/flowy-search/src/folder/handler.rs @@ -1,12 +1,14 @@ -use crate::{ - entities::{SearchFilterPB, SearchResultPB}, - services::manager::{SearchHandler, SearchType}, +use super::indexer::FolderIndexManagerImpl; +use crate::entities::{ + CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, SearchFilterPB, SearchResultPB, }; +use crate::services::manager::{SearchHandler, SearchType}; +use async_stream::stream; use flowy_error::FlowyResult; use lib_infra::async_trait::async_trait; +use std::pin::Pin; use std::sync::Arc; - -use super::indexer::FolderIndexManagerImpl; +use tokio_stream::{self, Stream}; pub struct FolderSearchHandler { pub index_manager: Arc, @@ -28,19 +30,26 @@ impl SearchHandler for FolderSearchHandler { &self, query: String, filter: Option, - ) -> FlowyResult> { - let mut results = self.index_manager.search(query, filter.clone())?; - if let Some(filter) = filter { - if let Some(workspace_id) = filter.workspace_id { - // Filter results by workspace ID - results.retain(|result| result.workspace_id == workspace_id); - } - } + ) -> Pin> + Send + 'static>> { + let index_manager = self.index_manager.clone(); - Ok(results) - } + Box::pin(stream! { + // Perform search (if search() returns a Result) + let mut items = match index_manager.search(query).await { + Ok(items) => items, + Err(err) => { + yield Err(err); + return; + } + }; - fn index_count(&self) -> u64 { - self.index_manager.num_docs() + if let Some(filter) = filter { + items.retain(|result| result.workspace_id == filter.workspace_id); + } + + // Build the search result. + let search_result = RepeatedSearchResponseItemPB {items}; + yield Ok(CreateSearchResultPBArgs::default().search_result(Some(search_result)).build().unwrap()) + }) } } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 17989aca29..5de540b0f2 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -1,14 +1,5 @@ -use std::{ - any::Any, - collections::HashMap, - fs, - ops::Deref, - path::Path, - sync::{Arc, Mutex, MutexGuard, Weak}, -}; - use crate::{ - entities::{ResultIconTypePB, SearchFilterPB, SearchResultPB}, + entities::SearchResponseItemPB, folder::schema::{ FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, @@ -19,172 +10,88 @@ use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexCont use flowy_error::{FlowyError, FlowyResult}; use flowy_search_pub::entities::{FolderIndexManager, IndexManager, IndexableData}; use flowy_user::services::authenticate_user::AuthenticateUser; +use std::sync::{Arc, Weak}; +use std::{collections::HashMap, fs}; use super::entities::FolderIndexData; +use crate::entities::ResultIconTypePB; +use lib_infra::async_trait::async_trait; use strsim::levenshtein; use tantivy::{ collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, Index, IndexReader, IndexWriter, TantivyDocument, Term, }; +use tokio::sync::RwLock; +use tracing::{error, info}; use uuid::Uuid; -#[derive(Clone)] -pub struct FolderIndexManagerImpl { - folder_schema: Option, - index: Option, - index_reader: Option, - index_writer: Option>>, +pub struct TantivyState { + pub index: Index, + pub folder_schema: FolderSchema, + pub index_reader: IndexReader, + pub index_writer: IndexWriter, } const FOLDER_INDEX_DIR: &str = "folder_index"; +#[derive(Clone)] +pub struct FolderIndexManagerImpl { + auth_user: Weak, + state: Arc>>, +} + impl FolderIndexManagerImpl { - pub fn new(auth_user: Option>) -> Self { - let auth_user = match auth_user { - Some(auth_user) => auth_user, - None => { - return FolderIndexManagerImpl::empty(); - }, - }; - - // AuthenticateUser is required to get the index path - let authenticate_user = auth_user.upgrade(); - - // Storage path is the users data path with an index directory - // Eg. /usr/flowy-data/indexes - let storage_path = match authenticate_user { - Some(auth_user) => auth_user.get_index_path(), - None => { - tracing::error!("FolderIndexManager: AuthenticateUser is not available"); - return FolderIndexManagerImpl::empty(); - }, - }; - - // We check if the `folder_index` directory exists, if not we create it - let index_path = storage_path.join(Path::new(FOLDER_INDEX_DIR)); - if !index_path.exists() { - let res = fs::create_dir_all(&index_path); - if let Err(e) = res { - tracing::error!( - "FolderIndexManager failed to create index directory: {:?}", - e - ); - return FolderIndexManagerImpl::empty(); - } - } - - // The folder schema is used to define the fields of the index along - // with how they are stored and if the field is indexed - let folder_schema = FolderSchema::new(); - - // We open the existing or newly created folder_index directory - // This is required by the Tantivy Index, as it will use it to store - // and read index data - let index = match MmapDirectory::open(index_path) { - // We open or create an index that takes the directory r/w and the schema. - Ok(dir) => match Index::open_or_create(dir, folder_schema.schema.clone()) { - Ok(index) => index, - Err(e) => { - tracing::error!("FolderIndexManager failed to open index: {:?}", e); - return FolderIndexManagerImpl::empty(); - }, - }, - Err(e) => { - tracing::error!("FolderIndexManager failed to open index directory: {:?}", e); - return FolderIndexManagerImpl::empty(); - }, - }; - - // We only need one IndexReader per index - let index_reader = index.reader(); - let index_writer = index.writer(50_000_000); - - let (index_reader, index_writer) = match (index_reader, index_writer) { - (Ok(reader), Ok(writer)) => (reader, writer), - _ => { - tracing::error!("FolderIndexManager failed to instantiate index writer and/or reader"); - return FolderIndexManagerImpl::empty(); - }, - }; - + pub fn new(auth_user: Weak) -> Self { Self { - folder_schema: Some(folder_schema), - index: Some(index), - index_reader: Some(index_reader), - index_writer: Some(Arc::new(Mutex::new(index_writer))), + auth_user, + state: Arc::new(RwLock::new(None)), } } - fn index_all(&self, indexes: Vec) -> Result<(), FlowyError> { - if indexes.is_empty() { - return Ok(()); + async fn with_writer(&self, f: F) -> FlowyResult + where + F: FnOnce(&mut IndexWriter, &FolderSchema) -> FlowyResult, + { + let mut lock = self.state.write().await; + if let Some(ref mut state) = *lock { + f(&mut state.index_writer, &state.folder_schema) + } else { + Err(FlowyError::internal().with_context("Index not initialized. Call initialize first")) + } + } + + /// Initializes the state using the workspace directory. + async fn initialize_with_workspace(&self) -> FlowyResult<()> { + let auth_user = self + .auth_user + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("AuthenticateUser is not available"))?; + + let index_path = auth_user.get_index_path()?.join(FOLDER_INDEX_DIR); + if !index_path.exists() { + fs::create_dir_all(&index_path).map_err(|e| { + error!("Failed to create folder index directory: {:?}", e); + FlowyError::internal().with_context("Failed to create folder index") + })?; } - let mut index_writer = self.get_index_writer()?; - let folder_schema = self.get_folder_schema()?; + info!("Folder indexer initialized at: {:?}", index_path); + let folder_schema = FolderSchema::new(); + let dir = MmapDirectory::open(index_path)?; + let index = Index::open_or_create(dir, folder_schema.schema.clone())?; + let index_reader = index.reader()?; + let index_writer = index.writer(50_000_000)?; - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - - for data in indexes { - let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - let _ = index_writer.add_document(doc![ - id_field => data.id.clone(), - title_field => data.data.clone(), - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.to_string(), - ]); - } - - index_writer.commit()?; + *self.state.write().await = Some(TantivyState { + index, + folder_schema, + index_reader, + index_writer, + }); Ok(()) } - pub fn num_docs(&self) -> u64 { - self - .index_reader - .clone() - .map(|reader| reader.searcher().num_docs()) - .unwrap_or(0) - } - - fn empty() -> Self { - Self { - folder_schema: None, - index: None, - index_reader: None, - index_writer: None, - } - } - - fn get_index_writer(&self) -> FlowyResult> { - match &self.index_writer { - Some(index_writer) => match index_writer.deref().lock() { - Ok(writer) => Ok(writer), - Err(e) => { - tracing::error!("FolderIndexManager failed to lock index writer: {:?}", e); - Err(FlowyError::folder_index_manager_unavailable()) - }, - }, - None => Err(FlowyError::folder_index_manager_unavailable()), - } - } - - fn get_folder_schema(&self) -> FlowyResult { - match &self.folder_schema { - Some(folder_schema) => Ok(folder_schema.clone()), - None => Err(FlowyError::folder_index_manager_unavailable()), - } - } - fn extract_icon( &self, view_icon: Option, @@ -200,100 +107,68 @@ impl FolderIndexManagerImpl { icon = Some(view_icon.value); } else { icon_ty = ResultIconTypePB::Icon.into(); - let layout_ty: i64 = view_layout.into(); + let layout_ty = view_layout as i64; icon = Some(layout_ty.to_string()); } - (icon, icon_ty) } - pub fn search( - &self, - query: String, - _filter: Option, - ) -> Result, FlowyError> { - let folder_schema = self.get_folder_schema()?; - - let (index, index_reader) = self - .index - .as_ref() - .zip(self.index_reader.as_ref()) - .ok_or_else(FlowyError::folder_index_manager_unavailable)?; - - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - - let length = query.len(); - let distance: u8 = if length >= 2 { 2 } else { 1 }; - - let mut query_parser = QueryParser::for_index(&index.clone(), vec![title_field]); - query_parser.set_field_fuzzy(title_field, true, distance, true); - let built_query = query_parser.parse_query(&query.clone())?; - - let searcher = index_reader.searcher(); - let mut search_results: Vec = vec![]; - let top_docs = searcher.search(&built_query, &TopDocs::with_limit(10))?; - for (_score, doc_address) in top_docs { - let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?; - - let mut content = HashMap::new(); - let named_doc = retrieved_doc.to_named_doc(&folder_schema.schema); - for (k, v) in named_doc.0 { - content.insert(k, v[0].clone()); - } - - if content.is_empty() { - continue; - } - - let s = serde_json::to_string(&content)?; - let result: SearchResultPB = serde_json::from_str::(&s)?.into(); - let score = self.score_result(&query, &result.data); - search_results.push(result.with_score(score)); - } - - Ok(search_results) - } - - // Score result by distance fn score_result(&self, query: &str, term: &str) -> f64 { let distance = levenshtein(query, term) as f64; 1.0 / (distance + 1.0) } - fn get_schema_fields(&self) -> Result<(Field, Field, Field, Field, Field), FlowyError> { - let folder_schema = match self.folder_schema.clone() { - Some(schema) => schema, - _ => return Err(FlowyError::folder_index_manager_unavailable()), - }; + /// Simple implementation to index all given data by spawning async tasks. + fn index_all(&self, data_vec: Vec) -> Result<(), FlowyError> { + for data in data_vec { + let indexer = self.clone(); + tokio::spawn(async move { + let _ = indexer.add_index(data).await; + }); + } + Ok(()) + } - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + /// Searches the index using the given query string. + pub async fn search(&self, query: String) -> Result, FlowyError> { + let lock = self.state.read().await; + let state = lock + .as_ref() + .ok_or_else(FlowyError::folder_index_manager_unavailable)?; + let schema = &state.folder_schema; + let index = &state.index; + let reader = &state.index_reader; - Ok(( - id_field, - title_field, - icon_field, - icon_ty_field, - workspace_id_field, - )) + let title_field = schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let mut parser = QueryParser::for_index(index, vec![title_field]); + parser.set_field_fuzzy(title_field, true, 2, true); + + let built_query = parser.parse_query(&query)?; + let searcher = reader.searcher(); + let top_docs = searcher.search(&built_query, &TopDocs::with_limit(10))?; + + let mut results = Vec::new(); + for (_score, doc_address) in top_docs { + let doc: TantivyDocument = searcher.doc(doc_address)?; + let named_doc = doc.to_named_doc(&schema.schema); + let mut content = HashMap::new(); + for (k, v) in named_doc.0 { + content.insert(k, v[0].clone()); + } + if !content.is_empty() { + let s = serde_json::to_string(&content)?; + let result: SearchResponseItemPB = serde_json::from_str::(&s)?.into(); + results.push(result.with_score(self.score_result(&query, &result.data) as f64)); + } + } + + Ok(results) } } +#[async_trait] impl IndexManager for FolderIndexManagerImpl { - fn is_indexed(&self) -> bool { - self - .index_reader - .clone() - .map(|reader| reader.searcher().num_docs() > 0) - .unwrap_or(false) - } - - fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: Uuid) { + async fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: Uuid) { let indexer = self.clone(); let wid = workspace_id; tokio::spawn(async move { @@ -301,31 +176,35 @@ impl IndexManager for FolderIndexManagerImpl { match msg { IndexContent::Create(value) => match serde_json::from_value::(value) { Ok(view) => { - let _ = indexer.add_index(IndexableData { - id: view.id, - data: view.name, - icon: view.icon, - layout: view.layout, - workspace_id: wid, - }); + let _ = indexer + .add_index(IndexableData { + id: view.id, + data: view.name, + icon: view.icon, + layout: view.layout, + workspace_id: wid, + }) + .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), + Err(err) => tracing::error!("FolderIndexManager error deserialize (create): {:?}", err), }, IndexContent::Update(value) => match serde_json::from_value::(value) { Ok(view) => { - let _ = indexer.update_index(IndexableData { - id: view.id, - data: view.name, - icon: view.icon, - layout: view.layout, - workspace_id: wid, - }); + let _ = indexer + .update_index(IndexableData { + id: view.id, + data: view.name, + icon: view.icon, + layout: view.layout, + workspace_id: wid, + }) + .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), + Err(err) => tracing::error!("FolderIndexManager error deserialize (update): {:?}", err), }, IndexContent::Delete(ids) => { - if let Err(e) = indexer.remove_indices(ids) { - tracing::error!("FolderIndexManager error deserialize: {:?}", e); + if let Err(e) = indexer.remove_indices(ids).await { + tracing::error!("FolderIndexManager error (delete): {:?}", e); } }, } @@ -333,100 +212,108 @@ impl IndexManager for FolderIndexManagerImpl { }); } - fn update_index(&self, data: IndexableData) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = - self.get_schema_fields()?; - - let delete_term = Term::from_field_text(id_field, &data.id.clone()); - - // Remove old index - index_writer.delete_term(delete_term); - + async fn add_index(&self, data: IndexableData) -> Result<(), FlowyError> { let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - // Add new index - let _ = index_writer.add_document(doc![ - id_field => data.id.clone(), - title_field => data.data, - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.to_string(), - ]); - - index_writer.commit()?; - - Ok(()) - } - - fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - for id in ids { - let delete_term = Term::from_field_text(id_field, &id); - index_writer.delete_term(delete_term); - } - - index_writer.commit()?; - - Ok(()) - } - - fn add_index(&self, data: IndexableData) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = - self.get_schema_fields()?; - - let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - // Add new index - let _ = index_writer.add_document(doc![ - id_field => data.id, - title_field => data.data, - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.to_string(), - ]); - - index_writer.commit()?; - - Ok(()) - } - - /// Removes all indexes that are related by workspace id. This is useful - /// for cleaning indexes when eg. removing/leaving a workspace. - /// - fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - let id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - let delete_term = Term::from_field_text(id_field, &workspace_id.to_string()); - index_writer.delete_term(delete_term); - - index_writer.commit()?; - - Ok(()) - } - - fn as_any(&self) -> &dyn Any { self + .with_writer(|index_writer, folder_schema| { + let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = + get_schema_fields(folder_schema)?; + let _ = index_writer.add_document(doc![ + id_field => data.id, + title_field => data.data, + icon_field => icon.unwrap_or_default(), + icon_ty_field => icon_ty, + workspace_id_field => data.workspace_id.to_string(), + ]); + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn update_index(&self, data: IndexableData) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = + get_schema_fields(folder_schema)?; + let delete_term = Term::from_field_text(id_field, &data.id); + index_writer.delete_term(delete_term); + + let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); + let _ = index_writer.add_document(doc![ + id_field => data.id, + title_field => data.data, + icon_field => icon.unwrap_or_default(), + icon_ty_field => icon_ty, + workspace_id_field => data.workspace_id.to_string(), + ]); + + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; + for id in ids { + let delete_term = Term::from_field_text(id_field, &id); + index_writer.delete_term(delete_term); + } + + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let id_field = folder_schema + .schema + .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + + let delete_term = Term::from_field_text(id_field, &workspace_id.to_string()); + index_writer.delete_term(delete_term); + index_writer.commit()?; + Ok(()) + }) + .await?; + Ok(()) + } + + async fn is_indexed(&self) -> bool { + let lock = self.state.read().await; + if let Some(ref state) = *lock { + state.index_reader.searcher().num_docs() > 0 + } else { + false + } } } +#[async_trait] impl FolderIndexManager for FolderIndexManagerImpl { + async fn initialize(&self) { + if let Err(e) = self.initialize_with_workspace().await { + error!("Failed to initialize FolderIndexManager: {:?}", e); + } + } + fn index_all_views(&self, views: Vec>, workspace_id: Uuid) { let indexable_data = views .into_iter() .map(|view| IndexableData::from_view(view, workspace_id)) .collect(); - let _ = self.index_all(indexable_data); } @@ -440,23 +327,50 @@ impl FolderIndexManager for FolderIndexManagerImpl { for change in changes { match change { FolderViewChange::Inserted { view_id } => { - let view = views_iter.find(|view| view.id == view_id); - if let Some(view) = view { + if let Some(view) = views_iter.find(|view| view.id == view_id) { let indexable_data = IndexableData::from_view(view, workspace_id); - let _ = self.add_index(indexable_data); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.add_index(indexable_data).await; + }); } }, FolderViewChange::Updated { view_id } => { - let view = views_iter.find(|view| view.id == view_id); - if let Some(view) = view { + if let Some(view) = views_iter.find(|view| view.id == view_id) { let indexable_data = IndexableData::from_view(view, workspace_id); - let _ = self.update_index(indexable_data); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.update_index(indexable_data).await; + }); } }, FolderViewChange::Deleted { view_ids } => { - let _ = self.remove_indices(view_ids); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.remove_indices(view_ids).await; + }); }, - }; + } } } } + +fn get_schema_fields( + folder_schema: &FolderSchema, +) -> Result<(Field, Field, Field, Field, Field), FlowyError> { + let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; + let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; + let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; + let workspace_id_field = folder_schema + .schema + .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + + Ok(( + id_field, + title_field, + icon_field, + icon_ty_field, + workspace_id_field, + )) +} diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 84659b3037..30c6783fb0 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use super::notifier::{SearchNotifier, SearchResultChanged, SearchResultReceiverRunner}; -use crate::entities::{SearchFilterPB, SearchResultNotificationPB, SearchResultPB}; +use crate::entities::{SearchFilterPB, SearchResponsePB, SearchResultPB}; +use allo_isolate::Isolate; use flowy_error::FlowyResult; - use lib_infra::async_trait::async_trait; -use tokio::sync::broadcast; +use lib_infra::isolate_stream::{IsolateSink, SinkExt}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio_stream::{self, Stream, StreamExt}; +use tracing::{error, trace}; #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum SearchType { @@ -19,15 +20,12 @@ pub trait SearchHandler: Send + Sync + 'static { /// returns the type of search this handler is responsible for fn search_type(&self) -> SearchType; - /// performs a search and returns the results + /// performs a search and returns a stream of results async fn perform_search( &self, query: String, filter: Option, - ) -> FlowyResult>; - - /// returns the number of indexed objects - fn index_count(&self) -> u64; + ) -> Pin> + Send + 'static>>; } /// The [SearchManager] is used to inject multiple [SearchHandler]'s @@ -36,7 +34,7 @@ pub trait SearchHandler: Send + Sync + 'static { /// pub struct SearchManager { pub handlers: HashMap>, - notifier: SearchNotifier, + current_search: Arc>>, // Track current search } impl SearchManager { @@ -46,45 +44,88 @@ impl SearchManager { .map(|handler| (handler.search_type(), handler)) .collect(); - // Initialize Search Notifier - let (notifier, _) = broadcast::channel(100); - tokio::spawn(SearchResultReceiverRunner(Some(notifier.subscribe())).run()); - - Self { handlers, notifier } + Self { + handlers, + current_search: Arc::new(tokio::sync::Mutex::new(None)), + } } pub fn get_handler(&self, search_type: SearchType) -> Option<&Arc> { self.handlers.get(&search_type) } - pub fn perform_search( + pub async fn perform_search( &self, query: String, + stream_port: i64, filter: Option, - channel: Option, + search_id: String, ) { - let max: usize = self.handlers.len(); - let handlers = self.handlers.clone(); - for (_, handler) in handlers { - let q = query.clone(); - let f = filter.clone(); - let ch = channel.clone(); - let notifier = self.notifier.clone(); - - tokio::spawn(async move { - let res = handler.perform_search(q.clone(), f).await; - - let items = res.unwrap_or_default(); - - let notification = SearchResultNotificationPB { - items, - sends: max as u64, - channel: ch, - query: q, - }; - - let _ = notifier.send(SearchResultChanged::SearchResultUpdate(notification)); - }); + // Cancel previous search by updating current_search + { + let mut current = self.current_search.lock().await; + *current = Some(search_id.clone()); } + + let handlers = self.handlers.clone(); + let sink = IsolateSink::new(Isolate::new(stream_port)); + let mut join_handles = vec![]; + let current_search = self.current_search.clone(); + + for (_, handler) in handlers { + let mut clone_sink = sink.clone(); + let query = query.clone(); + let filter = filter.clone(); + let search_id = search_id.clone(); + let current_search = current_search.clone(); + + let handle = tokio::spawn(async move { + let mut stream = handler.perform_search(query.clone(), filter).await; + while let Some(result) = stream.next().await { + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] search changed, cancel search: {}", query); + break; + } + + if let Ok(result) = result { + let resp = SearchResponsePB { + result: Some(result), + search_id: search_id.clone(), + is_loading: true, + }; + if let Ok::, _>(data) = resp.try_into() { + if let Err(err) = clone_sink.send(data).await { + error!("Failed to send search result: {}", err); + break; + } + } + } + } + + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] search changed, cancel search: {}", query); + return; + } + + let resp = SearchResponsePB { + result: None, + search_id: search_id.clone(), + is_loading: true, + }; + if let Ok::, _>(data) = resp.try_into() { + let _ = clone_sink.send(data).await; + } + }); + join_handles.push(handle); + } + futures::future::join_all(join_handles).await; } } + +async fn is_current_search( + current_search: &Arc>>, + search_id: &str, +) -> bool { + let current = current_search.lock().await; + current.as_ref().map_or(false, |id| id == search_id) +} diff --git a/frontend/rust-lib/flowy-search/src/services/mod.rs b/frontend/rust-lib/flowy-search/src/services/mod.rs index 2a417e6c62..ff8de9eb9a 100644 --- a/frontend/rust-lib/flowy-search/src/services/mod.rs +++ b/frontend/rust-lib/flowy-search/src/services/mod.rs @@ -1,2 +1 @@ pub mod manager; -pub mod notifier; diff --git a/frontend/rust-lib/flowy-search/src/services/notifier.rs b/frontend/rust-lib/flowy-search/src/services/notifier.rs deleted file mode 100644 index abbf5d4b0c..0000000000 --- a/frontend/rust-lib/flowy-search/src/services/notifier.rs +++ /dev/null @@ -1,61 +0,0 @@ -use async_stream::stream; -use flowy_notification::NotificationBuilder; -use futures::stream::StreamExt; -use tokio::sync::broadcast; - -use crate::entities::{SearchNotification, SearchResultNotificationPB}; - -const SEARCH_OBSERVABLE_SOURCE: &str = "Search"; -const SEARCH_ID: &str = "SEARCH_IDENTIFIER"; - -#[derive(Clone)] -pub enum SearchResultChanged { - SearchResultUpdate(SearchResultNotificationPB), -} - -pub type SearchNotifier = broadcast::Sender; - -pub(crate) struct SearchResultReceiverRunner( - pub(crate) Option>, -); - -impl SearchResultReceiverRunner { - pub(crate) async fn run(mut self) { - let mut receiver = self.0.take().expect("Only take once"); - let stream = stream! { - while let Ok(changed) = receiver.recv().await { - yield changed; - } - }; - stream - .for_each(|changed| async { - match changed { - SearchResultChanged::SearchResultUpdate(notification) => { - send_notification( - SEARCH_ID, - SearchNotification::DidUpdateResults, - notification.channel.clone(), - ) - .payload(notification) - .send(); - }, - } - }) - .await; - } -} - -#[tracing::instrument(level = "trace")] -pub fn send_notification( - id: &str, - ty: SearchNotification, - channel: Option, -) -> NotificationBuilder { - let observable_source = &format!( - "{}{}", - SEARCH_OBSERVABLE_SOURCE, - channel.unwrap_or_default() - ); - - NotificationBuilder::new(id, ty, observable_source) -} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs index 35fa6b153a..1ce0995144 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs @@ -1,19 +1,16 @@ -use client_api::entity::search_dto::SearchDocumentResponseItem; +use crate::af_cloud::AFServer; +use flowy_ai_pub::cloud::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; use flowy_error::FlowyError; use flowy_search_pub::cloud::SearchCloudService; use lib_infra::async_trait::async_trait; use uuid::Uuid; -use crate::af_cloud::AFServer; - pub(crate) struct AFCloudSearchCloudServiceImpl { pub inner: T, } -// The limit of what the score should be for results, used to -// filter out irrelevant results. -// https://community.openai.com/t/rule-of-thumb-cosine-similarity-thresholds/693670/5 -const SCORE_LIMIT: f64 = 0.3; const DEFAULT_PREVIEW: u32 = 80; #[async_trait] @@ -28,14 +25,22 @@ where ) -> Result, FlowyError> { let client = self.inner.try_get_client()?; let result = client - .search_documents(workspace_id, &query, 10, DEFAULT_PREVIEW) + .search_documents(workspace_id, &query, 10, DEFAULT_PREVIEW, None) .await?; - // Filter out irrelevant results - let result = result - .into_iter() - .filter(|r| r.score > SCORE_LIMIT) - .collect(); + Ok(result) + } + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result { + let client = self.inner.try_get_client()?; + let result = client + .generate_search_summary(workspace_id, &query, search_results) + .await?; Ok(result) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 3c79cd38ca..e59166fc37 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -127,7 +127,7 @@ where let try_get_client = self.server.try_get_client(); let client = try_get_client?; let response = client.sign_in_password(&email, &password).await?; - Ok(response) + Ok(response.gotrue_response) } async fn sign_in_with_magic_link( diff --git a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs index 122ce2e60f..84185d310f 100644 --- a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs +++ b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs @@ -20,5 +20,5 @@ pub trait UserWorkspaceService: Send + Sync { ) -> FlowyResult<()>; /// Removes local indexes when a workspace is left/deleted - fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()>; + async fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index 84d43fe4f2..ab9a35b483 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -93,9 +93,9 @@ impl AuthenticateUser { self.database.get_connection(uid) } - pub fn get_index_path(&self) -> PathBuf { - let uid = self.user_id().unwrap_or(0); - PathBuf::from(self.user_paths.user_data_dir(uid)).join("indexes") + pub fn get_index_path(&self) -> FlowyResult { + let uid = self.user_id()?; + Ok(PathBuf::from(self.user_paths.user_data_dir(uid)).join("indexes")) } pub fn get_user_data_dir(&self) -> FlowyResult { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 3f3d90127d..496019cd2e 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -279,6 +279,7 @@ impl UserManager { self .user_workspace_service .did_delete_workspace(workspace_id) + .await } #[instrument(level = "info", skip(self), err)] @@ -295,7 +296,8 @@ impl UserManager { self .user_workspace_service - .did_delete_workspace(workspace_id)?; + .did_delete_workspace(workspace_id) + .await?; Ok(()) } diff --git a/frontend/rust-lib/lib-infra/src/isolate_stream.rs b/frontend/rust-lib/lib-infra/src/isolate_stream.rs index 358214e985..cebc2b7d10 100644 --- a/frontend/rust-lib/lib-infra/src/isolate_stream.rs +++ b/frontend/rust-lib/lib-infra/src/isolate_stream.rs @@ -7,6 +7,7 @@ use std::pin::Pin; use std::task::{Context, Poll}; #[pin_project] +#[derive(Clone, Debug)] pub struct IsolateSink { isolate: Isolate, } From 69b98cb323d49a69c3240775e9ce75d745d2c5bb Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:47:55 +0800 Subject: [PATCH 128/207] fix: open board row as page (#7735) --- .../board/presentation/board_page.dart | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 386be9cc15..70d00bcd25 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -386,15 +386,15 @@ class _BoardContentState extends State<_BoardContent> { scrollManager: scrollManager, ), ), - cardBuilder: (context, column, columnItem) => + cardBuilder: (cardContext, column, columnItem) => MultiBlocProvider( key: ValueKey("board_card_${column.id}_${columnItem.id}"), providers: [ BlocProvider.value( - value: context.read(), + value: cardContext.read(), ), BlocProvider.value( - value: context.read(), + value: cardContext.read(), ), BlocProvider( create: (_) => ViewLockStatusBloc(view: widget.view) @@ -402,7 +402,7 @@ class _BoardContentState extends State<_BoardContent> { ), ], child: BlocBuilder( - builder: (context, state) { + builder: (lockStatusContext, state) { return IgnorePointer( ignoring: state.isLocked, child: _BoardCard( @@ -412,6 +412,13 @@ class _BoardContentState extends State<_BoardContent> { notifier: widget.focusScope, cellBuilder: cellBuilder, compactMode: compactMode, + onOpenCard: (rowMeta) => _openCard( + context: context, + databaseController: lockStatusContext + .read() + .databaseController, + rowMeta: rowMeta, + ), ), ); }, @@ -581,6 +588,7 @@ class _BoardCard extends StatefulWidget { required this.cellBuilder, required this.notifier, required this.compactMode, + required this.onOpenCard, }); final AppFlowyGroupData afGroupData; @@ -589,6 +597,7 @@ class _BoardCard extends StatefulWidget { final CardCellBuilder cellBuilder; final BoardFocusScope notifier; final bool compactMode; + final void Function(RowMetaPB) onOpenCard; @override State<_BoardCard> createState() => _BoardCardState(); @@ -698,10 +707,8 @@ class _BoardCardState extends State<_BoardCard> { groupingFieldId: widget.groupItem.fieldInfo.id, isEditing: _isEditing, cellBuilder: widget.cellBuilder, - onTap: (context) => _openCard( - context: context, - databaseController: databaseController, - rowMeta: context.read().rowController.rowMeta, + onTap: (context) => widget.onOpenCard( + context.read().rowController.rowMeta, ), onShiftTap: (_) { Focus.of(context).requestFocus(); From 54df6b2863ce451fc0bbfa3052f61273d8ef44a3 Mon Sep 17 00:00:00 2001 From: Morn Date: Mon, 14 Apr 2025 14:14:05 +0800 Subject: [PATCH 129/207] fix: link_preview launch review issues (#7731) * fix: some link_preview launch review issues * fix: some UI issues * chore: pasting a link will not check whether it is an image * fix: copy link to block not supported well * fix: mention UI issues * feat: support get youtube channel info * chore: update translation * feat: add shadow in appflowy theme * chore: remove AFThemeExtensionV2 * fix: some UI issues --- .../document_copy_and_paste_test.dart | 16 +- .../document/document_link_preview_test.dart | 9 +- .../lib/core/helpers/url_launcher.dart | 22 +- .../presentation/editor_configuration.dart | 1 - .../document/presentation/editor_page.dart | 16 +- .../ai/ai_writer_toolbar_item.dart | 11 +- .../copy_and_paste/custom_paste_command.dart | 4 +- .../delta/text_delta_extension.dart | 7 + .../link/link_create_menu.dart | 25 +-- .../link_embed_block_component.dart | 124 ++++++----- .../link_embed/link_embed_menu.dart | 46 ++-- .../link_preview/custom_link_parser.dart | 70 ++++--- .../link_preview/custom_link_preview.dart | 130 ++++++------ .../custom_link_preview_block_component.dart | 198 +++++++++--------- .../default_selectable_mixin.dart | 77 +++++++ .../link_parsers/default_parser.dart | 88 ++++++++ .../link_parsers/youtube_parser.dart | 86 ++++++++ .../link_preview/link_preview_cache.dart | 25 --- .../link_preview/paste_as/paste_as_menu.dart | 40 ++-- .../mention/mention_link_block.dart | 36 +++- .../mention/mention_link_preview.dart | 49 +++-- .../presentation/editor_plugins/plugins.dart | 1 - .../custom_format_toolbar_items.dart | 5 +- .../custom_hightlight_color_toolbar_item.dart | 5 +- .../custom_link_toolbar_item.dart | 6 +- .../custom_text_align_toolbar_item.dart | 9 +- .../custom_text_color_toolbar_item.dart | 8 +- .../text_heading_toolbar_item.dart | 9 +- .../text_suggestions_toolbar_item.dart | 8 +- .../lib/shared/flowy_error_page.dart | 1 - .../startup/tasks/appflowy_cloud_task.dart | 1 - .../helpers/handle_open_workspace_error.dart | 2 - .../lib/util/share_log_files.dart | 3 - .../appearance/desktop_appearance.dart | 6 - .../appearance/mobile_appearance.dart | 7 - .../sidebar/workspace/sidebar_workspace.dart | 1 - .../pages/account/account_deletion.dart | 4 - .../pages/settings_manage_data_view.dart | 1 - .../lib/src/theme/data/builder.dart | 30 +++ .../appflowy_ui/lib/src/theme/data/data.dart | 13 +- .../lib/src/theme/shadow/shadow.dart | 8 + .../packages/flowy_infra/lib/language.dart | 2 +- .../flowy_infra/lib/theme_extension_v2.dart | 99 --------- frontend/appflowy_flutter/pubspec.lock | 8 - frontend/appflowy_flutter/pubspec.yaml | 1 - .../link_preview/link_preview_test.dart | 47 +++++ frontend/resources/translations/en.json | 2 +- frontend/resources/translations/zh-CN.json | 21 ++ 48 files changed, 838 insertions(+), 550 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart delete mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart delete mode 100644 frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart create mode 100644 frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart index ee82f01d3f..d1e34edcb5 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -347,8 +347,9 @@ void main() { await tester.tapButton(menu); final convertToLinkButton = find.text( - LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl.tr(), - ); + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(), + ); expect(convertToLinkButton, findsOneWidget); await tester.tapButton(convertToLinkButton); }, @@ -384,7 +385,6 @@ void main() { expect(node.attributes[LinkPreviewBlockKeys.url], url); }); - await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: @@ -483,16 +483,6 @@ void main() { }); }); - testWidgets('paste image url without extension', (tester) async { - const plainText = - 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; - await tester.pasteContent(plainText: plainText, (editorState) { - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotEmpty); - }); - }); - const testMarkdownText = ''' # I'm h1 ## I'm h2 diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart index f74011ee2b..39f8bfd4f6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart @@ -5,6 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; @@ -397,7 +398,7 @@ void main() { Future hoverAndConvert( WidgetTester tester, - PasteMenuType command, + LinkEmbedConvertCommand command, ) async { final embed = find.byType(LinkEmbedBlockComponent); expect(embed, findsOneWidget); @@ -425,7 +426,7 @@ void main() { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); - await hoverAndConvert(tester, PasteMenuType.mention); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention); final node = tester.editor.getNodeAtPath([0]); checkMention(node, link); }); @@ -434,7 +435,7 @@ void main() { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); - await hoverAndConvert(tester, PasteMenuType.url); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL); final node = tester.editor.getNodeAtPath([0]); checkUrl(node, link); }); @@ -444,7 +445,7 @@ void main() { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); - await hoverAndConvert(tester, PasteMenuType.bookmark); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark); final node = tester.editor.getNodeAtPath([0]); checkBookmark(node, link); }); diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index fa61e7f5b8..0502e79604 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -45,16 +45,18 @@ Future afLaunchUri( } // try to launch the uri directly - bool result; - try { - result = await launcher.launchUrl( - uri, - mode: mode, - webOnlyWindowName: webOnlyWindowName, - ); - } on PlatformException catch (e) { - Log.error('Failed to open uri: $e'); - return false; + bool result = await launcher.canLaunchUrl(uri); + if (result) { + try { + result = await launcher.launchUrl( + uri, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + return false; + } } // if the uri is not a valid url, try to launch it with http scheme diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index b3d99ab84b..5e7eefc24e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -983,7 +983,6 @@ CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( return const EdgeInsets.symmetric(vertical: 10); }, ), - cache: LinkPreviewDataCache(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index c2c18e48eb..0ffb7de73a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -20,9 +20,9 @@ import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -352,7 +352,6 @@ class _AppFlowyEditorPageState extends State final isLocked = context.read()?.state.isLocked ?? false; - final themeV2 = AFThemeExtensionV2.of(context); final editor = Directionality( textDirection: textDirection, child: AppFlowyEditor( @@ -431,6 +430,7 @@ class _AppFlowyEditorPageState extends State ), ); } + final appTheme = AppFlowyTheme.of(context); return Center( child: BlocProvider.value( value: context.read(), @@ -443,15 +443,9 @@ class _AppFlowyEditorPageState extends State ), items: toolbarItems, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).cardColor, - boxShadow: [ - BoxShadow( - offset: Offset(0, 4), - blurRadius: 24, - color: themeV2.shadow_medium, - ), - ], + borderRadius: BorderRadius.circular(appTheme.borderRadius.l), + color: appTheme.surfaceColorScheme.primary, + boxShadow: [appTheme.shadow.small], ), toolbarBuilder: (_, child, onDismiss, isMetricsChanged) => BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index 35575fe85a..467a847c53 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -5,8 +5,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -119,7 +119,7 @@ class _AiWriterToolbarActionListState extends State { } Widget buildChild(BuildContext context) { - final themeV2 = AFThemeExtensionV2.of(context); + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; final child = FlowyIconButton( width: 48, height: 32, @@ -131,13 +131,13 @@ class _AiWriterToolbarActionListState extends State { FlowySvg( FlowySvgs.toolbar_ai_writer_m, size: Size.square(20), - color: themeV2.icon_primary, + color: iconScheme.primary, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: themeV2.icon_tertiary, + color: iconScheme.primary, ), ], ), @@ -180,6 +180,7 @@ class ImproveWritingButton extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); final child = FlowyIconButton( width: 36, height: 32, @@ -187,7 +188,7 @@ class ImproveWritingButton extends StatelessWidget { icon: FlowySvg( FlowySvgs.toolbar_ai_improve_writing_m, size: Size.square(20.0), - color: AFThemeExtensionV2.of(context).icon_primary, + color: theme.iconColorTheme.primary, ), onPressed: () { if (_isAIEnabled(editorState)) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart index dd3f7362a9..6399d3b11f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -186,7 +186,7 @@ Future _pasteAsLinkPreview( node.delta?.toPlainText().isNotEmpty == true) { return false; } - + if (!isMobile) return false; final bool isImageUrl; try { isImageUrl = await _isImageUrl(text); @@ -195,7 +195,7 @@ Future _pasteAsLinkPreview( return false; } - if (!isMobile && !isImageUrl) return false; + if (!isImageUrl) return false; // insert the text with link format final textTransaction = editorState.transaction diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart index 5beba66c32..905c033bda 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -27,9 +28,15 @@ extension TextDeltaExtension on Delta { if (op.text == MentionBlockKeys.mentionChar) { final mention = attributes?[MentionBlockKeys.mention]; final mentionPageId = mention?[MentionBlockKeys.pageId]; + final mentionType = mention?[MentionBlockKeys.type]; if (mentionPageId != null) { text += await getMentionPageName(mentionPageId); continue; + } else if (mentionType == MentionType.externalLink.name) { + final url = mention?[MentionBlockKeys.url] ?? ''; + final info = await LinkInfoCache.get(url); + text += info?.title ?? url; + continue; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart index a6e1a78299..6213896feb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -307,17 +308,13 @@ void showLinkCreateMenu( ShapeDecoration buildToolbarLinkDecoration( BuildContext context, { double radius = 12.0, -}) => - ShapeDecoration( - color: Theme.of(context).cardColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radius), - ), - shadows: [ - const BoxShadow( - color: LinkStyle.shadowMedium, - blurRadius: 24, - offset: Offset(0, 4), - ), - ], - ); +}) { + final theme = AppFlowyTheme.of(context); + return ShapeDecoration( + color: theme.surfaceColorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + shadows: [theme.shadow.small], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart index 6dc6501c03..baf9702a36 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart @@ -1,6 +1,9 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -37,11 +40,12 @@ class LinkEmbedBlockComponent extends BlockComponentStatefulWidget { }); @override - State createState() => + DefaultSelectableMixinState createState() => LinkEmbedBlockComponentState(); } -class LinkEmbedBlockComponentState extends State +class LinkEmbedBlockComponentState + extends DefaultSelectableMixinState with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @@ -49,11 +53,11 @@ class LinkEmbedBlockComponentState extends State @override Node get node => widget.node; - String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; + String get url => widget.node.attributes[LinkPreviewBlockKeys.url] ?? ''; - EmbedLoadingStatus status = EmbedLoadingStatus.loading; + LinkLoadingStatus status = LinkLoadingStatus.loading; final parser = LinkParser(); - LinkInfo linkInfo = LinkInfo(); + late LinkInfo linkInfo = LinkInfo(url: url); final showActionsNotifier = ValueNotifier(false); bool isMenuShowing = false, isHovering = false; @@ -62,13 +66,14 @@ class LinkEmbedBlockComponentState extends State void initState() { super.initState(); parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); if (mounted) { setState(() { - if (v.isEmpty() && linkInfo.isEmpty()) { - status = EmbedLoadingStatus.error; - } else { + if (hasNewInfo) { linkInfo = v; - status = EmbedLoadingStatus.idle; + status = LinkLoadingStatus.idle; + } else if (!hasOldInfo) { + status = LinkLoadingStatus.error; } }); } @@ -98,7 +103,13 @@ class LinkEmbedBlockComponentState extends State }, child: buildChild(context), ); - result = Padding(padding: padding, child: result); + final parent = node.parent; + EdgeInsets newPadding = padding; + if (parent?.type == CalloutBlockKeys.type) { + newPadding = padding.copyWith(right: padding.right + 10); + } + + result = Padding(padding: newPadding, child: result); if (widget.showActions && widget.actionBuilder != null) { result = BlockComponentActionWrapper( @@ -115,7 +126,7 @@ class LinkEmbedBlockComponentState extends State fillSceme = theme.fillColorScheme, borderScheme = theme.borderColorScheme; Widget child; - final isIdle = status == EmbedLoadingStatus.idle; + final isIdle = status == LinkLoadingStatus.idle; if (isIdle) { child = buildContent(context); } else { @@ -123,6 +134,7 @@ class LinkEmbedBlockComponentState extends State } return Container( height: 450, + key: widgetKey, decoration: BoxDecoration( color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover, borderRadius: BorderRadius.all(Radius.circular(16)), @@ -150,7 +162,7 @@ class LinkEmbedBlockComponentState extends State node: node, onReload: () { setState(() { - status = EmbedLoadingStatus.loading; + status = LinkLoadingStatus.loading; }); Future.delayed(const Duration(milliseconds: 200), () { if (mounted) parser.start(url); @@ -185,43 +197,51 @@ class LinkEmbedBlockComponentState extends State ), ), ), - Container( - height: 64, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), - child: Row( - children: [ - SizedBox.square( - dimension: 40, - child: Center( - child: linkInfo.buildIconWidget(size: Size.square(32)), - ), - ), - HSpace(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowyText( - linkInfo.siteName ?? '', - color: textScheme.primary, - fontSize: 14, - figmaLineHeight: 20, - fontWeight: FontWeight.w600, - overflow: TextOverflow.ellipsis, + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: Center( + child: linkInfo.buildIconWidget(size: Size.square(32)), ), - VSpace(4), - FlowyText.regular( - url, - color: textScheme.secondary, - fontSize: 12, - figmaLineHeight: 16, - overflow: TextOverflow.ellipsis, + ), + HSpace(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + linkInfo.siteName ?? '', + color: textScheme.primary, + fontSize: 14, + figmaLineHeight: 20, + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + ), + VSpace(4), + FlowyText.regular( + url, + color: textScheme.secondary, + fontSize: 12, + figmaLineHeight: 16, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], - ), + ), + ], ), - ], + ), ), ), ], @@ -230,7 +250,7 @@ class LinkEmbedBlockComponentState extends State Widget buildErrorLoadingWidget(BuildContext context) { final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme; - final isLoading = status == EmbedLoadingStatus.loading; + final isLoading = status == LinkLoadingStatus.loading; return isLoading ? Center( child: SizedBox.square( @@ -264,7 +284,7 @@ class LinkEmbedBlockComponentState extends State ), TextSpan( text: LocaleKeys - .document_plugins_linkPreview_linkPreviewMenu_refuseConnect + .document_plugins_linkPreview_linkPreviewMenu_unableToDisplay .tr(), style: TextStyle( color: textSceme.primary, @@ -281,6 +301,10 @@ class LinkEmbedBlockComponentState extends State ), ); } -} -enum EmbedLoadingStatus { loading, idle, error } + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart index 8b9ac122a8..bddfcb9b54 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -3,7 +3,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -70,7 +69,7 @@ class _LinkEmbedMenuState extends State { return Container( padding: EdgeInsets.all(4), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), color: fillScheme.primaryAlpha80, ), child: Row( @@ -90,18 +89,19 @@ class _LinkEmbedMenuState extends State { FlowySvgs.toolbar_link_m, color: iconScheme.tertiary, ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), tooltipText: LocaleKeys.editor_copyLink.tr(), preferBelow: false, onPressed: () => copyLink(context), ), - buildTurnIntoBotton(), + buildconvertBotton(), buildMoreOptionBotton(), ], ), ); } - Widget buildTurnIntoBotton() { + Widget buildconvertBotton() { final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; return AppFlowyPopover( offset: Offset(0, 6), @@ -117,22 +117,22 @@ class _LinkEmbedMenuState extends State { turnintoMenuNum--; checkToHideMenu(); }, - popupBuilder: (context) => buildTurnIntoMenu(), + popupBuilder: (context) => buildConvertMenu(), child: FlowyIconButton( icon: FlowySvg( FlowySvgs.turninto_m, color: iconScheme.tertiary, ), - tooltipText: LocaleKeys.document_toolbar_turnInto.tr(), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.editor_convertTo.tr(), preferBelow: false, onPressed: showTurnIntoMenu, ), ); } - Widget buildTurnIntoMenu() { - final types = - PasteMenuType.values.where((e) => e != PasteMenuType.embed).toList(); + Widget buildConvertMenu() { + final types = LinkEmbedConvertCommand.values; return Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( @@ -149,16 +149,16 @@ class _LinkEmbedMenuState extends State { figmaLineHeight: 20, ), onTap: () { - if (command == PasteMenuType.bookmark) { + if (command == LinkEmbedConvertCommand.toBookmark) { final transaction = editorState.transaction; transaction.updateNode(node, { LinkPreviewBlockKeys.url: url, LinkEmbedKeys.previewType: '', }); editorState.apply(transaction); - } else if (command == PasteMenuType.mention) { + } else if (command == LinkEmbedConvertCommand.toMention) { convertUrlPreviewNodeToMention(editorState, node); - } else if (command == PasteMenuType.url) { + } else if (command == LinkEmbedConvertCommand.toURL) { convertUrlPreviewNodeToLink(editorState, node); } }, @@ -192,6 +192,7 @@ class _LinkEmbedMenuState extends State { FlowySvgs.toolbar_more_m, color: iconScheme.tertiary, ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(), preferBelow: false, onPressed: showMoreOptionMenu, @@ -330,3 +331,24 @@ enum LinkEmbedMenuCommand { } } } + +enum LinkEmbedConvertCommand { + toMention, + toURL, + toBookmark; + + String get title { + switch (this) { + case toMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart index 0032d9cd33..1907f68d29 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart @@ -5,39 +5,39 @@ import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:favicon/favicon.dart'; import 'package:flutter/material.dart'; -// ignore: depend_on_referenced_packages -import 'package:flutter_link_previewer/flutter_link_previewer.dart' hide Size; +import 'link_parsers/default_parser.dart'; +import 'link_parsers/youtube_parser.dart'; class LinkParser { - static final LinkInfoCache _cache = LinkInfoCache(); final Set> _listeners = >{}; + static final Map _hostToParsers = { + 'www.youtube.com': YoutubeParser(), + 'youtube.com': YoutubeParser(), + 'youtu.be': YoutubeParser(), + }; - Future start(String url) async { - final data = await _cache.get(url); + Future start(String url, {LinkInfoParser? parser}) async { + final uri = Uri.tryParse(LinkInfoParser.formatUrl(url)) ?? Uri.parse(url); + final data = await LinkInfoCache.get(uri); if (data != null) { refreshLinkInfo(data); } - await _getLinkInfo(url); + + final host = uri.host; + final currentParser = parser ?? _hostToParsers[host] ?? DefaultParser(); + await _getLinkInfo(uri, currentParser); } - Future _getLinkInfo(String url) async { + Future _getLinkInfo(Uri uri, LinkInfoParser parser) async { try { - final previewData = await getPreviewData(url); - final favicon = await FaviconFinder.getBest(url); - final linkInfo = LinkInfo( - siteName: previewData.title, - description: previewData.description, - imageUrl: previewData.image?.url, - faviconUrl: favicon?.url, - ); - if (!linkInfo.isEmpty()) await _cache.set(url, linkInfo); + final linkInfo = await parser.parse(uri) ?? LinkInfo(url: '$uri'); + if (!linkInfo.isEmpty()) await LinkInfoCache.set(uri, linkInfo); refreshLinkInfo(linkInfo); return linkInfo; } catch (e, s) { Log.error('get link info error: ', e, s); - refreshLinkInfo(LinkInfo()); + refreshLinkInfo(LinkInfo(url: '$uri')); return null; } } @@ -60,35 +60,45 @@ class LinkParser { class LinkInfo { factory LinkInfo.fromJson(Map json) => LinkInfo( siteName: json['siteName'], + url: json['url'] ?? '', + title: json['title'], description: json['description'], imageUrl: json['imageUrl'], faviconUrl: json['faviconUrl'], ); LinkInfo({ + required this.url, this.siteName, + this.title, this.description, this.imageUrl, this.faviconUrl, }); + final String url; final String? siteName; + final String? title; final String? description; final String? imageUrl; final String? faviconUrl; Map toJson() => { + 'url': url, 'siteName': siteName, + 'title': title, 'description': description, 'imageUrl': imageUrl, 'faviconUrl': faviconUrl, }; + @override + String toString() { + return 'LinkInfo{url: $url, siteName: $siteName, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl}'; + } + bool isEmpty() { - return siteName == null || - description == null || - imageUrl == null || - faviconUrl == null; + return title == null; } Widget buildIconWidget({Size size = const Size.square(20.0)}) { @@ -116,20 +126,26 @@ class LinkInfo { } class LinkInfoCache { - final _linkInfoPrefix = 'link_info'; + static const _linkInfoPrefix = 'link_info'; - Future get(String url) async { + static Future get(Uri uri) async { final option = await getIt().getWithFormat( - _linkInfoPrefix + url, + '$_linkInfoPrefix$uri', (value) => LinkInfo.fromJson(jsonDecode(value)), ); return option; } - Future set(String url, LinkInfo data) async { + static Future set(Uri uri, LinkInfo data) async { await getIt().set( - _linkInfoPrefix + url, + '$_linkInfoPrefix$uri', jsonEncode(data.toJson()), ); } } + +enum LinkLoadingStatus { + loading, + idle, + error, +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index 0b6ad9fd7b..880a33b817 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -3,8 +3,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -14,6 +16,8 @@ import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; +import 'custom_link_parser.dart'; + class CustomLinkPreviewWidget extends StatelessWidget { const CustomLinkPreviewWidget({ super.key, @@ -23,7 +27,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { this.description, this.imageUrl, this.isHovering = false, - this.status = LinkPreviewStatus.loading, + this.status = LinkLoadingStatus.loading, }); final Node node; @@ -32,7 +36,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { final String? imageUrl; final String url; final bool isHovering; - final LinkPreviewStatus status; + final LinkLoadingStatus status; @override Widget build(BuildContext context) { @@ -46,6 +50,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { .text .fontSize ?? 16.0; + final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type && + !Theme.of(context).isLightMode; final (fontSize, width) = UniversalPlatform.isDesktopOrWeb ? (documentFontSize, 160.0) : (documentFontSize - 2, 120.0); @@ -53,7 +59,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border.all( - color: isHovering + color: isHovering || isInDarkCallout ? borderScheme.greyTertiaryHover : borderScheme.greyTertiary, ), @@ -67,43 +73,44 @@ class CustomLinkPreviewWidget extends StatelessWidget { buildImage(context), Expanded( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 60, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - buildLoadingOrErrorWidget(), - if (title != null) - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: FlowyText.medium( - title!, - overflow: TextOverflow.ellipsis, - fontSize: fontSize, - color: textScheme.primary, - figmaLineHeight: 20, - ), + padding: const EdgeInsets.fromLTRB(20, 12, 58, 12), + child: status != LinkLoadingStatus.idle + ? buildLoadingOrErrorWidget() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText.medium( + title!, + overflow: TextOverflow.ellipsis, + fontSize: fontSize, + color: textScheme.primary, + figmaLineHeight: 20, + ), + ), + if (description != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: FlowyText( + description!, + overflow: TextOverflow.ellipsis, + fontSize: fontSize - 4, + figmaLineHeight: 16, + color: textScheme.primary, + ), + ), + FlowyText( + url.toString(), + overflow: TextOverflow.ellipsis, + color: textScheme.secondary, + fontSize: fontSize - 4, + figmaLineHeight: 16, + ), + ], ), - if (description != null) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: FlowyText( - description!, - overflow: TextOverflow.ellipsis, - fontSize: fontSize - 4, - figmaLineHeight: 16, - color: textScheme.primary, - ), - ), - FlowyText( - url.toString(), - overflow: TextOverflow.ellipsis, - color: textScheme.secondary, - fontSize: fontSize - 4, - figmaLineHeight: 16, - ), - ], - ), ), ), ], @@ -112,9 +119,12 @@ class CustomLinkPreviewWidget extends StatelessWidget { ); if (UniversalPlatform.isDesktopOrWeb) { - return InkWell( - onTap: () => afLaunchUrlString(url), - child: child, + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => afLaunchUrlString(url), + child: child, + ), ); } @@ -156,7 +166,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { iconScheme = theme.iconColorTheme; final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0; Widget child; - if (imageUrl != null) { + if (imageUrl?.isNotEmpty ?? false) { child = FlowyNetworkImage( url: imageUrl!, width: width, @@ -184,26 +194,22 @@ class CustomLinkPreviewWidget extends StatelessWidget { } Widget buildLoadingOrErrorWidget() { - if (status == LinkPreviewStatus.loading) { - return Expanded( - child: const Center( - child: SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator.adaptive(), - ), + if (status == LinkLoadingStatus.loading) { + return const Center( + child: SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(), ), ); - } else if (status == LinkPreviewStatus.error) { - return Expanded( - child: const Center( - child: SizedBox( - height: 16, - width: 16, - child: Icon( - Icons.error_outline, - color: Colors.red, - ), + } else if (status == LinkLoadingStatus.error) { + return const Center( + child: SizedBox( + height: 16, + width: 16, + child: Icon( + Icons.error_outline, + color: Colors.red, ), ), ); @@ -211,5 +217,3 @@ class CustomLinkPreviewWidget extends StatelessWidget { return SizedBox.shrink(); } } - -enum LinkPreviewStatus { loading, error, idle } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart index 3772169331..3f2128db52 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart @@ -1,19 +1,19 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'custom_link_preview.dart'; +import 'default_selectable_mixin.dart'; import 'link_preview_menu.dart'; class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { CustomLinkPreviewBlockComponentBuilder({ super.configuration, - this.cache, }); - final LinkPreviewDataCacheInterface? cache; - @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; @@ -35,7 +35,6 @@ class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { configuration: configuration, showActions: showActions(node), actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), - cache: cache, ); } @@ -51,18 +50,15 @@ class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget { super.showActions, super.actionBuilder, super.configuration = const BlockComponentConfiguration(), - this.cache, }); - final LinkPreviewDataCacheInterface? cache; - @override - State createState() => + DefaultSelectableMixinState createState() => CustomLinkPreviewBlockComponentState(); } class CustomLinkPreviewBlockComponentState - extends State + extends DefaultSelectableMixinState with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @@ -72,8 +68,9 @@ class CustomLinkPreviewBlockComponentState String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; - late final LinkPreviewParser parser; - late Future future; + final parser = LinkParser(); + LinkLoadingStatus status = LinkLoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); final showActionsNotifier = ValueNotifier(false); bool isMenuShowing = false, isHovering = false; @@ -81,21 +78,26 @@ class CustomLinkPreviewBlockComponentState @override void initState() { super.initState(); - parser = LinkPreviewParser(url: url, cache: widget.cache); - future = parser.start(); + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = LinkLoadingStatus.idle; + } else if (!hasOldInfo) { + status = LinkLoadingStatus.error; + } + }); + } + }); + parser.start(url); } @override - void didUpdateWidget(CustomLinkPreviewBlockComponent oldWidget) { - super.didUpdateWidget(oldWidget); - final url = widget.node.attributes[LinkPreviewBlockKeys.url]!; - final oldUrl = oldWidget.node.attributes[LinkPreviewBlockKeys.url]!; - if (url != oldUrl) { - parser = LinkPreviewParser(url: url, cache: widget.cache); - setState(() { - future = parser.start(); - }); - } + void dispose() { + parser.dispose(); + super.dispose(); } @override @@ -114,91 +116,79 @@ class CustomLinkPreviewBlockComponentState }, hitTestBehavior: HitTestBehavior.opaque, opaque: false, - child: ValueListenableBuilder( + child: ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (context, showActions, child) { - return FutureBuilder( - future: future, - builder: (context, snapshot) { - Widget child; - - if (snapshot.connectionState != ConnectionState.done) { - child = CustomLinkPreviewWidget( - node: node, - url: url, - isHovering: showActions, - ); - } else { - final title = parser.getContent(LinkPreviewRegex.title); - final description = - parser.getContent(LinkPreviewRegex.description); - final image = parser.getContent(LinkPreviewRegex.image); - - if (title == null && description == null && image == null) { - child = CustomLinkPreviewWidget( - node: node, - url: url, - isHovering: showActions, - status: LinkPreviewStatus.error, - ); - } else { - child = CustomLinkPreviewWidget( - node: node, - url: url, - title: title, - description: description, - imageUrl: image, - isHovering: showActions, - status: LinkPreviewStatus.idle, - ); - } - } - - child = Padding(padding: padding, child: child); - - if (widget.showActions && widget.actionBuilder != null) { - child = BlockComponentActionWrapper( - node: node, - actionBuilder: widget.actionBuilder!, - child: child, - ); - } - - child = Stack( - children: [ - child, - if (showActions) - Positioned( - top: 16, - right: 16, - child: CustomLinkPreviewMenu( - onMenuShowed: () { - isMenuShowing = true; - }, - onMenuHided: () { - isMenuShowing = false; - if (!isHovering && mounted) { - showActionsNotifier.value = false; - } - }, - onReload: () { - if (mounted) { - setState(() { - future = parser.start(); - }); - } - }, - node: node, - ), - ), - ], - ); - - return child; - }, - ); + return buildPreview(showActions); }, ), ); } + + Widget buildPreview(bool showActions) { + Widget child = CustomLinkPreviewWidget( + key: widgetKey, + node: node, + url: url, + isHovering: showActions, + title: linkInfo.siteName, + description: linkInfo.description, + imageUrl: linkInfo.imageUrl, + status: status, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + child = Stack( + children: [ + child, + if (showActions) + Positioned( + top: 12, + right: 12, + child: CustomLinkPreviewMenu( + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + onReload: () { + setState(() { + status = LinkLoadingStatus.loading; + }); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) parser.start(url); + }); + }, + node: node, + ), + ), + ], + ); + + final parent = node.parent; + EdgeInsets newPadding = padding; + if (parent?.type == CalloutBlockKeys.type) { + newPadding = padding.copyWith(right: padding.right + 10); + } + child = Padding(padding: newPadding, child: child); + + return child; + } + + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart new file mode 100644 index 0000000000..c894811522 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +abstract class DefaultSelectableMixinState + extends State with SelectableMixin { + final widgetKey = GlobalKey(); + RenderBox? get _renderBox => + widgetKey.currentContext?.findRenderObject() as RenderBox?; + + Node get currentNode; + + EdgeInsets get boxPadding => EdgeInsets.zero; + + @override + Position start() => Position(path: currentNode.path); + + @override + Position end() => Position(path: currentNode.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + final box = _renderBox; + if (box is RenderBox) { + return boxPadding.topLeft & box.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final box = widgetKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && box is RenderBox) { + return [ + box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: currentNode.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => + _renderBox!.localToGlobal(offset); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart new file mode 100644 index 0000000000..f05f4a9706 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart @@ -0,0 +1,88 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_backend/log.dart'; + +import 'package:http/http.dart' as http; +// ignore: depend_on_referenced_packages +import 'package:html/parser.dart' as html_parser; + +abstract class LinkInfoParser { + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }); + + static String formatUrl(String url) { + Uri? uri = Uri.tryParse(url); + if (uri == null) return url; + if (!uri.hasScheme) uri = Uri.tryParse('http://$url'); + if (uri == null) return url; + final isHome = (uri.hasEmptyPath || uri.path == '/') && !uri.hasQuery; + final homeUrl = '${uri.scheme}://${uri.host}/'; + if (isHome) return homeUrl; + return '$uri'; + } +} + +class DefaultParser implements LinkInfoParser { + @override + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }) async { + try { + final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; + final http.Response response = + await http.get(link, headers: headers).timeout(timeout); + final code = response.statusCode; + if (code != 200 && isHome) { + throw Exception('Http request error: $code'); + } + // else if (!isHome && code == 403) { + // uri = Uri.parse('${uri.scheme}://${uri.host}/'); + // response = await http.get(uri).timeout(timeout); + // } + + final document = html_parser.parse(response.body); + + final siteName = document + .querySelector('meta[property="og:site_name"]') + ?.attributes['content']; + + String? title = document + .querySelector('meta[property="og:title"]') + ?.attributes['content']; + title ??= document.querySelector('title')?.text; + + String? description = document + .querySelector('meta[property="og:description"]') + ?.attributes['content']; + description ??= document + .querySelector('meta[name="description"]') + ?.attributes['content']; + + String? imageUrl = document + .querySelector('meta[property="og:image"]') + ?.attributes['content']; + if (imageUrl != null && !imageUrl.startsWith('http')) { + imageUrl = link.resolve(imageUrl).toString(); + } + + final favicon = + 'https://www.google.com/s2/favicons?sz=64&domain=${link.host}'; + + return LinkInfo( + url: '$link', + siteName: siteName, + title: title, + description: description, + imageUrl: imageUrl, + faviconUrl: favicon, + ); + } catch (e) { + Log.error('Parse link $link error: $e'); + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart new file mode 100644 index 0000000000..6f1ac6fb22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:http/http.dart' as http; +import 'default_parser.dart'; + +class YoutubeParser implements LinkInfoParser { + @override + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }) async { + try { + final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; + if (isHome) { + return DefaultParser().parse( + link, + timeout: timeout, + headers: headers, + ); + } + + final requestLink = + 'https://www.youtube.com/oembed?url=$link&format=json'; + final http.Response response = await http + .get(Uri.parse(requestLink), headers: headers) + .timeout(timeout); + final code = response.statusCode; + if (code != 200) { + throw Exception('Http request error: $code'); + } + + final youtubeInfo = YoutubeInfo.fromJson(jsonDecode(response.body)); + + final favicon = + 'https://www.google.com/s2/favicons?sz=64&domain=${link.host}'; + return LinkInfo( + url: '$link', + title: youtubeInfo.title, + siteName: youtubeInfo.authorName, + imageUrl: youtubeInfo.thumbnailUrl, + faviconUrl: favicon, + ); + } catch (e) { + Log.error('Parse link $link error: $e'); + return null; + } + } +} + +class YoutubeInfo { + YoutubeInfo({ + this.title, + this.authorName, + this.version, + this.providerName, + this.providerUrl, + this.thumbnailUrl, + }); + + YoutubeInfo.fromJson(Map json) { + title = json['title']; + authorName = json['author_name']; + version = json['version']; + providerName = json['provider_name']; + providerUrl = json['provider_url']; + thumbnailUrl = json['thumbnail_url']; + } + String? title; + String? authorName; + String? version; + String? providerName; + String? providerUrl; + String? thumbnailUrl; + + Map toJson() => { + 'title': title, + 'author_name': authorName, + 'version': version, + 'provider_name': providerName, + 'provider_url': providerUrl, + 'thumbnail_url': thumbnailUrl, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart deleted file mode 100644 index 6688cfe304..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -import 'package:appflowy/core/config/kv.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; - -class LinkPreviewDataCache implements LinkPreviewDataCacheInterface { - @override - Future get(String url) async { - final option = - await getIt().getWithFormat( - url, - (value) => LinkPreviewData.fromJson(jsonDecode(value)), - ); - return option; - } - - @override - Future set(String url, LinkPreviewData data) async { - await getIt().set( - url, - jsonEncode(data.toJson()), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart index 16febe54cb..d31b2f8fd9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -3,8 +3,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; @@ -28,11 +28,10 @@ class PasteAsMenuService { void dismiss() { if (_menuEntry != null) { - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); keepEditorFocusNotifier.decrease(); + // editorState.service.scrollService?.enable(); + // editorState.service.keyboardService?.enable(); } - _menuEntry?.remove(); _menuEntry = null; } @@ -58,6 +57,7 @@ class PasteAsMenuService { children: [ ltrb.buildPositioned( child: PasteAsMenu( + editorState: editorState, onSelect: (t) { final selection = editorState.selection; if (selection == null) return; @@ -91,8 +91,9 @@ class PasteAsMenuService { Overlay.of(context).insert(_menuEntry!); - editorState.service.keyboardService?.disable(showCursor: true); - editorState.service.scrollService?.disable(); + keepEditorFocusNotifier.increase(); + // editorState.service.keyboardService?.disable(showCursor: true); + // editorState.service.scrollService?.disable(); } } @@ -101,9 +102,11 @@ class PasteAsMenu extends StatefulWidget { super.key, required this.onSelect, required this.onDismiss, + required this.editorState, }); final ValueChanged onSelect; final VoidCallback onDismiss; + final EditorState editorState; @override State createState() => _PasteAsMenuState(); @@ -113,24 +116,28 @@ class _PasteAsMenuState extends State { final focusNode = FocusNode(debugLabel: 'paste_as_menu'); final ValueNotifier selectedIndexNotifier = ValueNotifier(0); + EditorState get editorState => widget.editorState; + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( (_) => focusNode.requestFocus(), ); + editorState.selectionNotifier.addListener(dismiss); } @override void dispose() { focusNode.dispose(); selectedIndexNotifier.dispose(); + editorState.selectionNotifier.removeListener(dismiss); super.dispose(); } @override Widget build(BuildContext context) { - final themeV2 = AFThemeExtensionV2.of(context); + final theme = AppFlowyTheme.of(context); return Focus( focusNode: focusNode, onKeyEvent: onKeyEvent, @@ -140,14 +147,8 @@ class _PasteAsMenuState extends State { padding: EdgeInsets.all(6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: Theme.of(context).cardColor, - boxShadow: [ - BoxShadow( - offset: Offset(0, 4), - blurRadius: 16, - color: themeV2.shadow_medium, - ), - ], + color: theme.surfaceColorScheme.primary, + boxShadow: [theme.shadow.medium], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -156,7 +157,7 @@ class _PasteAsMenuState extends State { height: 32, padding: EdgeInsets.all(8), child: FlowyText.semibold( - color: themeV2.text_tertiary, + color: theme.textColorScheme.primary, LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs .tr(), ), @@ -201,8 +202,11 @@ class _PasteAsMenuState extends State { length = PasteMenuType.values.length; if (event.logicalKey == LogicalKeyboardKey.enter) { onSelect(PasteMenuType.values[index]); + return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.escape) { dismiss(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + dismiss(); } else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft] .contains(event.logicalKey)) { if (index == 0) { @@ -211,6 +215,7 @@ class _PasteAsMenuState extends State { index--; } changeIndex(index); + return KeyEventResult.handled; } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight] .contains(event.logicalKey)) { if (index == length - 1) { @@ -219,8 +224,9 @@ class _PasteAsMenuState extends State { index++; } changeIndex(index); + return KeyEventResult.handled; } - return KeyEventResult.handled; + return KeyEventResult.ignored; } void onSelect(PasteMenuType type) => widget.onSelect.call(type); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart index 7a807fef64..06ebcb5002 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart @@ -42,8 +42,8 @@ class MentionLinkBlock extends StatefulWidget { class _MentionLinkBlockState extends State { final parser = LinkParser(); _LoadingStatus status = _LoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); final previewController = PopoverController(); - LinkInfo linkInfo = LinkInfo(); bool isHovering = false; int previewFocusNum = 0; bool isPreviewHovering = false; @@ -67,13 +67,14 @@ class _MentionLinkBlockState extends State { super.initState(); parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); if (mounted) { setState(() { - if (v.isEmpty() && linkInfo.isEmpty()) { - status = _LoadingStatus.error; - } else { + if (hasNewInfo) { linkInfo = v; status = _LoadingStatus.idle; + } else if (!hasOldInfo) { + status = _LoadingStatus.error; } }); } @@ -148,6 +149,7 @@ class _MentionLinkBlockState extends State { Widget buildIconWithTitle(BuildContext context) { final theme = AppFlowyTheme.of(context); + final siteName = linkInfo.siteName, linkTitle = linkInfo.title ?? url; return MouseRegion( cursor: SystemMouseCursors.click, @@ -169,12 +171,25 @@ class _MentionLinkBlockState extends State { buildIcon(), HSpace(4), Flexible( - child: FlowyText( - linkInfo.siteName ?? url, - color: theme.textColorScheme.primary, - fontSize: 14, - figmaLineHeight: 20, + child: RichText( overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + if (siteName != null) ...[ + TextSpan( + text: siteName, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.secondary), + ), + WidgetSpan(child: HSpace(2)), + ], + TextSpan( + text: linkTitle, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.primary), + ), + ], + ), ), ), HSpace(2), @@ -323,9 +338,10 @@ class _MentionLinkBlockState extends State { maxHeight: 48 + size.height, ); } + final hasImage = linkInfo.imageUrl?.isNotEmpty ?? false; return BoxConstraints( maxWidth: max(300, size.width), - maxHeight: 300, + maxHeight: hasImage ? 300 : 180, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart index 00082f127a..00b161379e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart @@ -56,7 +56,9 @@ class _MentionLinkPreviewState extends State { Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context), textColorScheme = theme.textColorScheme; - + final imageUrl = linkInfo.imageUrl ?? '', + description = linkInfo.description ?? ''; + final imageHeight = 120.0; final card = MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, @@ -67,20 +69,21 @@ class _MentionLinkPreviewState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: - const BorderRadius.vertical(top: Radius.circular(16)), - child: FlowyNetworkImage( - url: linkInfo.imageUrl ?? '', - width: 280, - height: 120, + if (imageUrl.isNotEmpty) + ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: 280, + height: imageHeight, + ), ), - ), VSpace(12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FlowyText.semibold( - linkInfo.siteName ?? '', + linkInfo.title ?? linkInfo.siteName ?? '', fontSize: 14, figmaLineHeight: 20, color: textColorScheme.primary, @@ -88,18 +91,20 @@ class _MentionLinkPreviewState extends State { ), ), VSpace(4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: FlowyText( - linkInfo.description ?? '', - fontSize: 12, - figmaLineHeight: 16, - color: textColorScheme.secondary, - maxLines: 3, - overflow: TextOverflow.ellipsis, + if (description.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText( + description, + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.secondary, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), ), - ), - VSpace(36), + VSpace(36), + ], Container( margin: const EdgeInsets.symmetric(horizontal: 16), height: 28, @@ -109,7 +114,7 @@ class _MentionLinkPreviewState extends State { HSpace(6), Expanded( child: FlowyText( - linkInfo.description ?? '', + linkInfo.siteName ?? linkInfo.url, fontSize: 12, figmaLineHeight: 16, color: textColorScheme.primary, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index 63aaf34e8d..4161036a08 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -40,7 +40,6 @@ export 'inline_math_equation/inline_math_equation.dart'; export 'inline_math_equation/inline_math_equation_toolbar_item.dart'; export 'keyboard_interceptor/keyboard_interceptor.dart'; export 'link_preview/custom_link_preview.dart'; -export 'link_preview/link_preview_cache.dart'; export 'link_preview/link_preview_menu.dart'; export 'math_equation/math_equation_block_component.dart'; export 'math_equation/mobile_math_equation_toolbar_item.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart index 77d94451c8..7dbf192bae 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart @@ -6,8 +6,8 @@ import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; @@ -70,6 +70,7 @@ class _FormatToolbarItem extends ToolbarItem { ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); final isDark = !Theme.of(context).isLightMode; + final theme = AppFlowyTheme.of(context); final child = FlowyIconButton( width: 36, @@ -81,7 +82,7 @@ class _FormatToolbarItem extends ToolbarItem { size: Size.square(20.0), color: (isDark && isHighlight) ? Color(0xFF282E3A) - : AFThemeExtensionV2.of(context).icon_primary, + : theme.iconColorTheme.primary, ), onPressed: () => editorState.toggleAttribute( name, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart index cd332e14ef..2e115d240d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -3,7 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; -import 'package:flowy_infra/theme_extension_v2.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -82,7 +82,8 @@ class _HighlightColorPickerWidgetState } Widget buildChild(BuildContext context) { - final iconColor = AFThemeExtensionV2.of(context).icon_primary; + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorTheme.primary; final child = FlowyIconButton( width: 36, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index 693c7a64ce..cbbce9c943 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -8,7 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -34,7 +34,7 @@ final customLinkItem = ToolbarItem( final hoverColor = isHref ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); - + final theme = AppFlowyTheme.of(context); final child = FlowyIconButton( width: 36, height: 32, @@ -45,7 +45,7 @@ final customLinkItem = ToolbarItem( size: Size.square(20.0), color: (isDark && isHref) ? Color(0xFF282E3A) - : AFThemeExtensionV2.of(context).icon_primary, + : theme.iconColorTheme.primary, ), onPressed: () { getIt().hideToolbar(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart index d751728526..2a1688db19 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart @@ -3,8 +3,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -96,7 +96,8 @@ class _TextAlignActionListState extends State { } Widget buildChild(BuildContext context) { - final themeV2 = AFThemeExtensionV2.of(context); + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorTheme.primary; final child = FlowyIconButton( width: 48, height: 32, @@ -108,13 +109,13 @@ class _TextAlignActionListState extends State { FlowySvg( FlowySvgs.toolbar_alignment_m, size: Size.square(20), - color: themeV2.icon_primary, + color: iconColor, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: themeV2.icon_tertiary, + color: iconColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart index 525eebe917..80f2d3138d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart @@ -4,8 +4,8 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; @@ -57,6 +57,9 @@ class _TextColorPickerWidgetState extends State { @override Widget build(BuildContext context) { + if (editorState.selection == null) { + return const SizedBox.shrink(); + } final selectionRectList = editorState.selectionRects(); final top = selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; @@ -78,7 +81,8 @@ class _TextColorPickerWidgetState extends State { } Widget buildChild(BuildContext context) { - final iconColor = AFThemeExtensionV2.of(context).icon_primary; + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorTheme.primary; final child = FlowyIconButton( width: 36, height: 32, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart index 625fedff79..8140a7b7f3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart @@ -4,8 +4,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -78,7 +78,8 @@ class _TextHeadingActionListState extends State { } Widget buildChild(BuildContext context) { - final themeV2 = AFThemeExtensionV2.of(context); + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorTheme.primary; final child = FlowyIconButton( width: 48, height: 32, @@ -90,13 +91,13 @@ class _TextHeadingActionListState extends State { FlowySvg( FlowySvgs.toolbar_text_format_m, size: Size.square(20), - color: themeV2.icon_primary, + color: iconColor, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: themeV2.icon_tertiary, + color: iconColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart index a0aded3f8e..3c8f55caef 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -8,9 +8,9 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; @@ -119,8 +119,8 @@ class _SuggestionsActionListState extends State { } Widget buildChild(BuildContext context) { - final themeV2 = AFThemeExtensionV2.of(context); - + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorTheme.primary; final child = FlowyHover( isSelected: () => isSelected, style: HoverStyle( @@ -163,7 +163,7 @@ class _SuggestionsActionListState extends State { FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: themeV2.icon_tertiary, + color: iconColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart index 5c5e788ddb..5942271206 100644 --- a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -100,7 +100,6 @@ class _DesktopSyncErrorPage extends StatelessWidget { onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( - message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index d8dc4dd121..184cc9dd09 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -136,7 +136,6 @@ class AppFlowyCloudDeepLink { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( - message: err.msg, ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart index 51c693843f..ccad6c0a26 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart @@ -17,14 +17,12 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) { case ErrorCode.InvalidEncryptSecret: case ErrorCode.NetworkError: showToastNotification( - message: error.msg, type: ToastificationType.error, ); break; default: showToastNotification( - message: error.msg, type: ToastificationType.error, callbacks: ToastificationCallbacks( diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart index 0435c5fe52..b8dd390627 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -25,7 +25,6 @@ Future shareLogFiles(BuildContext? context) async { if (archiveLogFiles.isEmpty) { if (context != null && context.mounted) { showToastNotification( - message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); @@ -42,7 +41,6 @@ Future shareLogFiles(BuildContext? context) async { if (zip == null) { if (context != null && context.mounted) { showToastNotification( - message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); @@ -72,7 +70,6 @@ Future shareLogFiles(BuildContext? context) async { } catch (e) { if (context != null && context.mounted) { showToastNotification( - message: e.toString(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart index 568fd76db0..2a707b6b2d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -2,7 +2,6 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; class DesktopAppearance extends BaseAppearance { @@ -152,11 +151,6 @@ class DesktopAppearance extends BaseAppearance { lightIconColor: theme.lightIconColor, toolbarHoverColor: theme.toolbarHoverColor, ), - isLight - ? lightAFThemeV2 - : darkAFThemeV2.copyWith( - icon_primary: theme.icon, - ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index 7778f1c9ce..eda3153459 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -4,7 +4,6 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; class MobileAppearance extends BaseAppearance { @@ -30,7 +29,6 @@ class MobileAppearance extends BaseAppearance { ); final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); - final isLight = brightness == Brightness.light; final theme = brightness == Brightness.light ? appTheme.lightTheme @@ -285,11 +283,6 @@ class MobileAppearance extends BaseAppearance { toolbarHoverColor: theme.toolbarHoverColor, ), ToolbarColorExtension.fromBrightness(brightness), - isLight - ? lightAFThemeV2 - : darkAFThemeV2.copyWith( - icon_primary: theme.icon, - ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 20160d32ec..50ea9d83c7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -169,7 +169,6 @@ class _SidebarWorkspaceState extends State { if (message != null) { showToastNotification( - message: message, type: result.fold( (_) => ToastificationType.success, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart index 8cabffc6e3..e6c011156b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -193,7 +193,6 @@ Future deleteMyAccount( if (!isChecked) { showToastNotification( - type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -208,7 +207,6 @@ Future deleteMyAccount( if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { showToastNotification( - type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -226,7 +224,6 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - message: LocaleKeys .newSettings_myAccount_deleteAccount_deleteAccountSuccess .tr(), @@ -245,7 +242,6 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - type: ToastificationType.error, bottomPadding: bottomPadding, message: f.msg, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 194ad02beb..a2d911ea40 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -157,7 +157,6 @@ class SettingsManageDataView extends StatelessWidget { if (context.mounted) { showToastNotification( - message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index c17dd14578..32b3335d0f 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -8,6 +8,7 @@ import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; import 'package:appflowy_ui/src/theme/dimensions.dart'; +import 'package:appflowy_ui/src/theme/shadow/shadow.dart'; import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; import 'package:flutter/material.dart'; @@ -92,6 +93,35 @@ class AppFlowyThemeBuilder { }; } + AppFlowyShadow buildShadow(Brightness brightness) { + return switch (brightness) { + Brightness.light => AppFlowyShadow( + small: const BoxShadow( + offset: Offset(0.0, 2.0), + blurRadius: 16.0, + color: Color(0x1F000000), + ), + medium: const BoxShadow( + offset: Offset(0.0, 4.0), + blurRadius: 32.0, + color: Color(0x1F000000), + ), + ), + Brightness.dark => AppFlowyShadow( + small: BoxShadow( + offset: Offset(0.0, 2.0), + blurRadius: 16.0, + color: Color(0x7A000000), + ), + medium: BoxShadow( + offset: Offset(0.0, 4.0), + blurRadius: 32.0, + color: Color(0x7A000000), + ), + ), + }; + } + AppFlowyBorderColorScheme buildBorderColorScheme( AppFlowyBaseColorScheme colorScheme, Brightness brightness, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart index 68fb378c0f..9494bdf0e2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart @@ -8,6 +8,7 @@ import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; import 'package:appflowy_ui/src/theme/data/builder.dart'; +import 'package:appflowy_ui/src/theme/shadow/shadow.dart'; import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; import 'package:appflowy_ui/src/theme/text_style/text_style.dart'; import 'package:flutter/material.dart'; @@ -36,6 +37,8 @@ abstract class AppFlowyBaseTheme { AppFlowySpacing get spacing; AppFlowyBrandColorScheme get brandColorScheme; + + AppFlowyShadow get shadow; } class AppFlowyThemeData extends AppFlowyBaseTheme { @@ -67,10 +70,10 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { colorScheme, Brightness.light, ); + final shadow = themeBuilder.buildShadow(Brightness.light); final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); final borderRadius = themeBuilder.buildBorderRadius(colorScheme); final spacing = themeBuilder.buildSpacing(colorScheme); - return AppFlowyThemeData( colorScheme: colorScheme, textColorScheme: textColorScheme, @@ -83,6 +86,7 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { borderRadius: borderRadius, spacing: spacing, brandColorScheme: brandColorScheme, + shadow: shadow, ); } @@ -113,10 +117,10 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { colorScheme, Brightness.dark, ); + final shadow = themeBuilder.buildShadow(Brightness.dark); final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); final borderRadius = themeBuilder.buildBorderRadius(colorScheme); final spacing = themeBuilder.buildSpacing(colorScheme); - return AppFlowyThemeData( colorScheme: colorScheme, textColorScheme: textColorScheme, @@ -129,6 +133,7 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { borderRadius: borderRadius, spacing: spacing, brandColorScheme: brandColorScheme, + shadow: shadow, ); } @@ -144,6 +149,7 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { required this.brandColorScheme, required this.iconColorTheme, required this.backgroundColorScheme, + required this.shadow, this.brightness = Brightness.light, }); @@ -184,6 +190,9 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { @override final AppFlowyBackgroundColorScheme backgroundColorScheme; + @override + final AppFlowyShadow shadow; + static AppFlowyTextColorScheme buildTextColorScheme( AppFlowyBaseColorScheme colorScheme, Brightness brightness, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart new file mode 100644 index 0000000000..9bb2ac1116 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +class AppFlowyShadow { + AppFlowyShadow({required this.small, required this.medium}); + + final BoxShadow small; + final BoxShadow medium; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index 5ad2435e99..6f37058f00 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -81,7 +81,7 @@ String languageFromLocale(Locale locale) { case "ur": return "اردو"; case "hin": - return "हिन्दी"; + return "हिन्दी"; } // If not found then the language code will be displayed return locale.languageCode; diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart deleted file mode 100644 index b9136a00bc..0000000000 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart +++ /dev/null @@ -1,99 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'package:flutter/material.dart'; - -@immutable -class AFThemeExtensionV2 extends ThemeExtension { - static AFThemeExtensionV2 of(BuildContext context) => - Theme.of(context).extension()!; - - static AFThemeExtensionV2? maybeOf(BuildContext context) => - Theme.of(context).extension(); - - const AFThemeExtensionV2({ - required this.icon_primary, - required this.icon_tertiary, - required this.text_tertiary, - required this.border_grey_quaternary, - required this.fill_theme_select, - required this.fill_grey_thick_alpha_1, - required this.shadow_medium, - }); - - final Color icon_primary; - final Color icon_tertiary; - final Color text_tertiary; - final Color border_grey_quaternary; - final Color fill_theme_select; - final Color fill_grey_thick_alpha_1; - final Color shadow_medium; - - @override - AFThemeExtensionV2 copyWith({ - Color? icon_primary, - Color? icon_tertiary, - Color? text_tertiary, - Color? border_grey_quaternary, - Color? fill_theme_select, - Color? fill_grey_thick_alpha_1, - Color? shadow_medium, - }) => - AFThemeExtensionV2( - icon_primary: icon_primary ?? this.icon_primary, - icon_tertiary: icon_tertiary ?? this.icon_tertiary, - text_tertiary: text_tertiary ?? this.text_tertiary, - border_grey_quaternary: - border_grey_quaternary ?? this.border_grey_quaternary, - fill_theme_select: fill_theme_select ?? this.fill_theme_select, - fill_grey_thick_alpha_1: - fill_grey_thick_alpha_1 ?? this.fill_grey_thick_alpha_1, - shadow_medium: shadow_medium ?? this.shadow_medium, - ); - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! AFThemeExtensionV2) { - return this; - } - return AFThemeExtensionV2( - icon_primary: - Color.lerp(icon_primary, other.icon_primary, t) ?? icon_primary, - icon_tertiary: - Color.lerp(icon_tertiary, other.icon_tertiary, t) ?? icon_tertiary, - text_tertiary: - Color.lerp(text_tertiary, other.text_tertiary, t) ?? text_tertiary, - border_grey_quaternary: - Color.lerp(border_grey_quaternary, other.border_grey_quaternary, t) ?? - border_grey_quaternary, - fill_theme_select: - Color.lerp(fill_theme_select, other.fill_theme_select, t) ?? - fill_theme_select, - fill_grey_thick_alpha_1: Color.lerp( - fill_grey_thick_alpha_1, other.fill_grey_thick_alpha_1, t) ?? - fill_grey_thick_alpha_1, - shadow_medium: - Color.lerp(shadow_medium, other.shadow_medium, t) ?? shadow_medium, - ); - } -} - -const AFThemeExtensionV2 darkAFThemeV2 = AFThemeExtensionV2( - icon_primary: Color(0xFF1F2329), - icon_tertiary: Color(0xFF99A1A8), - text_tertiary: Color(0xFFB5BBD3), - border_grey_quaternary: Color(0xFFE8ECF3), - fill_theme_select: Color(0x00BCF01F), - fill_grey_thick_alpha_1: Color(0x1F23290F), - shadow_medium: Color(0x1F22251F), -); - -const AFThemeExtensionV2 lightAFThemeV2 = AFThemeExtensionV2( - icon_primary: Color(0xFF1F2329), - icon_tertiary: Color(0xFF99A1A8), - text_tertiary: Color(0xFFB5BBD3), - border_grey_quaternary: Color(0xFFE8ECF3), - fill_theme_select: Color(0x00BCF01F), - fill_grey_thick_alpha_1: Color(0x1F23290F), - shadow_medium: Color(0x1F22251F), -); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index ae44b95eb3..1c6d66431d 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -673,14 +673,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - favicon: - dependency: "direct main" - description: - name: favicon - sha256: ebb7423ba7ccc87d3cce9b60de1b5f72004de3cddeedd88d98463fc7f66faf0c - url: "https://pub.dev" - source: hosted - version: "1.1.2" ffi: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index de26c31c1e..0cc553d1a7 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -31,7 +31,6 @@ dependencies: auto_size_text_field: ^2.2.3 auto_updater: ^1.0.0 avatar_stack: ^3.0.0 - favicon: ^1.1.2 # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 diff --git a/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart new file mode 100644 index 0000000000..5b6f88801a --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + test( + 'description', + () async { + final links = [ + 'https://www.baidu.com/', + 'https://appflowy.io/', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://github.com/', + 'https://www.figma.com/design/3K0ai4FhDOJ3Lts8G3KOVP/Page?node-id=7282-4007&p=f&t=rpfvEvh9K9J9WkIo-0', + 'https://www.figma.com/files/drafts', + 'https://www.youtube.com/watch?v=LyY5Rh9qBvA', + 'https://www.youtube.com/', + 'https://www.youtube.com/watch?v=a6GDT7', + 'http://www.test.com/', + 'https://www.baidu.com/s?wd=test&rsv_spt=1&rsv_iqid=0xb6a7840b00e5324a&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=22073068_7_oem_dg&rsv_dl=tb&rsv_enter=1&rsv_sug3=5&rsv_sug1=4&rsv_sug7=100&rsv_sug2=0&rsv_btype=i&prefixsug=test&rsp=9&inputT=478&rsv_sug4=547', + 'https://www.google.com/', + 'https://www.google.com.hk/search?q=test&oq=test&gs_lcrp=EgZjaHJvbWUyCQgAEEUYORiABDIHCAEQABiABDIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIHCAUQABiABDIHCAYQABiABDIHCAcQABiABDIHCAgQLhiABDIHCAkQABiABNIBCTE4MDJqMGoxNagCCLACAfEFAQs7K9PprSfxBQELOyvT6a0n&sourceid=chrome&ie=UTF-8', + 'www.baidu.com', + 'baidu.com', + 'com', + 'https://www.baidu.com', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://appflowy.com/app/c29fafc4-b7c0-4549-8702-71339b0fd9ea/59f36be8-9b2f-4d3e-b6a1-816c6c2043e5?blockId=GCY_T4', + ]; + + final parser = DefaultParser(); + int i = 1; + for (final link in links) { + final formatLink = LinkInfoParser.formatUrl(link); + final siteInfo = await parser + .parse(Uri.tryParse(formatLink) ?? Uri.parse(formatLink)); + if (siteInfo?.isEmpty() ?? true) { + debugPrint('$i : $formatLink ---- empty \n'); + } else { + debugPrint('$i : $formatLink ---- \n$siteInfo \n'); + } + i++; + } + }, + timeout: const Timeout(Duration(seconds: 120)), + ); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 91f9ce7743..46cab3030a 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2041,7 +2041,7 @@ "reload": "Reload", "removeLink": "Remove Link", "pasteHint": "Paste in https://...", - "refuseConnect": "refued to connect." + "unableToDisplay": "unable to display" } } }, diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 2731a0fb2e..d1433929bc 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -1517,6 +1517,27 @@ "placeholder": "粘贴视频链接", "copiedToPasteBoard": "视频链接已复制到剪贴板", "insertVideo": "添加视频" + }, + "linkPreview": { + "typeSelection": { + "pasteAs": "粘贴为", + "mention": "提及", + "URL": "URL", + "bookmark": "书签", + "embed": "嵌入" + }, + "linkPreviewMenu": { + "toMetion": "转换为提及", + "toUrl": "转换为URL", + "toEmbed": "转换为嵌入", + "toBookmark": "转换为书签", + "copyLink": "复制链接", + "replace": "替换", + "reload": "重新加载", + "removeLink": "移除链接", + "pasteHint": "粘贴 https://...", + "unableToDisplay": "无法显示" + } } }, "outlineBlock": { From 437deaf986353c72d8281a3bc4ae05746e61f834 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Apr 2025 14:38:25 +0800 Subject: [PATCH 130/207] chore: update logs --- .../command_palette/folder_search_test.dart | 12 +- .../widgets/search_result_tile.dart | 67 ++++---- .../widgets/search_results_list.dart | 161 +++++++++++++++--- frontend/appflowy_flutter/macos/Podfile.lock | 46 ++--- frontend/rust-lib/Cargo.lock | 24 +-- frontend/rust-lib/Cargo.toml | 4 +- .../flowy-search/src/document/handler.rs | 12 +- .../flowy-search/src/entities/result.rs | 4 +- .../flowy-search/src/services/manager.rs | 17 +- 9 files changed, 233 insertions(+), 114 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart index 265d313320..a1d7f12667 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart @@ -44,11 +44,11 @@ void main() { await tester.pumpAndSettle(const Duration(milliseconds: 200)); // Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna) - expect(find.byType(SearchResultTile), findsNWidgets(2)); + expect(find.byType(SearchResultCell), findsNWidgets(2)); // The score should be higher for "ViewOna" thus it should be shown first final secondDocumentWidget = tester - .widget(find.byType(SearchResultTile).first) as SearchResultTile; + .widget(find.byType(SearchResultCell).first) as SearchResultCell; expect(secondDocumentWidget.item.data, secondDocument); // Change search to "ViewOne" @@ -57,8 +57,8 @@ void main() { // The score should be higher for "ViewOne" thus it should be shown first final firstDocumentWidget = tester.widget( - find.byType(SearchResultTile).first, - ) as SearchResultTile; + find.byType(SearchResultCell).first, + ) as SearchResultCell; expect(firstDocumentWidget.item.data, firstDocument); }); @@ -89,11 +89,11 @@ void main() { ); await tester.enterText(searchFieldFinder, 'Page-$randomValue'); await tester.pumpAndSettle(const Duration(milliseconds: 200)); - expect(find.byType(SearchResultTile), findsNWidgets(2)); + expect(find.byType(SearchResultCell), findsNWidgets(2)); /// check results final svgs = find.descendant( - of: find.byType(SearchResultTile), + of: find.byType(SearchResultCell), matching: find.byType(FlowySvg), ); expect(svgs, findsNWidgets(2)); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart index a11721d1c3..551823f08e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart @@ -13,23 +13,25 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -class SearchResultTile extends StatefulWidget { - const SearchResultTile({ +class SearchResultCell extends StatefulWidget { + const SearchResultCell({ super.key, required this.item, required this.onSelected, + required this.onHover, this.isTrashed = false, }); final SearchResponseItemPB item; final VoidCallback onSelected; + final Function(SearchResponseItemPB) onHover; final bool isTrashed; @override - State createState() => _SearchResultTileState(); + State createState() => _SearchResultCellState(); } -class _SearchResultTileState extends State { +class _SearchResultCellState extends State { bool _hasFocus = false; final focusNode = FocusNode(); @@ -132,33 +134,36 @@ class _SearchResultTileState extends State { ); } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _handleSelection, - child: Focus( - focusNode: focusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey == LogicalKeyboardKey.enter) { - _handleSelection(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), - child: FlowyHover( - isSelected: () => _hasFocus, - style: HoverStyle( - borderRadius: BorderRadius.circular(8), - hoverColor: - Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - foregroundColorOnHover: AFThemeExtension.of(context).textColor, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 30), - child: tileContent, + return MouseRegion( + onEnter: (_) => widget.onHover(widget.item), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleSelection, + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _handleSelection(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + child: FlowyHover( + isSelected: () => _hasFocus, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: tileContent, + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index b2ca24b68f..2eb3b44b3e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -7,7 +7,7 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -class SearchResultList extends StatelessWidget { +class SearchResultList extends StatefulWidget { const SearchResultList({ required this.trash, required this.resultItems, @@ -19,6 +19,13 @@ class SearchResultList extends StatelessWidget { final List resultItems; final List resultSummaries; + @override + State createState() => _SearchResultListState(); +} + +class _SearchResultListState extends State { + dynamic _selectedCellData; + Widget _buildSectionHeader(String title) => Padding( padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8), @@ -28,6 +35,18 @@ class SearchResultList extends StatelessWidget { ), ); + void _onHoverSummary(SearchSummaryPB summary) { + setState(() { + _selectedCellData = summary; + }); + } + + void _onHoverResult(SearchResponseItemPB item) { + setState(() { + _selectedCellData = item; + }); + } + Widget _buildSummariesSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -36,10 +55,11 @@ class SearchResultList extends StatelessWidget { ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: resultSummaries.length, + itemCount: widget.resultSummaries.length, separatorBuilder: (_, __) => const Divider(height: 0), - itemBuilder: (_, index) => SearchSummaryTile( - summary: resultSummaries[index], + itemBuilder: (_, index) => SearchSummaryCell( + summary: widget.resultSummaries[index], + onHover: _onHoverSummary, ), ), ], @@ -55,14 +75,15 @@ class SearchResultList extends StatelessWidget { ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: resultItems.length, + itemCount: widget.resultItems.length, separatorBuilder: (_, __) => const Divider(height: 0), itemBuilder: (_, index) { - final item = resultItems[index]; - return SearchResultTile( + final item = widget.resultItems[index]; + return SearchResultCell( item: item, onSelected: () => FlowyOverlay.pop(context), - isTrashed: trash.any((t) => t.id == item.viewId), + isTrashed: widget.trash.any((t) => t.id == item.viewId), + onHover: _onHoverResult, ); }, ), @@ -74,31 +95,127 @@ class SearchResultList extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), - child: ListView( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), + child: Row( children: [ - if (resultSummaries.isNotEmpty) _buildSummariesSection(), - const VSpace(10), - if (resultItems.isNotEmpty) _buildResultsSection(context), + Flexible( + flex: 7, + child: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + if (widget.resultSummaries.isNotEmpty) _buildSummariesSection(), + const VSpace(10), + if (widget.resultItems.isNotEmpty) + _buildResultsSection(context), + ], + ), + ), + Flexible( + flex: 3, + child: SearchCellDetail(cellData: _selectedCellData), + ), ], ), ); } } -class SearchSummaryTile extends StatelessWidget { - const SearchSummaryTile({required this.summary, super.key}); +class SearchCellDetail extends StatelessWidget { + const SearchCellDetail({ + super.key, + required this.cellData, + }); - final SearchSummaryPB summary; + final dynamic cellData; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: FlowyText( - summary.content, - maxLines: 10, + if (cellData == null) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: FlowyText( + 'Hover over an item to see details', + fontSize: 12, + color: Colors.grey, + ), + ), + ); + } + + if (cellData is SearchSummaryPB) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FlowyText( + 'AI Summary', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + const VSpace(8), + FlowyText( + cellData.content, + fontSize: 12, + ), + ], + ), + ); + } + + if (cellData is SearchResponseItemPB) { + final item = cellData as SearchResponseItemPB; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + item.data, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + const VSpace(8), + if (item.preview.isNotEmpty) + FlowyText( + item.preview, + fontSize: 12, + ), + ], + ), + ); + } + + return const SizedBox.shrink(); + } +} + +class SearchSummaryCell extends StatefulWidget { + const SearchSummaryCell({ + required this.summary, + required this.onHover, + super.key, + }); + + final SearchSummaryPB summary; + final Function(SearchSummaryPB) onHover; + + @override + State createState() => _SearchSummaryCellState(); +} + +class _SearchSummaryCellState extends State { + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => widget.onHover(widget.summary), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: FlowyText( + widget.summary.content, + maxLines: 3, + ), ), ); } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 30ee626f09..b4a1a3d20d 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 - appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 - auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 - connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 - file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 - irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c - window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index fd3ae1a6b9..671d8bccf2 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "bytes", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "again", "anyhow", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "futures-channel", "futures-util", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "bincode", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "async-trait", @@ -1969,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "bincode", "bytes", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "app-error", "jsonwebtoken", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "bytes", @@ -6784,7 +6784,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6#c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 0a3cf409bb..8d8e11f6a0 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -105,8 +105,8 @@ tantivy = { version = "0.24.0" } # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c95dcc6bc7762d4b89d3d382e8ed2b6a68742ba6" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "873415478ed58686c98df578e2c39d07ddce6773" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "873415478ed58686c98df578e2c39d07ddce6773" } [profile.dev] opt-level = 0 diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index 45bf5864c7..b808b38e74 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,6 +1,6 @@ use crate::entities::{ CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, SearchResultPB, - SearchSourcePB, SearchSummaryPB, + SearchSummaryPB, }; use crate::{ entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, @@ -82,7 +82,6 @@ impl SearchHandler for DocumentSearchHandler { }, }; - trace!("[Search] search results: {:?}", result_items); let summary_input = result_items .iter() .map(|v| SearchResult { @@ -137,13 +136,8 @@ impl SearchHandler for DocumentSearchHandler { let summaries: Vec = summary_result .summaries .into_iter() - .filter_map(|v| { - v.metadata.as_object().and_then(|object| { - let id = object.get("id")?.as_str()?.to_string(); - let source = object.get("source")?.as_str()?.to_string(); - let metadata = SearchSourcePB {id, source }; - Some(SearchSummaryPB { content: v.content, metadata: Some(metadata) }) - }) + .map(|v| { + SearchSummaryPB { content: v.content, source_ids: v.sources.iter().map(|id| id.to_string()).collect() } }) .collect(); diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index d7d6db0820..48de24728d 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -28,8 +28,8 @@ pub struct SearchSummaryPB { #[pb(index = 1)] pub content: String, - #[pb(index = 2, one_of)] - pub metadata: Option, + #[pb(index = 2)] + pub source_ids: Vec, } #[derive(ProtoBuf, Default, Debug, Clone)] diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 30c6783fb0..157781eceb 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -62,16 +62,14 @@ impl SearchManager { search_id: String, ) { // Cancel previous search by updating current_search - { - let mut current = self.current_search.lock().await; - *current = Some(search_id.clone()); - } + *self.current_search.lock().await = Some(search_id.clone()); let handlers = self.handlers.clone(); let sink = IsolateSink::new(Isolate::new(stream_port)); let mut join_handles = vec![]; let current_search = self.current_search.clone(); + tracing::info!("[Search] perform search: {}", query); for (_, handler) in handlers { let mut clone_sink = sink.clone(); let query = query.clone(); @@ -80,11 +78,16 @@ impl SearchManager { let current_search = current_search.clone(); let handle = tokio::spawn(async move { + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] cancel search: {}", query); + return; + } + let mut stream = handler.perform_search(query.clone(), filter).await; while let Some(result) = stream.next().await { if !is_current_search(¤t_search, &search_id).await { - trace!("[Search] search changed, cancel search: {}", query); - break; + trace!("[Search] discard search stream: {}", query); + return; } if let Ok(result) = result { @@ -103,7 +106,7 @@ impl SearchManager { } if !is_current_search(¤t_search, &search_id).await { - trace!("[Search] search changed, cancel search: {}", query); + trace!("[Search] discard search result: {}", query); return; } From ddbaf0d530f12eb48bbe7cc8ecbce668473d1b9e Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 14 Apr 2025 14:58:00 +0800 Subject: [PATCH 131/207] feat: otp improvement on mobile (#7738) --- .../setting/user_session_setting_group.dart | 11 +++++-- .../lib/user/application/sign_in_bloc.dart | 7 +++++ .../desktop_sign_in_screen.dart | 9 ++++-- .../sign_in_screen/mobile_sign_in_screen.dart | 13 +++++--- .../sign_in_screen/sign_in_screen.dart | 16 +++------- .../widgets/anonymous_sign_in_button.dart | 18 ++++------- .../continue_with_email_and_password.dart | 31 ++++++++++++++++--- .../widgets/sign_in_anonymous_button.dart | 25 ++++++++++----- .../macos/Runner/Configs/AppInfo.xcconfig | 2 +- .../lib/src/theme/data/builder.dart | 2 +- frontend/rust-lib/Cargo.lock | 6 ++-- frontend/rust-lib/Cargo.toml | 6 ++-- 12 files changed, 93 insertions(+), 53 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index b3b7cb71c5..617de1db50 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -63,8 +63,15 @@ class UserSessionSettingGroup extends StatelessWidget { ); }, builder: (context, state) { - return const ThirdPartySignInButtons( - expanded: true, + return Column( + children: [ + const ContinueWithEmailAndPassword(), + const VSpace(12.0), + const ThirdPartySignInButtons( + expanded: true, + ), + const VSpace(16.0), + ], ); }, ), diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 82269d1f1f..06a03765c9 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -186,6 +186,13 @@ class SignInBloc extends Bloc { Emitter emit, { required String email, }) async { + if (state.isSubmitting) { + Log.error('Sign in with magic link is already in progress'); + return; + } + + Log.info('Sign in with magic link: $email'); + emit( state.copyWith( isSubmitting: true, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 65e8ee550b..40901e92e1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -5,6 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/settings/show_settings.dart'; import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; @@ -25,6 +26,7 @@ class DesktopSignInScreen extends StatelessWidget { return BlocBuilder( builder: (context, state) { + final bottomPadding = UniversalPlatform.isDesktop ? 20.0 : 24.0; return Scaffold( appBar: _buildAppBar(), body: Center( @@ -40,7 +42,10 @@ class DesktopSignInScreen extends StatelessWidget { VSpace(theme.spacing.xxl), // continue with email and password - const ContinueWithEmailAndPassword(), + isLocalAuthEnabled + ? const SignInAnonymousButtonV3() + : const ContinueWithEmailAndPassword(), + VSpace(theme.spacing.xxl), // third-party sign in. @@ -65,7 +70,7 @@ class DesktopSignInScreen extends StatelessWidget { SignInAnonymousButtonV2(), ], ), - const VSpace(16), + VSpace(bottomPadding), ], ), ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index 6326e1a811..7e2222abf2 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -40,7 +40,7 @@ class MobileSignInScreen extends StatelessWidget { ? const SignInAnonymousButtonV3() : const ContinueWithEmailAndPassword(), const VSpace(spacing), - if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), + if (isAuthEnabled) _buildThirdPartySignInButtons(context), const VSpace(spacing * 1.5), const SignInAgreement(), const VSpace(spacing), @@ -75,7 +75,8 @@ class MobileSignInScreen extends StatelessWidget { ); } - Widget _buildThirdPartySignInButtons(ColorScheme colorScheme) { + Widget _buildThirdPartySignInButtons(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( children: [ Row( @@ -84,10 +85,12 @@ class MobileSignInScreen extends StatelessWidget { const Expanded(child: Divider()), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: FlowyText( + child: Text( LocaleKeys.signIn_or.tr(), - fontSize: 12, - color: colorScheme.onSecondary, + style: TextStyle( + fontSize: 16, + color: theme.textColorScheme.secondary, + ), ), ), const Expanded(child: Divider()), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index a428545d8d..afae06d50a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -2,15 +2,13 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; -import '../../helpers/helpers.dart'; - class SignInScreen extends StatelessWidget { const SignInScreen({super.key}); @@ -23,13 +21,9 @@ class SignInScreen extends StatelessWidget { child: BlocConsumer( listener: _showSignInError, builder: (context, state) { - final isLoading = context.read().state.isSubmitting; - if (UniversalPlatform.isMobile) { - return isLoading - ? const MobileLoadingScreen() - : const MobileSignInScreen(); - } - return const DesktopSignInScreen(); + return UniversalPlatform.isDesktop + ? const DesktopSignInScreen() + : const MobileSignInScreen(); }, ), ); @@ -47,7 +41,7 @@ class SignInScreen extends StatelessWidget { } }, (error) { - handleOpenWorkspaceError(context, error); + Log.error('Sign in error: $error'); }, ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart index c6b2d5401c..2b11068abb 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart @@ -2,8 +2,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy_ui/appflowy_ui.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'; @@ -41,17 +41,11 @@ class SignInAnonymousButtonV3 extends StatelessWidget { final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 32), - maximumSize: const Size(double.infinity, 38), - ), - onPressed: onTap, - child: FlowyText( - text, - fontSize: 14, - color: Theme.of(context).colorScheme.onPrimary, - ), + return AFFilledTextButton.primary( + text: text, + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, ); }, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart index a0dda40a36..efa72e7341 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -23,6 +23,8 @@ class _ContinueWithEmailAndPasswordState final focusNode = FocusNode(); final emailKey = GlobalKey(); + bool _hasPushedContinueWithMagicLinkOrPasscodePage = false; + @override void dispose() { controller.dispose(); @@ -47,10 +49,12 @@ class _ContinueWithEmailAndPasswordState ), ); } else if (successOrFail == null && !state.isSubmitting) { - _pushContinueWithMagicLinkOrPasscodePage( - context, - controller.text, - ); + emailKey.currentState?.clearError(); + + // _pushContinueWithMagicLinkOrPasscodePage( + // context, + // controller.text, + // ); } }, child: Column( @@ -95,12 +99,21 @@ class _ContinueWithEmailAndPasswordState context .read() .add(SignInEvent.signInWithMagicLink(email: email)); + + _pushContinueWithMagicLinkOrPasscodePage( + context, + email, + ); } void _pushContinueWithMagicLinkOrPasscodePage( BuildContext context, String email, ) { + if (_hasPushedContinueWithMagicLinkOrPasscodePage) { + return; + } + final signInBloc = context.read(); // push the a continue with magic link or passcode screen @@ -111,7 +124,13 @@ class _ContinueWithEmailAndPasswordState value: signInBloc, child: ContinueWithMagicLinkOrPasscodePage( email: email, - backToLogin: () => Navigator.pop(context), + backToLogin: () { + Navigator.pop(context); + + emailKey.currentState?.clearError(); + + _hasPushedContinueWithMagicLinkOrPasscodePage = false; + }, onEnterPasscode: (passcode) => signInBloc.add( SignInEvent.signInWithPasscode( email: email, @@ -122,6 +141,8 @@ class _ContinueWithEmailAndPasswordState ), ), ); + + _hasPushedContinueWithMagicLinkOrPasscodePage = true; } // void _pushContinueWithPasswordPage( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index a0b31c2fe5..2b40502b0d 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -6,7 +6,6 @@ import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.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'; @@ -78,13 +77,16 @@ class ChangeCloudModeButton extends StatelessWidget { @override Widget build(BuildContext context) { - return FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - 'Cloud', - decoration: TextDecoration.underline, - color: Colors.grey, - fontSize: 12, + final theme = AppFlowyTheme.of(context); + return AFGhostIconTextButton( + text: 'Cloud', + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), onTap: () async { await useAppFlowyBetaCloudWithURL( @@ -93,6 +95,13 @@ class ChangeCloudModeButton extends StatelessWidget { ); await runAppFlowy(); }, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); + }, ); } } diff --git a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig index da469610eb..d2b3d7e9b3 100644 --- a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = AppFlowy PRODUCT_BUNDLE_IDENTIFIER = io.appflowy.appflowy // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2024 AppFlowy.IO. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 AppFlowy.IO. All rights reserved. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index 32b3335d0f..8923f61e1f 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -191,7 +191,7 @@ class AppFlowyThemeBuilder { quaternary: colorScheme.neutral.neutral1000, quaternaryHover: colorScheme.neutral.neutral900, transparent: colorScheme.neutral.alphaWhite0, - primaryAlpha5: colorScheme.neutral.alphaGrey100005, + primaryAlpha5: colorScheme.neutral.alphaGrey10005, primaryAlpha5Hover: colorScheme.neutral.alphaGrey10010, primaryAlpha80: colorScheme.neutral.alphaGrey100080, primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index fdf8c8348e..006c43d696 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "af-plugin", "anyhow", @@ -362,7 +362,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "anyhow", "futures-util", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index d3427ef99c..475cefb3d4 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } From a44ad63230edf4eb21807568050571f576ca15e9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Apr 2025 16:40:54 +0800 Subject: [PATCH 132/207] chore: display preview --- .../command_palette/folder_search_test.dart | 6 +- .../command_palette/search_result_ext.dart | 10 +- .../search_result_list_bloc.dart | 62 ++++++ .../command_palette/command_palette.dart | 6 +- ...sult_tile.dart => search_result_cell.dart} | 106 +++++++--- .../widgets/search_results_list.dart | 200 +++++------------- .../widgets/search_summary_cell.dart | 91 ++++++++ frontend/resources/translations/en.json | 4 +- .../flowy-search/src/document/handler.rs | 91 ++++---- .../flowy-search/src/entities/result.rs | 29 +-- .../flowy-search/src/folder/entities.rs | 4 +- .../flowy-search/src/folder/indexer.rs | 2 +- 12 files changed, 368 insertions(+), 243 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart rename frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/{search_result_tile.dart => search_result_cell.dart} (66%) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart index a1d7f12667..0b77a0167b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart @@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -49,7 +49,7 @@ void main() { // The score should be higher for "ViewOna" thus it should be shown first final secondDocumentWidget = tester .widget(find.byType(SearchResultCell).first) as SearchResultCell; - expect(secondDocumentWidget.item.data, secondDocument); + expect(secondDocumentWidget.item.displayName, secondDocument); // Change search to "ViewOne" await tester.enterText(searchFieldFinder, firstDocument); @@ -59,7 +59,7 @@ void main() { final firstDocumentWidget = tester.widget( find.byType(SearchResultCell).first, ) as SearchResultCell; - expect(firstDocumentWidget.item.data, firstDocument); + expect(firstDocumentWidget.item.displayName, firstDocument); }); testWidgets('Displaying icons in search results', (tester) async { diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart index 563eac8a0a..6b6ea6d5c0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart @@ -5,19 +5,19 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; -extension GetIcon on SearchResponseItemPB { +extension GetIcon on ResultIconPB { Widget? getIcon() { - final iconValue = icon.value, iconType = icon.ty; + final iconValue = value, iconType = ty; if (iconType == ResultIconTypePB.Emoji) { return iconValue.isNotEmpty ? FlowyText.emoji(iconValue, fontSize: 18) : null; - } else if (icon.ty == ResultIconTypePB.Icon) { + } else if (ty == ResultIconTypePB.Icon) { if (_resultIconValueTypes.contains(iconValue)) { - return FlowySvg(icon.getViewSvg(), size: const Size.square(18)); + return FlowySvg(getViewSvg(), size: const Size.square(18)); } return RawEmojiIconWidget( - emoji: EmojiIconData(iconType.toFlowyIconType(), icon.value), + emoji: EmojiIconData(iconType.toFlowyIconType(), value), emojiSize: 18, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart new file mode 100644 index 0000000000..c77530ba77 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_result_list_bloc.freezed.dart'; + +class SearchResultListBloc + extends Bloc { + SearchResultListBloc() : super(SearchResultListState.initial()) { + // Register event handlers + on<_OnHoverSummary>(_onHoverSummary); + on<_OnHoverResult>(_onHoverResult); + } + + FutureOr _onHoverSummary( + _OnHoverSummary event, + Emitter emit, + ) { + emit( + state.copyWith( + hoveredSummary: event.summary, + hoveredResult: null, + ), + ); + } + + FutureOr _onHoverResult( + _OnHoverResult event, + Emitter emit, + ) { + emit( + state.copyWith( + hoveredSummary: null, + hoveredResult: event.item, + ), + ); + } +} + +@freezed +class SearchResultListEvent with _$SearchResultListEvent { + const factory SearchResultListEvent.onHoverSummary({ + required SearchSummaryPB summary, + }) = _OnHoverSummary; + const factory SearchResultListEvent.onHoverResult({ + required SearchResponseItemPB item, + }) = _OnHoverResult; +} + +@freezed +class SearchResultListState with _$SearchResultListState { + const SearchResultListState._(); + const factory SearchResultListState({ + @Default(null) SearchSummaryPB? hoveredSummary, + @Default(null) SearchResponseItemPB? hoveredResult, + }) = _SearchResultListState; + + factory SearchResultListState.initial() => const SearchResultListState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart index 9a507decc6..f54fbebe14 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -135,9 +135,9 @@ class CommandPaletteModal extends StatelessWidget { alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 100), constraints: const BoxConstraints( - maxHeight: 520, - maxWidth: 510, - minHeight: 420, + maxHeight: 600, + maxWidth: 800, + minHeight: 600, ), expandHeight: false, child: shortcutBuilder( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart similarity index 66% rename from frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart index 551823f08e..1bd3d0b03a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -4,6 +4,7 @@ import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -12,19 +13,18 @@ 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'; class SearchResultCell extends StatefulWidget { const SearchResultCell({ super.key, required this.item, required this.onSelected, - required this.onHover, this.isTrashed = false, }); final SearchResponseItemPB item; final VoidCallback onSelected; - final Function(SearchResponseItemPB) onHover; final bool isTrashed; @override @@ -46,7 +46,7 @@ class _SearchResultCellState extends State { widget.onSelected(); getIt().add( ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.item.viewId), + action: NavigationAction(objectId: widget.item.id), ), ); } @@ -58,10 +58,10 @@ class _SearchResultCellState extends State { @override Widget build(BuildContext context) { - final title = widget.item.data.orDefault( + final title = widget.item.displayName.orDefault( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); - final icon = widget.item.getIcon(); + final icon = widget.item.icon.getIcon(); final cleanedPreview = _cleanPreview(widget.item.preview); final hasPreview = cleanedPreview.isNotEmpty; final trashHintText = @@ -134,36 +134,45 @@ class _SearchResultCellState extends State { ); } - return MouseRegion( - onEnter: (_) => widget.onHover(widget.item), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _handleSelection, - child: Focus( - focusNode: focusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey == LogicalKeyboardKey.enter) { - _handleSelection(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleSelection, + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _handleSelection(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + onFocusChange: (hasFocus) { + setState(() { + context.read().add( + SearchResultListEvent.onHoverResult(item: widget.item), + ); + _hasFocus = hasFocus; + }); + }, + child: FlowyHover( + onHover: (value) { + context.read().add( + SearchResultListEvent.onHoverResult(item: widget.item), + ); }, - onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), - child: FlowyHover( - isSelected: () => _hasFocus, - style: HoverStyle( - borderRadius: BorderRadius.circular(8), - hoverColor: - Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - foregroundColorOnHover: AFThemeExtension.of(context).textColor, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 30), - child: tileContent, - ), + isSelected: () => _hasFocus, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: tileContent, ), ), ), @@ -192,3 +201,32 @@ class _DocumentPreview extends StatelessWidget { ); } } + +class SearchResultPreview extends StatelessWidget { + const SearchResultPreview({ + super.key, + required this.data, + }); + + final SearchResponseItemPB data; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText(LocaleKeys.commandPalette_pagePreview.tr()), + const Divider( + thickness: 1, + ), + const VSpace(6), + Expanded( + child: FlowyText( + data.content, + maxLines: 30, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index 2eb3b44b3e..5d2e305c15 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -1,13 +1,17 @@ +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -class SearchResultList extends StatefulWidget { +import 'search_result_cell.dart'; +import 'search_summary_cell.dart'; + +class SearchResultList extends StatelessWidget { const SearchResultList({ required this.trash, required this.resultItems, @@ -19,13 +23,6 @@ class SearchResultList extends StatefulWidget { final List resultItems; final List resultSummaries; - @override - State createState() => _SearchResultListState(); -} - -class _SearchResultListState extends State { - dynamic _selectedCellData; - Widget _buildSectionHeader(String title) => Padding( padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8), @@ -35,31 +32,18 @@ class _SearchResultListState extends State { ), ); - void _onHoverSummary(SearchSummaryPB summary) { - setState(() { - _selectedCellData = summary; - }); - } - - void _onHoverResult(SearchResponseItemPB item) { - setState(() { - _selectedCellData = item; - }); - } - Widget _buildSummariesSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(LocaleKeys.commandPalette_aiSummary.tr()), + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: widget.resultSummaries.length, + itemCount: resultSummaries.length, separatorBuilder: (_, __) => const Divider(height: 0), itemBuilder: (_, index) => SearchSummaryCell( - summary: widget.resultSummaries[index], - onHover: _onHoverSummary, + summary: resultSummaries[index], ), ), ], @@ -75,15 +59,14 @@ class _SearchResultListState extends State { ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: widget.resultItems.length, + itemCount: resultItems.length, separatorBuilder: (_, __) => const Divider(height: 0), itemBuilder: (_, index) { - final item = widget.resultItems[index]; + final item = resultItems[index]; return SearchResultCell( item: item, onSelected: () => FlowyOverlay.pop(context), - isTrashed: widget.trash.any((t) => t.id == item.viewId), - onHover: _onHoverResult, + isTrashed: trash.any((t) => t.id == item.id), ); }, ), @@ -95,128 +78,55 @@ class _SearchResultListState extends State { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), - child: Row( - children: [ - Flexible( - flex: 7, - child: ListView( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - children: [ - if (widget.resultSummaries.isNotEmpty) _buildSummariesSection(), - const VSpace(10), - if (widget.resultItems.isNotEmpty) - _buildResultsSection(context), - ], - ), - ), - Flexible( - flex: 3, - child: SearchCellDetail(cellData: _selectedCellData), - ), - ], - ), - ); - } -} - -class SearchCellDetail extends StatelessWidget { - const SearchCellDetail({ - super.key, - required this.cellData, - }); - - final dynamic cellData; - - @override - Widget build(BuildContext context) { - if (cellData == null) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center( - child: FlowyText( - 'Hover over an item to see details', - fontSize: 12, - color: Colors.grey, - ), - ), - ); - } - - if (cellData is SearchSummaryPB) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( + child: BlocProvider( + create: (context) => SearchResultListBloc(), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const FlowyText( - 'AI Summary', - fontSize: 14, - fontWeight: FontWeight.bold, - ), - const VSpace(8), - FlowyText( - cellData.content, - fontSize: 12, - ), - ], - ), - ); - } - - if (cellData is SearchResponseItemPB) { - final item = cellData as SearchResponseItemPB; - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText( - item.data, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - const VSpace(8), - if (item.preview.isNotEmpty) - FlowyText( - item.preview, - fontSize: 12, + Flexible( + flex: 7, + child: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + if (resultSummaries.isNotEmpty) _buildSummariesSection(), + const VSpace(10), + if (resultItems.isNotEmpty) _buildResultsSection(context), + ], ), + ), + const HSpace(10), + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), + child: const SearchCellPreview(), + ), + ), ], ), - ); - } - - return const SizedBox.shrink(); - } -} - -class SearchSummaryCell extends StatefulWidget { - const SearchSummaryCell({ - required this.summary, - required this.onHover, - super.key, - }); - - final SearchSummaryPB summary; - final Function(SearchSummaryPB) onHover; - - @override - State createState() => _SearchSummaryCellState(); -} - -class _SearchSummaryCellState extends State { - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => widget.onHover(widget.summary), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: FlowyText( - widget.summary.content, - maxLines: 3, - ), ), ); } } + +class SearchCellPreview extends StatelessWidget { + const SearchCellPreview({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.hoveredSummary != null) { + return SearchSummaryPreview(summary: state.hoveredSummary!); + } else if (state.hoveredResult != null) { + return SearchResultPreview(data: state.hoveredResult!); + } + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart new file mode 100644 index 0000000000..c911c46735 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SearchSummaryCell extends StatelessWidget { + const SearchSummaryCell({ + required this.summary, + super.key, + }); + + final SearchSummaryPB summary; + + @override + Widget build(BuildContext context) { + return FlowyHover( + onHover: (value) { + context.read().add( + SearchResultListEvent.onHoverSummary(summary: summary), + ); + }, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: FlowyText( + summary.content, + maxLines: 3, + ), + ), + ); + } +} + +class SearchSummaryPreview extends StatelessWidget { + const SearchSummaryPreview({ + required this.summary, + super.key, + }); + + final SearchSummaryPB summary; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText(LocaleKeys.commandPalette_aiOverviewSource.tr()), + const Divider( + thickness: 1, + ), + const VSpace(6), + ...summary.sources.map((e) => SearchSummarySource(source: e)), + ], + ); + } +} + +class SearchSummarySource extends StatelessWidget { + const SearchSummarySource({ + required this.source, + super.key, + }); + + final SearchSourcePB source; + + @override + Widget build(BuildContext context) { + final icon = source.icon.getIcon(); + return Row( + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + FlowyText(source.displayName), + ], + ); + } +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 147902d799..4279f95425 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2693,7 +2693,9 @@ "commandPalette": { "placeholder": "Search or ask a question...", "bestMatches": "Best matches", - "aiSummary": "AI summary", + "aiOverview": "AI overview", + "aiOverviewSource": "Sources", + "pagePreview": "Preview", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index b808b38e74..9077829c07 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,6 +1,6 @@ use crate::entities::{ CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, SearchResultPB, - SearchSummaryPB, + SearchSourcePB, SearchSummaryPB, }; use crate::{ entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, @@ -8,6 +8,7 @@ use crate::{ }; use async_stream::stream; use flowy_error::FlowyResult; +use flowy_folder::entities::ViewPB; use flowy_folder::{manager::FolderManager, ViewLayout}; use flowy_search_pub::cloud::{SearchCloudService, SearchResult}; use lib_infra::async_trait::async_trait; @@ -34,7 +35,6 @@ impl DocumentSearchHandler { } } } - #[async_trait] impl SearchHandler for DocumentSearchHandler { fn search_type(&self) -> SearchType { @@ -50,14 +50,15 @@ impl SearchHandler for DocumentSearchHandler { let folder_manager = self.folder_manager.clone(); Box::pin(stream! { - let filter = match filter { - Some(f) => f, - None => { - yield Ok(CreateSearchResultPBArgs::default().build().unwrap()); - return; - }, + // Exit early if there is no filter. + let filter = if let Some(f) = filter { + f + } else { + yield Ok(CreateSearchResultPBArgs::default().build().unwrap()); + return; }; + // Parse workspace id. let workspace_id = match Uuid::from_str(&filter.workspace_id) { Ok(id) => id, Err(e) => { @@ -66,62 +67,54 @@ impl SearchHandler for DocumentSearchHandler { } }; + // Retrieve all available views. let views = match folder_manager.get_all_views_pb().await { Ok(views) => views, Err(e) => { yield Err(e); return; - }, + } }; + // Execute document search. let result_items = match cloud_service.document_search(&workspace_id, query.clone()).await { Ok(items) => items, Err(e) => { yield Err(e); return; - }, + } }; - let summary_input = result_items + // Prepare input for search summary generation. + let summary_input: Vec = result_items .iter() .map(|v| SearchResult { object_id: v.object_id, content: v.content.clone(), }) - .collect::>(); + .collect(); + // Build search response items. let mut items: Vec = Vec::new(); - for item in result_items.iter() { + for item in &result_items { if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { - let icon: Option = match view.icon.clone() { - Some(view_icon) => Some(ResultIconPB::from(view_icon)), - None => { - let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); - Some(ResultIconPB { - ty: ResultIconTypePB::Icon, - value: view_layout_ty.to_string(), - }) - }, - }; - items.push(SearchResponseItemPB { index_type: IndexTypePB::Document, - view_id: item.object_id.to_string(), id: item.object_id.to_string(), - data: view.name.clone(), - icon, + display_name: view.name.clone(), + icon: extract_icon(view), score: item.score, workspace_id: item.workspace_id.to_string(), preview: item.preview.clone(), - }); + content: item.content.clone()} + ); } else { warn!("No view found for search result: {:?}", item); } } - let search_result = RepeatedSearchResponseItemPB { - items, - }; + // Yield primary search result. + let search_result = RepeatedSearchResponseItemPB { items }; yield Ok( CreateSearchResultPBArgs::default() .search_result(Some(search_result)) @@ -129,7 +122,7 @@ impl SearchHandler for DocumentSearchHandler { .unwrap(), ); - // Search summary generation. + // Generate and yield search summary. match cloud_service.generate_search_summary(&workspace_id, query.clone(), summary_input).await { Ok(summary_result) => { trace!("[Search] search summary: {:?}", summary_result); @@ -137,13 +130,26 @@ impl SearchHandler for DocumentSearchHandler { .summaries .into_iter() .map(|v| { - SearchSummaryPB { content: v.content, source_ids: v.sources.iter().map(|id| id.to_string()).collect() } + let sources: Vec = v.sources + .iter() + .flat_map(|id| { + if let Some(view) = views.iter().find(|v| v.id == id.to_string()) { + Some(SearchSourcePB { + id: id.to_string(), + display_name: view.name.clone(), + icon: extract_icon(view), + }) + } else { + None + } + }) + .collect(); + + SearchSummaryPB { content: v.content, sources } }) .collect(); - let summary_result = RepeatedSearchSummaryPB { - items: summaries, - }; + let summary_result = RepeatedSearchSummaryPB { items: summaries }; yield Ok( CreateSearchResultPBArgs::default() .search_summary(Some(summary_result)) @@ -158,3 +164,16 @@ impl SearchHandler for DocumentSearchHandler { }) } } + +fn extract_icon(view: &ViewPB) -> Option { + match view.icon.clone() { + Some(view_icon) => Some(ResultIconPB::from(view_icon)), + None => { + let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); + Some(ResultIconPB { + ty: ResultIconTypePB::Icon, + value: view_layout_ty.to_string(), + }) + }, + } +} diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 48de24728d..28f4c0111f 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -29,7 +29,7 @@ pub struct SearchSummaryPB { pub content: String, #[pb(index = 2)] - pub source_ids: Vec, + pub sources: Vec, } #[derive(ProtoBuf, Default, Debug, Clone)] @@ -38,7 +38,10 @@ pub struct SearchSourcePB { pub id: String, #[pb(index = 2)] - pub source: String, + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, } #[derive(ProtoBuf, Default, Debug, Clone)] @@ -53,38 +56,38 @@ pub struct SearchResponseItemPB { pub index_type: IndexTypePB, #[pb(index = 2)] - pub view_id: String, - - #[pb(index = 3)] pub id: String, - #[pb(index = 4)] - pub data: String, + #[pb(index = 3)] + pub display_name: String, - #[pb(index = 5, one_of)] + #[pb(index = 4, one_of)] pub icon: Option, - #[pb(index = 6)] + #[pb(index = 5)] pub score: f64, - #[pb(index = 7)] + #[pb(index = 6)] pub workspace_id: String, - #[pb(index = 8, one_of)] + #[pb(index = 7, one_of)] pub preview: Option, + + #[pb(index = 8)] + pub content: String, } impl SearchResponseItemPB { pub fn with_score(&self, score: f64) -> Self { SearchResponseItemPB { index_type: self.index_type.clone(), - view_id: self.view_id.clone(), id: self.id.clone(), - data: self.data.clone(), + display_name: self.display_name.clone(), icon: self.icon.clone(), score, workspace_id: self.workspace_id.clone(), preview: self.preview.clone(), + content: self.content.clone(), } } } diff --git a/frontend/rust-lib/flowy-search/src/folder/entities.rs b/frontend/rust-lib/flowy-search/src/folder/entities.rs index 98f6c469ca..1e9fb1f0d9 100644 --- a/frontend/rust-lib/flowy-search/src/folder/entities.rs +++ b/frontend/rust-lib/flowy-search/src/folder/entities.rs @@ -24,13 +24,13 @@ impl From for SearchResponseItemPB { Self { index_type: IndexTypePB::View, - view_id: data.id.clone(), id: data.id, - data: data.title, + display_name: data.title, score: 0.0, icon, workspace_id: data.workspace_id, preview: None, + content: "".to_string(), } } } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 5de540b0f2..9875c02465 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -158,7 +158,7 @@ impl FolderIndexManagerImpl { if !content.is_empty() { let s = serde_json::to_string(&content)?; let result: SearchResponseItemPB = serde_json::from_str::(&s)?.into(); - results.push(result.with_score(self.score_result(&query, &result.data) as f64)); + results.push(result.with_score(self.score_result(&query, &result.display_name))); } } From 35bc0957604c670738b22ef884aae9dd3769fdbe Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Apr 2025 22:05:21 +0800 Subject: [PATCH 133/207] chore: local and server result --- .../command_palette/recent_history_test.dart | 7 +- .../command_palette/command_palette_bloc.dart | 268 ++++++++++-------- .../search_result_list_bloc.dart | 20 +- .../command_palette/search_service.dart | 42 ++- .../command_palette/command_palette.dart | 4 +- .../widgets/recent_views_list.dart | 4 +- ...tile.dart => search_recent_view_cell.dart} | 4 +- .../widgets/search_result_cell.dart | 22 +- .../widgets/search_results_list.dart | 71 +++-- .../widgets/search_summary_cell.dart | 23 +- frontend/resources/translations/en.json | 4 +- .../flowy-ai/src/local_ai/resource.rs | 4 +- .../flowy-search/src/document/handler.rs | 17 +- .../flowy-search/src/entities/index_type.rs | 31 -- .../rust-lib/flowy-search/src/entities/mod.rs | 2 - .../flowy-search/src/entities/notification.rs | 6 +- .../flowy-search/src/entities/result.rs | 56 ++-- .../flowy-search/src/folder/entities.rs | 8 +- .../flowy-search/src/folder/handler.rs | 8 +- .../flowy-search/src/folder/indexer.rs | 27 +- .../flowy-search/src/services/manager.rs | 32 +-- 21 files changed, 346 insertions(+), 314 deletions(-) rename frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/{recent_view_tile.dart => search_recent_view_cell.dart} (94%) delete mode 100644 frontend/rust-lib/flowy-search/src/entities/index_type.rs diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart index 277ae8f21e..b9495ae0e7 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -27,11 +27,12 @@ void main() { expect(find.byType(RecentViewsList), findsOneWidget); // Expect three recent history items - expect(find.byType(RecentViewTile), findsNWidgets(3)); + expect(find.byType(SearchRecentViewCell), findsNWidgets(3)); // Expect the first item to be the last viewed document final firstDocumentWidget = - tester.widget(find.byType(RecentViewTile).first) as RecentViewTile; + tester.widget(find.byType(SearchRecentViewCell).first) + as SearchRecentViewCell; expect(firstDocumentWidget.view.name, secondDocument); }); }); diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart index b01402c868..b53f3f8c23 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -11,10 +11,25 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'command_palette_bloc.freezed.dart'; +class Debouncer { + Debouncer({required this.delay}); + + final Duration delay; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + void dispose() { + _timer?.cancel(); + } +} + class CommandPaletteBloc extends Bloc { CommandPaletteBloc() : super(CommandPaletteState.initial()) { - // Register event handlers on<_SearchChanged>(_onSearchChanged); on<_PerformSearch>(_onPerformSearch); on<_NewSearchStream>(_onNewSearchStream); @@ -26,38 +41,35 @@ class CommandPaletteBloc _initTrash(); } - Timer? _debounceOnChanged; + final Debouncer _searchDebouncer = Debouncer( + delay: const Duration(milliseconds: 300), + ); final TrashService _trashService = TrashService(); final TrashListener _trashListener = TrashListener(); - String? _oldQuery; + String? _activeQuery; String? _workspaceId; @override Future close() { _trashListener.close(); - _debounceOnChanged?.cancel(); + _searchDebouncer.dispose(); state.searchResponseStream?.dispose(); return super.close(); } Future _initTrash() async { - // Start listening for trash updates _trashListener.start( - trashUpdated: (trashOrFailed) { - add( - CommandPaletteEvent.trashChanged( - trash: trashOrFailed.toNullable(), - ), - ); - }, + trashUpdated: (trashOrFailed) => add( + CommandPaletteEvent.trashChanged( + trash: trashOrFailed.toNullable(), + ), + ), ); - // Read initial trash state and forward results final trashOrFailure = await _trashService.readTrash(); - add( - CommandPaletteEvent.trashChanged( - trash: trashOrFailure.toNullable()?.items, - ), + trashOrFailure.fold( + (trash) => add(CommandPaletteEvent.trashChanged(trash: trash.items)), + (error) => debugPrint('Failed to load trash: $error'), ); } @@ -65,9 +77,7 @@ class CommandPaletteBloc _SearchChanged event, Emitter emit, ) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer( - const Duration(milliseconds: 300), + _searchDebouncer.run( () { if (!isClosed) { add(CommandPaletteEvent.performSearch(search: event.search)); @@ -80,31 +90,44 @@ class CommandPaletteBloc _PerformSearch event, Emitter emit, ) async { - if (event.search.isNotEmpty && event.search != state.query) { - _oldQuery = state.query; + if (event.search.isEmpty && event.search != state.query) { + emit( + state.copyWith( + query: null, + isLoading: false, + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, + resultSummaries: [], + ), + ); + } else { emit(state.copyWith(query: event.search, isLoading: true)); + _activeQuery = event.search; - // Fire off search asynchronously (fire and forget) unawaited( SearchBackendService.performSearch( event.search, workspaceId: _workspaceId, ).then( - (result) => result.onSuccess((stream) { - if (!isClosed) { - add(CommandPaletteEvent.newSearchStream(stream: stream)); - } - }), - ), - ); - } else { - // Clear state if search is empty or unchanged - emit( - state.copyWith( - query: null, - isLoading: false, - resultItems: [], - resultSummaries: [], + (result) => result.fold( + (stream) { + if (!isClosed && _activeQuery == event.search) { + add(CommandPaletteEvent.newSearchStream(stream: stream)); + } + }, + (error) { + debugPrint('Search error: $error'); + if (!isClosed) { + add( + CommandPaletteEvent.resultsChanged( + searchId: '', + isLoading: false, + ), + ); + } + }, + ), ), ); } @@ -123,83 +146,88 @@ class CommandPaletteBloc ); event.stream.listen( - onItems: ( - List items, - String searchId, - bool isLoading, - ) { - if (_isActiveSearch(searchId)) { - add( - CommandPaletteEvent.resultsChanged( - items: items, - searchId: searchId, - isLoading: isLoading, - ), - ); - } - }, - onSummaries: ( - List summaries, - String searchId, - bool isLoading, - ) { - if (_isActiveSearch(searchId)) { - add( - CommandPaletteEvent.resultsChanged( - summaries: summaries, - searchId: searchId, - isLoading: isLoading, - ), - ); - } - }, - onFinished: (String searchId) { - if (_isActiveSearch(searchId)) { - add( - CommandPaletteEvent.resultsChanged( - searchId: searchId, - isLoading: false, - ), - ); - } - }, + onLocalItems: (items, searchId) => _handleResultsUpdate( + searchId: searchId, + localItems: items, + ), + onServerItems: (items, searchId, isLoading) => _handleResultsUpdate( + searchId: searchId, + serverItems: items, + isLoading: isLoading, + ), + onSummaries: (summaries, searchId, isLoading) => _handleResultsUpdate( + searchId: searchId, + summaries: summaries, + isLoading: isLoading, + ), + onFinished: (searchId) => _handleResultsUpdate( + searchId: searchId, + isLoading: false, + ), ); } + void _handleResultsUpdate({ + required String searchId, + List? serverItems, + List? localItems, + List? summaries, + bool isLoading = true, + }) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + searchId: searchId, + serverItems: serverItems, + localItems: localItems, + summaries: summaries, + isLoading: isLoading, + ), + ); + } + } + FutureOr _onResultsChanged( _ResultsChanged event, Emitter emit, ) async { - // If query was updated since last emission, clear previous results. - if (state.query != _oldQuery) { - emit( - state.copyWith( - resultItems: [], - resultSummaries: [], - isLoading: event.isLoading, - ), - ); - _oldQuery = state.query; - } - - // Check for outdated search streams if (state.searchId != event.searchId) return; - final updatedItems = - event.items ?? List.from(state.resultItems); - final updatedSummaries = - event.summaries ?? List.from(state.resultSummaries); + final combinedItems = {}; + for (final item in event.serverItems ?? state.serverResponseItems) { + combinedItems[item.id] = SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: item.content, + workspaceId: item.workspaceId, + ); + } + + for (final item in event.localItems ?? state.localResponseItems) { + combinedItems.putIfAbsent( + item.id, + () => SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: '', + workspaceId: item.workspaceId, + ), + ); + } emit( state.copyWith( - resultItems: updatedItems, - resultSummaries: updatedSummaries, + serverResponseItems: event.serverItems ?? state.serverResponseItems, + localResponseItems: event.localItems ?? state.localResponseItems, + resultSummaries: event.summaries ?? state.resultSummaries, + combinedResponseItems: combinedItems, isLoading: event.isLoading, ), ); } - // Update trash state and, in case of null, retry reading trash from the service FutureOr _onTrashChanged( _TrashChanged event, Emitter emit, @@ -216,7 +244,6 @@ class CommandPaletteBloc } } - // Update the workspace and clear current search results and query FutureOr _onWorkspaceChanged( _WorkspaceChanged event, Emitter emit, @@ -225,27 +252,20 @@ class CommandPaletteBloc emit( state.copyWith( query: '', - resultItems: [], + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, resultSummaries: [], isLoading: false, ), ); } - // Clear search state FutureOr _onClearSearch( _ClearSearch event, Emitter emit, ) { - emit( - state.copyWith( - query: '', - resultItems: [], - resultSummaries: [], - isLoading: false, - searchId: null, - ), - ); + emit(CommandPaletteState.initial().copyWith(trash: state.trash)); } bool _isActiveSearch(String searchId) => @@ -264,7 +284,8 @@ class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.resultsChanged({ required String searchId, required bool isLoading, - List? items, + List? serverItems, + List? localItems, List? summaries, }) = _ResultsChanged; @@ -277,12 +298,30 @@ class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.clearSearch() = _ClearSearch; } +class SearchResultItem { + const SearchResultItem({ + required this.id, + required this.icon, + required this.content, + required this.displayName, + this.workspaceId, + }); + + final String id; + final String content; + final ResultIconPB icon; + final String displayName; + final String? workspaceId; +} + @freezed class CommandPaletteState with _$CommandPaletteState { const CommandPaletteState._(); const factory CommandPaletteState({ @Default(null) String? query, - @Default([]) List resultItems, + @Default([]) List serverResponseItems, + @Default([]) List localResponseItems, + @Default({}) Map combinedResponseItems, @Default([]) List resultSummaries, @Default(null) SearchResponseStream? searchResponseStream, required bool isLoading, @@ -290,6 +329,7 @@ class CommandPaletteState with _$CommandPaletteState { @Default(null) String? searchId, }) = _CommandPaletteState; - factory CommandPaletteState.initial() => - const CommandPaletteState(isLoading: false); + factory CommandPaletteState.initial() => const CommandPaletteState( + isLoading: false, + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart index c77530ba77..58e5a951da 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; @@ -13,6 +14,7 @@ class SearchResultListBloc // Register event handlers on<_OnHoverSummary>(_onHoverSummary); on<_OnHoverResult>(_onHoverResult); + on<_OpenPage>(_onOpenPage); } FutureOr _onHoverSummary( @@ -23,6 +25,7 @@ class SearchResultListBloc state.copyWith( hoveredSummary: event.summary, hoveredResult: null, + openPageId: null, ), ); } @@ -35,9 +38,17 @@ class SearchResultListBloc state.copyWith( hoveredSummary: null, hoveredResult: event.item, + openPageId: null, ), ); } + + FutureOr _onOpenPage( + _OpenPage event, + Emitter emit, + ) { + emit(state.copyWith(openPageId: event.pageId)); + } } @freezed @@ -46,8 +57,12 @@ class SearchResultListEvent with _$SearchResultListEvent { required SearchSummaryPB summary, }) = _OnHoverSummary; const factory SearchResultListEvent.onHoverResult({ - required SearchResponseItemPB item, + required SearchResultItem item, }) = _OnHoverResult; + + const factory SearchResultListEvent.openPage({ + required String pageId, + }) = _OpenPage; } @freezed @@ -55,7 +70,8 @@ class SearchResultListState with _$SearchResultListState { const SearchResultListState._(); const factory SearchResultListState({ @Default(null) SearchSummaryPB? hoveredSummary, - @Default(null) SearchResponseItemPB? hoveredResult, + @Default(null) SearchResultItem? hoveredResult, + @Default(null) String? openPageId, }) = _SearchResultListState; factory SearchResultListState.initial() => const SearchResultListState(); diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart index 6b862bc9ab..6f05b88081 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -50,12 +50,18 @@ class SearchResponseStream { List items, String searchId, bool isLoading, - )? _onItems; + )? _onServerItems; void Function( List summaries, String searchId, bool isLoading, )? _onSummaries; + + void Function( + List items, + String searchId, + )? _onLocalItems; + void Function(String searchId)? _onFinished; int get nativePort => _port.sendPort.nativePort; @@ -65,21 +71,28 @@ class SearchResponseStream { } void _onResultsChanged(Uint8List data) { - final response = SearchResponsePB.fromBuffer(data); + final searchState = SearchStatePB.fromBuffer(data); - if (response.hasResult()) { - if (response.result.hasSearchResult()) { - _onItems?.call( - response.result.searchResult.items, + if (searchState.hasResponse()) { + if (searchState.response.hasSearchResult()) { + _onServerItems?.call( + searchState.response.searchResult.items, searchId, - response.isLoading, + searchState.isLoading, ); } - if (response.result.hasSearchSummary()) { + if (searchState.response.hasSearchSummary()) { _onSummaries?.call( - response.result.searchSummary.items, + searchState.response.searchSummary.items, + searchId, + searchState.isLoading, + ); + } + + if (searchState.response.hasLocalSearchResult()) { + _onLocalItems?.call( + searchState.response.localSearchResult.items, searchId, - response.isLoading, ); } } else { @@ -92,16 +105,21 @@ class SearchResponseStream { List items, String searchId, bool isLoading, - )? onItems, + )? onServerItems, required void Function( List summaries, String searchId, bool isLoading, )? onSummaries, + required void Function( + List items, + String searchId, + )? onLocalItems, required void Function(String searchId)? onFinished, }) { - _onItems = onItems; + _onServerItems = onServerItems; _onSummaries = onSummaries; + _onLocalItems = onLocalItems; _onFinished = onFinished; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart index f54fbebe14..a08c46679c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -153,13 +153,13 @@ class CommandPaletteModal extends StatelessWidget { ), ), ], - if (state.resultItems.isNotEmpty && + if (state.combinedResponseItems.isNotEmpty && (state.query?.isNotEmpty ?? false)) ...[ const Divider(height: 0), Flexible( child: SearchResultList( trash: state.trash, - resultItems: state.resultItems, + resultItems: state.combinedResponseItems.values.toList(), resultSummaries: state.resultSummaries, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart index b0f87005d2..3bc160ee81 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart @@ -4,7 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -52,7 +52,7 @@ class RecentViewsList extends StatelessWidget { ) : FlowySvg(view.iconData, size: const Size.square(20)); - return RecentViewTile( + return SearchRecentViewCell( icon: SizedBox(width: 24, child: icon), view: view, onSelected: onSelected, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart similarity index 94% rename from frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart index 645b9696c8..a803f9b44c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart @@ -7,8 +7,8 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; -class RecentViewTile extends StatelessWidget { - const RecentViewTile({ +class SearchRecentViewCell extends StatelessWidget { + const SearchRecentViewCell({ super.key, required this.icon, required this.view, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart index 1bd3d0b03a..7034b79821 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -1,11 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; -import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -19,12 +16,10 @@ class SearchResultCell extends StatefulWidget { const SearchResultCell({ super.key, required this.item, - required this.onSelected, this.isTrashed = false, }); - final SearchResponseItemPB item; - final VoidCallback onSelected; + final SearchResultItem item; final bool isTrashed; @override @@ -43,12 +38,9 @@ class _SearchResultCellState extends State { /// Helper to handle the selection action. void _handleSelection() { - widget.onSelected(); - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.item.id), - ), - ); + context.read().add( + SearchResultListEvent.openPage(pageId: widget.item.id), + ); } /// Helper to clean up preview text. @@ -62,7 +54,7 @@ class _SearchResultCellState extends State { LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); final icon = widget.item.icon.getIcon(); - final cleanedPreview = _cleanPreview(widget.item.preview); + final cleanedPreview = _cleanPreview(widget.item.content); final hasPreview = cleanedPreview.isNotEmpty; final trashHintText = widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null; @@ -208,7 +200,7 @@ class SearchResultPreview extends StatelessWidget { required this.data, }); - final SearchResponseItemPB data; + final SearchResultItem data; @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index 5d2e305c15..af2944d09c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -1,3 +1,7 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:flutter/material.dart'; @@ -20,9 +24,8 @@ class SearchResultList extends StatelessWidget { }); final List trash; - final List resultItems; + final List resultItems; final List resultSummaries; - Widget _buildSectionHeader(String title) => Padding( padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8), @@ -65,7 +68,6 @@ class SearchResultList extends StatelessWidget { final item = resultItems[index]; return SearchResultCell( item: item, - onSelected: () => FlowyOverlay.pop(context), isTrashed: trash.any((t) => t.id == item.id), ); }, @@ -80,33 +82,46 @@ class SearchResultList extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6), child: BlocProvider( create: (context) => SearchResultListBloc(), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - flex: 7, - child: ListView( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - children: [ - if (resultSummaries.isNotEmpty) _buildSummariesSection(), - const VSpace(10), - if (resultItems.isNotEmpty) _buildResultsSection(context), - ], - ), - ), - const HSpace(10), - Flexible( - flex: 3, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 16, + child: BlocListener( + listener: (context, state) { + if (state.openPageId != null) { + FlowyOverlay.pop(context); + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: state.openPageId!), + ), + ); + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 7, + child: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + if (resultSummaries.isNotEmpty) _buildSummariesSection(), + const VSpace(10), + if (resultItems.isNotEmpty) _buildResultsSection(context), + ], ), - child: const SearchCellPreview(), ), - ), - ], + const HSpace(10), + if (resultItems.any((item) => item.content.isNotEmpty)) + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), + child: const SearchCellPreview(), + ), + ), + ], + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart index c911c46735..15e8bb18f7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -36,7 +36,7 @@ class SearchSummaryCell extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: FlowyText( summary.content, - maxLines: 3, + maxLines: 20, ), ), ); @@ -78,14 +78,19 @@ class SearchSummarySource extends StatelessWidget { @override Widget build(BuildContext context) { final icon = source.icon.getIcon(); - return Row( - children: [ - if (icon != null) ...[ - SizedBox(width: 24, child: icon), - const HSpace(6), - ], - FlowyText(source.displayName), - ], + return SizedBox( + height: 30, + child: FlowyButton( + leftIcon: icon, + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + text: FlowyText(source.displayName), + onTap: () { + context.read().add( + SearchResultListEvent.openPage(pageId: source.id), + ); + }, + ), ); } } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 4279f95425..21c39c63f5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2694,8 +2694,8 @@ "placeholder": "Search or ask a question...", "bestMatches": "Best matches", "aiOverview": "AI overview", - "aiOverviewSource": "Sources", - "pagePreview": "Preview", + "aiOverviewSource": "Reference sources", + "pagePreview": "Content preview", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index 2fc6fad2cc..172204746a 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -71,10 +71,10 @@ impl LocalAIResourceController { ) -> Self { let (resource_notify, _) = tokio::sync::broadcast::channel(1); let (app_state_sender, _) = tokio::sync::broadcast::channel(1); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] let mut offline_app_disk_watch: Option = None; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] { match watch_offline_app() { Ok((new_watcher, mut rx)) => { diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index 9077829c07..fc68f850e5 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,9 +1,9 @@ use crate::entities::{ - CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, SearchResultPB, - SearchSourcePB, SearchSummaryPB, + CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, + SearchResponsePB, SearchSourcePB, SearchSummaryPB, }; use crate::{ - entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, + entities::{ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, services::manager::{SearchHandler, SearchType}, }; use async_stream::stream; @@ -45,7 +45,7 @@ impl SearchHandler for DocumentSearchHandler { &self, query: String, filter: Option, - ) -> Pin> + Send + 'static>> { + ) -> Pin> + Send + 'static>> { let cloud_service = self.cloud_service.clone(); let folder_manager = self.folder_manager.clone(); @@ -99,13 +99,10 @@ impl SearchHandler for DocumentSearchHandler { for item in &result_items { if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { items.push(SearchResponseItemPB { - index_type: IndexTypePB::Document, id: item.object_id.to_string(), display_name: view.name.clone(), icon: extract_icon(view), - score: item.score, workspace_id: item.workspace_id.to_string(), - preview: item.preview.clone(), content: item.content.clone()} ); } else { @@ -133,15 +130,11 @@ impl SearchHandler for DocumentSearchHandler { let sources: Vec = v.sources .iter() .flat_map(|id| { - if let Some(view) = views.iter().find(|v| v.id == id.to_string()) { - Some(SearchSourcePB { + views.iter().find(|v| v.id == id.to_string()).map(|view| SearchSourcePB { id: id.to_string(), display_name: view.name.clone(), icon: extract_icon(view), }) - } else { - None - } }) .collect(); diff --git a/frontend/rust-lib/flowy-search/src/entities/index_type.rs b/frontend/rust-lib/flowy-search/src/entities/index_type.rs deleted file mode 100644 index 77adc76a97..0000000000 --- a/frontend/rust-lib/flowy-search/src/entities/index_type.rs +++ /dev/null @@ -1,31 +0,0 @@ -use flowy_derive::ProtoBuf_Enum; - -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum IndexTypePB { - View = 0, - Document = 1, - DocumentBlock = 2, - DatabaseRow = 3, -} - -impl Default for IndexTypePB { - fn default() -> Self { - Self::View - } -} - -impl std::convert::From for i32 { - fn from(notification: IndexTypePB) -> Self { - notification as i32 - } -} - -impl std::convert::From for IndexTypePB { - fn from(notification: i32) -> Self { - match notification { - 1 => IndexTypePB::View, - 2 => IndexTypePB::DocumentBlock, - _ => IndexTypePB::DatabaseRow, - } - } -} diff --git a/frontend/rust-lib/flowy-search/src/entities/mod.rs b/frontend/rust-lib/flowy-search/src/entities/mod.rs index b4d7c682b9..dc6aaace08 100644 --- a/frontend/rust-lib/flowy-search/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-search/src/entities/mod.rs @@ -1,10 +1,8 @@ -mod index_type; mod notification; mod query; mod result; mod search_filter; -pub use index_type::*; pub use notification::*; pub use query::*; pub use result::*; diff --git a/frontend/rust-lib/flowy-search/src/entities/notification.rs b/frontend/rust-lib/flowy-search/src/entities/notification.rs index 0a51eb85d3..3f1cdab67a 100644 --- a/frontend/rust-lib/flowy-search/src/entities/notification.rs +++ b/frontend/rust-lib/flowy-search/src/entities/notification.rs @@ -1,10 +1,10 @@ -use super::SearchResultPB; +use super::SearchResponsePB; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResponsePB { +pub struct SearchStatePB { #[pb(index = 1, one_of)] - pub result: Option, + pub response: Option, #[pb(index = 2)] pub search_id: String, diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 28f4c0111f..8f5ba11ded 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -1,4 +1,3 @@ -use super::IndexTypePB; use collab_folder::{IconType, ViewIcon}; use derive_builder::Builder; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; @@ -7,7 +6,7 @@ use flowy_folder::entities::ViewIconPB; #[derive(Debug, Default, ProtoBuf, Builder, Clone)] #[builder(name = "CreateSearchResultPBArgs")] #[builder(pattern = "mutable")] -pub struct SearchResultPB { +pub struct SearchResponsePB { #[pb(index = 1, one_of)] #[builder(default)] pub search_result: Option, @@ -15,6 +14,10 @@ pub struct SearchResultPB { #[pb(index = 2, one_of)] #[builder(default)] pub search_summary: Option, + + #[pb(index = 3, one_of)] + #[builder(default)] + pub local_search_result: Option, } #[derive(ProtoBuf, Default, Debug, Clone)] @@ -53,43 +56,40 @@ pub struct RepeatedSearchResponseItemPB { #[derive(ProtoBuf, Default, Debug, Clone)] pub struct SearchResponseItemPB { #[pb(index = 1)] - pub index_type: IndexTypePB, - - #[pb(index = 2)] pub id: String, - #[pb(index = 3)] + #[pb(index = 2)] pub display_name: String, - #[pb(index = 4, one_of)] + #[pb(index = 3, one_of)] pub icon: Option, - #[pb(index = 5)] - pub score: f64, - - #[pb(index = 6)] + #[pb(index = 4)] pub workspace_id: String, - #[pb(index = 7, one_of)] - pub preview: Option, - - #[pb(index = 8)] + #[pb(index = 5)] pub content: String, } -impl SearchResponseItemPB { - pub fn with_score(&self, score: f64) -> Self { - SearchResponseItemPB { - index_type: self.index_type.clone(), - id: self.id.clone(), - display_name: self.display_name.clone(), - icon: self.icon.clone(), - score, - workspace_id: self.workspace_id.clone(), - preview: self.preview.clone(), - content: self.content.clone(), - } - } +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedLocalSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct LocalSearchResponseItemPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, + + #[pb(index = 4)] + pub workspace_id: String, } #[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)] diff --git a/frontend/rust-lib/flowy-search/src/folder/entities.rs b/frontend/rust-lib/flowy-search/src/folder/entities.rs index 1e9fb1f0d9..1bb763b4a6 100644 --- a/frontend/rust-lib/flowy-search/src/folder/entities.rs +++ b/frontend/rust-lib/flowy-search/src/folder/entities.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::entities::{IndexTypePB, ResultIconPB, SearchResponseItemPB}; +use crate::entities::{LocalSearchResponseItemPB, ResultIconPB}; #[derive(Debug, Serialize, Deserialize)] pub struct FolderIndexData { @@ -11,7 +11,7 @@ pub struct FolderIndexData { pub workspace_id: String, } -impl From for SearchResponseItemPB { +impl From for LocalSearchResponseItemPB { fn from(data: FolderIndexData) -> Self { let icon = if data.icon.is_empty() { None @@ -23,14 +23,10 @@ impl From for SearchResponseItemPB { }; Self { - index_type: IndexTypePB::View, id: data.id, display_name: data.title, - score: 0.0, icon, workspace_id: data.workspace_id, - preview: None, - content: "".to_string(), } } } diff --git a/frontend/rust-lib/flowy-search/src/folder/handler.rs b/frontend/rust-lib/flowy-search/src/folder/handler.rs index 975609a227..e21ce1c98c 100644 --- a/frontend/rust-lib/flowy-search/src/folder/handler.rs +++ b/frontend/rust-lib/flowy-search/src/folder/handler.rs @@ -1,6 +1,6 @@ use super::indexer::FolderIndexManagerImpl; use crate::entities::{ - CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, SearchFilterPB, SearchResultPB, + CreateSearchResultPBArgs, RepeatedLocalSearchResponseItemPB, SearchFilterPB, SearchResponsePB, }; use crate::services::manager::{SearchHandler, SearchType}; use async_stream::stream; @@ -30,7 +30,7 @@ impl SearchHandler for FolderSearchHandler { &self, query: String, filter: Option, - ) -> Pin> + Send + 'static>> { + ) -> Pin> + Send + 'static>> { let index_manager = self.index_manager.clone(); Box::pin(stream! { @@ -48,8 +48,8 @@ impl SearchHandler for FolderSearchHandler { } // Build the search result. - let search_result = RepeatedSearchResponseItemPB {items}; - yield Ok(CreateSearchResultPBArgs::default().search_result(Some(search_result)).build().unwrap()) + let search_result = RepeatedLocalSearchResponseItemPB {items}; + yield Ok(CreateSearchResultPBArgs::default().local_search_result(Some(search_result)).build().unwrap()) }) } } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 9875c02465..641c1f5f96 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -1,9 +1,6 @@ -use crate::{ - entities::SearchResponseItemPB, - folder::schema::{ - FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, - FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, - }, +use crate::folder::schema::{ + FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, + FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, }; use collab::core::collab::{IndexContent, IndexContentReceiver}; use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexContent, ViewLayout}; @@ -14,9 +11,8 @@ use std::sync::{Arc, Weak}; use std::{collections::HashMap, fs}; use super::entities::FolderIndexData; -use crate::entities::ResultIconTypePB; +use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB}; use lib_infra::async_trait::async_trait; -use strsim::levenshtein; use tantivy::{ collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, Index, IndexReader, IndexWriter, TantivyDocument, Term, @@ -113,11 +109,6 @@ impl FolderIndexManagerImpl { (icon, icon_ty) } - fn score_result(&self, query: &str, term: &str) -> f64 { - let distance = levenshtein(query, term) as f64; - 1.0 / (distance + 1.0) - } - /// Simple implementation to index all given data by spawning async tasks. fn index_all(&self, data_vec: Vec) -> Result<(), FlowyError> { for data in data_vec { @@ -130,7 +121,7 @@ impl FolderIndexManagerImpl { } /// Searches the index using the given query string. - pub async fn search(&self, query: String) -> Result, FlowyError> { + pub async fn search(&self, query: String) -> Result, FlowyError> { let lock = self.state.read().await; let state = lock .as_ref() @@ -157,8 +148,8 @@ impl FolderIndexManagerImpl { } if !content.is_empty() { let s = serde_json::to_string(&content)?; - let result: SearchResponseItemPB = serde_json::from_str::(&s)?.into(); - results.push(result.with_score(self.score_result(&query, &result.display_name))); + let result: LocalSearchResponseItemPB = serde_json::from_str::(&s)?.into(); + results.push(result); } } @@ -200,11 +191,11 @@ impl IndexManager for FolderIndexManagerImpl { }) .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize (update): {:?}", err), + Err(err) => error!("FolderIndexManager error deserialize (update): {:?}", err), }, IndexContent::Delete(ids) => { if let Err(e) = indexer.remove_indices(ids).await { - tracing::error!("FolderIndexManager error (delete): {:?}", e); + error!("FolderIndexManager error (delete): {:?}", e); } }, } diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 157781eceb..72a3c12793 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -1,4 +1,4 @@ -use crate::entities::{SearchFilterPB, SearchResponsePB, SearchResultPB}; +use crate::entities::{SearchFilterPB, SearchResponsePB, SearchStatePB}; use allo_isolate::Isolate; use flowy_error::FlowyResult; use lib_infra::async_trait::async_trait; @@ -25,7 +25,7 @@ pub trait SearchHandler: Send + Sync + 'static { &self, query: String, filter: Option, - ) -> Pin> + Send + 'static>>; + ) -> Pin> + Send + 'static>>; } /// The [SearchManager] is used to inject multiple [SearchHandler]'s @@ -34,7 +34,7 @@ pub trait SearchHandler: Send + Sync + 'static { /// pub struct SearchManager { pub handlers: HashMap>, - current_search: Arc>>, // Track current search + current_search: Arc>>, } impl SearchManager { @@ -84,23 +84,21 @@ impl SearchManager { } let mut stream = handler.perform_search(query.clone(), filter).await; - while let Some(result) = stream.next().await { + while let Some(Ok(search_result)) = stream.next().await { if !is_current_search(¤t_search, &search_id).await { trace!("[Search] discard search stream: {}", query); return; } - if let Ok(result) = result { - let resp = SearchResponsePB { - result: Some(result), - search_id: search_id.clone(), - is_loading: true, - }; - if let Ok::, _>(data) = resp.try_into() { - if let Err(err) = clone_sink.send(data).await { - error!("Failed to send search result: {}", err); - break; - } + let resp = SearchStatePB { + response: Some(search_result), + search_id: search_id.clone(), + is_loading: true, + }; + if let Ok::, _>(data) = resp.try_into() { + if let Err(err) = clone_sink.send(data).await { + error!("Failed to send search result: {}", err); + break; } } } @@ -110,8 +108,8 @@ impl SearchManager { return; } - let resp = SearchResponsePB { - result: None, + let resp = SearchStatePB { + response: None, search_id: search_id.clone(), is_loading: true, }; From 99fb6ab743c3493a2621cf7605df237e86fbd75b Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Apr 2025 23:07:42 +0800 Subject: [PATCH 134/207] chore: fix linux watch --- frontend/rust-lib/flowy-ai/src/local_ai/resource.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index 172204746a..f2c1d1d041 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -5,7 +5,7 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_infra::async_trait::async_trait; use crate::entities::LackOfAIResourcePB; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] use crate::local_ai::watch::{watch_offline_app, WatchContext}; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, @@ -58,7 +58,7 @@ pub struct LocalAIResourceController { user_service: Arc, resource_service: Arc, resource_notify: tokio::sync::broadcast::Sender<()>, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[allow(dead_code)] app_disk_watch: Option, app_state_sender: tokio::sync::broadcast::Sender, @@ -97,7 +97,7 @@ impl LocalAIResourceController { Self { user_service, resource_service: Arc::new(resource_service), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] app_disk_watch: offline_app_disk_watch, app_state_sender, resource_notify, From 1630e11c4d2a6260ce95758a1cce303ca878f863 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 15 Apr 2025 09:39:33 +0800 Subject: [PATCH 135/207] chore: update login page icons and i18n (#7742) * feat: update i18n and icons * chore: replace appflowy with welcome to appflowy * fix: protenial delete page error * fix: flutter analyze --- .../document_create_and_delete_test.dart | 2 + .../base/view_page/more_bottom_sheet.dart | 2 +- .../bottom_sheet/bottom_sheet_view_page.dart | 4 +- .../home/mobile_home_setting_page.dart | 8 +--- .../personal_info_setting_group.dart | 6 +-- .../lib/user/application/sign_in_bloc.dart | 7 +++ .../sign_in_screen/mobile_sign_in_screen.dart | 44 +++++-------------- .../widgets/anonymous_sign_in_button.dart | 2 +- .../continue_with_email_and_password.dart | 14 +++--- .../widgets/sign_in_agreement.dart | 5 +-- .../widgets/sign_in_anonymous_button.dart | 4 +- .../resources/flowy_icons/20x/cloud_mode.svg | 3 ++ .../flowy_icons/20x/sign_in_settings.svg | 4 ++ frontend/resources/translations/en.json | 3 +- 14 files changed, 48 insertions(+), 60 deletions(-) create mode 100644 frontend/resources/flowy_icons/20x/cloud_mode.svg create mode 100644 frontend/resources/flowy_icons/20x/sign_in_settings.svg diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart index 43320509ce..c2e00a4b48 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart @@ -13,6 +13,8 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); + final finder = find.text(gettingStarted, findRichText: true); + await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2)); // create a new document const pageName = 'Test Document'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index 6609b10136..be134e0a92 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -66,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { break; case MobileViewBottomSheetBodyAction.delete: context.read().add(const ViewEvent.delete()); - context.pop(); + Navigator.of(context).pop(); break; case MobileViewBottomSheetBodyAction.addToFavorites: _addFavorite(context); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 991cf82b5d..9497774298 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -182,7 +182,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), _divider(), ..._buildPublishActions(context), - _divider(), + MobileQuickActionButton( text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, @@ -236,6 +236,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.unpublish, ), ), + _divider(), ]; } else { return [ @@ -246,6 +247,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.publish, ), ), + _divider(), ]; } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index 09e22fc746..a01df20549 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -71,12 +71,6 @@ class _MobileHomeSettingPageState extends State { } Widget _buildSettingsWidget(UserProfilePB userProfile) { - // show the third-party sign in buttons if user logged in with local session and auth is enabled. - - final isLocalAuthEnabled = - userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled; - ''; - return BlocProvider( create: (context) => UserWorkspaceBloc(userProfile: userProfile) ..add(const UserWorkspaceEvent.initial()), @@ -105,7 +99,7 @@ class _MobileHomeSettingPageState extends State { const AboutSettingGroup(), UserSessionSettingGroup( userProfile: userProfile, - showThirdPartyLogin: isLocalAuthEnabled, + showThirdPartyLogin: false, ), const VSpace(20), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index cfdf3defb0..37191a2ae2 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; @@ -7,10 +5,10 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../widgets/widgets.dart'; - import 'personal_info.dart'; class PersonalInfoSettingGroup extends StatelessWidget { @@ -32,7 +30,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { selector: (state) => state.userProfile.name, builder: (context, userName) { return MobileSettingGroup( - groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(), + groupTitle: LocaleKeys.settings_accountPage_title.tr(), settingItemList: [ MobileSettingItem( name: userName, diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 06a03765c9..339af51f9f 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -219,6 +219,13 @@ class SignInBloc extends Bloc { required String email, required String passcode, }) async { + if (state.isSubmitting) { + Log.error('Sign in with passcode is already in progress'); + return; + } + + Log.info('Sign in with passcode: $email, $passcode'); + emit( state.copyWith( isSubmitting: true, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index 7e2222abf2..9eb7d5a965 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -7,6 +7,7 @@ import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/user/presentation/widgets/flowy_logo_title.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -21,34 +22,29 @@ class MobileSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const double spacing = 16; - final colorScheme = Theme.of(context).colorScheme; return BlocBuilder( builder: (context, state) { + final theme = AppFlowyTheme.of(context); return Scaffold( resizeToAvoidBottomInset: false, body: Padding( padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), child: Column( children: [ - const Spacer(flex: 4), - _buildLogo(), - const VSpace(spacing), - _buildAppNameText(colorScheme), - const VSpace(spacing * 2), + const Spacer(), + FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()), + VSpace(theme.spacing.xxl), isLocalAuthEnabled ? const SignInAnonymousButtonV3() : const ContinueWithEmailAndPassword(), - const VSpace(spacing), - if (isAuthEnabled) _buildThirdPartySignInButtons(context), - const VSpace(spacing * 1.5), + VSpace(theme.spacing.xxl), + if (isAuthEnabled) ...[ + _buildThirdPartySignInButtons(context), + VSpace(theme.spacing.xxl), + ], const SignInAgreement(), - const VSpace(spacing), - if (!isAuthEnabled) const Spacer(flex: 2), - const Spacer(flex: 2), const Spacer(), - Expanded(child: _buildSettingsButton(context)), - if (Platform.isAndroid) const Spacer(), + _buildSettingsButton(context), ], ), ), @@ -57,24 +53,6 @@ class MobileSignInScreen extends StatelessWidget { ); } - Widget _buildLogo() { - return const FlowySvg( - FlowySvgs.flowy_logo_xl, - size: Size.square(56), - blendMode: null, - ); - } - - Widget _buildAppNameText(ColorScheme colorScheme) { - return FlowyText( - LocaleKeys.appName.tr(), - textAlign: TextAlign.center, - fontSize: 28, - color: const Color(0xFF00BCF0), - fontWeight: FontWeight.w700, - ); - } - Widget _buildThirdPartySignInButtons(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart index 2b11068abb..a7a1b9722d 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart @@ -29,7 +29,7 @@ class SignInAnonymousButtonV3 extends StatelessWidget { }, child: BlocBuilder( builder: (context, state) { - final text = LocaleKeys.signUp_getStartedText.tr(); + final text = LocaleKeys.signIn_continueWithLocalModel.tr(); final onTap = state.anonUsers.isEmpty ? () { context diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart index efa72e7341..8034dccd32 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -131,12 +131,14 @@ class _ContinueWithEmailAndPasswordState _hasPushedContinueWithMagicLinkOrPasscodePage = false; }, - onEnterPasscode: (passcode) => signInBloc.add( - SignInEvent.signInWithPasscode( - email: email, - passcode: passcode, - ), - ), + onEnterPasscode: (passcode) { + signInBloc.add( + SignInEvent.signInWithPasscode( + email: email, + passcode: passcode, + ), + ); + }, ), ), ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart index 19a5ab9cf6..76ce87ffc1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -1,5 +1,4 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -25,9 +24,7 @@ class SignInAgreement extends StatelessWidget { text: TextSpan( children: [ TextSpan( - text: isLocalAuthEnabled - ? LocaleKeys.web_signInLocalAgreement.tr() - : LocaleKeys.web_signInAgreement.tr(), + text: LocaleKeys.web_signInAgreement.tr(), style: textStyle, ), TextSpan( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index 2b40502b0d..33ef1d7bb0 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -79,7 +79,7 @@ class ChangeCloudModeButton extends StatelessWidget { Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFGhostIconTextButton( - text: 'Cloud', + text: LocaleKeys.signIn_switchToAppFlowyCloud.tr(), textColor: (context, isHovering, disabled) { return theme.textColorScheme.secondary; }, @@ -97,7 +97,7 @@ class ChangeCloudModeButton extends StatelessWidget { }, iconBuilder: (context, isHovering, disabled) { return FlowySvg( - FlowySvgs.settings_s, + FlowySvgs.cloud_mode_m, size: Size.square(20), color: theme.textColorScheme.secondary, ); diff --git a/frontend/resources/flowy_icons/20x/cloud_mode.svg b/frontend/resources/flowy_icons/20x/cloud_mode.svg new file mode 100644 index 0000000000..5aaf68e3db --- /dev/null +++ b/frontend/resources/flowy_icons/20x/cloud_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/sign_in_settings.svg b/frontend/resources/flowy_icons/20x/sign_in_settings.svg new file mode 100644 index 0000000000..5d88d23086 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/sign_in_settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 46cab3030a..351dc54de4 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -36,7 +36,8 @@ "loginButtonText": "Login", "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", - "anonymous": "Anonymous", + "continueWithLocalModel": "Continue with local model", + "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Anonymous mode", "buttonText": "Sign In", "signingInText": "Signing in...", From d01909830d83dcd621bf817190d00b98262472d5 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:14:20 +0800 Subject: [PATCH 136/207] fix: ai writer not working in database rows (#7746) * fix: ai writer not working in database rows * chore: dart analysis --- .../database/widgets/row/row_document.dart | 42 +++++++++++-------- .../database_document_page.dart | 7 +++- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart index 8e229f6b21..436dbd085d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; @@ -120,29 +121,34 @@ class _RowEditor extends StatelessWidget { return context; }, dispose: (_, editorContext) => editorContext.dispose(), - child: EditorDropHandler( + child: AiWriterScrollWrapper( viewId: view.id, editorState: editorState, - isLocalMode: context.read().isLocalMode, - dropManagerState: context.read(), - child: EditorTransactionService( + child: EditorDropHandler( viewId: view.id, editorState: editorState, - child: Provider( - create: (context) => - DatabasePluginWidgetBuilderSize(horizontalPadding: 0), - child: AppFlowyEditorPage( - shrinkWrap: true, - autoFocus: false, - editorState: editorState, - styleCustomizer: EditorStyleCustomizer( - context: context, - padding: const EdgeInsets.only(left: 16, right: 54), + isLocalMode: context.read().isLocalMode, + dropManagerState: context.read(), + child: EditorTransactionService( + viewId: view.id, + editorState: editorState, + child: Provider( + create: (context) => DatabasePluginWidgetBuilderSize( + horizontalPadding: 0, + ), + child: AppFlowyEditorPage( + shrinkWrap: true, + autoFocus: false, + editorState: editorState, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: const EdgeInsets.only(left: 16, right: 54), + ), + showParagraphPlaceholder: (editorState, _) => + editorState.document.isEmpty, + placeholderText: (_) => + LocaleKeys.cardDetails_notesPlaceholder.tr(), ), - showParagraphPlaceholder: (editorState, _) => - editorState.document.isEmpty, - placeholderText: (_) => - LocaleKeys.cardDetails_notesPlaceholder.tr(), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart index 186671b427..9568784464 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; @@ -104,7 +105,11 @@ class _DatabaseDocumentPageState extends State { return BlocListener( listener: _onNotificationAction, listenWhen: (_, curr) => curr.action != null, - child: _buildEditorPage(context, state), + child: AiWriterScrollWrapper( + viewId: widget.view.id, + editorState: editorState, + child: _buildEditorPage(context, state), + ), ); }, ), From d9748d5ef1d95542bc07f51d36db4d82005403d2 Mon Sep 17 00:00:00 2001 From: Morn Date: Tue, 15 Apr 2025 13:50:53 +0800 Subject: [PATCH 137/207] fix: block does not expand with grid size (#7745) * fix: block does not expand with grid size * fix: replace listenForSizeChanged with SizeChangedLayoutNotifier --- .../desktop/database/database_view_test.dart | 37 ++++++++++++++++++ .../database/tab_bar/tab_bar_view.dart | 6 ++- .../base/built_in_page_widget.dart | 7 +++- .../simple_column_block_component.dart | 7 +++- .../simple_column_block_width_resizer.dart | 9 +++-- .../simple_columns_block_component.dart | 38 ++++++++++++++----- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart index e35c9cc9d8..71656c1ea6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart @@ -1,5 +1,10 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -73,5 +78,37 @@ void main() { await tester.pumpAndSettle(); }); + + testWidgets('insert grid in column', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create page and show slash menu + await tester.createNewPageWithNameUnderParent(name: 'test page'); + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + /// create a column + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_twoColumns.tr(), + ); + final actionList = find.byType(BlockActionList); + expect(actionList, findsNWidgets(2)); + final position = tester.getCenter(actionList.last); + + /// tap the second child of column + await tester.tapAt(position.copyWith(dx: position.dx + 50)); + + /// create a grid + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_grid.tr(), + ); + + final grid = find.byType(GridPageContent); + expect(grid, findsOneWidget); + }); }); } 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 3554f9112e..7c2dc40869 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 @@ -362,9 +362,13 @@ const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder'; const kDatabasePluginWidgetBuilderNode = 'node'; class DatabasePluginWidgetBuilderSize { - const DatabasePluginWidgetBuilderSize({required this.horizontalPadding}); + const DatabasePluginWidgetBuilderSize({ + required this.horizontalPadding, + this.verticalPadding = 16.0, + }); final double horizontalPadding; + final double verticalPadding; } class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart index f96a06b21d..090ecdce78 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; @@ -6,6 +7,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class BuiltInPageWidget extends StatefulWidget { const BuiltInPageWidget({ @@ -77,6 +79,9 @@ class _BuiltInPageWidgetState extends State { } Widget _buildPage(BuildContext context, ViewPB view) { + final verticalPadding = + context.read()?.verticalPadding ?? + 0.0; return Focus( focusNode: focusNode, onFocusChange: (value) { @@ -85,7 +90,7 @@ class _BuiltInPageWidgetState extends State { } }, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: EdgeInsets.symmetric(vertical: verticalPadding), child: widget.builder(view), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart index 979dd92ce0..c426ad640f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; @@ -115,7 +116,11 @@ class SimpleColumnBlockComponentState extends State crossAxisAlignment: CrossAxisAlignment.start, children: node.children.map( (e) { - Widget child = IntrinsicHeight( + Widget child = Provider( + create: (_) => DatabasePluginWidgetBuilderSize( + verticalPadding: 0, + horizontalPadding: 0, + ), child: editorState.renderer.build(context, e), ); if (SimpleColumnsBlockConstants.enableDebugBorder) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart index f94056cd3a..69bec33c61 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart @@ -9,10 +9,12 @@ class SimpleColumnBlockWidthResizer extends StatefulWidget { super.key, required this.columnNode, required this.editorState, + this.height, }); final Node columnNode; final EditorState editorState; + final double? height; @override State createState() => @@ -53,15 +55,14 @@ class _SimpleColumnBlockWidthResizerState child: ValueListenableBuilder( valueListenable: isHovering, builder: (context, isHovering, child) { - if (isDraggingAppFlowyEditorBlock.value) { - return SizedBox.shrink(); - } + final hide = isDraggingAppFlowyEditorBlock.value || !isHovering; return MouseRegion( cursor: SystemMouseCursors.resizeLeftRight, child: Container( width: 2, + height: widget.height ?? 20, margin: EdgeInsets.symmetric(horizontal: 2), - color: isHovering + color: !hide ? Theme.of(context).colorScheme.primary : Colors.transparent, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart index 8408c0f775..58ecde5f2f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart @@ -92,17 +92,18 @@ class ColumnsBlockComponentState extends State final ScrollController scrollController = ScrollController(); + final ValueNotifier heightValueNotifier = ValueNotifier(null); + @override void initState() { super.initState(); - _updateColumnsBlock(); } @override void dispose() { scrollController.dispose(); - + heightValueNotifier.dispose(); super.dispose(); } @@ -110,15 +111,13 @@ class ColumnsBlockComponentState extends State Widget build(BuildContext context) { Widget child = Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.start, children: _buildChildren(), ); child = Align( alignment: Alignment.topLeft, - child: IntrinsicHeight( - child: child, - ), + child: child, ); child = Padding( @@ -141,7 +140,10 @@ class ColumnsBlockComponentState extends State // the columns block does not support the block actions and selection // because the columns block is a layout wrapper, it does not have a content - return child; + return NotificationListener( + onNotification: (v) => updateHeightValueNotifier(v), + child: SizeChangedLayoutNotifier(child: child), + ); } List _buildChildren() { @@ -164,9 +166,15 @@ class ColumnsBlockComponentState extends State if (i != length - 1) { children.add( - SimpleColumnBlockWidthResizer( - columnNode: childNode, - editorState: editorState, + ValueListenableBuilder( + valueListenable: heightValueNotifier, + builder: (context, height, child) { + return SimpleColumnBlockWidthResizer( + columnNode: childNode, + editorState: editorState, + height: height, + ); + }, ), ); } @@ -194,6 +202,16 @@ class ColumnsBlockComponentState extends State } } + bool updateHeightValueNotifier(SizeChangedLayoutNotification notification) { + if (!mounted) return true; + final height = _renderBox?.size.height; + if (heightValueNotifier.value == height) return true; + WidgetsBinding.instance.addPostFrameCallback((_) { + heightValueNotifier.value = height; + }); + return true; + } + @override Position start() => Position(path: widget.node.path); From 3b3ae7fde93bb71ea959e18958b32a511e8eaef1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 15 Apr 2025 15:59:15 +0800 Subject: [PATCH 138/207] chore: loading for search summary --- .../command_palette/command_palette_bloc.dart | 43 +++--- .../command_palette/search_service.dart | 14 +- .../command_palette/command_palette.dart | 4 +- .../widgets/search_result_cell.dart | 9 +- .../widgets/search_results_list.dart | 125 +++++++++++++++--- .../widgets/search_summary_cell.dart | 67 +++++++--- frontend/resources/translations/en.json | 2 + frontend/rust-lib/Cargo.lock | 42 ++---- frontend/rust-lib/Cargo.toml | 4 +- .../flowy-search/src/document/handler.rs | 17 ++- .../flowy-search/src/entities/notification.rs | 3 - .../flowy-search/src/entities/result.rs | 11 ++ .../flowy-search/src/services/manager.rs | 2 - 13 files changed, 250 insertions(+), 93 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart index b53f3f8c23..01f638fe7a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -94,15 +94,16 @@ class CommandPaletteBloc emit( state.copyWith( query: null, - isLoading: false, + searching: false, serverResponseItems: [], localResponseItems: [], combinedResponseItems: {}, resultSummaries: [], + generatingAIOverview: false, ), ); } else { - emit(state.copyWith(query: event.search, isLoading: true)); + emit(state.copyWith(query: event.search, searching: true)); _activeQuery = event.search; unawaited( @@ -122,7 +123,8 @@ class CommandPaletteBloc add( CommandPaletteEvent.resultsChanged( searchId: '', - isLoading: false, + searching: false, + generatingAIOverview: false, ), ); } @@ -150,19 +152,23 @@ class CommandPaletteBloc searchId: searchId, localItems: items, ), - onServerItems: (items, searchId, isLoading) => _handleResultsUpdate( + onServerItems: (items, searchId, searching, generatingAIOverview) => + _handleResultsUpdate( searchId: searchId, serverItems: items, - isLoading: isLoading, + searching: searching, + generatingAIOverview: generatingAIOverview, ), - onSummaries: (summaries, searchId, isLoading) => _handleResultsUpdate( + onSummaries: (summaries, searchId, searching, generatingAIOverview) => + _handleResultsUpdate( searchId: searchId, summaries: summaries, - isLoading: isLoading, + searching: searching, + generatingAIOverview: generatingAIOverview, ), onFinished: (searchId) => _handleResultsUpdate( searchId: searchId, - isLoading: false, + searching: false, ), ); } @@ -172,7 +178,8 @@ class CommandPaletteBloc List? serverItems, List? localItems, List? summaries, - bool isLoading = true, + bool searching = true, + bool generatingAIOverview = false, }) { if (_isActiveSearch(searchId)) { add( @@ -181,7 +188,8 @@ class CommandPaletteBloc serverItems: serverItems, localItems: localItems, summaries: summaries, - isLoading: isLoading, + searching: searching, + generatingAIOverview: generatingAIOverview, ), ); } @@ -223,7 +231,8 @@ class CommandPaletteBloc localResponseItems: event.localItems ?? state.localResponseItems, resultSummaries: event.summaries ?? state.resultSummaries, combinedResponseItems: combinedItems, - isLoading: event.isLoading, + searching: event.searching, + generatingAIOverview: event.generatingAIOverview, ), ); } @@ -256,7 +265,8 @@ class CommandPaletteBloc localResponseItems: [], combinedResponseItems: {}, resultSummaries: [], - isLoading: false, + searching: false, + generatingAIOverview: false, ), ); } @@ -283,7 +293,8 @@ class CommandPaletteEvent with _$CommandPaletteEvent { }) = _NewSearchStream; const factory CommandPaletteEvent.resultsChanged({ required String searchId, - required bool isLoading, + required bool searching, + required bool generatingAIOverview, List? serverItems, List? localItems, List? summaries, @@ -324,12 +335,14 @@ class CommandPaletteState with _$CommandPaletteState { @Default({}) Map combinedResponseItems, @Default([]) List resultSummaries, @Default(null) SearchResponseStream? searchResponseStream, - required bool isLoading, + required bool searching, + required bool generatingAIOverview, @Default([]) List trash, @Default(null) String? searchId, }) = _CommandPaletteState; factory CommandPaletteState.initial() => const CommandPaletteState( - isLoading: false, + searching: false, + generatingAIOverview: false, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart index 6f05b88081..89e5b604f8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -49,12 +49,14 @@ class SearchResponseStream { void Function( List items, String searchId, - bool isLoading, + bool searching, + bool generatingAIOverview, )? _onServerItems; void Function( List summaries, String searchId, - bool isLoading, + bool searching, + bool generatingAIOverview, )? _onSummaries; void Function( @@ -78,14 +80,16 @@ class SearchResponseStream { _onServerItems?.call( searchState.response.searchResult.items, searchId, - searchState.isLoading, + searchState.response.searching, + searchState.response.generatingAiSummary, ); } if (searchState.response.hasSearchSummary()) { _onSummaries?.call( searchState.response.searchSummary.items, searchId, - searchState.isLoading, + searchState.response.searching, + searchState.response.generatingAiSummary, ); } @@ -105,11 +109,13 @@ class SearchResponseStream { List items, String searchId, bool isLoading, + bool generatingAIOverview, )? onServerItems, required void Function( List summaries, String searchId, bool isLoading, + bool generatingAIOverview, )? onSummaries, required void Function( List items, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart index a08c46679c..648712bd15 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -144,7 +144,7 @@ class CommandPaletteModal extends StatelessWidget { // Change mainAxisSize to max so Expanded works correctly. Column( children: [ - SearchField(query: state.query, isLoading: state.isLoading), + SearchField(query: state.query, isLoading: state.searching), if (state.query?.isEmpty ?? true) ...[ const Divider(height: 0), Flexible( @@ -167,7 +167,7 @@ class CommandPaletteModal extends StatelessWidget { // When there are no results and the query is not empty and not loading, // show the no results message, centered in the available space. else if ((state.query?.isNotEmpty ?? false) && - !state.isLoading) ...[ + !state.searching) ...[ const Divider(height: 0), Expanded( child: const _NoResultsHint(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart index 7034b79821..e8e5a37f82 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -207,9 +207,12 @@ class SearchResultPreview extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText(LocaleKeys.commandPalette_pagePreview.tr()), - const Divider( - thickness: 1, + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_pagePreview.tr(), + fontSize: 12, + ), ), const VSpace(6), Expanded( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index af2944d09c..483da06f40 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -3,6 +3,7 @@ import 'package:appflowy/workspace/application/action_navigation/action_navigati import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -11,6 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'search_result_cell.dart'; import 'search_summary_cell.dart'; @@ -35,22 +37,37 @@ class SearchResultList extends StatelessWidget { ), ); - Widget _buildSummariesSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), - ListView.separated( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: resultSummaries.length, - separatorBuilder: (_, __) => const Divider(height: 0), - itemBuilder: (_, index) => SearchSummaryCell( - summary: resultSummaries[index], + Widget _buildAIOverviewSection(BuildContext context) { + final state = context.read().state; + + if (state.generatingAIOverview) { + return Row( + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), + const HSpace(10), + const AIOverviewIndicator(), + ], + ); + } + if (resultSummaries.isNotEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: resultSummaries.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) => SearchSummaryCell( + summary: resultSummaries[index], + ), ), - ), - ], - ); + ], + ); + } + + return const SizedBox.shrink(); } Widget _buildResultsSection(BuildContext context) { @@ -83,6 +100,8 @@ class SearchResultList extends StatelessWidget { child: BlocProvider( create: (context) => SearchResultListBloc(), child: BlocListener( + listenWhen: (previous, current) => + previous.openPageId != current.openPageId, listener: (context, state) { if (state.openPageId != null) { FlowyOverlay.pop(context); @@ -102,24 +121,28 @@ class SearchResultList extends StatelessWidget { shrinkWrap: true, physics: const ClampingScrollPhysics(), children: [ - if (resultSummaries.isNotEmpty) _buildSummariesSection(), + _buildAIOverviewSection(context), const VSpace(10), if (resultItems.isNotEmpty) _buildResultsSection(context), ], ), ), const HSpace(10), - if (resultItems.any((item) => item.content.isNotEmpty)) + if (resultItems.any((item) => item.content.isNotEmpty)) ...[ + const VerticalDivider( + thickness: 1.0, + ), Flexible( flex: 3, child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 12, + horizontal: 8, vertical: 16, ), child: const SearchCellPreview(), ), ), + ], ], ), ), @@ -145,3 +168,69 @@ class SearchCellPreview extends StatelessWidget { ); } } + +class AIOverviewIndicator extends StatelessWidget { + const AIOverviewIndicator({ + super.key, + this.duration = const Duration(seconds: 1), + }); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); + return SelectionContainer.disabled( + child: SizedBox( + height: 20, + width: 100, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4), + children: [ + buildDot(const Color(0xFF9327FF)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(duration: slice * 2, begin: 0, end: 0), + buildDot(const Color(0xFFFB006D)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: 0) + .then() + .slideY(begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(begin: 0, end: 0), + buildDot(const Color(0xFFFFCE00)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice * 2, begin: 0, end: 0) + .then() + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0), + ], + ), + ), + ); + } + + Widget buildDot(Color color) { + return SizedBox.square( + dimension: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart index 15e8bb18f7..89553709fc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -56,17 +57,50 @@ class SearchSummaryPreview extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText(LocaleKeys.commandPalette_aiOverviewSource.tr()), - const Divider( - thickness: 1, + if (summary.highlights.isNotEmpty) ...[ + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_aiOverviewHighlights.tr(), + fontSize: 12, + ), + ), + const VSpace(6), + SearchSummaryHighlight(text: summary.highlights), + const VSpace(36), + ], + + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_aiOverviewSource.tr(), + fontSize: 12, + ), ), + // Sources const VSpace(6), ...summary.sources.map((e) => SearchSummarySource(source: e)), + + // Highlights ], ); } } +class SearchSummaryHighlight extends StatelessWidget { + const SearchSummaryHighlight({ + required this.text, + super.key, + }); + + final String text; + + @override + Widget build(BuildContext context) { + return AIMarkdownText(markdown: text); + } +} + class SearchSummarySource extends StatelessWidget { const SearchSummarySource({ required this.source, @@ -78,18 +112,21 @@ class SearchSummarySource extends StatelessWidget { @override Widget build(BuildContext context) { final icon = source.icon.getIcon(); - return SizedBox( - height: 30, - child: FlowyButton( - leftIcon: icon, - hoverColor: - Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - text: FlowyText(source.displayName), - onTap: () { - context.read().add( - SearchResultListEvent.openPage(pageId: source.id), - ); - }, + return FlowyTooltip( + message: LocaleKeys.commandPalette_clickToOpenPage.tr(), + child: SizedBox( + height: 30, + child: FlowyButton( + leftIcon: icon, + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + text: FlowyText(source.displayName), + onTap: () { + context.read().add( + SearchResultListEvent.openPage(pageId: source.id), + ); + }, + ), ), ); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b176a08f9f..f9ddbdf012 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2699,7 +2699,9 @@ "bestMatches": "Best matches", "aiOverview": "AI overview", "aiOverviewSource": "Reference sources", + "aiOverviewHighlights": "Highlights", "pagePreview": "Content preview", + "clickToOpenPage": "Click to open page", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 671d8bccf2..44d659ed56 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "bytes", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "again", "anyhow", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "futures-channel", "futures-util", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "bincode", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "async-trait", @@ -1786,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1969,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "bincode", "bytes", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "app-error", "jsonwebtoken", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "bytes", @@ -5189,7 +5189,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5209,7 +5209,6 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5277,19 +5276,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", - "proc-macro2", - "quote", - "syn 2.0.94", -] - [[package]] name = "phf_shared" version = "0.8.0" @@ -6784,7 +6770,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 8d8e11f6a0..4166331fd9 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -105,8 +105,8 @@ tantivy = { version = "0.24.0" } # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "873415478ed58686c98df578e2c39d07ddce6773" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "873415478ed58686c98df578e2c39d07ddce6773" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" } [profile.dev] opt-level = 0 diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index fc68f850e5..d236f4b639 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -77,6 +77,12 @@ impl SearchHandler for DocumentSearchHandler { }; // Execute document search. + yield Ok( + CreateSearchResultPBArgs::default().searching(true) + .build() + .unwrap(), + ); + let result_items = match cloud_service.document_search(&workspace_id, query.clone()).await { Ok(items) => items, Err(e) => { @@ -114,7 +120,9 @@ impl SearchHandler for DocumentSearchHandler { let search_result = RepeatedSearchResponseItemPB { items }; yield Ok( CreateSearchResultPBArgs::default() + .searching(false) .search_result(Some(search_result)) + .generating_ai_summary(true) .build() .unwrap(), ); @@ -138,7 +146,7 @@ impl SearchHandler for DocumentSearchHandler { }) .collect(); - SearchSummaryPB { content: v.content, sources } + SearchSummaryPB { content: v.content, sources, highlights: v.highlights } }) .collect(); @@ -146,12 +154,19 @@ impl SearchHandler for DocumentSearchHandler { yield Ok( CreateSearchResultPBArgs::default() .search_summary(Some(summary_result)) + .generating_ai_summary(false) .build() .unwrap(), ); } Err(e) => { warn!("Failed to generate search summary: {:?}", e); + yield Ok( + CreateSearchResultPBArgs::default() + .generating_ai_summary(false) + .build() + .unwrap(), + ); } } }) diff --git a/frontend/rust-lib/flowy-search/src/entities/notification.rs b/frontend/rust-lib/flowy-search/src/entities/notification.rs index 3f1cdab67a..4f12305d9a 100644 --- a/frontend/rust-lib/flowy-search/src/entities/notification.rs +++ b/frontend/rust-lib/flowy-search/src/entities/notification.rs @@ -8,9 +8,6 @@ pub struct SearchStatePB { #[pb(index = 2)] pub search_id: String, - - #[pb(index = 3)] - pub is_loading: bool, } #[derive(ProtoBuf_Enum, Debug, Default)] diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 8f5ba11ded..a01f01b074 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -18,6 +18,14 @@ pub struct SearchResponsePB { #[pb(index = 3, one_of)] #[builder(default)] pub local_search_result: Option, + + #[pb(index = 4)] + #[builder(default)] + pub searching: bool, + + #[pb(index = 5)] + #[builder(default)] + pub generating_ai_summary: bool, } #[derive(ProtoBuf, Default, Debug, Clone)] @@ -33,6 +41,9 @@ pub struct SearchSummaryPB { #[pb(index = 2)] pub sources: Vec, + + #[pb(index = 3)] + pub highlights: String, } #[derive(ProtoBuf, Default, Debug, Clone)] diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 72a3c12793..a71449d5d2 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -93,7 +93,6 @@ impl SearchManager { let resp = SearchStatePB { response: Some(search_result), search_id: search_id.clone(), - is_loading: true, }; if let Ok::, _>(data) = resp.try_into() { if let Err(err) = clone_sink.send(data).await { @@ -111,7 +110,6 @@ impl SearchManager { let resp = SearchStatePB { response: None, search_id: search_id.clone(), - is_loading: true, }; if let Ok::, _>(data) = resp.try_into() { let _ = clone_sink.send(data).await; From 92912367338490e5c0a9294145786431af34a59e Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 15 Apr 2025 16:49:33 +0800 Subject: [PATCH 139/207] chore: clippy --- .../widgets/search_results_list.dart | 1 - frontend/rust-lib/Cargo.lock | 42 ++++++++++++------- frontend/rust-lib/Cargo.toml | 4 +- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index 483da06f40..1a7a1d94bb 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -3,7 +3,6 @@ import 'package:appflowy/workspace/application/action_navigation/action_navigati import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 44d659ed56..a84de4cdd6 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "bytes", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "again", "anyhow", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "futures-channel", "futures-util", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "bincode", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "async-trait", @@ -1786,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1969,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "bincode", "bytes", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "app-error", "jsonwebtoken", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "bytes", @@ -5189,7 +5189,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros", + "phf_macros 0.8.0", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5209,6 +5209,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5276,6 +5277,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "phf_shared" version = "0.8.0" @@ -6770,7 +6784,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=a11b94240946fa8f0549e5cf1c6505b7fa7e0a16#a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9ea3c46c26b006decfd3c98fffe910dd49a6607d#9ea3c46c26b006decfd3c98fffe910dd49a6607d" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 4166331fd9..88972e5bfb 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -105,8 +105,8 @@ tantivy = { version = "0.24.0" } # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "a11b94240946fa8f0549e5cf1c6505b7fa7e0a16" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9ea3c46c26b006decfd3c98fffe910dd49a6607d" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9ea3c46c26b006decfd3c98fffe910dd49a6607d" } [profile.dev] opt-level = 0 From 69b5452af5ad24c50c26e753b1ebed76e4c8804e Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:17:39 +0800 Subject: [PATCH 140/207] fix: undo not working in database row full page (#7749) --- .../database_document_page.dart | 61 ++++++++----------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart index 9568784464..ee52be8c26 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; @@ -7,9 +8,10 @@ import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; -import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; @@ -19,8 +21,10 @@ import 'package:appflowy/workspace/application/action_navigation/navigation_acti import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; import '../../workspace/application/view/view_bloc.dart'; @@ -49,18 +53,6 @@ class DatabaseDocumentPage extends StatefulWidget { class _DatabaseDocumentPageState extends State { EditorState? editorState; - @override - void initState() { - super.initState(); - EditorNotification.addListener(_onEditorNotification); - } - - @override - void dispose() { - EditorNotification.removeListener(_onEditorNotification); - super.dispose(); - } - @override Widget build(BuildContext context) { return MultiBlocProvider( @@ -126,21 +118,34 @@ class _DatabaseDocumentPageState extends State { styleCustomizer: EditorStyleCustomizer( context: context, padding: EditorStyleCustomizer.documentPadding, + editorState: state.editorState!, ), header: _buildDatabaseDataContent(context, state.editorState!), initialSelection: widget.initialSelection, useViewInfoBloc: false, + placeholderText: (node) => + node.type == ParagraphBlockKeys.type && !node.isInTable + ? LocaleKeys.editor_slashPlaceHolder.tr() + : '', ), ); - return EditorTransactionService( - viewId: widget.view.id, - editorState: state.editorState!, - child: Column( - children: [ - if (state.isDeleted) _buildBanner(context), - Expanded(child: appflowyEditorPage), - ], + return Provider( + create: (_) { + final context = SharedEditorContext(); + context.isInDatabaseRowPage = true; + return context; + }, + dispose: (_, editorContext) => editorContext.dispose(), + child: EditorTransactionService( + viewId: widget.view.id, + editorState: state.editorState!, + child: Column( + children: [ + if (state.isDeleted) _buildBanner(context), + Expanded(child: appflowyEditorPage), + ], + ), ), ); } @@ -213,20 +218,6 @@ class _DatabaseDocumentPageState extends State { ); } - void _onEditorNotification(EditorNotificationType type) { - final editorState = this.editorState; - if (editorState == null) { - return; - } - if (type == EditorNotificationType.undo) { - undoCommand.execute(editorState); - } else if (type == EditorNotificationType.redo) { - redoCommand.execute(editorState); - } else if (type == EditorNotificationType.exitEditing) { - editorState.selection = null; - } - } - void _onNotificationAction( BuildContext context, ActionNavigationState state, From c6511cfb55d4006169e737e38310ae3b76826f15 Mon Sep 17 00:00:00 2001 From: Morn Date: Tue, 15 Apr 2025 17:43:50 +0800 Subject: [PATCH 141/207] fix: mobile slash menu position error (#7747) * fix: mobile slash menu position error * fix: mobile slash menu sometimes not showing up --- .../selection_menu/mobile_selection_menu.dart | 77 +++++++++++-------- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart index 7f293cc1c9..0b700b6504 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -166,7 +165,8 @@ class MobileSelectionMenu extends SelectionMenuService { if (selectionRects.isEmpty) { return null; } - calculateSelectionMenuOffset(selectionRects.first); + final screenSize = MediaQuery.of(context).size; + calculateSelectionMenuOffset(selectionRects.first, screenSize); final (left, top, right, bottom) = getPosition(); return _Position(left, top, right, bottom); } @@ -205,50 +205,65 @@ class MobileSelectionMenu extends SelectionMenuService { return (left, top, right, bottom); } - void calculateSelectionMenuOffset(Rect rect) { + void calculateSelectionMenuOffset(Rect rect, Size screenSize) { // Workaround: We can customize the padding through the [EditorStyle], // but the coordinates of overlay are not properly converted currently. // Just subtract the padding here as a result. - const menuHeight = 192.0, menuWidth = 240.0 + 10; - const menuOffset = Offset(0, 10); + const menuHeight = 192.0, menuWidth = 240.0; final editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final editorHeight = editorState.renderBox!.size.height; + final screenHeight = screenSize.height; final editorWidth = editorState.renderBox!.size.width; + final rectHeight = rect.height; // show below default - _alignment = Alignment.topLeft; - final bottomRight = rect.bottomRight; - final topRight = rect.topRight; - var offset = bottomRight + menuOffset; - final limitX = editorWidth - menuWidth + editorOffset.dx; + _alignment = Alignment.bottomRight; + final bottomRight = rect.topLeft; + final offset = bottomRight; + final limitX = editorWidth + editorOffset.dx - menuWidth, + limitY = screenHeight - + editorHeight + + editorOffset.dy - + menuHeight - + rectHeight; _offset = Offset( - min(offset.dx, limitX), - offset.dy, + editorWidth - offset.dx - menuWidth, + screenHeight - offset.dy - menuHeight - rectHeight, ); - // show above if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { - offset = topRight - menuOffset; - _alignment = Alignment.bottomLeft; - - _offset = Offset( - offset.dx, - editorOffset.dy + editorHeight - offset.dy, - ); + /// show above + if (offset.dy > menuHeight) { + _offset = Offset( + _offset.dx, + offset.dy - menuHeight, + ); + _alignment = Alignment.topRight; + } else { + _offset = Offset( + _offset.dx, + limitY, + ); + } } - // show on left - if (_offset.dx - editorOffset.dx > editorWidth / 2) { - _alignment = _alignment == Alignment.topLeft - ? Alignment.topRight - : Alignment.bottomRight; - - final x = editorWidth - _offset.dx + editorOffset.dx; - _offset = Offset( - min(x, limitX), - _offset.dy, - ); + if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) { + /// show left + if (offset.dx > menuWidth) { + _alignment = _alignment == Alignment.bottomRight + ? Alignment.bottomLeft + : Alignment.topLeft; + _offset = Offset( + offset.dx - menuWidth, + _offset.dy, + ); + } else { + _offset = Offset( + limitX, + _offset.dy, + ); + } } } } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 1c6d66431d..cc574f7e64 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: "361b99c38370abeeb19656f89e8c31cb3666623b" - resolved-ref: "361b99c38370abeeb19656f89e8c31cb3666623b" + ref: "4967ed5" + resolved-ref: "4967ed57d9190948c08f868972c0babfdc470ba7" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 0cc553d1a7..e9dfbf48d0 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -185,7 +185,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: '361b99c38370abeeb19656f89e8c31cb3666623b' + ref: '4967ed5' appflowy_editor_plugins: git: From f62686fdeb1304e3eaabd829d95f77adbd7ad9d4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 15 Apr 2025 21:05:56 +0800 Subject: [PATCH 142/207] chore: support local chat --- frontend/rust-lib/Cargo.lock | 42 +++++++------------ frontend/rust-lib/Cargo.toml | 4 +- .../src/persistence/chat_message_sql.rs | 5 ++- frontend/rust-lib/flowy-server/src/lib.rs | 1 - .../impls/chat.rs} | 39 +++++++++++------ .../src/local_server/impls/mod.rs | 2 + .../flowy-server/src/local_server/server.rs | 13 ++++-- frontend/rust-lib/flowy-server/src/server.rs | 5 +-- 8 files changed, 59 insertions(+), 52 deletions(-) rename frontend/rust-lib/flowy-server/src/{default_impl.rs => local_server/impls/chat.rs} (83%) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 671d8bccf2..342e454b9d 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "bytes", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "again", "anyhow", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "futures-channel", "futures-util", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "bincode", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "async-trait", @@ -1786,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1969,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "bincode", "bytes", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "app-error", "jsonwebtoken", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "bytes", @@ -5189,7 +5189,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5209,7 +5209,6 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5277,19 +5276,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", - "proc-macro2", - "quote", - "syn 2.0.94", -] - [[package]] name = "phf_shared" version = "0.8.0" @@ -6784,7 +6770,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=873415478ed58686c98df578e2c39d07ddce6773#873415478ed58686c98df578e2c39d07ddce6773" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=39057f85a408a40dc702a9e54fd1b9dc91bb36e4#39057f85a408a40dc702a9e54fd1b9dc91bb36e4" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 8d8e11f6a0..f1d811bf2d 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -105,8 +105,8 @@ tantivy = { version = "0.24.0" } # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "873415478ed58686c98df578e2c39d07ddce6773" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "873415478ed58686c98df578e2c39d07ddce6773" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "39057f85a408a40dc702a9e54fd1b9dc91bb36e4" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "39057f85a408a40dc702a9e54fd1b9dc91bb36e4" } [profile.dev] opt-level = 0 diff --git a/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs index aa4dd8215d..6eaf6798e3 100644 --- a/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs +++ b/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs @@ -65,7 +65,10 @@ pub fn select_chat_messages( query = query.filter(chat_message_table::message_id.lt(before_message_id)); } query = query - .order((chat_message_table::message_id.desc(),)) + .order(( + chat_message_table::created_at.desc(), + chat_message_table::message_id.desc(), + )) .limit(limit_val); let messages: Vec = query.load::(&mut *conn)?; diff --git a/frontend/rust-lib/flowy-server/src/lib.rs b/frontend/rust-lib/flowy-server/src/lib.rs index 33f4b0c0d8..034991a984 100644 --- a/frontend/rust-lib/flowy-server/src/lib.rs +++ b/frontend/rust-lib/flowy-server/src/lib.rs @@ -5,5 +5,4 @@ pub mod local_server; mod response; mod server; -mod default_impl; pub mod util; diff --git a/frontend/rust-lib/flowy-server/src/default_impl.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs similarity index 83% rename from frontend/rust-lib/flowy-server/src/default_impl.rs rename to frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 02e313115f..a68dbc8b4f 100644 --- a/frontend/rust-lib/flowy-server/src/default_impl.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -6,15 +6,16 @@ use flowy_ai_pub::cloud::{ }; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use lib_infra::util::timestamp; use serde_json::Value; use std::collections::HashMap; use std::path::Path; use uuid::Uuid; -pub(crate) struct DefaultChatCloudServiceImpl; +pub(crate) struct LocalServerChatServiceImpl; #[async_trait] -impl ChatCloudService for DefaultChatCloudServiceImpl { +impl ChatCloudService for LocalServerChatServiceImpl { async fn create_chat( &self, _uid: &i64, @@ -22,29 +23,40 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { _chat_id: &Uuid, _rag_ids: Vec, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + Ok(()) } async fn create_question( &self, _workspace_id: &Uuid, _chat_id: &Uuid, - _message: &str, - _message_type: ChatMessageType, + message: &str, + message_type: ChatMessageType, _metadata: &[ChatMessageMetadata], ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + match message_type { + ChatMessageType::System => Ok(ChatMessage::new_system(timestamp(), message.to_string())), + ChatMessageType::User => Ok(ChatMessage::new_human( + timestamp(), + message.to_string(), + None, + )), + } } async fn create_answer( &self, _workspace_id: &Uuid, _chat_id: &Uuid, - _message: &str, - _question_id: i64, - _metadata: Option, + message: &str, + question_id: i64, + metadata: Option, ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + let mut message = ChatMessage::new_ai(timestamp(), message.to_string(), Some(question_id)); + if let Some(metadata) = metadata { + message.metadata = metadata; + } + Ok(message) } async fn stream_answer( @@ -81,9 +93,12 @@ impl ChatCloudService for DefaultChatCloudServiceImpl { &self, _workspace_id: &Uuid, _chat_id: &Uuid, - _message_id: i64, + message_id: i64, ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], + }) } async fn get_answer( diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs index 0280cfbefb..f63265e734 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs @@ -1,8 +1,10 @@ +pub(crate) use chat::*; pub(crate) use database::*; pub(crate) use document::*; pub(crate) use folder::*; pub(crate) use user::*; +mod chat; mod database; mod document; mod folder; diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index cb8b545c53..280a56cfe0 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -1,13 +1,13 @@ use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; -use tokio::sync::mpsc; - +use flowy_ai_pub::cloud::ChatCloudService; use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::FlowyError; use flowy_folder_pub::cloud::FolderCloudService; use flowy_storage_pub::cloud::StorageCloudService; +use tokio::sync::mpsc; // use flowy_user::services::database::{ // get_user_profile, get_user_workspace, open_collab_db, open_user_db, // }; @@ -15,8 +15,9 @@ use flowy_user_pub::cloud::UserCloudService; use flowy_user_pub::entities::*; use crate::local_server::impls::{ - LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, - LocalServerFolderCloudServiceImpl, LocalServerUserAuthServiceImpl, + LocalServerChatServiceImpl, LocalServerDatabaseCloudServiceImpl, + LocalServerDocumentCloudServiceImpl, LocalServerFolderCloudServiceImpl, + LocalServerUserAuthServiceImpl, }; use crate::AppFlowyServer; @@ -78,4 +79,8 @@ impl AppFlowyServer for LocalServer { fn database_ai_service(&self) -> Option> { None } + + fn chat_service(&self) -> Arc { + Arc::new(LocalServerChatServiceImpl) + } } diff --git a/frontend/rust-lib/flowy-server/src/server.rs b/frontend/rust-lib/flowy-server/src/server.rs index ee07eefa5a..4c92fe28d2 100644 --- a/frontend/rust-lib/flowy-server/src/server.rs +++ b/frontend/rust-lib/flowy-server/src/server.rs @@ -12,7 +12,6 @@ use tokio_stream::wrappers::WatchStream; #[cfg(feature = "enable_supabase")] use {collab_entity::CollabObject, collab_plugins::cloud_storage::RemoteCollabStorage}; -use crate::default_impl::DefaultChatCloudServiceImpl; use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_folder_pub::cloud::FolderCloudService; @@ -103,9 +102,7 @@ pub trait AppFlowyServer: Send + Sync + 'static { /// An `Arc` wrapping the `DocumentCloudService` interface. fn document_service(&self) -> Arc; - fn chat_service(&self) -> Arc { - Arc::new(DefaultChatCloudServiceImpl) - } + fn chat_service(&self) -> Arc; /// Bridge for the Cloud AI Search features /// From 846172a709856f6c91d30a2f8107af434eaa80b4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 15 Apr 2025 22:19:16 +0800 Subject: [PATCH 143/207] chore: adjust ui --- .../search_result_list_bloc.dart | 5 ++ .../widgets/search_result_cell.dart | 14 +++- .../widgets/search_results_list.dart | 83 ++++++++++++++----- .../widgets/search_summary_cell.dart | 8 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 15 ++-- 5 files changed, 96 insertions(+), 29 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart index 58e5a951da..e5953ae61b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart @@ -25,6 +25,7 @@ class SearchResultListBloc state.copyWith( hoveredSummary: event.summary, hoveredResult: null, + userHovered: event.userHovered, openPageId: null, ), ); @@ -38,6 +39,7 @@ class SearchResultListBloc state.copyWith( hoveredSummary: null, hoveredResult: event.item, + userHovered: event.userHovered, openPageId: null, ), ); @@ -55,9 +57,11 @@ class SearchResultListBloc class SearchResultListEvent with _$SearchResultListEvent { const factory SearchResultListEvent.onHoverSummary({ required SearchSummaryPB summary, + required bool userHovered, }) = _OnHoverSummary; const factory SearchResultListEvent.onHoverResult({ required SearchResultItem item, + required bool userHovered, }) = _OnHoverResult; const factory SearchResultListEvent.openPage({ @@ -72,6 +76,7 @@ class SearchResultListState with _$SearchResultListState { @Default(null) SearchSummaryPB? hoveredSummary, @Default(null) SearchResultItem? hoveredResult, @Default(null) String? openPageId, + @Default(false) bool userHovered, }) = _SearchResultListState; factory SearchResultListState.initial() => const SearchResultListState(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart index e8e5a37f82..2485da4a69 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -17,10 +17,12 @@ class SearchResultCell extends StatefulWidget { super.key, required this.item, this.isTrashed = false, + this.isHovered = false, }); final SearchResultItem item; final bool isTrashed; + final bool isHovered; @override State createState() => _SearchResultCellState(); @@ -142,7 +144,10 @@ class _SearchResultCellState extends State { onFocusChange: (hasFocus) { setState(() { context.read().add( - SearchResultListEvent.onHoverResult(item: widget.item), + SearchResultListEvent.onHoverResult( + item: widget.item, + userHovered: true, + ), ); _hasFocus = hasFocus; }); @@ -150,10 +155,13 @@ class _SearchResultCellState extends State { child: FlowyHover( onHover: (value) { context.read().add( - SearchResultListEvent.onHoverResult(item: widget.item), + SearchResultListEvent.onHoverResult( + item: widget.item, + userHovered: true, + ), ); }, - isSelected: () => _hasFocus, + isSelected: () => _hasFocus || widget.isHovered, style: HoverStyle( borderRadius: BorderRadius.circular(8), hoverColor: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index 1a7a1d94bb..d90888e3e9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -16,7 +16,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'search_result_cell.dart'; import 'search_summary_cell.dart'; -class SearchResultList extends StatelessWidget { +class SearchResultList extends StatefulWidget { const SearchResultList({ required this.trash, required this.resultItems, @@ -27,6 +27,26 @@ class SearchResultList extends StatelessWidget { final List trash; final List resultItems; final List resultSummaries; + + @override + State createState() => _SearchResultListState(); +} + +class _SearchResultListState extends State { + late final SearchResultListBloc bloc; + + @override + void initState() { + super.initState(); + bloc = SearchResultListBloc(); + } + + @override + void dispose() { + bloc.close(); + super.dispose(); + } + Widget _buildSectionHeader(String title) => Padding( padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8), @@ -48,7 +68,21 @@ class SearchResultList extends StatelessWidget { ], ); } - if (resultSummaries.isNotEmpty) { + + if (widget.resultSummaries.isNotEmpty) { + if (!bloc.state.userHovered) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + bloc.add( + SearchResultListEvent.onHoverSummary( + summary: widget.resultSummaries[0], + userHovered: false, + ), + ); + }, + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -56,10 +90,11 @@ class SearchResultList extends StatelessWidget { ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: resultSummaries.length, + itemCount: widget.resultSummaries.length, separatorBuilder: (_, __) => const Divider(height: 0), itemBuilder: (_, index) => SearchSummaryCell( - summary: resultSummaries[index], + summary: widget.resultSummaries[index], + isHovered: bloc.state.hoveredSummary != null, ), ), ], @@ -78,13 +113,14 @@ class SearchResultList extends StatelessWidget { ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: resultItems.length, + itemCount: widget.resultItems.length, separatorBuilder: (_, __) => const Divider(height: 0), itemBuilder: (_, index) { - final item = resultItems[index]; + final item = widget.resultItems[index]; return SearchResultCell( item: item, - isTrashed: trash.any((t) => t.id == item.id), + isTrashed: widget.trash.any((t) => t.id == item.id), + isHovered: bloc.state.hoveredResult?.id == item.id, ); }, ), @@ -96,11 +132,9 @@ class SearchResultList extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), - child: BlocProvider( - create: (context) => SearchResultListBloc(), + child: BlocProvider.value( + value: bloc, child: BlocListener( - listenWhen: (previous, current) => - previous.openPageId != current.openPageId, listener: (context, state) { if (state.openPageId != null) { FlowyOverlay.pop(context); @@ -116,18 +150,27 @@ class SearchResultList extends StatelessWidget { children: [ Flexible( flex: 7, - child: ListView( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - children: [ - _buildAIOverviewSection(context), - const VSpace(10), - if (resultItems.isNotEmpty) _buildResultsSection(context), - ], + child: BlocBuilder( + buildWhen: (previous, current) => + previous.hoveredResult != current.hoveredResult || + previous.hoveredSummary != current.hoveredSummary, + builder: (context, state) { + return ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + _buildAIOverviewSection(context), + const VSpace(10), + if (widget.resultItems.isNotEmpty) + _buildResultsSection(context), + ], + ); + }, ), ), const HSpace(10), - if (resultItems.any((item) => item.content.isNotEmpty)) ...[ + if (widget.resultItems + .any((item) => item.content.isNotEmpty)) ...[ const VerticalDivider( thickness: 1.0, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart index 89553709fc..20a02589f5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -14,17 +14,23 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class SearchSummaryCell extends StatelessWidget { const SearchSummaryCell({ required this.summary, + required this.isHovered, super.key, }); final SearchSummaryPB summary; + final bool isHovered; @override Widget build(BuildContext context) { return FlowyHover( + isSelected: () => isHovered, onHover: (value) { context.read().add( - SearchResultListEvent.onHoverSummary(summary: summary), + SearchResultListEvent.onHoverSummary( + summary: summary, + userHovered: true, + ), ); }, style: HoverStyle( diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 0b27f4f525..ec12ac4963 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -453,7 +453,6 @@ impl AIManager { if let Some(local_model) = self.local_ai.get_plugin_chat_model() { let model = AIModel::local(local_model, "".to_string()); current_active_local_ai_model = Some(model.clone()); - trace!("[Model Selection] current local ai model: {}", model.name); models.push(model); } @@ -466,7 +465,7 @@ impl AIManager { } // Global active model is the model selected by the user in the workspace settings. - let server_active_model = self + let mut server_active_model = self .get_workspace_select_model() .await .map(|m| AIModel::server(m, "".to_string())) @@ -478,10 +477,14 @@ impl AIManager { ); let mut user_selected_model = server_active_model.clone(); + // when current select model is deprecated, reset the model to default + if !models.iter().any(|m| m.name == server_active_model.name) { + server_active_model = AIModel::default(); + } + let source_key = ai_available_models_key(&source); - // If source is provided, try to get the user-selected model from the store. User selected - // model will be used as the active model if it exists. + // We use source to identify user selected model. source can be document id or chat id. match self.store_preferences.get_object::(&source_key) { None => { // when there is selected model and current local ai is active, then use local ai @@ -491,6 +494,8 @@ impl AIManager { }, Some(mut model) => { trace!("[Model Selection] user previous select model: {:?}", model); + // If source is provided, try to get the user-selected model from the store. User selected + // model will be used as the active model if it exists. if model.is_local { if let Some(local_ai_model) = ¤t_active_local_ai_model { if local_ai_model.name != model.name { @@ -508,7 +513,7 @@ impl AIManager { .iter() .find(|m| m.name == user_selected_model.name) .cloned() - .or(Some(server_active_model)); + .or(Some(server_active_model.clone())); // Update the stored preference if a different model is used. if let Some(ref active_model) = active_model { From 5c3e81e6dc7febb02661b29b593532063e70636c Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 15 Apr 2025 22:35:06 +0800 Subject: [PATCH 144/207] chore: adjust ui --- .../application/settings/ai/local_ai_bloc.dart | 17 ----------------- .../widgets/search_summary_cell.dart | 4 +--- .../pages/setting_ai_view/ollama_setting.dart | 1 + frontend/resources/translations/en.json | 2 +- 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart index c4a4ee3afa..a90f319a94 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -125,21 +125,4 @@ class LocalAiPluginState with _$LocalAiPluginState { orElse: () => false, ); } - - bool get showSettings { - return maybeWhen( - ready: (isEnabled, _, runningState, lackOfResource) { - final isConnecting = [ - RunningStatePB.Connecting, - RunningStatePB.Connected, - ].contains(runningState); - - final resourcesReadyOrMissingModel = lackOfResource == null || - lackOfResource.resourceType == LackOfAIResourceTypePB.MissingModel; - - return !isConnecting && resourcesReadyOrMissingModel; - }, - orElse: () => false, - ); - } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart index 20a02589f5..84b8f6646b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -67,7 +67,7 @@ class SearchSummaryPreview extends StatelessWidget { Opacity( opacity: 0.5, child: FlowyText( - LocaleKeys.commandPalette_aiOverviewHighlights.tr(), + LocaleKeys.commandPalette_aiOverviewMoreDetails.tr(), fontSize: 12, ), ), @@ -86,8 +86,6 @@ class SearchSummaryPreview extends StatelessWidget { // Sources const VSpace(6), ...summary.sources.map((e) => SearchSummarySource(source: e)), - - // Highlights ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart index 2abcb552d1..6f38043927 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart @@ -62,6 +62,7 @@ class _SettingItemWidget extends StatelessWidget { SizedBox( height: 32, child: FlowyTextField( + autoFocus: false, hintText: item.hintText, text: item.content, onChanged: (content) { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index f9ddbdf012..6274540914 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2699,7 +2699,7 @@ "bestMatches": "Best matches", "aiOverview": "AI overview", "aiOverviewSource": "Reference sources", - "aiOverviewHighlights": "Highlights", + "aiOverviewMoreDetails": "More details", "pagePreview": "Content preview", "clickToOpenPage": "Click to open page", "recentHistory": "Recent history", From 9b077969e785ea4ef42090b782b8ef6b27269c3f Mon Sep 17 00:00:00 2001 From: Morn Date: Wed, 16 Apr 2025 10:11:31 +0800 Subject: [PATCH 145/207] fix: error position for slash search result (#7758) --- .../selection_menu/mobile_selection_menu.dart | 2 + .../mobile_selection_menu_widget.dart | 52 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart index 0b700b6504..f69360575a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart @@ -70,6 +70,7 @@ class MobileSelectionMenu extends SelectionMenuService { final editorWidth = editorState.renderBox!.size.width; _positionNotifier = ValueNotifier(position); + final showAtTop = position.top != null; _selectionMenuEntry = OverlayEntry( builder: (context) { return SizedBox( @@ -93,6 +94,7 @@ class MobileSelectionMenu extends SelectionMenuService { child: MobileSelectionMenuWidget( selectionMenuStyle: style, singleColumn: singleColumn, + showAtTop: showAtTop, items: selectionMenuItems ..forEach((element) { if (element is MobileSelectionMenuItem) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart index e259d49d52..d96dd224e1 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart @@ -22,6 +22,7 @@ class MobileSelectionMenuWidget extends StatefulWidget { required this.deleteSlashByDefault, required this.singleColumn, required this.startOffset, + required this.showAtTop, this.nameBuilder, }); @@ -38,6 +39,7 @@ class MobileSelectionMenuWidget extends StatefulWidget { final bool deleteSlashByDefault; final bool singleColumn; + final bool showAtTop; final int startOffset; final SelectionMenuItemNameBuilder? nameBuilder; @@ -172,27 +174,37 @@ class _MobileSelectionMenuWidgetState extends State { @override Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - child: DecoratedBox( - decoration: BoxDecoration( - color: widget.selectionMenuStyle.selectionMenuBackgroundColor, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withValues(alpha: 0.1), - ), - ], - borderRadius: BorderRadius.circular(6.0), - ), - child: _showingItems.isEmpty - ? _buildNoResultsWidget(context) - : _buildResultsWidget( - context, - _showingItems, - widget.itemCountFilter, + return SizedBox( + height: 192, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showAtTop) Spacer(), + Focus( + focusNode: _focusNode, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.selectionMenuStyle.selectionMenuBackgroundColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), ), + child: _showingItems.isEmpty + ? _buildNoResultsWidget(context) + : _buildResultsWidget( + context, + _showingItems, + widget.itemCountFilter, + ), + ), + ), + if (!widget.showAtTop) Spacer(), + ], ), ); } From a5eb2cdd9a0171ecbc442aef09a8cf8db469a214 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 16 Apr 2025 10:15:40 +0800 Subject: [PATCH 146/207] feat: improve white label scripts on Windows (#7755) * feat: improve white label scripts on Windows * feat: add font white label script * chore: integrate font white label script --- .../assets/fonts/FlowyIconData.ttf | Bin 1904 -> 0 bytes .../ai_chat/presentation/chat_avatar.dart | 2 +- .../presentation/chat_welcome_page.dart | 2 +- .../sign_in_screen/widgets/logo/logo.dart | 2 +- .../presentation/screens/splash_screen.dart | 2 +- .../mobile_workspace_start_screen.dart | 2 +- .../notification/notification_service.dart | 14 +- .../appearance/desktop_appearance.dart | 2 + .../menu/sidebar/header/sidebar_top_menu.dart | 4 +- frontend/appflowy_flutter/pubspec.yaml | 7 +- ...lowy_ai_chat_logo.svg => ai_chat_logo.svg} | 0 .../16x/{flowy_logo.svg => app_logo.svg} | 0 .../40x/{flowy_logo.svg => app_logo.svg} | 0 ...k_mode.svg => app_logo_with_text_dark.svg} | 0 ..._text.svg => app_logo_with_text_light.svg} | 0 .../scripts/white_label/code_white_label.sh | 72 +++++++ .../scripts/white_label/font_white_label.sh | 198 ++++++++++++++++++ .../scripts/white_label/icon_white_label.sh | 49 +++-- frontend/scripts/white_label/white_label.sh | 30 ++- 19 files changed, 352 insertions(+), 34 deletions(-) delete mode 100644 frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf rename frontend/resources/flowy_icons/16x/{flowy_ai_chat_logo.svg => ai_chat_logo.svg} (100%) rename frontend/resources/flowy_icons/16x/{flowy_logo.svg => app_logo.svg} (100%) rename frontend/resources/flowy_icons/40x/{flowy_logo.svg => app_logo.svg} (100%) rename frontend/resources/flowy_icons/40x/{flowy_logo_dark_mode.svg => app_logo_with_text_dark.svg} (100%) rename frontend/resources/flowy_icons/40x/{flowy_logo_text.svg => app_logo_with_text_light.svg} (100%) create mode 100644 frontend/scripts/white_label/code_white_label.sh create mode 100644 frontend/scripts/white_label/font_white_label.sh diff --git a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf b/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf deleted file mode 100644 index 8f03a5c8f932e4e65caac7823e05f2c1a0eb7e8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1904 zcmd^9L2D#M6#i;@GBeI>Cg4VqMM5Lln8h$NnS(5A48de41O%649b_+|ccy20LQi+o zUD;&zu*pG0FoGxX;#s^3q6dF~_y;_QvMe6v;6V|O8oyWF(aDnhf~}^yzW06aRlWD9 zZYTg19K%9mbLZW+?2oQL1k_Ekci-8mH&^YgL)s^_+nu2keYpPHm$ZN9eAM?xz0OaK z9|4O#h& zifhzgqHPDx(0%{C9f$gub0rEB{l(SWe*ndK>RVv$*7oUMs++e|GdV2rkvW}sE4wo* zf1x6K0Y886cW-jF#!7N5Atx+{#964C6^1RNOzu=f4$;eKU=_KI*4uYc^&K5B_qmo5&RAyg38r}5!<=MulCJ78mKF2LVag(V!MM}A-No7%V`chr z&)UYPbdwx8O!uUNrgGVzl>tkf%t5s?zdK|lVu>p&C7;K<@yQE6R;R3e40+Q|vu9RH zvVYdc71MyltPSjnwA!@WbWXZQ;_Pn8ofjLqk9q5+*~4OCPQ9U;tE#RRUQthuN~&7< zr&3j=qv8wd>*M0=%*<@@xX6&RxX&f-`l}185;v!2RN?8kKaWZh#x3M1;CcL#MiaL< z%}#tz(@Jp;MeJnQB7ZN#dE$PCr}($f87`1N%5WL4;UvQqT*WtXoAS@`oD=+zVTEhF zRVklCRYe)L$Un+(p7^s2PvJ%NRfY@Xzt36)fa#u7}Ym9`rqJFRtIPn~mnO zeSc(!qy~ZG+s?lB!Z@+l?4G}`wHpsQVNmOY!zH^H2HN%gP^^|89*njqZ8+N5cKiFj z6QASG8ErRC24P??*Ba;eO*e33N4s4~A$ibmYTdJYaX7SF*;#fJhkI^E*F3GGTlM;6 z4p>Ks2qVN8u;(6oZDWz_4X#c8Z<<&px=#b9PBf~GfF~xjk8BZSPj?s zcc=!aLDnI^cJUBKbe~W7pVl(g0ngep-T^jvg67<&Z=bvR#4+!WL|x=}xzaY-B&<@- zwpmq;)m`qtN&mpaNCqytj7htY-~n@KlGUt4s)@Phkf>$${=Y7pofU15@f~{COn)`m qTd4E7tPf}3t^EHl8kWi_OXX4R#$mME4G)4{Z_sro%)|>1Oys}7A{Fld 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 d5ecd09c38..59b7fbd39b 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 @@ -27,7 +27,7 @@ class ChatAIAvatar extends StatelessWidget { child: const CircleAvatar( backgroundColor: Colors.transparent, child: FlowySvg( - FlowySvgs.flowy_logo_s, + FlowySvgs.app_logo_s, size: Size.square(16), blendMode: null, ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart index d7a90bd18a..30dc918f70 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart @@ -46,7 +46,7 @@ class ChatWelcomePage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( - FlowySvgs.flowy_logo_xl, + FlowySvgs.app_logo_xl, size: Size.square(32), blendMode: null, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart index eb29807b5b..8e126db7ad 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart @@ -12,7 +12,7 @@ class AFLogo extends StatelessWidget { @override Widget build(BuildContext context) { return FlowySvg( - FlowySvgs.flowy_logo_xl, + FlowySvgs.app_logo_xl, blendMode: null, size: size, ); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart index 146bf06df1..71345aa8dd 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -115,7 +115,7 @@ class Body extends StatelessWidget { return Container( alignment: Alignment.center, child: UniversalPlatform.isMobile - ? const FlowySvg(FlowySvgs.flowy_logo_xl, blendMode: null) + ? const FlowySvg(FlowySvgs.app_logo_xl, blendMode: null) : const _DesktopSplashBody(), ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart index 59b61aa54b..a6124da60b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart @@ -57,7 +57,7 @@ class _MobileWorkspaceStartScreenState children: [ const Spacer(), const FlowySvg( - FlowySvgs.flowy_logo_xl, + FlowySvgs.app_logo_xl, size: Size.square(64), blendMode: null, ), diff --git a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart index 7a19e2a822..3f9657c5cf 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart @@ -1,8 +1,12 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:local_notifier/local_notifier.dart'; +/// The app name used in the local notification. +/// +/// DO NOT Use i18n here, because the i18n plugin is not ready +/// before the local notification is initialized. +const _localNotifierAppName = 'AppFlowy'; + /// Manages Local Notifications /// /// Currently supports: @@ -12,7 +16,11 @@ import 'package:local_notifier/local_notifier.dart'; /// class NotificationService { static Future initialize() async { - await localNotifier.setup(appName: LocaleKeys.appName.tr()); + await localNotifier.setup( + appName: _localNotifierAppName, + // Don't create a shortcut on Windows, because the setup.exe will create a shortcut + shortcutPolicy: ShortcutPolicy.requireNoCreate, + ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart index 2a707b6b2d..c1e539cf58 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -14,6 +14,8 @@ class DesktopAppearance extends BaseAppearance { ) { assert(codeFontFamily.isNotEmpty); + fontFamily = fontFamily.isEmpty ? defaultFontFamily : fontFamily; + final isLight = brightness == Brightness.light; final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart index 559c189925..67930c336a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart @@ -50,8 +50,8 @@ class SidebarTopMenu extends StatelessWidget { } final svgData = Theme.of(context).brightness == Brightness.dark - ? FlowySvgs.flowy_logo_dark_mode_xl - : FlowySvgs.flowy_logo_text_xl; + ? FlowySvgs.app_logo_with_text_dark_xl + : FlowySvgs.app_logo_with_text_light_xl; return Padding( padding: const EdgeInsets.only(top: 12.0, left: 8), diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index e9dfbf48d0..8fe956a194 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -254,9 +254,6 @@ flutter: uses-material-design: true fonts: - - family: FlowyIconData - fonts: - - asset: assets/fonts/FlowyIconData.ttf - family: Poppins fonts: - asset: assets/google_fonts/Poppins/Poppins-ExtraLight.ttf @@ -282,6 +279,9 @@ flutter: - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf style: italic + # White-label font configuration will be added here + # BEGIN: WHITE_LABEL_FONT + # END: WHITE_LABEL_FONT # To add assets to your application, add an assets section, like this: assets: @@ -298,6 +298,7 @@ flutter: - assets/images/login/ - assets/translations/ - assets/icons/icons.json + - assets/fonts/ # The following assets will be excluded in release. # BEGIN: EXCLUDE_IN_RELEASE diff --git a/frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg b/frontend/resources/flowy_icons/16x/ai_chat_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg rename to frontend/resources/flowy_icons/16x/ai_chat_logo.svg diff --git a/frontend/resources/flowy_icons/16x/flowy_logo.svg b/frontend/resources/flowy_icons/16x/app_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/16x/flowy_logo.svg rename to frontend/resources/flowy_icons/16x/app_logo.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo.svg b/frontend/resources/flowy_icons/40x/app_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo.svg rename to frontend/resources/flowy_icons/40x/app_logo.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo_dark_mode.svg b/frontend/resources/flowy_icons/40x/app_logo_with_text_dark.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo_dark_mode.svg rename to frontend/resources/flowy_icons/40x/app_logo_with_text_dark.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo_text.svg b/frontend/resources/flowy_icons/40x/app_logo_with_text_light.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo_text.svg rename to frontend/resources/flowy_icons/40x/app_logo_with_text_light.svg diff --git a/frontend/scripts/white_label/code_white_label.sh b/frontend/scripts/white_label/code_white_label.sh new file mode 100644 index 0000000000..1123a394ee --- /dev/null +++ b/frontend/scripts/white_label/code_white_label.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --company-name Set the custom company name" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --company-name \"MyCompany Ltd.\"" +} + +CUSTOM_COMPANY_NAME="" +CODE_FILE="appflowy_flutter/lib/workspace/application/notification/notification_service.dart" + +while [[ $# -gt 0 ]]; do + case $1 in + --company-name) + CUSTOM_COMPANY_NAME="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$CUSTOM_COMPANY_NAME" ]; then + echo "Error: Company name is required" + show_usage + exit 1 +fi + +if [ ! -f "$CODE_FILE" ]; then + echo "Error: Code file not found at $CODE_FILE" + exit 1 +fi + +echo "Replacing '_localNotifierAppName' value with '$CUSTOM_COMPANY_NAME' in code file..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +echo "Processing code file..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # First, escape any special characters in the company name + ESCAPED_COMPANY_NAME=$(echo "$CUSTOM_COMPANY_NAME" | sed 's/[\/&]/\\&/g') + # Replace the _localNotifierAppName value with the custom company name + sed $SED_INPLACE "s/const _localNotifierAppName = 'AppFlowy'/const _localNotifierAppName = '$ESCAPED_COMPANY_NAME'/" "$CODE_FILE" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $CODE_FILE with sed" + exit 1 + fi +else + # For Unix-like systems + sed $SED_INPLACE "s/const _localNotifierAppName = 'AppFlowy'/const _localNotifierAppName = '$CUSTOM_COMPANY_NAME'/" "$CODE_FILE" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $CODE_FILE with sed" + exit 1 + fi +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/font_white_label.sh b/frontend/scripts/white_label/font_white_label.sh new file mode 100644 index 0000000000..412ee6b062 --- /dev/null +++ b/frontend/scripts/white_label/font_white_label.sh @@ -0,0 +1,198 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --font-path Set the path to the folder containing font files (.ttf or .otf files)" + echo " --font-family Set the name of the font family" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --font-path \"/path/to/fonts\" --font-family \"CustomFont\"" +} + +FONT_PATH="" +FONT_FAMILY="" +TARGET_FONT_DIR="appflowy_flutter/assets/fonts/" +PUBSPEC_FILE="appflowy_flutter/pubspec.yaml" +BASE_APPEARANCE_FILE="appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart" + +while [[ $# -gt 0 ]]; do + case $1 in + --font-path) + FONT_PATH="$2" + shift 2 + ;; + --font-family) + FONT_FAMILY="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$FONT_PATH" ]; then + echo "Error: Font path is required" + show_usage + exit 1 +fi + +if [ -z "$FONT_FAMILY" ]; then + echo "Error: Font family name is required" + show_usage + exit 1 +fi + +# Check if source directory exists +if [ ! -d "$FONT_PATH" ]; then + echo "Error: Font directory not found at $FONT_PATH" + exit 1 +fi + +# Create target directory if it doesn't exist +mkdir -p "$TARGET_FONT_DIR" + +# Clean existing fonts in target directory +echo "Cleaning existing fonts in $TARGET_FONT_DIR..." +rm -rf "$TARGET_FONT_DIR"/* + +# Copy font files to target directory +echo "Copying font files from $FONT_PATH to $TARGET_FONT_DIR..." +found_fonts=false +for ext in ttf otf; do + if ls "$FONT_PATH"/*."$ext" >/dev/null 2>&1; then + cp "$FONT_PATH"/*."$ext" "$TARGET_FONT_DIR"/ 2>/dev/null && found_fonts=true + fi +done + +if [ "$found_fonts" = false ]; then + echo "Error: No font files (.ttf or .otf) found in source directory" + exit 1 +fi + +# Generate font configuration for pubspec.yaml +echo "Generating font configuration..." + +# Create temporary file for font configuration +TEMP_FILE=$(mktemp) + +{ + echo " # BEGIN: WHITE_LABEL_FONT" + echo " - family: $FONT_FAMILY" + echo " fonts:" + + # Generate entries for each font file + for font_file in "$TARGET_FONT_DIR"/*; do + filename=$(basename "$font_file") + echo " - asset: assets/fonts/$filename" + + # Try to detect font weight from filename + if [[ $filename =~ (Thin|ExtraLight|Light|Regular|Medium|SemiBold|Bold|ExtraBold|Black) ]]; then + case ${BASH_REMATCH[1]} in + "Thin") echo " weight: 100";; + "ExtraLight") echo " weight: 200";; + "Light") echo " weight: 300";; + "Regular") echo " weight: 400";; + "Medium") echo " weight: 500";; + "SemiBold") echo " weight: 600";; + "Bold") echo " weight: 700";; + "ExtraBold") echo " weight: 800";; + "Black") echo " weight: 900";; + esac + fi + + # Try to detect italic style from filename + if [[ $filename =~ Italic ]]; then + echo " style: italic" + fi + done + echo " # END: WHITE_LABEL_FONT" +} > "$TEMP_FILE" + +# Update pubspec.yaml +echo "Updating pubspec.yaml..." +if [ -f "$PUBSPEC_FILE" ]; then + # Create a backup of the original file + cp "$PUBSPEC_FILE" "${PUBSPEC_FILE}.bak" + + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Windows-specific handling + # First, remove existing white label font configuration + awk '/# BEGIN: WHITE_LABEL_FONT/,/# END: WHITE_LABEL_FONT/{ next } /# White-label font configuration will be added here/{ print; system("cat '"$TEMP_FILE"'"); next } 1' "$PUBSPEC_FILE" > "${PUBSPEC_FILE}.tmp" + + if [ $? -eq 0 ]; then + mv "${PUBSPEC_FILE}.tmp" "$PUBSPEC_FILE" + rm -f "${PUBSPEC_FILE}.bak" + else + echo "Error: Failed to update pubspec.yaml" + mv "${PUBSPEC_FILE}.bak" "$PUBSPEC_FILE" + rm -f "${PUBSPEC_FILE}.tmp" + rm -f "$TEMP_FILE" + exit 1 + fi + else + # Unix-like systems handling + if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" + else + SED_INPLACE="-i ''" + fi + + # Remove existing white label font configuration + sed $SED_INPLACE '/# BEGIN: WHITE_LABEL_FONT/,/# END: WHITE_LABEL_FONT/d' "$PUBSPEC_FILE" + + # Add new font configuration + sed $SED_INPLACE "/# White-label font configuration will be added here/r $TEMP_FILE" "$PUBSPEC_FILE" + + if [ $? -ne 0 ]; then + echo "Error: Failed to update pubspec.yaml" + mv "${PUBSPEC_FILE}.bak" "$PUBSPEC_FILE" + rm -f "$TEMP_FILE" + exit 1 + fi + rm -f "${PUBSPEC_FILE}.bak" + fi +else + echo "Error: pubspec.yaml not found at $PUBSPEC_FILE" + rm -f "$TEMP_FILE" + exit 1 +fi + +# Update base_appearance.dart +echo "Updating base_appearance.dart..." +if [ -f "$BASE_APPEARANCE_FILE" ]; then + # Create a backup of the original file + cp "$BASE_APPEARANCE_FILE" "${BASE_APPEARANCE_FILE}.bak" + + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Windows-specific handling + sed -i "s/const defaultFontFamily = '.*'/const defaultFontFamily = '$FONT_FAMILY'/" "$BASE_APPEARANCE_FILE" + else + # Unix-like systems handling + sed -i '' "s/const defaultFontFamily = '.*'/const defaultFontFamily = '$FONT_FAMILY'/" "$BASE_APPEARANCE_FILE" + fi + + if [ $? -ne 0 ]; then + echo "Error: Failed to update base_appearance.dart" + mv "${BASE_APPEARANCE_FILE}.bak" "$BASE_APPEARANCE_FILE" + exit 1 + fi + rm -f "${BASE_APPEARANCE_FILE}.bak" +else + echo "Error: base_appearance.dart not found at $BASE_APPEARANCE_FILE" + exit 1 +fi + +# Cleanup +rm -f "$TEMP_FILE" + +echo "Font white labeling completed successfully!" diff --git a/frontend/scripts/white_label/icon_white_label.sh b/frontend/scripts/white_label/icon_white_label.sh index eb45d0f02f..ca70bc1661 100644 --- a/frontend/scripts/white_label/icon_white_label.sh +++ b/frontend/scripts/white_label/icon_white_label.sh @@ -3,16 +3,16 @@ show_usage() { echo "Usage: $0 [options]" echo "Options:" - echo " --icon-path Set the path to the application icon (.svg file)" + echo " --icon-path Set the path to the folder containing application icons (.svg files)" echo " --help Show this help message" echo "" echo "Example:" - echo " $0 --icon-path \"/path/to/new/icon.svg\"" + echo " $0 --icon-path \"/path/to/icons_folder\"" } NEW_ICON_PATH="" ICON_DIR="resources/flowy_icons" -ICON_NAME_NEED_REPLACE=("flowy_logo.svg" "flowy_ai_chat_logo.svg" "flowy_logo_dark_mode.svg" "flowy_logo_text.svg") +ICON_NAME_NEED_REPLACE=("app_logo.svg" "ai_chat_logo.svg" "app_logo_with_text_light.svg" "app_logo_with_text_dark.svg") while [[ $# -gt 0 ]]; do case $1 in @@ -38,12 +38,17 @@ if [ -z "$NEW_ICON_PATH" ]; then exit 1 fi +if [ ! -d "$NEW_ICON_PATH" ]; then + echo "Error: New icon directory not found at $NEW_ICON_PATH" + exit 1 +fi + if [ ! -d "$ICON_DIR" ]; then echo "Error: Icon directory not found at $ICON_DIR" exit 1 fi -echo "Replacing icon..." +echo "Replacing icons..." echo "Processing icon files..." if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then @@ -52,13 +57,18 @@ if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then echo "Checking subdirectory: $(basename "$subdir")" for file in "${subdir}"*.svg; do if [ -f "$file" ] && [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then - echo "Updating: $(basename "$subdir")/$(basename "$file")" - cp "$NEW_ICON_PATH" "$file" - if [ $? -eq 0 ]; then - echo "Successfully replaced $(basename "$file") in $(basename "$subdir") with new icon" + new_icon="${NEW_ICON_PATH}/$(basename "$file")" + if [ -f "$new_icon" ]; then + echo "Updating: $(basename "$subdir")/$(basename "$file")" + cp "$new_icon" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") in $(basename "$subdir") with new icon" + else + echo "Error: Failed to replace $(basename "$file") in $(basename "$subdir")" + exit 1 + fi else - echo "Error: Failed to replace $(basename "$file") in $(basename "$subdir")" - exit 1 + echo "Warning: New icon file $(basename "$file") not found in $NEW_ICON_PATH" fi fi done @@ -67,15 +77,18 @@ if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then else for file in $(find "$ICON_DIR" -name "*.svg" -type f); do if [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then - echo "Updating: $(basename "$file")" - - cp "$NEW_ICON_PATH" "$file" - - if [ $? -eq 0 ]; then - echo "Successfully replaced $(basename "$file") with new icon" + new_icon="${NEW_ICON_PATH}/$(basename "$file")" + if [ -f "$new_icon" ]; then + echo "Updating: $(basename "$file")" + cp "$new_icon" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") with new icon" + else + echo "Error: Failed to replace $(basename "$file")" + exit 1 + fi else - echo "Error: Failed to replace $(basename "$file")" - exit 1 + echo "Warning: New icon file $(basename "$file") not found in $NEW_ICON_PATH" fi fi done diff --git a/frontend/scripts/white_label/white_label.sh b/frontend/scripts/white_label/white_label.sh index 2d6004cf9d..8ecd187210 100644 --- a/frontend/scripts/white_label/white_label.sh +++ b/frontend/scripts/white_label/white_label.sh @@ -7,6 +7,8 @@ COMPANY_NAME="AppFlowy Inc." COPYRIGHT="Copyright © 2025 AppFlowy Inc." ICON_PATH="" WINDOWS_ICON_PATH="" +FONT_PATH="" +FONT_FAMILY="" PLATFORMS=("windows" "linux" "macos" "ios" "android") show_usage() { @@ -18,6 +20,8 @@ show_usage() { echo " --copyright Set the copyright information" echo " --icon-path Set the path to the application icon (.svg)" echo " --windows-icon-path Set the path to the windows application icon (.ico)" + echo " --font-path Set the path to the folder containing font files (.ttf or .otf files)" + echo " --font-family Set the name of the font family" echo " --platforms Comma-separated list of platforms to white label (windows,linux,macos,ios,android)" echo " --help Show this help message" echo "" @@ -26,7 +30,8 @@ show_usage() { echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" echo " --platforms \"windows,linux,macos\" \\" echo " --windows-icon-path \"./assets/icons/mycompany.ico\" \\" - echo " --icon-path \"./assets/icons/mycompany.svg\"" + echo " --icon-path \"./assets/icons/\" \\" + echo " --font-path \"./assets/fonts/\" --font-family \"CustomFont\"" } while [[ $# -gt 0 ]]; do @@ -55,6 +60,14 @@ while [[ $# -gt 0 ]]; do WINDOWS_ICON_PATH="$2" shift 2 ;; + --font-path) + FONT_PATH="$2" + shift 2 + ;; + --font-family) + FONT_FAMILY="$2" + shift 2 + ;; --platforms) IFS=',' read -ra PLATFORMS <<< "$2" shift 2 @@ -77,8 +90,8 @@ if [ -z "$APP_NAME" ] || [ -z "$APP_IDENTIFIER" ] || [ -z "$COMPANY_NAME" ] || [ exit 1 fi -if [ ! -f "$ICON_PATH" ]; then - echo "Error: Icon file not found at $ICON_PATH" +if [ ! -d "$ICON_PATH" ]; then + echo "Error: Icon directory not found at $ICON_PATH" exit 1 fi @@ -111,6 +124,17 @@ bash "scripts/white_label/i18n_white_label.sh" --company-name "$COMPANY_NAME" echo -e "\033[32mRunning icon white label script...\033[0m" bash "scripts/white_label/icon_white_label.sh" --icon-path "$ICON_PATH" +echo -e "\033[32mRunning code white label script...\033[0m" +bash "scripts/white_label/code_white_label.sh" --company-name "$COMPANY_NAME" + +# Run font white label script if font parameters are provided +if [ ! -z "$FONT_PATH" ] && [ ! -z "$FONT_FAMILY" ]; then + echo -e "\033[32mRunning font white label script...\033[0m" + bash "scripts/white_label/font_white_label.sh" \ + --font-path "$FONT_PATH" \ + --font-family "$FONT_FAMILY" +fi + for platform in "${PLATFORMS[@]}"; do run_platform_script "$platform" done From be1d2b4b9259fb0a692f64c3b7fb14aa8aa4b2f1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 12:06:20 +0800 Subject: [PATCH 147/207] chore: delay collab initialization until schema is configured; finalize to ensure collab includes its schema --- frontend/rust-lib/collab-integrate/src/collab_builder.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index e31c38d043..10149bf259 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -277,7 +277,7 @@ impl AppFlowyCollabBuilder { let collab_db = collab_db.clone(); let device_id = self.workspace_integrate.device_id()?; let collab = tokio::task::spawn_blocking(move || { - let mut collab = CollabBuilder::new(object.uid, &object.object_id, data_source) + let collab = CollabBuilder::new(object.uid, &object.object_id, data_source) .with_device_id(device_id) .build()?; let persistence_config = CollabPersistenceConfig::default(); @@ -290,7 +290,6 @@ impl AppFlowyCollabBuilder { persistence_config, ); collab.add_plugin(Box::new(db_plugin)); - collab.initialize(); Ok::<_, Error>(collab) }) .await??; From 3214ec075be8dd3b2d51a309c66f6aa6cfcd419d Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 14:04:12 +0800 Subject: [PATCH 148/207] chore: fix flaky initialize folder indexer --- .../tasks/app_window_size_manager.dart | 8 ++- .../rust-lib/flowy-folder/src/manager_init.rs | 7 ++- .../flowy-search/src/folder/indexer.rs | 51 +++++++++++++++---- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart index 7d41f2dceb..5636ed70cb 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart @@ -12,6 +12,10 @@ class WindowSizeManager { static const double maxWindowHeight = 8192.0; static const double maxWindowWidth = 8192.0; + // Default windows size + static const double defaultWindowHeight = 960.0; + static const double defaultWindowWidth = 1280.0; + static const double maxScaleFactor = 2.0; static const double minScaleFactor = 0.5; @@ -36,8 +40,8 @@ class WindowSizeManager { Future getSize() async { final defaultWindowSize = jsonEncode( { - WindowSizeManager.height: minWindowHeight, - WindowSizeManager.width: minWindowWidth, + WindowSizeManager.height: defaultWindowHeight, + WindowSizeManager.width: defaultWindowWidth, }, ); final windowSize = await getIt().get(KVKeys.windowSize); diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index 59eb3e21ee..62cce7c394 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -138,7 +138,12 @@ impl FolderManager { Arc::downgrade(&self.user), ); - self.folder_indexer.initialize().await; + let weak_folder_indexer = Arc::downgrade(&self.folder_indexer); + tokio::spawn(async move { + if let Some(folder_indexer) = weak_folder_indexer.upgrade() { + folder_indexer.initialize().await; + } + }); Ok(()) } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 641c1f5f96..71ac5d5e60 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -1,3 +1,5 @@ +use super::entities::FolderIndexData; +use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB}; use crate::folder::schema::{ FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, @@ -7,27 +9,32 @@ use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexCont use flowy_error::{FlowyError, FlowyResult}; use flowy_search_pub::entities::{FolderIndexManager, IndexManager, IndexableData}; use flowy_user::services::authenticate_user::AuthenticateUser; +use lib_infra::async_trait::async_trait; +use std::path::PathBuf; use std::sync::{Arc, Weak}; use std::{collections::HashMap, fs}; - -use super::entities::FolderIndexData; -use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB}; -use lib_infra::async_trait::async_trait; use tantivy::{ collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, - Index, IndexReader, IndexWriter, TantivyDocument, Term, + Index, IndexReader, IndexWriter, TantivyDocument, TantivyError, Term, }; use tokio::sync::RwLock; use tracing::{error, info}; use uuid::Uuid; pub struct TantivyState { + pub path: PathBuf, pub index: Index, pub folder_schema: FolderSchema, pub index_reader: IndexReader, pub index_writer: IndexWriter, } +impl Drop for TantivyState { + fn drop(&mut self) { + tracing::trace!("Dropping TantivyState at {:?}", self.path); + } +} + const FOLDER_INDEX_DIR: &str = "folder_index"; #[derive(Clone)] @@ -57,7 +64,19 @@ impl FolderIndexManagerImpl { } /// Initializes the state using the workspace directory. - async fn initialize_with_workspace(&self) -> FlowyResult<()> { + async fn initialize(&self) -> FlowyResult<()> { + if let Some(state) = self.state.write().await.take() { + info!("Re-initializing folder indexer"); + drop(state); + } + + // Since the directory lock may not be immediately released, + // a workaround is implemented by waiting for 3 seconds before proceeding further. This delay helps + // to avoid errors related to trying to open an index directory while an IndexWriter is still active. + // + // Also, we don't need to initialize the indexer immediately. + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + let auth_user = self .auth_user .upgrade() @@ -73,12 +92,26 @@ impl FolderIndexManagerImpl { info!("Folder indexer initialized at: {:?}", index_path); let folder_schema = FolderSchema::new(); - let dir = MmapDirectory::open(index_path)?; + let dir = MmapDirectory::open(index_path.clone())?; let index = Index::open_or_create(dir, folder_schema.schema.clone())?; let index_reader = index.reader()?; - let index_writer = index.writer(50_000_000)?; + + let index_writer = match index.writer::<_>(50_000_000) { + Ok(index_writer) => index_writer, + Err(err) => { + if let TantivyError::LockFailure(_, _) = err { + error!( + "Failed to acquire lock for index writer: {:?}, retry later", + err + ); + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + } + index.writer::<_>(50_000_000)? + }, + }; *self.state.write().await = Some(TantivyState { + path: index_path, index, folder_schema, index_reader, @@ -295,7 +328,7 @@ impl IndexManager for FolderIndexManagerImpl { #[async_trait] impl FolderIndexManager for FolderIndexManagerImpl { async fn initialize(&self) { - if let Err(e) = self.initialize_with_workspace().await { + if let Err(e) = self.initialize().await { error!("Failed to initialize FolderIndexManager: {:?}", e); } } From e6951012f0a9f27df394a324e72a196b8bb30e6c Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 14:15:38 +0800 Subject: [PATCH 149/207] chore: do not generate search summary if result is empty --- frontend/rust-lib/flowy-search/src/document/handler.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index d236f4b639..2127ef0d98 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -90,6 +90,7 @@ impl SearchHandler for DocumentSearchHandler { return; } }; + trace!("[Search] search result: {:?}", result_items); // Prepare input for search summary generation. let summary_input: Vec = result_items @@ -122,11 +123,15 @@ impl SearchHandler for DocumentSearchHandler { CreateSearchResultPBArgs::default() .searching(false) .search_result(Some(search_result)) - .generating_ai_summary(true) + .generating_ai_summary(!result_items.is_empty()) .build() .unwrap(), ); + if result_items.is_empty() { + return; + } + // Generate and yield search summary. match cloud_service.generate_search_summary(&workspace_id, query.clone(), summary_input).await { Ok(summary_result) => { From be132d867a17e213d1e638918d48e4bdf92ab5f0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 14:31:10 +0800 Subject: [PATCH 150/207] chore: lock analyzer version --- frontend/appflowy_flutter/pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 8fe956a194..28c9f32134 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -11,6 +11,7 @@ environment: sdk: '>=3.3.0 <4.0.0' dependencies: + analyzer: 6.11.0 any_date: ^1.0.4 app_links: ^6.3.3 appflowy_backend: From ecfcf3be4d15d82fcabb50905be3c38ec71fd617 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 14:37:45 +0800 Subject: [PATCH 151/207] chore: commit pub lock --- frontend/appflowy_flutter/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index cc574f7e64..120a630a8c 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -15,7 +15,7 @@ packages: source: sdk version: "0.3.3" analyzer: - dependency: transitive + dependency: "direct main" description: name: analyzer sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" From 92179fe61cb7ae7282de4996b2e82a980c50fdf0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 15:16:06 +0800 Subject: [PATCH 152/207] chore: do not get workspace usage when current user is not owner --- .../menu/sidebar_sections_bloc.dart | 5 ++- .../billing/settings_billing_bloc.dart | 2 +- .../settings/plan/settings_plan_bloc.dart | 7 +++-- .../sidebar/billing/sidebar_plan_bloc.dart | 31 +++++++++++-------- .../application/sidebar/space/space_bloc.dart | 5 ++- .../workspace/workspace_service.dart | 18 +++++++++-- frontend/appflowy_flutter/test/util.dart | 5 ++- .../rust-lib/flowy-user/src/event_handler.rs | 5 ++- .../user_manager/manager_user_workspace.rs | 7 +++-- 9 files changed, 61 insertions(+), 24 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart index 5a20b29c09..d6a6a73578 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart @@ -244,7 +244,10 @@ class SidebarSectionsBloc } void _initial(UserProfilePB userProfile, String workspaceId) { - _workspaceService = WorkspaceService(workspaceId: workspaceId); + _workspaceService = WorkspaceService( + workspaceId: workspaceId, + userId: userProfile.id, + ); _listener = WorkspaceSectionsListener( user: userProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart index ab324df87f..df880891e9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart @@ -29,7 +29,7 @@ class SettingsBillingBloc required Int64 userId, }) : super(const _Initial()) { _userService = UserBackendService(userId: userId); - _service = WorkspaceService(workspaceId: workspaceId); + _service = WorkspaceService(workspaceId: workspaceId, userId: userId); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart index dcc5156937..26975b00ff 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -23,7 +23,10 @@ class SettingsPlanBloc extends Bloc { required this.workspaceId, required Int64 userId, }) : super(const _Initial()) { - _service = WorkspaceService(workspaceId: workspaceId); + _service = WorkspaceService( + workspaceId: workspaceId, + userId: userId, + ); _userService = UserBackendService(userId: userId); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); @@ -43,7 +46,7 @@ class SettingsPlanBloc extends Bloc { FlowyError? error; final usageResult = snapshots.first.fold( - (s) => s as WorkspaceUsagePB, + (s) => s as WorkspaceUsagePB?, (f) { error = f; return null; diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart index 2fae0e4872..56d6ae8cc8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; @@ -182,20 +183,24 @@ class SidebarPlanBloc extends Bloc { ); } - void _checkWorkspaceUsage() { - if (state.workspaceId != null) { - final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!); - UserEventGetWorkspaceUsage(payload).send().then((result) { - result.onSuccess( - (usage) { - if (isClosed) { - return; - } - add(SidebarPlanEvent.updateWorkspaceUsage(usage)); - }, - ); - }); + Future _checkWorkspaceUsage() async { + if (state.workspaceId == null || state.userProfile == null) { + return; } + + await WorkspaceService( + workspaceId: state.workspaceId!, + userId: state.userProfile!.id, + ).getWorkspaceUsage().then((result) { + result.fold( + (usage) { + if (!isClosed && usage != null) { + add(SidebarPlanEvent.updateWorkspaceUsage(usage)); + } + }, + (error) => Log.error("Failed to get workspace usage: $error"), + ); + }); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index d4c1b4fe16..6d6ce05051 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -486,7 +486,10 @@ class SpaceBloc extends Bloc { } void _initial(UserProfilePB userProfile, String workspaceId) { - _workspaceService = WorkspaceService(workspaceId: workspaceId); + _workspaceService = WorkspaceService( + workspaceId: workspaceId, + userId: userProfile.id, + ); this.userProfile = userProfile; this.workspaceId = workspaceId; diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index b958e5cd30..ae6220994e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -1,15 +1,18 @@ import 'dart:async'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart' as fixnum; class WorkspaceService { - WorkspaceService({required this.workspaceId}); + WorkspaceService({required this.workspaceId, required this.userId}); final String workspaceId; + final fixnum.Int64 userId; Future> createView({ required String name, @@ -82,7 +85,18 @@ class WorkspaceService { return FolderEventMoveView(payload).send(); } - Future> getWorkspaceUsage() { + Future> getWorkspaceUsage() async { + final request = WorkspaceMemberIdPB()..uid = userId; + final result = await UserEventGetMemberInfo(request).send(); + final isOwner = result.fold( + (member) => member.role.isOwner, + (_) => false, + ); + + if (!isOwner) { + return FlowyResult.success(null); + } + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); return UserEventGetWorkspaceUsage(payload).send(); } diff --git a/frontend/appflowy_flutter/test/util.dart b/frontend/appflowy_flutter/test/util.dart index c4f2a21c64..3bb774411b 100644 --- a/frontend/appflowy_flutter/test/util.dart +++ b/frontend/appflowy_flutter/test/util.dart @@ -69,7 +69,10 @@ class AppFlowyUnitTest { } Future _initialServices() async { - workspaceService = WorkspaceService(workspaceId: currentWorkspace.id); + workspaceService = WorkspaceService( + workspaceId: currentWorkspace.id, + userId: userProfile.id, + ); } Future createWorkspace() async { diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 0e42525d04..85b9274df3 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -896,7 +896,10 @@ pub async fn get_workspace_member_info( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let member = manager.get_workspace_member_info(param.uid).await?; + let workspace_id = manager.get_session()?.user_workspace.workspace_id()?; + let member = manager + .get_workspace_member_info(param.uid, &workspace_id) + .await?; data_result_ok(member.into()) } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 496019cd2e..507916acf3 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -583,8 +583,11 @@ impl UserManager { Ok(UseAISettingPB::from(settings)) } - pub async fn get_workspace_member_info(&self, uid: i64) -> FlowyResult { - let workspace_id = self.get_session()?.user_workspace.workspace_id()?; + pub async fn get_workspace_member_info( + &self, + uid: i64, + workspace_id: &Uuid, + ) -> FlowyResult { let db = self.authenticate_user.get_sqlite_connection(uid)?; // Can opt in using memory cache if let Ok(member_record) = select_workspace_member(db, &workspace_id.to_string(), uid) { From f727dde74bd97d3be961251c2a4524346395ed7e Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 16 Apr 2025 15:59:43 +0800 Subject: [PATCH 153/207] fix: lose nested children when accepting AI responses (#7760) * chore: add gitkeep in assets/font dir * Revert "feat: improve white label scripts on Windows (#7755)" This reverts commit a5eb2cdd9a0171ecbc442aef09a8cf8db469a214. * chore: use --verbose * fix: lose nested children when accept ai response * chore: lock analyzer version * Reapply "feat: improve white label scripts on Windows (#7755)" This reverts commit c73186306eaf3e9f78114fcc29e39e3a2bce528d. * chore: lock analyzer version * chore: update editor version --- .../appflowy_flutter/assets/fonts/.gitkeep | 0 .../ai/operations/ai_writer_cubit.dart | 2 +- .../operations/ai_writer_node_extension.dart | 12 +++- .../base/markdown_text_robot.dart | 56 +++++++++++++++++-- .../packages/flowy_infra/pubspec.yaml | 1 + .../packages/flowy_infra_ui/pubspec.yaml | 2 + frontend/appflowy_flutter/pubspec.lock | 6 +- frontend/appflowy_flutter/pubspec.yaml | 18 +++--- .../freezed/generate_freezed.sh | 8 +-- frontend/scripts/code_generation/generate.sh | 2 +- 10 files changed, 84 insertions(+), 23 deletions(-) create mode 100644 frontend/appflowy_flutter/assets/fonts/.gitkeep diff --git a/frontend/appflowy_flutter/assets/fonts/.gitkeep b/frontend/appflowy_flutter/assets/fonts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart index 7b9da81d44..4bc13321b8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -19,7 +19,7 @@ import 'ai_writer_node_extension.dart'; /// Enable the debug log for the AiWriterCubit. /// /// This is useful for debugging the AI writer cubit. -const _aiWriterCubitDebugLog = false; +const _aiWriterCubitDebugLog = true; class AiWriterCubit extends Cubit { AiWriterCubit({ diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart index 6f4456f9ec..881871b154 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart @@ -34,7 +34,15 @@ extension AiWriterNodeExtension on EditorState { // if the selected nodes are not entirely selected, slice the nodes final slicedNodes = []; - final nodes = getNodesInSelection(selection); + final List flattenNodes = getNodesInSelection(selection); + final List nodes = []; + + for (final node in flattenNodes) { + if (nodes.any((element) => element.isParentOf(node))) { + continue; + } + nodes.add(node); + } for (final node in nodes) { final delta = node.delta; @@ -76,7 +84,7 @@ extension AiWriterNodeExtension on EditorState { // using \n will cause the ai response treat the text as a single line final markdown = await customDocumentToMarkdown( Document.blank()..insert([0], slicedNodes), - lineBreak: '\n\n', + lineBreak: '\n', ); // trim the last \n if it exists diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 0dea3d2864..259777db94 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -398,6 +398,15 @@ class MarkdownTextRobot { // it means the user selected the entire sentence, we just replace the node if (startIndex == 0 && length == node.delta?.length) { + if (nodes.isNotEmpty && node.children.isNotEmpty) { + // merge the children of the selected node and the first node of the ai response + nodes[0] = nodes[0].copyWith( + children: [ + ...node.children.map((e) => e.deepCopy()), + ...nodes[0].children, + ], + ); + } transaction ..insertNodes(node.path.next, nodes) ..deleteNode(node); @@ -441,7 +450,14 @@ class MarkdownTextRobot { ).root.children; // Get the selected nodes. - final nodes = editorState.getNodesInSelection(selection); + final flattenNodes = editorState.getNodesInSelection(selection); + final nodes = []; + for (final node in flattenNodes) { + if (nodes.any((element) => element.isParentOf(node))) { + continue; + } + nodes.add(node); + } // Note: Don't change its order, otherwise the delta will be incorrect. // step 1. merge the first selected node and the first node from the ai response @@ -465,9 +481,23 @@ class MarkdownTextRobot { transaction ..deleteText(firstNode, startIndex, length) ..insertTextDelta(firstNode, startIndex, firstMarkdownDelta); + + // if the first markdown node has children, we need to insert the children + // and delete the children of the first node that are in the selection. + if (firstMarkdownNode.children.isNotEmpty) { + transaction.insertNodes( + firstNode.path.child(0), + firstMarkdownNode.children.map((e) => e.deepCopy()), + ); + } + + final nodesToDelete = + firstNode.children.where((e) => e.path.inSelection(selection)); + transaction.deleteNodes(nodesToDelete); } // step 2 + bool handledLastNode = false; final lastNode = nodes.lastOrNull; final lastDelta = lastNode?.delta; final lastMarkdownNode = markdownNodes.lastOrNull; @@ -475,7 +505,10 @@ class MarkdownTextRobot { if (lastNode != null && lastDelta != null && lastMarkdownNode != null && - lastMarkdownDelta != null) { + lastMarkdownDelta != null && + firstNode?.id != lastNode.id) { + handledLastNode = true; + final endIndex = selection.endIndex; transaction.deleteText(lastNode, 0, endIndex); @@ -484,15 +517,30 @@ class MarkdownTextRobot { // selected text in the first node. if (lastMarkdownNode.id != firstMarkdownNode?.id) { transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta); + + if (lastMarkdownNode.children.isNotEmpty) { + transaction + ..insertNodes( + lastNode.path.child(0), + lastMarkdownNode.children.map((e) => e.deepCopy()), + ) + ..deleteNodes( + lastNode.children.where((e) => e.path.inSelection(selection)), + ); + } } } // step 3 final insertedPath = selection.start.path.nextNPath(1); - if (markdownNodes.length > 2) { + final insertLength = handledLastNode ? 2 : 1; + if (markdownNodes.length > insertLength) { transaction.insertNodes( insertedPath, - markdownNodes.skip(1).take(markdownNodes.length - 2).toList(), + markdownNodes + .skip(1) + .take(markdownNodes.length - insertLength) + .toList(), ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml index 478c649664..9bf0245dc0 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: freezed_annotation: ^2.1.0 file_picker: ^8.0.2 file: ^7.0.0 + analyzer: 6.11.0 dev_dependencies: build_runner: ^2.4.9 diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml index 62cb26d4e0..b5b5c22bc7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml @@ -31,6 +31,8 @@ dependencies: flowy_svg: path: ../flowy_svg + analyzer: 6.11.0 + dev_dependencies: build_runner: ^2.4.9 provider: ^6.0.5 diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index cc574f7e64..063b770ce3 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -15,7 +15,7 @@ packages: source: sdk version: "0.3.3" analyzer: - dependency: transitive + dependency: "direct main" description: name: analyzer sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4967ed5" - resolved-ref: "4967ed57d9190948c08f868972c0babfdc470ba7" + ref: "2361899" + resolved-ref: "23618990b2f4ab88df67d50598b2b53cd6853e0a" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 8fe956a194..3ca505e7a5 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -2,13 +2,13 @@ name: appflowy description: Bring projects, wikis, and teams together with AI. AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data. The best open source alternative to Notion. -publish_to: 'none' +publish_to: "none" version: 0.8.9 environment: - flutter: '>=3.27.4' - sdk: '>=3.3.0 <4.0.0' + flutter: ">=3.27.4" + sdk: ">=3.3.0 <4.0.0" dependencies: any_date: ^1.0.4 @@ -39,7 +39,7 @@ dependencies: calendar_view: git: url: https://github.com/Xazin/flutter_calendar_view - ref: '6fe0c98' + ref: "6fe0c98" collection: ^1.17.1 connectivity_plus: ^5.0.2 cross_file: ^0.3.4+1 @@ -75,7 +75,7 @@ dependencies: flutter_emoji_mart: git: url: https://github.com/LucasXu0/emoji_mart.git - ref: '355aa56' + ref: "355aa56" flutter_math_fork: ^0.7.3 flutter_slidable: ^3.0.0 @@ -152,6 +152,8 @@ dependencies: talker_bloc_logger: ^4.7.1 talker: ^4.7.1 + analyzer: 6.11.0 + dev_dependencies: # Introduce talker to log the bloc events, and only log the events in the development mode @@ -185,13 +187,13 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: '4967ed5' + ref: "2361899" appflowy_editor_plugins: git: url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git - path: 'packages/appflowy_editor_plugins' - ref: '4efcff7' + path: "packages/appflowy_editor_plugins" + ref: "4efcff7" sheet: git: diff --git a/frontend/scripts/code_generation/freezed/generate_freezed.sh b/frontend/scripts/code_generation/freezed/generate_freezed.sh index 45de7573d7..216a01b232 100755 --- a/frontend/scripts/code_generation/freezed/generate_freezed.sh +++ b/frontend/scripts/code_generation/freezed/generate_freezed.sh @@ -66,9 +66,9 @@ if [ "$exclude_packages" = false ]; then fi fi if [ "$verbose" = true ]; then - dart run build_runner build + dart run build_runner build --delete-conflicting-outputs else - dart run build_runner build >/dev/null 2>&1 + dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1 fi echo "🧊 Done generating freezed files ($d)." fi @@ -108,9 +108,9 @@ fi # Start the build_runner in the background if [ "$verbose" = true ]; then - dart run build_runner build -d & + dart run build_runner build --delete-conflicting-outputs & else - dart run build_runner build -d >/dev/null 2>&1 & + dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1 & fi # Get the PID of the background process diff --git a/frontend/scripts/code_generation/generate.sh b/frontend/scripts/code_generation/generate.sh index e08bc873fd..fd2edab785 100755 --- a/frontend/scripts/code_generation/generate.sh +++ b/frontend/scripts/code_generation/generate.sh @@ -64,7 +64,7 @@ cd .. cd freezed # Allow execution permissions on CI chmod +x ./generate_freezed.sh -./generate_freezed.sh "${args[@]}" --show-loading +./generate_freezed.sh "${args[@]}" --show-loading --verbose # Return to the original directory cd "$original_dir" From f6522297184bccc02c7c259d635bb86e0b6790f2 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 16:08:11 +0800 Subject: [PATCH 154/207] fix: open relation database row --- .../row/related_row_detail_bloc.dart | 31 +++++++++---------- .../flowy-database2/src/event_handler.rs | 20 ++++++++---- .../rust-lib/flowy-database2/src/event_map.rs | 6 ++-- .../rust-lib/flowy-database2/src/manager.rs | 9 ++++++ 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart index 4f975cd1a6..f735618dd8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart @@ -73,27 +73,24 @@ class RelatedRowDetailPageBloc }); } - /// initialize bloc through the `database_id` and `row_id`. The process is as - /// follows: - /// 1. use the `database_id` to get the database meta, which contains the - /// `inline_view_id` - /// 2. use the `inline_view_id` to instantiate a `DatabaseController`. - /// 3. use the `row_id` with the DatabaseController` to create `RowController` void _init(String databaseId, String initialRowId) async { - final databaseMeta = - await DatabaseEventGetDatabaseMeta(DatabaseIdPB(value: databaseId)) - .send() - .fold((s) => s, (f) => null); - if (databaseMeta == null) { + final viewId = await DatabaseEventGetDefaultDatabaseViewId( + DatabaseIdPB(value: databaseId), + ).send().fold( + (pb) => pb.value, + (error) => null, + ); + + if (viewId == null) { return; } - final inlineView = - await ViewBackendService.getView(databaseMeta.inlineViewId) - .fold((viewPB) => viewPB, (f) => null); - if (inlineView == null) { + + final databaseView = await ViewBackendService.getView(viewId) + .fold((viewPB) => viewPB, (f) => null); + if (databaseView == null) { return; } - final databaseController = DatabaseController(view: inlineView); + final databaseController = DatabaseController(view: databaseView); await databaseController.open().fold( (s) => databaseController.setIsLoading(false), (f) => null, @@ -104,7 +101,7 @@ class RelatedRowDetailPageBloc } final rowController = RowController( rowMeta: rowInfo.rowMeta, - viewId: inlineView.id, + viewId: databaseView.id, rowCache: databaseController.rowCache, ); diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index 6117a8ee8a..9164550fe4 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -865,17 +865,25 @@ pub(crate) async fn delete_group_handler( } #[tracing::instrument(level = "debug", skip(manager), err)] -pub(crate) async fn get_database_meta_handler( +pub(crate) async fn get_default_database_view_id_handler( data: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let manager = upgrade_manager(manager)?; let database_id = data.into_inner().value; - let inline_view_id = manager.get_database_inline_view_id(&database_id).await?; + let database_view_id = manager + .get_database_meta(&database_id) + .await? + .and_then(|mut d| d.linked_views.pop()) + .ok_or_else(|| { + FlowyError::internal().with_context(format!( + "Can't find any database view for given database id: {}", + database_id + )) + })?; - let data = DatabaseMetaPB { - database_id, - inline_view_id, + let data = DatabaseViewIdPB { + value: database_view_id, }; data_result_ok(data) } diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 6281cde745..824565e5b8 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -64,7 +64,7 @@ pub fn init(database_manager: Weak) -> AFPlugin { .event(DatabaseEvent::CreateGroup, create_group_handler) .event(DatabaseEvent::DeleteGroup, delete_group_handler) // Database - .event(DatabaseEvent::GetDatabaseMeta, get_database_meta_handler) + .event(DatabaseEvent::GetDefaultDatabaseViewId, get_default_database_view_id_handler) .event(DatabaseEvent::GetDatabases, get_databases_handler) // Calendar .event(DatabaseEvent::GetAllCalendarEvents, get_calendar_events_handler) @@ -305,8 +305,8 @@ pub enum DatabaseEvent { #[event(input = "DeleteGroupPayloadPB")] DeleteGroup = 115, - #[event(input = "DatabaseIdPB", output = "DatabaseMetaPB")] - GetDatabaseMeta = 119, + #[event(input = "DatabaseIdPB", output = "DatabaseViewIdPB")] + GetDefaultDatabaseViewId = 119, /// Returns all the databases #[event(output = "RepeatedDatabaseDescriptionPB")] diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index b2b2b0a676..49a946d108 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -166,6 +166,15 @@ impl DatabaseManager { items } + pub async fn get_database_meta(&self, database_id: &str) -> FlowyResult> { + let mut database_meta = None; + if let Some(lock) = self.workspace_database_manager.load_full() { + let wdb = lock.read().await; + database_meta = wdb.get_database_meta(database_id); + } + Ok(database_meta) + } + #[instrument(level = "trace", skip_all, err)] pub async fn update_database_indexing( &self, From 0be8dcc000e6d2e21ac212b617bdf7a8933c2b2f Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 16:29:28 +0800 Subject: [PATCH 155/207] chore: remove duplciate --- frontend/appflowy_flutter/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index eb7fd6b755..3ca505e7a5 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -11,7 +11,6 @@ environment: sdk: ">=3.3.0 <4.0.0" dependencies: - analyzer: 6.11.0 any_date: ^1.0.4 app_links: ^6.3.3 appflowy_backend: From 079112c9d2343b50af547781eeb9715310fa7bae Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 16:30:07 +0800 Subject: [PATCH 156/207] chore: clippy --- .../flowy-user/src/user_manager/manager_user_workspace.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 507916acf3..dae5a51ccc 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -606,7 +606,7 @@ impl UserManager { } let member = self - .get_workspace_member_info_from_remote(&workspace_id, uid) + .get_workspace_member_info_from_remote(workspace_id, uid) .await?; Ok(member) From 13acc3af860cfa7c1dbebb9562a1d16a8cf90c7b Mon Sep 17 00:00:00 2001 From: Morn Date: Wed, 16 Apr 2025 16:56:37 +0800 Subject: [PATCH 157/207] fix: auto focus on link name textfield after open the link_edit_menu (#7757) * fix: auto focus on link name textfield after open the link_edit_menu * chore: update pubspec.yaml --- .../desktop_toolbar/link/link_edit_menu.dart | 164 +++++++++++------- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 3 files changed, 100 insertions(+), 70 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart index b6a4ad89ba..e90ee22a80 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart @@ -13,6 +13,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:flutter/services.dart'; import 'link_create_menu.dart'; import 'link_search_text_field.dart'; import 'link_styles.dart'; @@ -38,15 +39,14 @@ class LinkEditMenu extends StatefulWidget { } class _LinkEditMenuState extends State { - ValueChanged get onApply => widget.onApply; - ValueChanged get onRemoveLink => widget.onRemoveLink; VoidCallback get onDismiss => widget.onDismiss; late TextEditingController linkNameController = TextEditingController(text: linkInfo.name); - final textFocusNode = FocusNode(); + late FocusNode textFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); + late FocusNode menuFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); late LinkInfo linkInfo = widget.linkInfo; late LinkSearchTextField searchTextField; bool isShowingSearchResult = false; @@ -74,12 +74,14 @@ class _LinkEditMenuState extends State { if (mounted) setState(() {}); }, )..searchRecentViews(); + makeSureHasFocus(); } @override void dispose() { linkNameController.dispose(); textFocusNode.dispose(); + menuFocusNode.dispose(); searchTextField.dispose(); super.dispose(); } @@ -91,56 +93,59 @@ class _LinkEditMenuState extends State { final errorHeight = showErrorText ? 20.0 : 0.0; return GestureDetector( onTap: onDismiss, - child: Container( - width: 400, - height: 250 + (showingRecent ? 32 : 0), - color: Colors.white.withAlpha(1), - child: Stack( - children: [ - GestureDetector( - onTap: hideSearchResult, - child: Container( - width: 400, - height: 192 + errorHeight, - decoration: buildToolbarLinkDecoration(context), + child: Focus( + focusNode: menuFocusNode, + child: Container( + width: 400, + height: 250 + (showingRecent ? 32 : 0), + color: Colors.white.withAlpha(1), + child: Stack( + children: [ + GestureDetector( + onTap: hideSearchResult, + child: Container( + width: 400, + height: 192 + errorHeight, + decoration: buildToolbarLinkDecoration(context), + ), ), - ), - Positioned( - top: 16, - left: 20, - child: FlowyText.semibold( - LocaleKeys.document_toolbar_pageOrURL.tr(), - color: LinkStyle.textTertiary, - fontSize: 12, - figmaLineHeight: 16, + Positioned( + top: 16, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_pageOrURL.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), ), - ), - Positioned( - top: 80 + errorHeight, - left: 20, - child: FlowyText.semibold( - LocaleKeys.document_toolbar_linkName.tr(), - color: LinkStyle.textTertiary, - fontSize: 12, - figmaLineHeight: 16, + Positioned( + top: 80 + errorHeight, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_linkName.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), ), - ), - Positioned( - top: 144 + errorHeight, - left: 20, - child: buildButtons(), - ), - Positioned( - top: 100 + errorHeight, - left: 20, - child: buildNameTextField(), - ), - Positioned( - top: 36, - left: 20, - child: buildLinkField(), - ), - ], + Positioned( + top: 144 + errorHeight, + left: 20, + child: buildButtons(), + ), + Positioned( + top: 100 + errorHeight, + left: 20, + child: buildNameTextField(), + ), + Positioned( + top: 36, + left: 20, + child: buildLinkField(), + ), + ], + ), ), ), ); @@ -254,23 +259,7 @@ class _LinkEditMenuState extends State { fontColor: Colors.white, fillColor: LinkStyle.fillThemeThick, fontWeight: FontWeight.w400, - onPressed: () { - if (isShowingSearchResult) { - onConfirm(); - return; - } - if (linkInfo.link.isEmpty) { - widget.onRemoveLink(linkInfo); - return; - } - if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { - setState(() { - showErrorText = true; - }); - return; - } - widget.onApply.call(linkInfo); - }, + onPressed: onApply, ); }, ), @@ -287,6 +276,7 @@ class _LinkEditMenuState extends State { child: TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, focusNode: textFocusNode, + autofocus: true, textAlign: TextAlign.left, controller: linkNameController, style: TextStyle( @@ -395,6 +385,45 @@ class _LinkEditMenuState extends State { ); } + KeyEventResult onFocusKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + if (key.logicalKey == LogicalKeyboardKey.enter) { + onApply(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future makeSureHasFocus() async { + final focusNode = textFocusNode; + if (!mounted || focusNode.hasFocus) return; + focusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + makeSureHasFocus(); + }); + } + + void onApply() { + if (isShowingSearchResult) { + onConfirm(); + return; + } + if (linkInfo.link.isEmpty) { + widget.onRemoveLink(linkInfo); + return; + } + if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onApply.call(linkInfo); + } + void onConfirm() { searchTextField.onSearchResult( onLink: onLinkSelected, @@ -404,6 +433,7 @@ class _LinkEditMenuState extends State { searchTextField.unfocus(); }, ); + menuFocusNode.requestFocus(); } Future getPageView() async { diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 063b770ce3..c871a41f7e 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -98,8 +98,8 @@ packages: dependency: "direct main" description: path: "." - ref: "2361899" - resolved-ref: "23618990b2f4ab88df67d50598b2b53cd6853e0a" + ref: "680222f" + resolved-ref: "680222f503f90d07c08c99c42764f9b08fd0f46c" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 3ca505e7a5..e8042d6a57 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -187,7 +187,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "2361899" + ref: "680222f" appflowy_editor_plugins: git: From c89f33e2f89f5cc82b3c3ff5b72f2f8b7e683605 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 20:59:25 +0800 Subject: [PATCH 158/207] chore: remove unused code --- .../application/workspace_error_bloc.dart | 15 +--- .../screens/workspace_error_screen.dart | 41 --------- frontend/rust-lib/Cargo.lock | 29 +------ frontend/rust-lib/flowy-ai/src/chat.rs | 3 +- frontend/rust-lib/flowy-ai/src/entities.rs | 4 + frontend/rust-lib/flowy-core/src/lib.rs | 9 ++ .../rust-lib/flowy-core/src/server_layer.rs | 32 +------ frontend/rust-lib/flowy-server/Cargo.toml | 12 +-- .../flowy-server/src/af_cloud/define.rs | 7 +- .../af_cloud/impls/user/cloud_service_impl.rs | 4 - .../src/local_server/impls/chat.rs | 6 +- .../src/local_server/impls/folder.rs | 6 +- .../src/local_server/impls/user.rs | 84 +++++++------------ .../flowy-server/src/local_server/server.rs | 58 +++++-------- .../flowy-server/tests/af_cloud_test/util.rs | 14 +++- frontend/rust-lib/flowy-user-pub/Cargo.toml | 1 + frontend/rust-lib/flowy-user-pub/src/cloud.rs | 2 - .../rust-lib/flowy-user/src/event_handler.rs | 18 ---- frontend/rust-lib/flowy-user/src/event_map.rs | 4 - .../src/services/authenticate_user.rs | 20 ----- .../src/services/sqlite_sql/user_sql.rs | 7 -- .../flowy-user/src/user_manager/manager.rs | 1 - .../user_manager/manager_user_workspace.rs | 18 ---- 23 files changed, 101 insertions(+), 294 deletions(-) diff --git a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart index ce51fdd10b..7ff50dbd02 100644 --- a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart @@ -1,9 +1,7 @@ import 'package:appflowy/plugins/database/application/defines.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -22,20 +20,10 @@ class WorkspaceErrorBloc void _dispatch() { on( (event, emit) async { - await event.when( + event.when( init: () { // _loadSnapshots(); }, - resetWorkspace: () async { - emit(state.copyWith(loadingState: const LoadingState.loading())); - final payload = ResetWorkspacePB.create() - ..workspaceId = userFolder.workspaceId - ..uid = userFolder.uid; - final result = await UserEventResetWorkspace(payload).send(); - if (!isClosed) { - add(WorkspaceErrorEvent.didResetWorkspace(result)); - } - }, didResetWorkspace: (result) { result.fold( (_) { @@ -68,7 +56,6 @@ class WorkspaceErrorBloc class WorkspaceErrorEvent with _$WorkspaceErrorEvent { const factory WorkspaceErrorEvent.init() = _Init; const factory WorkspaceErrorEvent.logout() = _DidLogout; - const factory WorkspaceErrorEvent.resetWorkspace() = _ResetWorkspace; const factory WorkspaceErrorEvent.didResetWorkspace( FlowyResult result, ) = _DidResetWorkspace; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart index d79127e04c..bd32696514 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart @@ -86,7 +86,6 @@ class WorkspaceErrorScreen extends StatelessWidget { const VSpace(50), const LogoutButton(), const VSpace(20), - const ResetWorkspaceButton(), ]); return Center( @@ -157,43 +156,3 @@ class LogoutButton extends StatelessWidget { ); } } - -class ResetWorkspaceButton extends StatelessWidget { - const ResetWorkspaceButton({super.key}); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 200, - height: 40, - child: BlocBuilder( - builder: (context, state) { - final isLoading = state.loadingState?.isLoading() ?? false; - final icon = isLoading - ? const Center( - child: CircularProgressIndicator.adaptive(), - ) - : null; - - return FlowyButton( - text: FlowyText.medium( - LocaleKeys.workspace_reset.tr(), - textAlign: TextAlign.center, - ), - onTap: () { - NavigatorAlertDialog( - title: LocaleKeys.workspace_resetWorkspacePrompt.tr(), - confirm: () { - context.read().add( - const WorkspaceErrorEvent.resetWorkspace(), - ); - }, - ).show(context); - }, - rightIcon: icon, - ); - }, - ), - ); - } -} diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 8e46148ea9..ac47524944 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -2957,7 +2957,6 @@ dependencies = [ "arc-swap", "assert-json-diff", "bytes", - "chrono", "client-api", "collab", "collab-database", @@ -2966,7 +2965,6 @@ dependencies = [ "collab-folder", "collab-plugins", "collab-user", - "dashmap 6.0.1", "dotenv", "flowy-ai-pub", "flowy-database-pub", @@ -2975,33 +2973,25 @@ dependencies = [ "flowy-folder-pub", "flowy-search-pub", "flowy-server-pub", + "flowy-sqlite", "flowy-storage", "flowy-storage-pub", "flowy-user-pub", "futures", "futures-util", - "hex", - "hyper 0.14.27", "lazy_static", - "lib-dispatch", "lib-infra", - "mime_guess", - "postgrest", "rand 0.8.5", - "reqwest 0.11.27", "semver", "serde", "serde_json", "thiserror 1.0.64", "tokio", - "tokio-retry", "tokio-stream", "tokio-util", "tracing", "tracing-subscriber", - "url", "uuid", - "yrs", ] [[package]] @@ -3138,6 +3128,7 @@ name = "flowy-user-pub" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "base64 0.21.5", "chrono", "client-api", @@ -5382,15 +5373,6 @@ dependencies = [ "postgres-protocol", ] -[[package]] -name = "postgrest" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7" -dependencies = [ - "reqwest 0.11.27", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -6130,7 +6112,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.25.2", "winreg 0.50.0", ] @@ -8443,12 +8424,6 @@ dependencies = [ "webpki", ] -[[package]] -name = "webpki-roots" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" - [[package]] name = "webpki-roots" version = "0.26.7" diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index 5be2b7b014..a8655ff647 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -581,6 +581,7 @@ impl Chat { author_type: record.author_type, author_id: record.author_id, reply_message_id: record.reply_message_id, + metadata: record.metadata, }) .collect::>(); @@ -641,7 +642,7 @@ fn save_chat_message_disk( author_type: message.author.author_type as i64, author_id: message.author.author_id.to_string(), reply_message_id: message.reply_message_id, - metadata: None, + metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), }) .collect::>(); insert_chat_messages(conn, &records)?; diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index d0cb7eda02..b62899eca3 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -296,6 +296,9 @@ pub struct ChatMessagePB { #[pb(index = 6, one_of)] pub reply_message_id: Option, + + #[pb(index = 7, one_of)] + pub metadata: Option, } #[derive(Debug, Clone, Default, ProtoBuf)] @@ -316,6 +319,7 @@ impl From for ChatMessagePB { author_type: chat_message.author.author_type as i64, author_id: chat_message.author.author_id.to_string(), reply_message_id: None, + metadata: Some(serde_json::to_string(&chat_message.metadata).unwrap_or_default()), } } } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 31b98da5e6..21f09c1dad 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -35,6 +35,7 @@ use crate::deps_resolve::*; use crate::log_filter::init_log; use crate::server_layer::{current_server_type, Server, ServerProvider}; use deps_resolve::reminder_deps::CollabInteractImpl; +use flowy_sqlite::DBConnection; use user_state_callback::UserStatusCallbackImpl; pub mod config; @@ -334,4 +335,12 @@ impl ServerUser for ServerUserImpl { fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() } + + fn user_id(&self) -> FlowyResult { + self.upgrade_user()?.user_id() + } + + fn get_sqlite_db(&self, uid: i64) -> Result { + self.upgrade_user()?.get_sqlite_connection(uid) + } } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 0d304c6063..8157748b4f 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -1,15 +1,15 @@ use arc_swap::ArcSwapOption; use dashmap::DashMap; +use diesel::Connection; +use serde_repr::*; use std::fmt::{Display, Formatter}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Weak}; -use serde_repr::*; - use flowy_error::{FlowyError, FlowyResult}; use flowy_server::af_cloud::define::ServerUser; use flowy_server::af_cloud::AppFlowyCloudServer; -use flowy_server::local_server::{LocalServer, LocalServerDB}; +use flowy_server::local_server::LocalServer; use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; @@ -115,10 +115,7 @@ impl ServerProvider { let server = match server_type { Server::Local => { - let local_db = Arc::new(LocalServerDBImpl { - storage_path: self.config.storage_path.clone(), - }); - let server = Arc::new(LocalServer::new(local_db)); + let server = Arc::new(LocalServer::new(self.user.clone())); Ok::, FlowyError>(server) }, Server::AppFlowyCloud => { @@ -171,24 +168,3 @@ pub fn current_server_type() -> Server { AuthenticatorType::AppFlowyCloud => Server::AppFlowyCloud, } } - -struct LocalServerDBImpl { - #[allow(dead_code)] - storage_path: String, -} - -impl LocalServerDB for LocalServerDBImpl { - fn get_user_profile(&self, _uid: i64) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("LocalServer doesn't support get_user_profile"), - ) - } - - fn get_user_workspace(&self, _uid: i64) -> Result, FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("LocalServer doesn't support get_user_workspace"), - ) - } -} diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index 9e67081eb7..e94570cc4a 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -12,20 +12,15 @@ crate-type = ["cdylib", "rlib"] tracing.workspace = true futures.workspace = true futures-util = "0.3.26" -reqwest = { version = "0.11.20", features = ["native-tls-vendored", "multipart", "blocking"] } -hyper = "0.14" serde.workspace = true serde_json.workspace = true thiserror = "1.0" tokio = { workspace = true, features = ["sync"] } lazy_static = "1.4.0" bytes = { workspace = true, features = ["serde"] } -tokio-retry = "0.3" anyhow.workspace = true arc-swap.workspace = true -dashmap.workspace = true uuid.workspace = true -chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } collab = { workspace = true } collab-plugins = { workspace = true } collab-document = { workspace = true } @@ -33,8 +28,6 @@ collab-entity = { workspace = true } collab-folder = { workspace = true } collab-database = { workspace = true } collab-user = { workspace = true } -hex = "0.4.3" -postgrest = "1.0" lib-infra = { workspace = true } flowy-user-pub = { workspace = true } flowy-folder-pub = { workspace = true } @@ -46,14 +39,11 @@ flowy-search-pub = { workspace = true } flowy-storage = { workspace = true } flowy-storage-pub = { workspace = true } flowy-ai-pub = { workspace = true } -mime_guess = "2.0" -url = "2.4" tokio-util = "0.7" tokio-stream = { workspace = true, features = ["sync"] } -lib-dispatch = { workspace = true } -yrs.workspace = true rand = "0.8.5" semver = "1.0.23" +flowy-sqlite = { workspace = true } [dependencies.client-api] workspace = true diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index db75ea93fe..3b2895b8f8 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,4 +1,5 @@ -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; @@ -10,4 +11,8 @@ pub const USER_DEVICE_ID: &str = "device_id"; pub trait ServerUser: Send + Sync { /// different user might return different workspace id. fn workspace_id(&self) -> FlowyResult; + + fn user_id(&self) -> FlowyResult; + + fn get_sqlite_db(&self, uid: i64) -> Result; } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index e59166fc37..6d7d9d743b 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -378,10 +378,6 @@ where Arc::into_inner(rx) } - async fn reset_workspace(&self, _collab_object: CollabObject) -> Result<(), FlowyError> { - Ok(()) - } - async fn create_collab_object( &self, collab_object: &CollabObject, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index a68dbc8b4f..a6bfea53b4 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -1,3 +1,4 @@ +use crate::af_cloud::define::ServerUser; use client_api::entity::ai_dto::{LocalAIConfig, RepeatedRelatedQuestion}; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, @@ -10,9 +11,12 @@ use lib_infra::util::timestamp; use serde_json::Value; use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; use uuid::Uuid; -pub(crate) struct LocalServerChatServiceImpl; +pub struct LocalServerChatServiceImpl { + pub user: Arc, +} #[async_trait] impl ChatCloudService for LocalServerChatServiceImpl { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 7bb3139953..bd2372e9d4 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,7 +1,6 @@ #![allow(unused_variables)] use std::sync::Arc; -use crate::local_server::LocalServerDB; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; use collab_entity::CollabType; @@ -14,10 +13,7 @@ use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; use uuid::Uuid; -pub(crate) struct LocalServerFolderCloudServiceImpl { - #[allow(dead_code)] - pub db: Arc, -} +pub(crate) struct LocalServerFolderCloudServiceImpl; #[async_trait] impl FolderCloudService for LocalServerFolderCloudServiceImpl { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 8d9e342e85..df9050623e 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -18,24 +18,19 @@ use lib_infra::box_any::BoxAny; use lib_infra::util::timestamp; use crate::local_server::uid::UserIDGenerator; -use crate::local_server::LocalServerDB; lazy_static! { //FIXME: seriously, userID generation should work using lock-free algorithm static ref ID_GEN: Mutex = Mutex::new(UserIDGenerator::new(1)); } -pub(crate) struct LocalServerUserAuthServiceImpl { - #[allow(dead_code)] - pub db: Arc, -} - +pub(crate) struct LocalServerUserServiceImpl; #[async_trait] -impl UserCloudService for LocalServerUserAuthServiceImpl { +impl UserCloudService for LocalServerUserServiceImpl { async fn sign_up(&self, params: BoxAny) -> Result { let params = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); - let workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_id = Uuid::new_v4().to_string(); let user_workspace = UserWorkspace::new_local(&workspace_id, uid); let user_name = if params.name.is_empty() { DEFAULT_USER_NAME() @@ -58,13 +53,9 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { } async fn sign_in(&self, params: BoxAny) -> Result { - let db = self.db.clone(); let params: SignInParams = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); - - let user_workspace = db - .get_user_workspace(uid)? - .unwrap_or_else(make_user_workspace); + let user_workspace = make_user_workspace(); Ok(AuthResponse { user_id: uid, user_uuid: Uuid::new_v4(), @@ -132,16 +123,7 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { } async fn get_user_profile(&self, credential: UserCredentials) -> Result { - match credential.uid { - None => Err(FlowyError::record_not_found()), - Some(uid) => { - self.db.get_user_profile(uid).map(|mut profile| { - // We don't want to expose the email in the local server - profile.email = "".to_string(); - profile - }) - }, - } + Err(FlowyError::local_version_not_support().with_context("Not support")) } async fn open_workspace(&self, workspace_id: &Uuid) -> Result { @@ -155,6 +137,32 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { Ok(vec![]) } + async fn create_workspace(&self, _workspace_name: &str) -> Result { + Err( + FlowyError::local_version_not_support() + .with_context("local server doesn't support multiple workspaces"), + ) + } + + async fn patch_workspace( + &self, + workspace_id: &Uuid, + new_workspace_name: Option<&str>, + new_workspace_icon: Option<&str>, + ) -> Result<(), FlowyError> { + Err( + FlowyError::local_version_not_support() + .with_context("local server doesn't support multiple workspaces"), + ) + } + + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + Err( + FlowyError::local_version_not_support() + .with_context("local server doesn't support multiple workspaces"), + ) + } + async fn get_user_awareness_doc_state( &self, uid: i64, @@ -172,10 +180,6 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { Ok(encode_collab.doc_state.to_vec()) } - async fn reset_workspace(&self, _collab_object: CollabObject) -> Result<(), FlowyError> { - Ok(()) - } - async fn create_collab_object( &self, _collab_object: &CollabObject, @@ -194,32 +198,6 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { .with_context("local server doesn't support batch create collab object"), ) } - - async fn create_workspace(&self, _workspace_name: &str) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - } - - async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - } - - async fn patch_workspace( - &self, - workspace_id: &Uuid, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, - ) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - } } fn make_user_workspace() -> UserWorkspace { diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 280a56cfe0..282d118203 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -1,40 +1,30 @@ use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; -use flowy_ai_pub::cloud::ChatCloudService; -use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; -use flowy_document_pub::cloud::DocumentCloudService; -use flowy_error::FlowyError; -use flowy_folder_pub::cloud::FolderCloudService; -use flowy_storage_pub::cloud::StorageCloudService; -use tokio::sync::mpsc; -// use flowy_user::services::database::{ -// get_user_profile, get_user_workspace, open_collab_db, open_user_db, -// }; -use flowy_user_pub::cloud::UserCloudService; -use flowy_user_pub::entities::*; - +use crate::af_cloud::define::ServerUser; use crate::local_server::impls::{ LocalServerChatServiceImpl, LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, LocalServerFolderCloudServiceImpl, - LocalServerUserAuthServiceImpl, + LocalServerUserServiceImpl, }; use crate::AppFlowyServer; - -pub trait LocalServerDB: Send + Sync + 'static { - fn get_user_profile(&self, uid: i64) -> Result; - fn get_user_workspace(&self, uid: i64) -> Result, FlowyError>; -} +use flowy_ai_pub::cloud::ChatCloudService; +use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; +use flowy_document_pub::cloud::DocumentCloudService; +use flowy_folder_pub::cloud::FolderCloudService; +use flowy_storage_pub::cloud::StorageCloudService; +use flowy_user_pub::cloud::UserCloudService; +use tokio::sync::mpsc; pub struct LocalServer { - local_db: Arc, + user: Arc, stop_tx: Option>, } impl LocalServer { - pub fn new(local_db: Arc) -> Self { + pub fn new(user: Arc) -> Self { Self { - local_db, + user, stop_tx: Default::default(), } } @@ -49,38 +39,36 @@ impl LocalServer { impl AppFlowyServer for LocalServer { fn user_service(&self) -> Arc { - Arc::new(LocalServerUserAuthServiceImpl { - db: self.local_db.clone(), - }) + Arc::new(LocalServerUserServiceImpl) } fn folder_service(&self) -> Arc { - Arc::new(LocalServerFolderCloudServiceImpl { - db: self.local_db.clone(), - }) + Arc::new(LocalServerFolderCloudServiceImpl) } fn database_service(&self) -> Arc { Arc::new(LocalServerDatabaseCloudServiceImpl()) } + fn database_ai_service(&self) -> Option> { + None + } + fn document_service(&self) -> Arc { Arc::new(LocalServerDocumentCloudServiceImpl()) } - fn file_storage(&self) -> Option> { - None + fn chat_service(&self) -> Arc { + Arc::new(LocalServerChatServiceImpl { + user: self.user.clone(), + }) } fn search_service(&self) -> Option> { None } - fn database_ai_service(&self) -> Option> { + fn file_storage(&self) -> Option> { None } - - fn chat_service(&self) -> Arc { - Arc::new(LocalServerChatServiceImpl) - } } diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index 9c88917df8..d00f484068 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -3,14 +3,14 @@ use semver::Version; use std::collections::HashMap; use std::sync::Arc; -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; use uuid::Uuid; +use crate::setup_log; use flowy_server::af_cloud::define::ServerUser; use flowy_server::af_cloud::AppFlowyCloudServer; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; - -use crate::setup_log; +use flowy_sqlite::DBConnection; /// To run the test, create a .env.ci file in the 'flowy-server' directory and set the following environment variables: /// @@ -42,6 +42,14 @@ impl ServerUser for FakeServerUserImpl { fn workspace_id(&self) -> FlowyResult { todo!() } + + fn user_id(&self) -> FlowyResult { + todo!() + } + + fn get_sqlite_db(&self, uid: i64) -> Result { + todo!() + } } pub async fn generate_sign_in_url(user_email: &str, config: &AFCloudConfiguration) -> String { diff --git a/frontend/rust-lib/flowy-user-pub/Cargo.toml b/frontend/rust-lib/flowy-user-pub/Cargo.toml index 0228e25d35..80d087e88e 100644 --- a/frontend/rust-lib/flowy-user-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-user-pub/Cargo.toml @@ -23,3 +23,4 @@ collab-folder = { workspace = true } tracing.workspace = true base64 = "0.21" client-api = { workspace = true } +arc-swap = "1.7.1" diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index 3f7f39910b..dde94aebdc 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -263,8 +263,6 @@ pub trait UserCloudService: Send + Sync + 'static { None } - async fn reset_workspace(&self, collab_object: CollabObject) -> Result<(), FlowyError>; - async fn create_collab_object( &self, collab_object: &CollabObject, diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 85b9274df3..c0b7a8d6c2 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -614,24 +614,6 @@ pub async fn get_all_reminder_event_handler( data_result_ok(reminders.into()) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn reset_workspace_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let reset_pb = data.into_inner(); - if reset_pb.workspace_id.is_empty() { - return Err(FlowyError::new( - ErrorCode::WorkspaceInitializeError, - "The workspace id is empty", - )); - } - let _session = manager.get_session()?; - manager.reset_workspace(reset_pb).await?; - Ok(()) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn remove_reminder_event_handler( data: AFPluginData, diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 2de1fdfbdc..7ed4948771 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -49,7 +49,6 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GetAllReminders, get_all_reminder_event_handler) .event(UserEvent::RemoveReminder, remove_reminder_event_handler) .event(UserEvent::UpdateReminder, update_reminder_event_handler) - .event(UserEvent::ResetWorkspace, reset_workspace_handler) .event(UserEvent::SetDateTimeSettings, set_date_time_settings) .event(UserEvent::GetDateTimeSettings, get_date_time_settings) .event(UserEvent::SetNotificationSettings, set_notification_settings) @@ -183,9 +182,6 @@ pub enum UserEvent { #[event(input = "ReminderPB")] UpdateReminder = 31, - #[event(input = "ResetWorkspacePB")] - ResetWorkspace = 32, - /// Change the Date/Time formats globally #[event(input = "DateTimeSettingsPB")] SetDateTimeSettings = 33, diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index ab9a35b483..bc9603e26a 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -1,7 +1,6 @@ use crate::migrations::session_migration::migrate_session_with_user_uuid; use crate::services::db::UserDB; use crate::services::entities::{UserConfig, UserPaths}; -use crate::services::sqlite_sql::user_sql::vacuum_database; use collab_integrate::CollabKVDB; use arc_swap::ArcSwapOption; @@ -18,8 +17,6 @@ use std::sync::{Arc, Weak}; use tracing::{error, info}; use uuid::Uuid; -const SQLITE_VACUUM_042: &str = "sqlite_vacuum_042_version"; - pub struct AuthenticateUser { pub user_config: UserConfig, pub(crate) database: Arc, @@ -44,23 +41,6 @@ impl AuthenticateUser { } } - pub fn vacuum_database_if_need(&self) { - if !self - .store_preferences - .get_bool_or_default(SQLITE_VACUUM_042) - { - if let Ok(session) = self.get_session() { - let _ = self.store_preferences.set_bool(SQLITE_VACUUM_042, true); - if let Ok(conn) = self.database.get_connection(session.user_id) { - info!("vacuum database 042"); - if let Err(err) = vacuum_database(conn) { - error!("vacuum database error: {:?}", err); - } - } - } - } - } - pub fn user_id(&self) -> FlowyResult { let session = self.get_session()?; Ok(session.user_id) diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs index 6da6f183cb..c63764a055 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs @@ -148,10 +148,3 @@ pub fn select_user_profile(uid: i64, mut conn: DBConnection) -> Result Result<(), FlowyError> { - sql_query("VACUUM") - .execute(&mut *conn) - .map_err(internal_error)?; - Ok(()) -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index b4bc8911e1..5b5d060539 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -268,7 +268,6 @@ impl UserManager { }, _ => error!("Failed to get collab db or sqlite pool"), } - self.authenticate_user.vacuum_database_if_need(); // migrations should run before set the first time installed version self.set_first_time_installed_version(); diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index dae5a51ccc..2fff0c260b 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -414,24 +414,6 @@ impl UserManager { Ok(workspaces) } - /// Reset the remote workspace using local workspace data. This is useful when a user wishes to - /// open a workspace on a new device that hasn't fully synchronized with the server. - pub async fn reset_workspace(&self, reset: ResetWorkspacePB) -> FlowyResult<()> { - let collab_object = CollabObject::new( - reset.uid, - reset.workspace_id.clone(), - CollabType::Folder, - reset.workspace_id.clone(), - self.authenticate_user.user_config.device_id.clone(), - ); - self - .cloud_services - .get_user_service()? - .reset_workspace(collab_object) - .await?; - Ok(()) - } - #[instrument(level = "info", skip(self), err)] pub async fn subscribe_workspace( &self, From 77fbf0f8a355bca2caaf123dd6b60e6ad06ab643 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 21:26:09 +0800 Subject: [PATCH 159/207] chore: local ai initialize --- frontend/rust-lib/Cargo.lock | 2 + frontend/rust-lib/Cargo.toml | 2 + frontend/rust-lib/flowy-ai/Cargo.toml | 4 +- frontend/rust-lib/flowy-ai/src/lib.rs | 2 +- .../flowy-ai/src/local_ai/controller.rs | 117 +++++++++++------- frontend/rust-lib/flowy-core/Cargo.toml | 3 +- .../rust-lib/flowy-core/src/server_layer.rs | 27 ++-- frontend/rust-lib/flowy-server/Cargo.toml | 1 + 8 files changed, 98 insertions(+), 60 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index ac47524944..2e85c57326 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -2609,6 +2609,7 @@ name = "flowy-core" version = "0.1.0" dependencies = [ "af-local-ai", + "af-plugin", "anyhow", "arc-swap", "base64 0.21.5", @@ -2966,6 +2967,7 @@ dependencies = [ "collab-plugins", "collab-user", "dotenv", + "flowy-ai", "flowy-ai-pub", "flowy-database-pub", "flowy-document-pub", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 581715985f..6e93b13a19 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -99,6 +99,8 @@ zip = "2.2.0" dashmap = "6.0.1" derive_builder = "0.20.2" tantivy = { version = "0.24.0" } +af-plugin = { version = "0.1" } +af-local-ai = { version = "0.1" } # Please using the following command to update the revision id # Current directory: frontend diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index a31d42da61..3a6aaf5898 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -35,8 +35,8 @@ serde_json = { workspace = true } anyhow = "1.0.86" tokio-stream = "0.1.15" tokio-util = { workspace = true, features = ["full"] } -af-local-ai = { version = "0.1.0" } -af-plugin = { version = "0.1.0" } +af-local-ai = { workspace = true } +af-plugin = { workspace = true } reqwest = { version = "0.11.27", features = ["json"] } sha2 = "0.10.7" base64 = "0.21.5" diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index 6ab100fd6e..ccd3920e0d 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -5,7 +5,7 @@ pub mod ai_manager; mod chat; mod completion; pub mod entities; -mod local_ai; +pub mod local_ai; // #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] // pub mod mcp; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index ac44e9ad55..494e11d073 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -24,10 +24,10 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use tokio::select; use tokio_stream::StreamExt; -use tracing::{debug, error, info, instrument}; +use tracing::{debug, error, info, instrument, warn}; use uuid::Uuid; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -53,7 +53,7 @@ pub struct LocalAIController { ai_plugin: Arc, resource: Arc, current_chat_id: ArcSwapOption, - store_preferences: Arc, + store_preferences: Weak, user_service: Arc, #[allow(dead_code)] cloud_service: Arc, @@ -70,7 +70,7 @@ impl Deref for LocalAIController { impl LocalAIController { pub fn new( plugin_manager: Arc, - store_preferences: Arc, + store_preferences: Weak, user_service: Arc, cloud_service: Arc, ) -> Self { @@ -94,7 +94,7 @@ impl LocalAIController { let mut running_state_rx = local_ai.subscribe_running_state(); let cloned_llm_res = Arc::clone(&local_ai_resource); - let cloned_store_preferences = Arc::clone(&store_preferences); + let cloned_store_preferences = store_preferences.clone(); let cloned_local_ai = Arc::clone(&local_ai); let cloned_user_service = Arc::clone(&user_service); @@ -110,44 +110,47 @@ impl LocalAIController { info!("[AI Plugin] state: {:?}", state); // Read whether plugin is enabled from store; default to true - let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); + if let Some(store_preferences) = cloned_store_preferences.upgrade() { + let enabled = store_preferences.get_bool(&key).unwrap_or(true); + // Only check resource status if the plugin isn’t in "UnexpectedStop" and is enabled + let (plugin_downloaded, lack_of_resource) = + if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { + // Possibly check plugin readiness and resource concurrency in parallel, + // but here we do it sequentially for clarity. + let downloaded = is_plugin_ready(); + let resource_lack = cloned_llm_res.get_lack_of_resource().await; + (downloaded, resource_lack) + } else { + (false, None) + }; - // Only check resource status if the plugin isn’t in "UnexpectedStop" and is enabled - let (plugin_downloaded, lack_of_resource) = - if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { - // Possibly check plugin readiness and resource concurrency in parallel, - // but here we do it sequentially for clarity. - let downloaded = is_plugin_ready(); - let resource_lack = cloned_llm_res.get_lack_of_resource().await; - (downloaded, resource_lack) + // If plugin is running, retrieve version + let plugin_version = if matches!(state, RunningState::Running { .. }) { + match cloned_local_ai.plugin_info().await { + Ok(info) => Some(info.version), + Err(_) => None, + } } else { - (false, None) + None }; - // If plugin is running, retrieve version - let plugin_version = if matches!(state, RunningState::Running { .. }) { - match cloned_local_ai.plugin_info().await { - Ok(info) => Some(info.version), - Err(_) => None, - } + // Broadcast the new local AI state + let new_state = RunningStatePB::from(state); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded, + lack_of_resource, + state: new_state, + plugin_version, + }) + .send(); } else { - None - }; - - // Broadcast the new local AI state - let new_state = RunningStatePB::from(state); - chat_notification_builder( - APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateLocalAIState, - ) - .payload(LocalAIPB { - enabled, - plugin_downloaded, - lack_of_resource, - state: new_state, - plugin_version, - }) - .send(); + warn!("[AI Plugin] store preferences is dropped"); + } } }); @@ -207,6 +210,13 @@ impl LocalAIController { Ok(()) } + fn upgrade_store_preferences(&self) -> FlowyResult> { + self + .store_preferences + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped")) + } + /// Indicate whether the local AI plugin is running. pub fn is_running(&self) -> bool { if !self.is_enabled() { @@ -228,7 +238,10 @@ impl LocalAIController { .workspace_id() .map(|workspace_id| local_ai_enabled_key(&workspace_id)) { - self.store_preferences.get_bool(&key).unwrap_or(false) + match self.upgrade_store_preferences() { + Ok(store) => store.get_bool(&key).unwrap_or(false), + Err(_) => false, + } } else { false } @@ -373,8 +386,9 @@ impl LocalAIController { pub async fn toggle_local_ai(&self) -> FlowyResult { let workspace_id = self.user_service.workspace_id()?; let key = local_ai_enabled_key(&workspace_id); - let enabled = !self.store_preferences.get_bool(&key).unwrap_or(true); - self.store_preferences.set_bool(&key, enabled)?; + let store_preferences = self.upgrade_store_preferences()?; + let enabled = !store_preferences.get_bool(&key).unwrap_or(true); + store_preferences.set_bool(&key, enabled)?; self.toggle_plugin(enabled).await?; Ok(enabled) } @@ -591,7 +605,16 @@ async fn initialize_ai_plugin( pub struct LLMResourceServiceImpl { user_service: Arc, cloud_service: Arc, - store_preferences: Arc, + store_preferences: Weak, +} + +impl LLMResourceServiceImpl { + fn upgrade_store_preferences(&self) -> FlowyResult> { + self + .store_preferences + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped")) + } } #[async_trait] impl LLMResourceService for LLMResourceServiceImpl { @@ -605,16 +628,14 @@ impl LLMResourceService for LLMResourceServiceImpl { } fn store_setting(&self, setting: LocalAISetting) -> Result<(), Error> { - self - .store_preferences - .set_object(LOCAL_AI_SETTING_KEY, &setting)?; + let store_preferences = self.upgrade_store_preferences()?; + store_preferences.set_object(LOCAL_AI_SETTING_KEY, &setting)?; Ok(()) } fn retrieve_setting(&self) -> Option { - self - .store_preferences - .get_object::(LOCAL_AI_SETTING_KEY) + let store_preferences = self.upgrade_store_preferences().ok()?; + store_preferences.get_object::(LOCAL_AI_SETTING_KEY) } } diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 6499f79284..b4e7bd5fec 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -37,7 +37,8 @@ flowy-storage-pub = { workspace = true } client-api.workspace = true flowy-ai = { workspace = true } flowy-ai-pub = { workspace = true } -af-local-ai = { version = "0.1.0" } +af-local-ai = { workspace = true } +af-plugin = { workspace = true } tracing.workspace = true diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 8157748b4f..176818abdf 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -1,11 +1,9 @@ +use crate::AppFlowyCoreConfig; +use af_plugin::manager::PluginManager; use arc_swap::ArcSwapOption; use dashmap::DashMap; -use diesel::Connection; -use serde_repr::*; -use std::fmt::{Display, Formatter}; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; -use std::sync::{Arc, Weak}; - +use flowy_ai::ai_manager::AIUserService; +use flowy_ai::local_ai::controller::LocalAIController; use flowy_error::{FlowyError, FlowyResult}; use flowy_server::af_cloud::define::ServerUser; use flowy_server::af_cloud::AppFlowyCloudServer; @@ -14,8 +12,10 @@ use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::entities::*; - -use crate::AppFlowyCoreConfig; +use serde_repr::*; +use std::fmt::{Display, Formatter}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, Weak}; #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] #[repr(u8)] @@ -66,10 +66,21 @@ impl ServerProvider { config: AppFlowyCoreConfig, server: Server, store_preferences: Weak, + user_service: impl AIUserService, server_user: impl ServerUser + 'static, ) -> Self { let user = Arc::new(server_user); let encryption = EncryptionImpl::new(None); + + let user_service = Arc::new(user_service); + let plugin_manager = Arc::new(PluginManager::new()); + let local_ai = Arc::new(LocalAIController::new( + plugin_manager.clone(), + store_preferences.clone(), + user_service.clone(), + chat_cloud_service.clone(), + )); + Self { config, providers: DashMap::new(), diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index e94570cc4a..5225eb817d 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -44,6 +44,7 @@ tokio-stream = { workspace = true, features = ["sync"] } rand = "0.8.5" semver = "1.0.23" flowy-sqlite = { workspace = true } +flowy-ai = { workspace = true } [dependencies.client-api] workspace = true From e2896b29117b7f51eb805ff0c718d3f6057e19ce Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Apr 2025 22:03:14 +0800 Subject: [PATCH 160/207] chore: remove inline view id reference --- .../cell/bloc/relation_cell_bloc.dart | 5 ++- .../relation_type_option_cubit.dart | 10 +++--- .../cell_editor/relation_cell_editor.dart | 2 +- frontend/rust-lib/Cargo.lock | 34 ++++++------------- frontend/rust-lib/Cargo.toml | 16 ++++----- .../src/entities/database_entities.rs | 5 +-- .../flowy-database2/src/event_handler.rs | 2 +- .../rust-lib/flowy-database2/src/manager.rs | 9 ----- .../src/services/database/database_editor.rs | 4 +-- .../src/services/share/csv/export.rs | 8 +++-- 10 files changed, 34 insertions(+), 61 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart index 70c5e074ab..ec789b03a0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart @@ -143,12 +143,11 @@ class RelationCellBloc extends Bloc { (f) => null, ); if (databaseMeta != null) { - final result = - await ViewBackendService.getView(databaseMeta.inlineViewId); + final result = await ViewBackendService.getView(databaseMeta.viewId); return result.fold( (s) => DatabaseMeta( databaseId: databaseId, - inlineViewId: databaseMeta.inlineViewId, + viewId: databaseMeta.viewId, databaseName: s.name, ), (f) => null, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart index 691b6b7227..4ddde80b79 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart @@ -17,11 +17,11 @@ class RelationDatabaseListCubit extends Cubit { .send() .fold>((s) => s.items, (f) => []); final futures = metaPBs.map((meta) { - return ViewBackendService.getView(meta.inlineViewId).then( + return ViewBackendService.getView(meta.viewId).then( (result) => result.fold( (s) => DatabaseMeta( databaseId: meta.databaseId, - inlineViewId: meta.inlineViewId, + viewId: meta.viewId, databaseName: s.name, ), (f) => null, @@ -43,10 +43,10 @@ class DatabaseMeta with _$DatabaseMeta { /// id of the database required String databaseId, - /// id of the inline view - required String inlineViewId, + /// id of the view + required String viewId, - /// name of the database, currently identical to the name of the inline view + /// name of the database required String databaseName, }) = _DatabaseMeta; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart index e68e77cd97..7f6960de9d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart @@ -256,7 +256,7 @@ class _CellEditorTitle extends StatelessWidget { } void _openRelatedDatbase(BuildContext context) { - FolderEventGetView(ViewIdPB(value: databaseMeta.inlineViewId)) + FolderEventGetView(ViewIdPB(value: databaseMeta.viewId)) .send() .then((result) { result.fold( diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index a84de4cdd6..4ae0275383 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1270,7 +1270,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "arc-swap", @@ -1295,7 +1295,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "async-trait", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "arc-swap", @@ -1356,7 +1356,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "bytes", @@ -1376,7 +1376,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "arc-swap", @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "collab-importer" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "async-recursion", @@ -1461,7 +1461,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "async-stream", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" dependencies = [ "anyhow", "collab", @@ -1786,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -5189,7 +5189,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5209,7 +5209,6 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5277,19 +5276,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", - "proc-macro2", - "quote", - "syn 2.0.94", -] - [[package]] name = "phf_shared" version = "0.8.0" diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 88972e5bfb..1efaa0cc97 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -141,14 +141,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } -collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs index 8c16db4379..2562bd84f7 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs @@ -26,9 +26,6 @@ pub struct DatabasePB { #[pb(index = 4)] pub layout_type: DatabaseLayoutPB, - - #[pb(index = 5)] - pub is_linked: bool, } #[derive(ProtoBuf, Default)] @@ -208,7 +205,7 @@ pub struct DatabaseMetaPB { pub database_id: String, #[pb(index = 2)] - pub inline_view_id: String, + pub view_id: String, } #[derive(Debug, Default, ProtoBuf)] diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index 9164550fe4..63d6fdf2c3 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -900,7 +900,7 @@ pub(crate) async fn get_databases_handler( if let Some(link_view) = meta.linked_views.first() { items.push(DatabaseMetaPB { database_id: meta.database_id, - inline_view_id: link_view.clone(), + view_id: link_view.clone(), }) } } diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 49a946d108..a7cca9960b 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -148,15 +148,6 @@ impl DatabaseManager { Ok(()) } - pub async fn get_database_inline_view_id(&self, database_id: &str) -> FlowyResult { - let lock = self.workspace_database()?; - let wdb = lock.read().await; - let database_collab = wdb.get_or_init_database(database_id).await?; - drop(wdb); - let lock_guard = database_collab.read().await; - Ok(lock_guard.get_inline_view_id()) - } - pub async fn get_all_databases_meta(&self) -> Vec { let mut items = vec![]; if let Some(lock) = self.workspace_database_manager.load_full() { diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index d079bdc8c2..227b96df4f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -1505,7 +1505,7 @@ impl DatabaseEditor { view_editor.set_row_orders(row_orders.clone()).await; // Collect database details in a single block holding the `read` lock - let (database_id, fields, is_linked) = { + let (database_id, fields) = { let database = self.database.read().await; ( database.get_database_id(), @@ -1514,7 +1514,6 @@ impl DatabaseEditor { .into_iter() .map(FieldIdPB::from) .collect::>(), - database.is_inline_view(view_id), ) }; @@ -1557,7 +1556,6 @@ impl DatabaseEditor { fields, rows: order_rows, layout_type: view_layout.into(), - is_linked, }); // Mark that the opening process is complete if let Some(tx) = self.is_loading_rows.load_full() { diff --git a/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs b/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs index dd704f43d5..3eab243fd7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs +++ b/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs @@ -28,8 +28,10 @@ impl CSVExport { style: CSVFormat, ) -> FlowyResult { let mut wtr = csv::Writer::from_writer(vec![]); - let inline_view_id = database.get_inline_view_id(); - let fields = database.get_fields_in_view(&inline_view_id, None); + let view_id = database + .get_first_database_view_id() + .ok_or_else(|| FlowyError::internal().with_context("failed to get first database view"))?; + let fields = database.get_fields_in_view(&view_id, None); // Write fields let field_records = fields @@ -49,7 +51,7 @@ impl CSVExport { field_by_field_id.insert(field.id.clone(), field); }); let rows = database - .get_rows_for_view(&inline_view_id, 20, None) + .get_rows_for_view(&view_id, 20, None) .await .filter_map(|result| async { result.ok() }) .collect::>() From 8f6366728284c5ee42c9113bfdaa40ffbf3bdfcf Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 17 Apr 2025 09:48:23 +0800 Subject: [PATCH 161/207] feat: upgrade ubuntu version to 22.04 in github action (#7764) * feat: upgrade ubuntu version to 22.04 in github action * fix: cargo clippy --- .github/workflows/release.yml | 2 +- .../link_preview/link_parsers/default_parser.dart | 5 ++--- .../flowy-user/src/user_manager/manager_user_workspace.rs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 918a2018f7..a4582ffa74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -338,7 +338,7 @@ jobs: - { arch: x86_64, target: x86_64-unknown-linux-gnu, - os: ubuntu-20.04, + os: ubuntu-22.04, extra-build-args: "", flutter_profile: production-linux-x86_64, } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart index f05f4a9706..ab0b246743 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart @@ -1,9 +1,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy_backend/log.dart'; - -import 'package:http/http.dart' as http; // ignore: depend_on_referenced_packages import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; abstract class LinkInfoParser { Future parse( @@ -70,7 +69,7 @@ class DefaultParser implements LinkInfoParser { } final favicon = - 'https://www.google.com/s2/favicons?sz=64&domain=${link.host}'; + 'https://www.faviconextractor.com/favicon/${link.host}?larger=true'; return LinkInfo( url: '$link', diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index dae5a51ccc..adc1b72266 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -593,7 +593,7 @@ impl UserManager { if let Ok(member_record) = select_workspace_member(db, &workspace_id.to_string(), uid) { if is_older_than_n_minutes(member_record.updated_at, 10) { self - .get_workspace_member_info_from_remote(&workspace_id, uid) + .get_workspace_member_info_from_remote(workspace_id, uid) .await?; } From fbf928e6e481ca7e40e9a1ed211b6681aaedd7fa Mon Sep 17 00:00:00 2001 From: Morn Date: Thu, 17 Apr 2025 11:01:30 +0800 Subject: [PATCH 162/207] chore: update CHANGELOG.md (#7765) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 289f7fd004..a5e7e268a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,24 @@ # Release Notes +## Version 0.8.9 - 16/04/2025 +### Desktop +#### New Features +- Supported pasting a link as a mention, providing a more condensed visualization of linked content +- Supported converting between link formats (e.g. transforming a mention into a bookmark) +- Improved the link editing experience with enhanced UX +- Added OTP (One-Time Password) support for sign-in authentication +- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet +#### Bug Fixes +- Fixed an issue where properties were not displaying in the row detail page +- Fixed a bug where Undo didn't work in the row detail page +- Fixed an issue where blocks didn't grow when the grid got bigger +- Fixed several bugs related to AI writers +### Mobile +#### New Features +- Added sign-in with OTP (One-Time Password) +#### Bug Fixes +- Fixed an issue where the slash menu sometimes failed to display +- Updated the mention page block to handle page selection with more context. + ## Version 0.8.8 - 01/04/2025 ### New Features - Added support for selecting AI models in AI writer From c633dd0919304a160f6ac06e0c523da3354f5797 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 11:11:54 +0800 Subject: [PATCH 163/207] chore: remove unused code --- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 8 +-- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 14 +---- frontend/rust-lib/flowy-ai/src/chat.rs | 3 +- .../flowy-ai/src/local_ai/controller.rs | 21 +------ .../flowy-ai/src/local_ai/resource.rs | 20 ------ .../src/middleware/chat_service_mw.rs | 61 ++++++++++--------- .../flowy-core/src/deps_resolve/chat_deps.rs | 7 +-- .../src/deps_resolve/cloud_service_impl.rs | 15 ++--- frontend/rust-lib/flowy-core/src/lib.rs | 8 +++ .../rust-lib/flowy-core/src/server_layer.rs | 30 +++++++-- .../flowy-server/src/af_cloud/define.rs | 2 + .../flowy-server/src/af_cloud/impls/chat.rs | 23 +------ .../src/local_server/impls/chat.rs | 10 +-- .../src/local_server/impls/folder.rs | 1 - .../src/local_server/impls/user.rs | 1 - .../flowy-server/tests/af_cloud_test/util.rs | 5 ++ .../src/services/authenticate_user.rs | 2 +- .../src/services/sqlite_sql/user_sql.rs | 4 +- .../user_manager/manager_user_workspace.rs | 5 +- 19 files changed, 97 insertions(+), 143 deletions(-) diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 79478f64fc..5e9923d464 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -141,6 +141,7 @@ pub trait ChatCloudService: Send + Sync + 'static { workspace_id: &Uuid, chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result; async fn stream_complete( @@ -158,13 +159,6 @@ pub trait ChatCloudService: Send + Sync + 'static { metadata: Option>, ) -> Result<(), FlowyError>; - async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result; - - async fn get_workspace_plan( - &self, - workspace_id: &Uuid, - ) -> Result, FlowyError>; - async fn get_chat_settings( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index ec12ac4963..d619b8ab7e 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -8,7 +8,6 @@ use crate::middleware::chat_service_mw::AICloudServiceMiddleware; use crate::persistence::{insert_chat, read_chat_metadata, ChatTable}; use std::collections::HashMap; -use af_plugin::manager::PluginManager; use dashmap::DashMap; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatSettings, UpdateChatParams, DEFAULT_AI_MODEL_NAME, @@ -35,7 +34,6 @@ use uuid::Uuid; pub trait AIUserService: Send + Sync + 'static { fn user_id(&self) -> Result; - fn device_id(&self) -> Result; fn workspace_id(&self) -> Result; fn sqlite_connection(&self, uid: i64) -> Result; fn application_root_dir(&self) -> Result; @@ -85,16 +83,9 @@ impl AIManager { store_preferences: Arc, storage_service: Weak, query_service: impl AIExternalService, + local_ai: Arc, ) -> AIManager { let user_service = Arc::new(user_service); - let plugin_manager = Arc::new(PluginManager::new()); - let local_ai = Arc::new(LocalAIController::new( - plugin_manager.clone(), - store_preferences.clone(), - user_service.clone(), - chat_cloud_service.clone(), - )); - let cloned_local_ai = local_ai.clone(); tokio::spawn(async move { cloned_local_ai.observe_plugin_resource().await; @@ -596,7 +587,8 @@ impl AIManager { message_id: i64, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; - let resp = chat.get_related_question(message_id).await?; + let ai_model = self.get_active_model(&chat_id.to_string()).await; + let resp = chat.get_related_question(message_id, ai_model).await?; Ok(resp) } diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index a8655ff647..a496e067eb 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -524,11 +524,12 @@ impl Chat { pub async fn get_related_question( &self, message_id: i64, + ai_model: Option, ) -> Result { let workspace_id = self.user_service.workspace_id()?; let resp = self .chat_service - .get_related_message(&workspace_id, &self.chat_id, message_id) + .get_related_message(&workspace_id, &self.chat_id, message_id, ai_model) .await?; trace!( diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 494e11d073..431d8ff681 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -6,7 +6,7 @@ use crate::notification::{ }; use af_plugin::manager::PluginManager; use anyhow::Error; -use flowy_ai_pub::cloud::{ChatCloudService, ChatMessageMetadata, ContextLoader, LocalAIConfig}; +use flowy_ai_pub::cloud::{ChatMessageMetadata, ContextLoader}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; use futures::Sink; @@ -55,8 +55,6 @@ pub struct LocalAIController { current_chat_id: ArcSwapOption, store_preferences: Weak, user_service: Arc, - #[allow(dead_code)] - cloud_service: Arc, } impl Deref for LocalAIController { @@ -72,7 +70,6 @@ impl LocalAIController { plugin_manager: Arc, store_preferences: Weak, user_service: Arc, - cloud_service: Arc, ) -> Self { debug!( "[AI Plugin] init local ai controller, thread: {:?}", @@ -83,7 +80,6 @@ impl LocalAIController { let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); let res_impl = LLMResourceServiceImpl { user_service: user_service.clone(), - cloud_service: cloud_service.clone(), store_preferences: store_preferences.clone(), }; let local_ai_resource = Arc::new(LocalAIResourceController::new( @@ -160,7 +156,6 @@ impl LocalAIController { current_chat_id: ArcSwapOption::default(), store_preferences, user_service, - cloud_service, } } #[instrument(level = "debug", skip_all)] @@ -379,10 +374,6 @@ impl LocalAIController { .map(|path| path.to_string_lossy().to_string()) } - pub async fn get_plugin_download_link(&self) -> FlowyResult { - self.resource.get_plugin_download_link().await - } - pub async fn toggle_local_ai(&self) -> FlowyResult { let workspace_id = self.user_service.workspace_id()?; let key = local_ai_enabled_key(&workspace_id); @@ -604,7 +595,6 @@ async fn initialize_ai_plugin( pub struct LLMResourceServiceImpl { user_service: Arc, - cloud_service: Arc, store_preferences: Weak, } @@ -618,15 +608,6 @@ impl LLMResourceServiceImpl { } #[async_trait] impl LLMResourceService for LLMResourceServiceImpl { - async fn fetch_local_ai_config(&self) -> Result { - let workspace_id = self.user_service.workspace_id()?; - let config = self - .cloud_service - .get_local_ai_config(&workspace_id) - .await?; - Ok(config) - } - fn store_setting(&self, setting: LocalAISetting) -> Result<(), Error> { let store_preferences = self.upgrade_store_preferences()?; store_preferences.set_object(LOCAL_AI_SETTING_KEY, &setting)?; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs index f2c1d1d041..6251ef8de5 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -1,6 +1,5 @@ use crate::ai_manager::AIUserService; use crate::local_ai::controller::LocalAISetting; -use flowy_ai_pub::cloud::LocalAIConfig; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_infra::async_trait::async_trait; @@ -33,7 +32,6 @@ struct ModelEntry { #[async_trait] pub trait LLMResourceService: Send + Sync + 'static { /// Get local ai configuration from remote server - async fn fetch_local_ai_config(&self) -> Result; fn store_setting(&self, setting: LocalAISetting) -> Result<(), anyhow::Error>; fn retrieve_setting(&self) -> Option; } @@ -125,11 +123,6 @@ impl LocalAIResourceController { .is_ok_and(|r| r.is_none()) } - pub async fn get_plugin_download_link(&self) -> FlowyResult { - let ai_config = self.get_local_ai_configuration().await?; - Ok(ai_config.plugin.url) - } - /// Retrieves model information and updates the current model settings. pub fn get_llm_setting(&self) -> LocalAISetting { self.resource_service.retrieve_setting().unwrap_or_default() @@ -271,19 +264,6 @@ impl LocalAIResourceController { Ok(config) } - /// Fetches the local AI configuration from the resource service. - async fn get_local_ai_configuration(&self) -> FlowyResult { - self - .resource_service - .fetch_local_ai_config() - .await - .map_err(|err| { - error!("[LLM Resource] Failed to fetch local ai config: {:?}", err); - FlowyError::local_ai() - .with_context("Can't retrieve model info. Please try again later".to_string()) - }) - } - pub(crate) fn user_model_folder(&self) -> FlowyResult { self.resource_dir().map(|dir| dir.join(LLM_MODEL_DIR)) } diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 8c27268139..54f1c929dd 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -10,9 +10,9 @@ use std::collections::HashMap; use flowy_ai_pub::cloud::{ AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, - ChatMessageType, ChatSettings, CompleteTextParams, CompletionStream, LocalAIConfig, - MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, - ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + ChatMessageType, ChatSettings, CompleteTextParams, CompletionStream, MessageCursor, ModelList, + RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, + StreamComplete, SubscriptionPlan, UpdateChatParams, }; use flowy_error::{FlowyError, FlowyResult}; use futures::{stream, Sink, StreamExt, TryStreamExt}; @@ -50,10 +50,6 @@ impl AICloudServiceMiddleware { } } - pub fn is_local_ai_enabled(&self) -> bool { - self.local_ai.is_enabled() - } - pub async fn index_message_metadata( &self, chat_id: &Uuid, @@ -63,7 +59,7 @@ impl AICloudServiceMiddleware { if metadata_list.is_empty() { return Ok(()); } - if self.is_local_ai_enabled() { + if self.local_ai.is_enabled() { let _ = index_process_sink .send(StreamMessage::IndexStart.to_string()) .await; @@ -262,28 +258,41 @@ impl ChatCloudService for AICloudServiceMiddleware { workspace_id: &Uuid, chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { - if self.local_ai.is_running() { - let questions = self - .local_ai - .get_related_question(&chat_id.to_string()) - .await - .map_err(|err| FlowyError::local_ai().with_context(err))?; - trace!("LocalAI related questions: {:?}", questions); + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; - let items = questions - .into_iter() - .map(|content| RelatedQuestion { - content, - metadata: None, + if use_local_ai { + if self.local_ai.is_running() { + let questions = self + .local_ai + .get_related_question(&chat_id.to_string()) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + trace!("LocalAI related questions: {:?}", questions); + + let items = questions + .into_iter() + .map(|content| RelatedQuestion { + content, + metadata: None, + }) + .collect::>(); + + Ok(RepeatedRelatedQuestion { message_id, items }) + } else { + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], }) - .collect::>(); - - Ok(RepeatedRelatedQuestion { message_id, items }) + } } else { self .cloud_service - .get_related_message(workspace_id, chat_id, message_id) + .get_related_message(workspace_id, chat_id, message_id, ai_model) .await } } @@ -359,10 +368,6 @@ impl ChatCloudService for AICloudServiceMiddleware { } } - async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { - self.cloud_service.get_local_ai_config(workspace_id).await - } - async fn get_workspace_plan( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs index 2d9a57b331..a8280f9f66 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -6,6 +6,7 @@ use collab::util::is_change_since_sv; use collab_entity::CollabType; use collab_integrate::persistence::collab_metadata_sql::AFCollabMetadata; use flowy_ai::ai_manager::{AIExternalService, AIManager, AIUserService}; +use flowy_ai::local_ai::controller::LocalAIController; use flowy_ai_pub::cloud::ChatCloudService; use flowy_error::FlowyError; use flowy_folder::ViewLayout; @@ -33,6 +34,7 @@ impl ChatDepsResolver { storage_service: Weak, folder_cloud_service: Arc, folder_service: impl FolderService, + local_ai: Arc, ) -> Arc { let user_service = ChatUserServiceImpl(authenticate_user); Arc::new(AIManager::new( @@ -44,6 +46,7 @@ impl ChatDepsResolver { folder_service: Box::new(folder_service), folder_cloud_service, }, + local_ai, )) } } @@ -166,10 +169,6 @@ impl AIUserService for ChatUserServiceImpl { self.upgrade_user()?.user_id() } - fn device_id(&self) -> Result { - self.upgrade_user()?.device_id() - } - fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 836bd6b32b..30fa6d0ae4 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -15,8 +15,8 @@ use flowy_ai_pub::cloud::search_dto::{ }; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, LocalAIConfig, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, - StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, + StreamComplete, SubscriptionPlan, UpdateChatParams, }; use flowy_database_pub::cloud::{ DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, @@ -753,11 +753,12 @@ impl ChatCloudService for ServerProvider { workspace_id: &Uuid, chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { self .get_server()? .chat_service() - .get_related_message(workspace_id, chat_id, message_id) + .get_related_message(workspace_id, chat_id, message_id, ai_model) .await } @@ -801,14 +802,6 @@ impl ChatCloudService for ServerProvider { .await } - async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { - self - .get_server()? - .chat_service() - .get_local_ai_config(workspace_id) - .await - } - async fn get_workspace_plan( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 21f09c1dad..7584628056 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -9,6 +9,7 @@ use flowy_folder::manager::FolderManager; use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_search::services::manager::SearchManager; use flowy_server::af_cloud::define::ServerUser; +use std::path::PathBuf; use std::sync::{Arc, Weak}; use std::time::Duration; use sysinfo::System; @@ -191,6 +192,7 @@ impl AppFlowyCore { Arc::downgrade(&storage_manager.storage_service), server_provider.clone(), folder_query_service.clone(), + server_provider.local_ai.clone(), ); let database_manager = DatabaseDepsResolver::resolve( @@ -343,4 +345,10 @@ impl ServerUser for ServerUserImpl { fn get_sqlite_db(&self, uid: i64) -> Result { self.upgrade_user()?.get_sqlite_connection(uid) } + + fn application_root_dir(&self) -> Result { + Ok(PathBuf::from( + self.upgrade_user()?.get_application_root_dir(), + )) + } } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 176818abdf..0c7bc88859 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -11,11 +11,14 @@ use flowy_server::local_server::LocalServer; use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; +use flowy_sqlite::DBConnection; use flowy_user_pub::entities::*; use serde_repr::*; use std::fmt::{Display, Formatter}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Weak}; +use uuid::Uuid; #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] #[repr(u8)] @@ -59,6 +62,7 @@ pub struct ServerProvider { authenticator: AtomicU8, user: Arc, pub(crate) uid: Arc>, + pub local_ai: Arc, } impl ServerProvider { @@ -66,19 +70,16 @@ impl ServerProvider { config: AppFlowyCoreConfig, server: Server, store_preferences: Weak, - user_service: impl AIUserService, server_user: impl ServerUser + 'static, ) -> Self { let user = Arc::new(server_user); let encryption = EncryptionImpl::new(None); - - let user_service = Arc::new(user_service); + let user_service = Arc::new(AIUserServiceImpl(user.clone())); let plugin_manager = Arc::new(PluginManager::new()); let local_ai = Arc::new(LocalAIController::new( plugin_manager.clone(), store_preferences.clone(), user_service.clone(), - chat_cloud_service.clone(), )); Self { @@ -90,6 +91,7 @@ impl ServerProvider { store_preferences, uid: Default::default(), user, + local_ai, } } @@ -179,3 +181,23 @@ pub fn current_server_type() -> Server { AuthenticatorType::AppFlowyCloud => Server::AppFlowyCloud, } } + +struct AIUserServiceImpl(Arc); + +impl AIUserService for AIUserServiceImpl { + fn user_id(&self) -> Result { + self.0.user_id() + } + + fn workspace_id(&self) -> Result { + self.0.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.0.get_sqlite_db(uid) + } + + fn application_root_dir(&self) -> Result { + self.0.application_root_dir() + } +} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index 3b2895b8f8..cf98cf5214 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,5 +1,6 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; +use std::path::PathBuf; use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; @@ -15,4 +16,5 @@ pub trait ServerUser: Send + Sync { fn user_id(&self) -> FlowyResult; fn get_sqlite_db(&self, uid: i64) -> Result; + fn application_root_dir(&self) -> Result; } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 4081d659ba..a92cd8ffa9 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -9,12 +9,11 @@ use client_api::entity::chat_dto::{ }; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - LocalAIConfig, ModelList, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + ModelList, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, }; use flowy_error::FlowyError; use futures_util::{StreamExt, TryStreamExt}; use lib_infra::async_trait::async_trait; -use lib_infra::util::{get_operating_system, OperatingSystem}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; @@ -186,6 +185,7 @@ where workspace_id: &Uuid, chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? @@ -226,25 +226,6 @@ where ); } - async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result { - let system = get_operating_system(); - let platform = match system { - OperatingSystem::MacOS => "macos", - _ => { - return Err( - FlowyError::not_support() - .with_context("local ai is not supported on this operating system"), - ); - }, - }; - let config = self - .inner - .try_get_client()? - .get_local_ai_config(workspace_id.to_string().as_str(), platform) - .await?; - Ok(config) - } - async fn get_workspace_plan( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index a6bfea53b4..bd49ffb75b 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -1,5 +1,5 @@ use crate::af_cloud::define::ServerUser; -use client_api::entity::ai_dto::{LocalAIConfig, RepeatedRelatedQuestion}; +use client_api::entity::ai_dto::RepeatedRelatedQuestion; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, @@ -98,6 +98,7 @@ impl ChatCloudService for LocalServerChatServiceImpl { _workspace_id: &Uuid, _chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { Ok(RepeatedRelatedQuestion { message_id, @@ -133,13 +134,6 @@ impl ChatCloudService for LocalServerChatServiceImpl { Err(FlowyError::not_support().with_context("indexing file is not supported in local server.")) } - async fn get_local_ai_config(&self, _workspace_id: &Uuid) -> Result { - Err( - FlowyError::not_support() - .with_context("Get local ai config is not supported in local server."), - ) - } - async fn get_workspace_plan( &self, _workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index bd2372e9d4..31a65e7fa9 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,5 +1,4 @@ #![allow(unused_variables)] -use std::sync::Arc; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index df9050623e..706e7f0597 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -5,7 +5,6 @@ use collab::preclude::Collab; use collab_entity::CollabObject; use collab_user::core::UserAwareness; use lazy_static::lazy_static; -use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index d00f484068..b747c0b2ae 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -1,6 +1,7 @@ use client_api::ClientConfiguration; use semver::Version; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use flowy_error::{FlowyError, FlowyResult}; @@ -50,6 +51,10 @@ impl ServerUser for FakeServerUserImpl { fn get_sqlite_db(&self, uid: i64) -> Result { todo!() } + + fn application_root_dir(&self) -> Result { + todo!() + } } pub async fn generate_sign_in_url(user_email: &str, config: &AFCloudConfiguration) -> String { diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index bc9603e26a..7ec63f93f7 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -14,7 +14,7 @@ use flowy_user_pub::session::Session; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, Weak}; -use tracing::{error, info}; +use tracing::info; use uuid::Uuid; pub struct AuthenticateUser { diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs index c63764a055..e2b6628832 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs @@ -1,5 +1,5 @@ -use diesel::{sql_query, RunQueryDsl}; -use flowy_error::{internal_error, FlowyError}; +use diesel::RunQueryDsl; +use flowy_error::FlowyError; use std::str::FromStr; use flowy_user_pub::cloud::UserUpdate; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 2fff0c260b..b5ae54309b 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -6,7 +6,6 @@ use std::convert::TryFrom; use std::str::FromStr; use std::sync::Arc; -use collab_entity::{CollabObject, CollabType}; use collab_integrate::CollabKVDB; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; @@ -20,7 +19,7 @@ use tracing::{error, info, instrument, trace, warn}; use uuid::Uuid; use crate::entities::{ - RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, + RepeatedUserWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, UpdateUserWorkspaceSettingPB, UseAISettingPB, UserWorkspacePB, WorkspaceSubscriptionInfoPB, }; use crate::migrations::AnonUser; @@ -575,7 +574,7 @@ impl UserManager { if let Ok(member_record) = select_workspace_member(db, &workspace_id.to_string(), uid) { if is_older_than_n_minutes(member_record.updated_at, 10) { self - .get_workspace_member_info_from_remote(&workspace_id, uid) + .get_workspace_member_info_from_remote(workspace_id, uid) .await?; } From 98b835227ed59bb64b572ba886bef04aa7d038d4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 11:22:14 +0800 Subject: [PATCH 164/207] chore: remove unused code --- .../flowy-ai/src/middleware/chat_service_mw.rs | 7 ------- .../rust-lib/flowy-ai/src/persistence/chat_sql.rs | 1 - .../src/deps_resolve/cloud_service_impl.rs | 11 ----------- .../rust-lib/flowy-server/src/af_cloud/impls/chat.rs | 12 ------------ .../flowy-server/src/local_server/impls/chat.rs | 10 ---------- 5 files changed, 41 deletions(-) diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 54f1c929dd..5a529d4201 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -368,13 +368,6 @@ impl ChatCloudService for AICloudServiceMiddleware { } } - async fn get_workspace_plan( - &self, - workspace_id: &Uuid, - ) -> Result, FlowyError> { - self.cloud_service.get_workspace_plan(workspace_id).await - } - async fn get_chat_settings( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs b/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs index e962f2c880..45e839ae7e 100644 --- a/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs +++ b/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs @@ -76,7 +76,6 @@ pub fn insert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult< .execute(&mut *conn) } -#[allow(dead_code)] pub fn update_chat( conn: &mut SqliteConnection, changeset: ChatTableChangeset, diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 30fa6d0ae4..a84aff211c 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -802,17 +802,6 @@ impl ChatCloudService for ServerProvider { .await } - async fn get_workspace_plan( - &self, - workspace_id: &Uuid, - ) -> Result, FlowyError> { - self - .get_server()? - .chat_service() - .get_workspace_plan(workspace_id) - .await - } - async fn get_chat_settings( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index a92cd8ffa9..9bfd0e23fd 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -226,18 +226,6 @@ where ); } - async fn get_workspace_plan( - &self, - workspace_id: &Uuid, - ) -> Result, FlowyError> { - let plans = self - .inner - .try_get_client()? - .get_active_workspace_subscriptions(workspace_id.to_string().as_str()) - .await?; - Ok(plans) - } - async fn get_chat_settings( &self, workspace_id: &Uuid, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index bd49ffb75b..fe90f45e77 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -134,16 +134,6 @@ impl ChatCloudService for LocalServerChatServiceImpl { Err(FlowyError::not_support().with_context("indexing file is not supported in local server.")) } - async fn get_workspace_plan( - &self, - _workspace_id: &Uuid, - ) -> Result, FlowyError> { - Err( - FlowyError::not_support() - .with_context("Get local ai config is not supported in local server."), - ) - } - async fn get_chat_settings( &self, _workspace_id: &Uuid, From af91a72187a5bd199c3f95a951608b6493f48998 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 14:07:01 +0800 Subject: [PATCH 165/207] chore: select message --- frontend/rust-lib/Cargo.lock | 1 + frontend/rust-lib/flowy-ai-pub/Cargo.toml | 3 +- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 2 + frontend/rust-lib/flowy-ai-pub/src/lib.rs | 1 + .../src/persistence/chat_message_sql.rs | 35 +++-- .../src/persistence/chat_sql.rs | 67 +++++++-- .../src/persistence/mod.rs | 0 frontend/rust-lib/flowy-ai/src/ai_manager.rs | 33 +++-- frontend/rust-lib/flowy-ai/src/chat.rs | 30 ++-- frontend/rust-lib/flowy-ai/src/lib.rs | 1 - .../src/middleware/chat_service_mw.rs | 19 +-- .../src/deps_resolve/cloud_service_impl.rs | 4 +- .../rust-lib/flowy-core/src/server_layer.rs | 2 +- .../flowy-server/src/af_cloud/impls/chat.rs | 4 +- .../src/local_server/impls/chat.rs | 131 ++++++++++++++---- .../flowy-server/src/local_server/server.rs | 6 +- .../2025-04-17-042326_chat_metadata/down.sql | 9 ++ .../2025-04-17-042326_chat_metadata/up.sql | 4 + frontend/rust-lib/flowy-sqlite/src/schema.rs | 26 ++-- 19 files changed, 276 insertions(+), 102 deletions(-) rename frontend/rust-lib/{flowy-ai => flowy-ai-pub}/src/persistence/chat_message_sql.rs (75%) rename frontend/rust-lib/{flowy-ai => flowy-ai-pub}/src/persistence/chat_sql.rs (66%) rename frontend/rust-lib/{flowy-ai => flowy-ai-pub}/src/persistence/mod.rs (100%) create mode 100644 frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql create mode 100644 frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 2e85c57326..29547be823 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -2564,6 +2564,7 @@ version = "0.1.0" dependencies = [ "client-api", "flowy-error", + "flowy-sqlite", "futures", "lib-infra", "serde", diff --git a/frontend/rust-lib/flowy-ai-pub/Cargo.toml b/frontend/rust-lib/flowy-ai-pub/Cargo.toml index dfb67490ac..93ea79bcab 100644 --- a/frontend/rust-lib/flowy-ai-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-ai-pub/Cargo.toml @@ -12,4 +12,5 @@ client-api = { workspace = true } futures.workspace = true serde_json.workspace = true serde.workspace = true -uuid.workspace = true \ No newline at end of file +uuid.workspace = true +flowy-sqlite = { workspace = true } \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 5e9923d464..0acf3c5bd3 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -85,6 +85,8 @@ pub trait ChatCloudService: Send + Sync + 'static { workspace_id: &Uuid, chat_id: &Uuid, rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError>; async fn create_question( diff --git a/frontend/rust-lib/flowy-ai-pub/src/lib.rs b/frontend/rust-lib/flowy-ai-pub/src/lib.rs index 1ede32218e..9a7423ec3f 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/lib.rs @@ -1 +1,2 @@ pub mod cloud; +pub mod persistence; diff --git a/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs similarity index 75% rename from frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs rename to frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs index 6eaf6798e3..ac3192064b 100644 --- a/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs @@ -1,3 +1,4 @@ +use crate::cloud::MessageCursor; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::upsert::excluded; use flowy_sqlite::{ @@ -51,19 +52,25 @@ pub fn select_chat_messages( mut conn: DBConnection, chat_id_val: &str, limit_val: i64, - after_message_id: Option, - before_message_id: Option, + offset: MessageCursor, ) -> QueryResult> { let mut query = dsl::chat_message_table .filter(chat_message_table::chat_id.eq(chat_id_val)) .into_boxed(); - if let Some(after_message_id) = after_message_id { - query = query.filter(chat_message_table::message_id.gt(after_message_id)); + + match offset { + MessageCursor::AfterMessageId(after_message_id) => { + query = query.filter(chat_message_table::message_id.gt(after_message_id)); + }, + MessageCursor::BeforeMessageId(before_message_id) => { + query = query.filter(chat_message_table::message_id.lt(before_message_id)); + }, + MessageCursor::Offset(offset_val) => { + query = query.offset(offset_val as i64); + }, + MessageCursor::NextBack => {}, } - if let Some(before_message_id) = before_message_id { - query = query.filter(chat_message_table::message_id.lt(before_message_id)); - } query = query .order(( chat_message_table::created_at.desc(), @@ -75,7 +82,7 @@ pub fn select_chat_messages( Ok(messages) } -pub fn select_single_message( +pub fn select_message( mut conn: DBConnection, message_id_val: i64, ) -> QueryResult> { @@ -86,6 +93,18 @@ pub fn select_single_message( Ok(message) } +pub fn select_message_content( + mut conn: DBConnection, + message_id_val: i64, +) -> QueryResult> { + let message = dsl::chat_message_table + .filter(chat_message_table::message_id.eq(message_id_val)) + .select(chat_message_table::content) + .first::(&mut *conn) + .optional()?; + Ok(message) +} + pub fn select_message_where_match_reply_message_id( mut conn: DBConnection, answer_message_id_val: i64, diff --git a/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs similarity index 66% rename from frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs rename to frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs index 45e839ae7e..218f6222c3 100644 --- a/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs @@ -16,10 +16,8 @@ pub struct ChatTable { pub chat_id: String, pub created_at: i64, pub name: String, - pub local_files: String, pub metadata: String, - pub local_enabled: bool, - pub sync_to_cloud: bool, + pub rag_ids: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -49,22 +47,57 @@ pub struct ChatTableFile { pub struct ChatTableChangeset { pub chat_id: String, pub name: Option, - pub local_files: Option, pub metadata: Option, - pub local_enabled: Option, - pub sync_to_cloud: Option, + pub rag_ids: Option, } impl ChatTableChangeset { pub fn from_metadata(metadata: ChatTableMetadata) -> Self { ChatTableChangeset { + chat_id: Default::default(), metadata: serde_json::to_string(&metadata).ok(), - ..Default::default() + name: None, + rag_ids: None, + } + } + + pub fn from_rag_ids(rag_ids: Vec) -> Self { + ChatTableChangeset { + chat_id: Default::default(), + // Serialize the Vec to a JSON array string + rag_ids: Some(serde_json::to_string(&rag_ids).unwrap_or_default()), + name: None, + metadata: None, } } } -pub fn insert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult { +pub fn serialize_rag_ids(rag_ids: &[String]) -> String { + serde_json::to_string(rag_ids).unwrap_or_default() +} + +pub fn deserialize_rag_ids(rag_ids_str: &Option) -> Vec { + match rag_ids_str { + Some(str) => serde_json::from_str(str).unwrap_or_default(), + None => Vec::new(), + } +} + +pub fn deserialize_chat_metadata(metadata: &str) -> T +where + T: serde::de::DeserializeOwned + Default, +{ + serde_json::from_str(metadata).unwrap_or_default() +} + +pub fn serialize_chat_metadata(metadata: &T) -> String +where + T: Serialize, +{ + serde_json::to_string(metadata).unwrap_or_default() +} + +pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult { diesel::insert_into(chat_table::table) .values(new_chat) .on_conflict(chat_table::chat_id) @@ -72,6 +105,8 @@ pub fn insert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult< .set(( chat_table::created_at.eq(excluded(chat_table::created_at)), chat_table::name.eq(excluded(chat_table::name)), + chat_table::metadata.eq(excluded(chat_table::metadata)), + chat_table::rag_ids.eq(excluded(chat_table::rag_ids)), )) .execute(&mut *conn) } @@ -85,7 +120,6 @@ pub fn update_chat( Ok(affected_row) } -#[allow(dead_code)] pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { let row = dsl::chat_table .filter(chat_table::chat_id.eq(chat_id_val)) @@ -93,7 +127,17 @@ pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult FlowyResult> { + let chat = dsl::chat_table + .filter(chat_table::chat_id.eq(chat_id_val)) + .first::(conn)?; + + Ok(deserialize_rag_ids(&chat.rag_ids)) +} + pub fn read_chat_metadata( conn: &mut SqliteConnection, chat_id_val: &str, @@ -102,8 +146,7 @@ pub fn read_chat_metadata( .select(chat_table::metadata) .filter(chat_table::chat_id.eq(chat_id_val)) .first::(&mut *conn)?; - let value = serde_json::from_str(&metadata_str).unwrap_or_default(); - Ok(value) + Ok(deserialize_chat_metadata(&metadata_str)) } #[allow(dead_code)] diff --git a/frontend/rust-lib/flowy-ai/src/persistence/mod.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs similarity index 100% rename from frontend/rust-lib/flowy-ai/src/persistence/mod.rs rename to frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index d619b8ab7e..1bb05e19b6 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -5,7 +5,9 @@ use crate::entities::{ }; use crate::local_ai::controller::{LocalAIController, LocalAISetting}; use crate::middleware::chat_service_mw::AICloudServiceMiddleware; -use crate::persistence::{insert_chat, read_chat_metadata, ChatTable}; +use flowy_ai_pub::persistence::{ + read_chat_metadata, serialize_chat_metadata, serialize_rag_ids, upsert_chat, ChatTable, +}; use std::collections::HashMap; use dashmap::DashMap; @@ -25,6 +27,7 @@ use flowy_ai_pub::cloud::ai_dto::AvailableModel; use flowy_storage_pub::storage::StorageService; use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; +use serde_json::json; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, Weak}; @@ -221,11 +224,17 @@ impl AIManager { .unwrap_or_default(); info!("[Chat] create chat with rag_ids: {:?}", rag_ids); + save_chat( + self.user_service.sqlite_connection(*uid)?, + chat_id, + "", + rag_ids.iter().map(|v| v.to_string()).collect(), + json!({}), + )?; self .cloud_service_wm - .create_chat(uid, &workspace_id, chat_id, rag_ids) + .create_chat(uid, &workspace_id, chat_id, rag_ids, "", json!({})) .await?; - save_chat(self.user_service.sqlite_connection(*uid)?, chat_id)?; let chat = Arc::new(Chat::new( self.user_service.user_id()?, @@ -705,18 +714,22 @@ async fn sync_chat_documents( Ok(()) } -fn save_chat(conn: DBConnection, chat_id: &Uuid) -> FlowyResult<()> { +fn save_chat( + conn: DBConnection, + chat_id: &Uuid, + name: &str, + rag_ids: Vec, + metadata: serde_json::Value, +) -> FlowyResult<()> { let row = ChatTable { chat_id: chat_id.to_string(), created_at: timestamp(), - name: "".to_string(), - local_files: "".to_string(), - metadata: "".to_string(), - local_enabled: false, - sync_to_cloud: false, + name: name.to_string(), + metadata: serialize_chat_metadata(&metadata), + rag_ids: Some(serialize_rag_ids(&rag_ids)), }; - insert_chat(conn, &row)?; + upsert_chat(conn, &row)?; Ok(()) } diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index a496e067eb..0610799f03 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -5,15 +5,15 @@ use crate::entities::{ }; use crate::middleware::chat_service_mw::AICloudServiceMiddleware; use crate::notification::{chat_notification_builder, ChatNotification}; -use crate::persistence::{ - insert_chat_messages, select_chat_messages, select_message_where_match_reply_message_id, - ChatMessageTable, -}; use crate::stream_message::StreamMessage; use allo_isolate::Isolate; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, }; +use flowy_ai_pub::persistence::{ + insert_chat_messages, select_chat_messages, select_message_where_match_reply_message_id, + ChatMessageTable, +}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; use futures::{SinkExt, StreamExt}; @@ -349,9 +349,9 @@ impl Chat { limit, before_message_id ); - let messages = self - .load_local_chat_messages(limit, None, before_message_id) - .await?; + + let offset = before_message_id.map_or(MessageCursor::NextBack, MessageCursor::BeforeMessageId); + let messages = self.load_local_chat_messages(limit, offset).await?; // If the number of messages equals the limit, then no need to load more messages from remote if messages.len() == limit as usize { @@ -397,9 +397,8 @@ impl Chat { limit, after_message_id, ); - let messages = self - .load_local_chat_messages(limit, after_message_id, None) - .await?; + let offset = after_message_id.map_or(MessageCursor::NextBack, MessageCursor::AfterMessageId); + let messages = self.load_local_chat_messages(limit, offset).await?; trace!( "[Chat] Loaded local chat messages: chat_id={}, messages={}", @@ -562,17 +561,10 @@ impl Chat { async fn load_local_chat_messages( &self, limit: i64, - after_message_id: Option, - before_message_id: Option, + offset: MessageCursor, ) -> Result, FlowyError> { let conn = self.user_service.sqlite_connection(self.uid)?; - let records = select_chat_messages( - conn, - &self.chat_id.to_string(), - limit, - after_message_id, - before_message_id, - )?; + let records = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?; let messages = records .into_iter() .map(|record| ChatMessagePB { diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index ccd3920e0d..400afd1507 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -12,7 +12,6 @@ pub mod local_ai; mod middleware; pub mod notification; -mod persistence; mod protobuf; mod stream_message; mod util; diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index 5a529d4201..a9a4022573 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -4,8 +4,8 @@ use crate::local_ai::controller::LocalAIController; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; -use crate::persistence::{select_single_message, ChatMessageTable}; use af_plugin::error::PluginError; +use flowy_ai_pub::persistence::{select_message, select_message_content, ChatMessageTable}; use std::collections::HashMap; use flowy_ai_pub::cloud::{ @@ -78,14 +78,13 @@ impl AICloudServiceMiddleware { Ok(()) } - fn get_message_record(&self, message_id: i64) -> FlowyResult { + fn get_message_content(&self, message_id: i64) -> FlowyResult { let uid = self.user_service.user_id()?; let conn = self.user_service.sqlite_connection(uid)?; - let row = select_single_message(conn, message_id)?.ok_or_else(|| { + let content = select_message_content(conn, message_id)?.ok_or_else(|| { FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) })?; - - Ok(row) + Ok(content) } fn handle_plugin_error(&self, err: PluginError) { @@ -114,10 +113,12 @@ impl ChatCloudService for AICloudServiceMiddleware { workspace_id: &Uuid, chat_id: &Uuid, rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError> { self .cloud_service - .create_chat(uid, workspace_id, chat_id, rag_ids) + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) .await } @@ -165,12 +166,12 @@ impl ChatCloudService for AICloudServiceMiddleware { info!("stream_answer use model: {:?}", ai_model); if use_local_ai { if self.local_ai.is_running() { - let row = self.get_message_record(message_id)?; + let content = self.get_message_content(message_id)?; match self .local_ai .stream_question( &chat_id.to_string(), - &row.content, + &content, Some(json!(format)), json!({}), ) @@ -202,7 +203,7 @@ impl ChatCloudService for AICloudServiceMiddleware { question_message_id: i64, ) -> Result { if self.local_ai.is_running() { - let content = self.get_message_record(question_message_id)?.content; + let content = self.get_message_content(question_message_id)?; match self .local_ai .ask_question(&chat_id.to_string(), &content) diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index a84aff211c..c916157ff0 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -667,11 +667,13 @@ impl ChatCloudService for ServerProvider { workspace_id: &Uuid, chat_id: &Uuid, rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError> { let server = self.get_server(); server? .chat_service() - .create_chat(uid, workspace_id, chat_id, rag_ids) + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) .await } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 0c7bc88859..e194f614cd 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -128,7 +128,7 @@ impl ServerProvider { let server = match server_type { Server::Local => { - let server = Arc::new(LocalServer::new(self.user.clone())); + let server = Arc::new(LocalServer::new(self.user.clone(), self.local_ai.clone())); Ok::, FlowyError>(server) }, Server::AppFlowyCloud => { diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 9bfd0e23fd..4b0611e497 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -35,12 +35,14 @@ where workspace_id: &Uuid, chat_id: &Uuid, rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError> { let chat_id = chat_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = CreateChatParams { chat_id, - name: "".to_string(), + name: name.to_string(), rag_ids, }; try_get_client? diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index fe90f45e77..23562d2b07 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -1,14 +1,22 @@ use crate::af_cloud::define::ServerUser; use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_ai::local_ai::stream_util::QuestionStream; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, }; -use flowy_error::FlowyError; +use flowy_ai_pub::persistence::{ + deserialize_chat_metadata, deserialize_rag_ids, read_chat, select_message_content, + serialize_chat_metadata, serialize_rag_ids, update_chat, upsert_chat, ChatTable, + ChatTableChangeset, +}; +use flowy_error::{FlowyError, FlowyResult}; +use futures_util::{stream, FutureExt, StreamExt}; use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; -use serde_json::Value; +use serde_json::{json, Value}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -16,6 +24,18 @@ use uuid::Uuid; pub struct LocalServerChatServiceImpl { pub user: Arc, + pub local_ai: Arc, +} + +impl LocalServerChatServiceImpl { + fn get_message_content(&self, message_id: i64) -> FlowyResult { + let uid = self.user.user_id()?; + let db = self.user.get_sqlite_db(uid)?; + let content = select_message_content(db, message_id)?.ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) + })?; + Ok(content) + } } #[async_trait] @@ -24,9 +44,28 @@ impl ChatCloudService for LocalServerChatServiceImpl { &self, _uid: &i64, _workspace_id: &Uuid, - _chat_id: &Uuid, - _rag_ids: Vec, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: Value, ) -> Result<(), FlowyError> { + let uid = self.user.user_id()?; + let db = self.user.get_sqlite_db(uid)?; + + let rag_ids = rag_ids + .iter() + .map(|v| v.to_string()) + .collect::>(); + + let row = ChatTable { + chat_id: chat_id.to_string(), + created_at: timestamp(), + name: name.to_string(), + metadata: serialize_chat_metadata(&metadata), + rag_ids: Some(serialize_rag_ids(&rag_ids)), + }; + + upsert_chat(db, &row)?; Ok(()) } @@ -66,21 +105,52 @@ impl ChatCloudService for LocalServerChatServiceImpl { async fn stream_answer( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, - _message_id: i64, - _format: ResponseFormat, + chat_id: &Uuid, + message_id: i64, + format: ResponseFormat, _ai_model: Option, ) -> Result { + if self.local_ai.is_running() { + let content = self.get_message_content(message_id)?; + match self + .local_ai + .stream_question( + &chat_id.to_string(), + &content, + Some(json!(format)), + json!({}), + ) + .await + { + Ok(stream) => Ok(QuestionStream::new(stream).boxed()), + Err(err) => Ok( + stream::once(async { Err(FlowyError::local_ai_unavailable().with_context(err)) }).boxed(), + ), + } + } else { + Err(FlowyError::local_ai_not_ready()) + } + } + + async fn get_answer( + &self, + _workspace_id: &Uuid, + _chat_id: &Uuid, + _question_message_id: i64, + ) -> Result { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } async fn get_chat_messages( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, - _offset: MessageCursor, - _limit: u64, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, ) -> Result { + let uid = self.user.user_id()?; + let db = self.user.get_sqlite_db(uid)?; + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } @@ -106,15 +176,6 @@ impl ChatCloudService for LocalServerChatServiceImpl { }) } - async fn get_answer( - &self, - _workspace_id: &Uuid, - _chat_id: &Uuid, - _question_message_id: i64, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - async fn stream_complete( &self, _workspace_id: &Uuid, @@ -137,18 +198,40 @@ impl ChatCloudService for LocalServerChatServiceImpl { async fn get_chat_settings( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, + chat_id: &Uuid, ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + let chat_id = chat_id.to_string(); + let uid = self.user.user_id()?; + let db = self.user.get_sqlite_db(uid)?; + let row = read_chat(db, &chat_id)?; + let rag_ids = deserialize_rag_ids(&row.rag_ids); + let metadata = deserialize_chat_metadata::(&row.metadata); + let setting = ChatSettings { + name: row.name, + rag_ids, + metadata, + }; + + Ok(setting) } async fn update_chat_settings( &self, _workspace_id: &Uuid, - _id: &Uuid, - _s: UpdateChatParams, + id: &Uuid, + s: UpdateChatParams, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + let uid = self.user.user_id()?; + let mut db = self.user.get_sqlite_db(uid)?; + let changeset = ChatTableChangeset { + chat_id: id.to_string(), + name: s.name, + metadata: s.metadata.map(|s| serialize_chat_metadata(&s)), + rag_ids: s.rag_ids.map(|s| serialize_rag_ids(&s)), + }; + + update_chat(&mut db, changeset)?; + Ok(()) } async fn get_available_models(&self, _workspace_id: &Uuid) -> Result { diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 282d118203..0ef930320e 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -8,6 +8,7 @@ use crate::local_server::impls::{ LocalServerUserServiceImpl, }; use crate::AppFlowyServer; +use flowy_ai::local_ai::controller::LocalAIController; use flowy_ai_pub::cloud::ChatCloudService; use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; @@ -18,13 +19,15 @@ use tokio::sync::mpsc; pub struct LocalServer { user: Arc, + local_ai: Arc, stop_tx: Option>, } impl LocalServer { - pub fn new(user: Arc) -> Self { + pub fn new(user: Arc, local_ai: Arc) -> Self { Self { user, + local_ai, stop_tx: Default::default(), } } @@ -61,6 +64,7 @@ impl AppFlowyServer for LocalServer { fn chat_service(&self) -> Arc { Arc::new(LocalServerChatServiceImpl { user: self.user.clone(), + local_ai: self.local_ai.clone(), }) } diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql new file mode 100644 index 0000000000..6edd4a5113 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql @@ -0,0 +1,9 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_table + ADD COLUMN local_enabled INTEGER; +ALTER TABLE chat_table + ADD COLUMN sync_to_cloud INTEGER; +ALTER TABLE chat_table + ADD COLUMN local_files TEXT; + +ALTER TABLE chat_table DROP COLUMN rag_ids; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql new file mode 100644 index 0000000000..0604601486 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE chat_table DROP COLUMN local_enabled; +ALTER TABLE chat_table DROP COLUMN local_files; +ALTER TABLE chat_table DROP COLUMN sync_to_cloud; +ALTER TABLE chat_table ADD COLUMN rag_ids TEXT; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 4ff70bf3c6..eda95c29f7 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -35,10 +35,8 @@ diesel::table! { chat_id -> Text, created_at -> BigInt, name -> Text, - local_files -> Text, metadata -> Text, - local_enabled -> Bool, - sync_to_cloud -> Bool, + rag_ids -> Nullable, } } @@ -128,15 +126,15 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, ); From 13065ac726e8e52ab9f0ff39838079b9e29eb926 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 15:47:17 +0800 Subject: [PATCH 166/207] chore: add tests --- frontend/rust-lib/Cargo.lock | 169 +---- .../event-integration-test/Cargo.toml | 9 - .../event-integration-test/tests/main.rs | 2 + .../tests/sql_test/chat_message_test.rs | 596 ++++++++++++++++++ .../tests/sql_test/mod.rs | 1 + .../src/persistence/chat_message_sql.rs | 48 +- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 6 +- frontend/rust-lib/flowy-ai/src/chat.rs | 32 +- .../rust-lib/flowy-ai/src/event_handler.rs | 4 +- .../flowy-ai/src/local_ai/controller.rs | 2 - .../src/middleware/chat_service_mw.rs | 4 +- .../src/deps_resolve/cloud_service_impl.rs | 2 +- frontend/rust-lib/flowy-server/Cargo.toml | 1 + .../flowy-server/src/af_cloud/impls/chat.rs | 2 +- .../src/local_server/impls/chat.rs | 151 ++++- .../flowy-server/tests/af_cloud_test/util.rs | 2 +- .../2025-04-17-042326_chat_metadata/down.sql | 2 +- 17 files changed, 795 insertions(+), 238 deletions(-) create mode 100644 frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs create mode 100644 frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 29547be823..cd06dc4870 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -2313,7 +2313,6 @@ dependencies = [ name = "event-integration-test" version = "0.1.0" dependencies = [ - "anyhow", "assert-json-diff", "bytes", "chrono", @@ -2322,15 +2321,11 @@ dependencies = [ "collab-document", "collab-entity", "collab-folder", - "collab-plugins", - "dotenv", "flowy-ai", "flowy-ai-pub", "flowy-core", - "flowy-database-pub", "flowy-database2", "flowy-document", - "flowy-document-pub", "flowy-folder", "flowy-folder-pub", "flowy-notification", @@ -2342,7 +2337,6 @@ dependencies = [ "flowy-user", "flowy-user-pub", "futures", - "futures-util", "lib-dispatch", "lib-infra", "nanoid", @@ -2352,10 +2346,7 @@ dependencies = [ "serde", "serde_json", "strum", - "tempdir", - "thread-id", "tokio", - "tokio-postgres", "tracing", "uuid", "walkdir", @@ -2415,12 +2406,6 @@ dependencies = [ "rand 0.8.5", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fancy-regex" version = "0.10.0" @@ -2491,12 +2476,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "finl_unicode" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" - [[package]] name = "fixedbitset" version = "0.4.2" @@ -2959,6 +2938,7 @@ dependencies = [ "arc-swap", "assert-json-diff", "bytes", + "chrono", "client-api", "collab", "collab-database", @@ -3199,12 +3179,6 @@ dependencies = [ "libc", ] -[[package]] -name = "fuchsia-cprng" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" - [[package]] name = "funty" version = "2.0.0" @@ -4621,15 +4595,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "md-5" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" -dependencies = [ - "digest", -] - [[package]] name = "md5" version = "0.7.0" @@ -5347,35 +5312,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "postgres-protocol" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b6c5ef183cd3ab4ba005f1ca64c21e8bd97ce4699cfea9e8d9a2c4958ca520" -dependencies = [ - "base64 0.21.5", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.8.5", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d2234cdee9408b523530a9b6d2d6b373d1db34f6a8e51dc03ded1828d7fb67c" -dependencies = [ - "bytes", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -5796,19 +5732,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" -dependencies = [ - "fuchsia-cprng", - "libc", - "rand_core 0.3.1", - "rdrand", - "winapi", -] - [[package]] name = "rand" version = "0.7.3" @@ -5854,21 +5777,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" -dependencies = [ - "rand_core 0.4.2", -] - -[[package]] -name = "rand_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" - [[package]] name = "rand_core" version = "0.5.1" @@ -5944,15 +5852,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rdrand" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" -dependencies = [ - "rand_core 0.3.1", -] - [[package]] name = "redox_syscall" version = "0.1.57" @@ -6053,15 +5952,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - [[package]] name = "rend" version = "0.4.0" @@ -6948,17 +6838,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" -dependencies = [ - "finl_unicode", - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "strsim" version = "0.10.0" @@ -7296,16 +7175,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tempdir" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" -dependencies = [ - "rand 0.4.6", - "remove_dir_all", -] - [[package]] name = "tempfile" version = "3.12.0" @@ -7538,32 +7407,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d340244b32d920260ae7448cb72b6e238bddc3d4f7603394e7dd46ed8e48f5b8" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot 0.12.1", - "percent-encoding", - "phf 0.11.2", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.8.5", - "socket2 0.5.5", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-retry" version = "0.3.0" @@ -8448,16 +8291,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "whoami" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/frontend/rust-lib/event-integration-test/Cargo.toml b/frontend/rust-lib/event-integration-test/Cargo.toml index 21d94ae28a..6b2d5af7ba 100644 --- a/frontend/rust-lib/event-integration-test/Cargo.toml +++ b/frontend/rust-lib/event-integration-test/Cargo.toml @@ -12,16 +12,13 @@ flowy-user-pub = { workspace = true } flowy-folder = { path = "../flowy-folder", features = ["test_helper"] } flowy-folder-pub = { workspace = true } flowy-database2 = { path = "../flowy-database2" } -flowy-database-pub = { workspace = true } flowy-document = { path = "../flowy-document" } -flowy-document-pub = { workspace = true } flowy-ai = { workspace = true } lib-dispatch = { workspace = true } lib-infra = { workspace = true } flowy-server = { path = "../flowy-server" } flowy-server-pub = { workspace = true } flowy-notification = { workspace = true } -anyhow.workspace = true flowy-storage = { workspace = true } flowy-storage-pub = { workspace = true } flowy-search = { workspace = true } @@ -31,8 +28,6 @@ serde.workspace = true serde_json.workspace = true protobuf.workspace = true tokio = { workspace = true, features = ["full"] } -futures-util = "0.3.26" -thread-id = "3.3.0" bytes.workspace = true nanoid = "0.4.0" tracing.workspace = true @@ -41,17 +36,13 @@ collab = { workspace = true } collab-document = { workspace = true } collab-folder = { workspace = true } collab-database = { workspace = true } -collab-plugins = { workspace = true } collab-entity = { workspace = true } rand = { version = "0.8.5", features = [] } strum = "0.25.0" [dev-dependencies] -dotenv = "0.15.0" -tempdir = "0.3.7" uuid.workspace = true assert-json-diff = "2.0.2" -tokio-postgres = { version = "0.7.8" } chrono = "0.4.31" zip.workspace = true walkdir = "2.5.0" diff --git a/frontend/rust-lib/event-integration-test/tests/main.rs b/frontend/rust-lib/event-integration-test/tests/main.rs index 05f19e9b75..cf4c1591ac 100644 --- a/frontend/rust-lib/event-integration-test/tests/main.rs +++ b/frontend/rust-lib/event-integration-test/tests/main.rs @@ -4,6 +4,8 @@ mod folder; // TODO(Mathias): Enable tests for search // mod search; + +mod sql_test; mod user; pub mod util; diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs new file mode 100644 index 0000000000..549548ccf1 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs @@ -0,0 +1,596 @@ +use event_integration_test::user_event::use_localhost_af_cloud; +use event_integration_test::EventIntegrationTest; +use flowy_ai_pub::cloud::MessageCursor; +use flowy_ai_pub::persistence::{ + insert_chat_messages, select_chat_messages, select_message, select_message_content, + select_message_where_match_reply_message_id, total_message_count, ChatMessageTable, +}; +use uuid::Uuid; + +#[tokio::test] +async fn chat_message_table_insert_select_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id_1 = 1000; + let message_id_2 = 2000; + + // Create test messages + let messages = vec![ + ChatMessageTable { + message_id: message_id_1, + chat_id: chat_id.clone(), + content: "Hello, this is a test message".to_string(), + created_at: 1625097600, // 2021-07-01 + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }, + ChatMessageTable { + message_id: message_id_2, + chat_id: chat_id.clone(), + content: "This is a reply to the test message".to_string(), + created_at: 1625097700, // 2021-07-01, 100 seconds later + author_type: 0, // AI + author_id: "ai".to_string(), + reply_message_id: Some(message_id_1), + metadata: Some(r#"{"source": "test"}"#.to_string()), + }, + ]; + + // Test insert_chat_messages + let result = insert_chat_messages(db_conn, &messages); + assert!( + result.is_ok(), + "Failed to insert chat messages: {:?}", + result + ); + + // Test select_chat_messages + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let messages_result = + select_chat_messages(db_conn, &chat_id, 10, MessageCursor::Offset(0)).unwrap(); + + assert_eq!(messages_result.messages.len(), 2); + assert_eq!(messages_result.total_count, 2); + assert!(!messages_result.has_more); + + // Verify the content of the returned messages + let first_message = messages_result + .messages + .iter() + .find(|m| m.message_id == message_id_1) + .unwrap(); + assert_eq!(first_message.content, "Hello, this is a test message"); + assert_eq!(first_message.author_type, 1); + + let second_message = messages_result + .messages + .iter() + .find(|m| m.message_id == message_id_2) + .unwrap(); + assert_eq!( + second_message.content, + "This is a reply to the test message" + ); + assert_eq!(second_message.reply_message_id, Some(message_id_1)); +} + +#[tokio::test] +async fn chat_message_table_cursor_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create multiple test messages with sequential IDs + let mut messages = Vec::new(); + for i in 1..6 { + messages.push(ChatMessageTable { + message_id: i * 1000, + chat_id: chat_id.clone(), + content: format!("Message {}", i), + created_at: 1625097600 + (i * 100), // Increasing timestamps + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }); + } + + // Insert messages + insert_chat_messages(db_conn, &messages).unwrap(); + + // Test MessageCursor::Offset + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_offset = select_chat_messages( + db_conn, + &chat_id, + 2, // Limit to 2 messages + MessageCursor::Offset(0), + ) + .unwrap(); + + assert_eq!(result_offset.messages.len(), 2); + assert!(result_offset.has_more); + + // Test MessageCursor::AfterMessageId + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_after = select_chat_messages( + db_conn, + &chat_id, + 3, // Limit to 3 messages + MessageCursor::AfterMessageId(2000), + ) + .unwrap(); + + assert_eq!(result_after.messages.len(), 3); // Should get message IDs 3000, 4000, 5000 + assert!(result_after.messages.iter().all(|m| m.message_id > 2000)); + + // Test MessageCursor::BeforeMessageId + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_before = select_chat_messages( + db_conn, + &chat_id, + 2, // Limit to 2 messages + MessageCursor::BeforeMessageId(4000), + ) + .unwrap(); + + assert_eq!(result_before.messages.len(), 2); // Should get message IDs 1000, 2000, 3000 + assert!(result_before.messages.iter().all(|m| m.message_id < 4000)); +} + +#[tokio::test] +async fn chat_message_total_count_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create test messages + let messages = vec![ + ChatMessageTable { + message_id: 1001, + chat_id: chat_id.clone(), + content: "Message 1".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }, + ChatMessageTable { + message_id: 1002, + chat_id: chat_id.clone(), + content: "Message 2".to_string(), + created_at: 1625097700, + author_type: 0, + author_id: "ai".to_string(), + reply_message_id: None, + metadata: None, + }, + ]; + + // Insert messages + insert_chat_messages(db_conn, &messages).unwrap(); + + // Test total_message_count + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 2); + + // Add one more message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let additional_message = ChatMessageTable { + message_id: 1003, + chat_id: chat_id.clone(), + content: "Message 3".to_string(), + created_at: 1625097800, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }; + + insert_chat_messages(db_conn, &[additional_message]).unwrap(); + + // Verify count increased + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let updated_count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(updated_count, 3); + + // Test count for non-existent chat + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let empty_count = total_message_count(db_conn, "non_existent_chat").unwrap(); + assert_eq!(empty_count, 0); +} + +#[tokio::test] +async fn chat_message_select_message_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 2001; + + // Create test message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: "This is a test message for select_message".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: Some(r#"{"test_key": "test_value"}"#.to_string()), + }; + + // Insert message + insert_chat_messages(db_conn, &[message]).unwrap(); + + // Test select_message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message(db_conn, message_id).unwrap(); + assert!(result.is_some()); + + let retrieved_message = result.unwrap(); + assert_eq!(retrieved_message.message_id, message_id); + assert_eq!(retrieved_message.chat_id, chat_id); + assert_eq!( + retrieved_message.content, + "This is a test message for select_message" + ); + assert_eq!(retrieved_message.author_id, "user_1"); + assert_eq!( + retrieved_message.metadata, + Some(r#"{"test_key": "test_value"}"#.to_string()) + ); + + // Test select_message with non-existent ID + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let non_existent = select_message(db_conn, 9999).unwrap(); + assert!(non_existent.is_none()); +} + +#[tokio::test] +async fn chat_message_select_content_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 3001; + let message_content = "This is the content to retrieve"; + + // Create test message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: message_content.to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }; + + // Insert message + insert_chat_messages(db_conn, &[message]).unwrap(); + + // Test select_message_content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let content = select_message_content(db_conn, message_id).unwrap(); + assert!(content.is_some()); + assert_eq!(content.unwrap(), message_content); + + // Test with non-existent message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let no_content = select_message_content(db_conn, 9999).unwrap(); + assert!(no_content.is_none()); +} + +#[tokio::test] +async fn chat_message_reply_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let question_id = 4001; + let answer_id = 4002; + + // Create question and answer messages + let question = ChatMessageTable { + message_id: question_id, + chat_id: chat_id.clone(), + content: "What is the question?".to_string(), + created_at: 1625097600, + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }; + + let answer = ChatMessageTable { + message_id: answer_id, + chat_id: chat_id.clone(), + content: "This is the answer".to_string(), + created_at: 1625097700, + author_type: 0, // AI + author_id: "ai".to_string(), + reply_message_id: Some(question_id), // Link to question + metadata: None, + }; + + // Insert messages + insert_chat_messages(db_conn, &[question, answer]).unwrap(); + + // Test select_message_where_match_reply_message_id + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message_where_match_reply_message_id(db_conn, &chat_id, question_id).unwrap(); + + assert!(result.is_some()); + let reply = result.unwrap(); + assert_eq!(reply.message_id, answer_id); + assert_eq!(reply.content, "This is the answer"); + assert_eq!(reply.reply_message_id, Some(question_id)); + + // Test with non-existent reply relation + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let no_reply = select_message_where_match_reply_message_id( + db_conn, &chat_id, 9999, // Non-existent question ID + ) + .unwrap(); + + assert!(no_reply.is_none()); + + // Test with wrong chat_id + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let wrong_chat = + select_message_where_match_reply_message_id(db_conn, "wrong_chat_id", question_id).unwrap(); + + assert!(wrong_chat.is_none()); +} + +#[tokio::test] +async fn chat_message_upsert_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 5001; + + // Create initial message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: "Original content".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + }; + + // Insert message + insert_chat_messages(db_conn, &[message]).unwrap(); + + // Check original content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let original = select_message(db_conn, message_id).unwrap().unwrap(); + assert_eq!(original.content, "Original content"); + + // Create updated message with same ID but different content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let updated_message = ChatMessageTable { + message_id, // Same ID + chat_id: chat_id.clone(), + content: "Updated content".to_string(), // New content + created_at: 1625097700, // Updated timestamp + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: Some(1000), // Added reply ID + metadata: Some(r#"{"updated": true}"#.to_string()), + }; + + // Upsert message + insert_chat_messages(db_conn, &[updated_message]).unwrap(); + + // Verify update + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message(db_conn, message_id).unwrap().unwrap(); + assert_eq!(result.content, "Updated content"); + assert_eq!(result.created_at, 1625097700); + assert_eq!(result.reply_message_id, Some(1000)); + assert_eq!(result.metadata, Some(r#"{"updated": true}"#.to_string())); + + // Count should still be 1 (update, not insert) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn chat_message_select_with_large_dataset() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create 100 test messages with sequential IDs + let mut messages = Vec::new(); + for i in 1..=100 { + messages.push(ChatMessageTable { + message_id: i * 100, + chat_id: chat_id.clone(), + content: format!("Message {}", i), + created_at: 1625097600 + (i * 10), // Increasing timestamps + author_type: if i % 2 == 0 { 0 } else { 1 }, // Alternate between AI and User + author_id: if i % 2 == 0 { + "ai".to_string() + } else { + "user_1".to_string() + }, + reply_message_id: if i > 1 && i % 2 == 0 { + Some((i - 1) * 100) + } else { + None + }, // Even messages reply to previous message + metadata: if i % 5 == 0 { + Some(format!(r#"{{"index": {}}}"#, i)) + } else { + None + }, + }); + } + + // Insert all 100 messages + insert_chat_messages(db_conn, &messages).unwrap(); + + // Verify total count + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 100, "Should have 100 messages in the database"); + + // Test 1: MessageCursor::Offset with small page size + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let page_size = 10; + let result_offset = + select_chat_messages(db_conn, &chat_id, page_size, MessageCursor::Offset(0)).unwrap(); + + assert_eq!( + result_offset.messages.len(), + page_size as usize, + "Should return exactly {page_size} messages" + ); + assert!( + result_offset.has_more, + "Should have more messages available" + ); + assert_eq!(result_offset.total_count, 100, "Total count should be 100"); + + // Test 2: Pagination with offset + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_page2 = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::Offset(page_size), + ) + .unwrap(); + + assert_eq!(result_page2.messages.len(), page_size as usize); + assert!( + result_page2.messages[0].message_id != result_offset.messages[0].message_id, + "Second page should have different messages than first page" + ); + + // Test 3: MessageCursor::AfterMessageId (forward pagination) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let middle_message_id = 5000; // Message ID from the middle + let result_after = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::AfterMessageId(middle_message_id), + ) + .unwrap(); + + assert_eq!(result_after.messages.len(), page_size as usize); + assert!( + result_after + .messages + .iter() + .all(|m| m.message_id > middle_message_id), + "All messages should have ID greater than the cursor" + ); + + // Test 4: MessageCursor::BeforeMessageId (backward pagination) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_before = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::BeforeMessageId(middle_message_id), + ) + .unwrap(); + + assert_eq!(result_before.messages.len(), page_size as usize); + assert!( + result_before + .messages + .iter() + .all(|m| m.message_id < middle_message_id), + "All messages should have ID less than the cursor" + ); + + // Test 5: Large page size (retrieve all) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_all = select_chat_messages( + db_conn, + &chat_id, + 200, // More than we have + MessageCursor::Offset(0), + ) + .unwrap(); + + assert_eq!( + result_all.messages.len(), + 100, + "Should return all 100 messages" + ); + assert!(!result_all.has_more, "Should not have more messages"); + + // Test 6: Empty result when using out of range cursor + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_out_of_range = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::AfterMessageId(10000), // After the last message + ) + .unwrap(); + + assert_eq!( + result_out_of_range.messages.len(), + 0, + "Should return no messages" + ); + assert!( + !result_out_of_range.has_more, + "Should not have more messages" + ); +} diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs new file mode 100644 index 0000000000..773bdab81f --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs @@ -0,0 +1 @@ +mod chat_message_test; diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs index ac3192064b..f5f985575a 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs @@ -35,6 +35,7 @@ pub fn insert_chat_messages( .do_update() .set(( chat_message_table::content.eq(excluded(chat_message_table::content)), + chat_message_table::metadata.eq(excluded(chat_message_table::metadata)), chat_message_table::created_at.eq(excluded(chat_message_table::created_at)), chat_message_table::author_type.eq(excluded(chat_message_table::author_type)), chat_message_table::author_id.eq(excluded(chat_message_table::author_id)), @@ -48,12 +49,18 @@ pub fn insert_chat_messages( Ok(()) } +pub struct ChatMessagesResult { + pub messages: Vec, + pub total_count: i64, + pub has_more: bool, +} + pub fn select_chat_messages( mut conn: DBConnection, chat_id_val: &str, - limit_val: i64, + limit_val: u64, offset: MessageCursor, -) -> QueryResult> { +) -> QueryResult { let mut query = dsl::chat_message_table .filter(chat_message_table::chat_id.eq(chat_id_val)) .into_boxed(); @@ -71,15 +78,46 @@ pub fn select_chat_messages( MessageCursor::NextBack => {}, } + // Get total count before applying limit + let total_count = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .count() + .first::(&mut *conn)?; + query = query .order(( chat_message_table::created_at.desc(), chat_message_table::message_id.desc(), )) - .limit(limit_val); + .limit(limit_val as i64); let messages: Vec = query.load::(&mut *conn)?; - Ok(messages) + + // Check if there are more messages + let has_more = if let Some(last_message) = messages.last() { + let remaining_count = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .filter(chat_message_table::message_id.lt(last_message.message_id)) + .count() + .first::(&mut *conn)?; + + remaining_count > 0 + } else { + false + }; + + Ok(ChatMessagesResult { + messages, + total_count, + has_more, + }) +} + +pub fn total_message_count(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .count() + .first::(&mut *conn) } pub fn select_message( @@ -107,10 +145,12 @@ pub fn select_message_content( pub fn select_message_where_match_reply_message_id( mut conn: DBConnection, + chat_id: &str, answer_message_id_val: i64, ) -> QueryResult> { dsl::chat_message_table .filter(chat_message_table::reply_message_id.eq(answer_message_id_val)) + .filter(chat_message_table::chat_id.eq(chat_id)) .first::(&mut *conn) .optional() } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 1bb05e19b6..a49771a544 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -270,7 +270,7 @@ impl AIManager { ) -> FlowyResult<()> { let chat = self.get_or_create_chat_instance(chat_id).await?; let question_message_id = chat - .get_question_id_from_answer_id(answer_message_id) + .get_question_id_from_answer_id(chat_id, answer_message_id) .await?; let model = model.map_or_else( @@ -567,7 +567,7 @@ impl AIManager { pub async fn load_prev_chat_messages( &self, chat_id: &Uuid, - limit: i64, + limit: u64, before_message_id: Option, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; @@ -580,7 +580,7 @@ impl AIManager { pub async fn load_latest_chat_messages( &self, chat_id: &Uuid, - limit: i64, + limit: u64, after_message_id: Option, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index 0610799f03..976acee3e4 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -63,18 +63,6 @@ impl Chat { pub fn close(&self) {} - #[allow(dead_code)] - pub async fn pull_latest_message(&self, limit: i64) { - let latest_message_id = self - .latest_message_id - .load(std::sync::atomic::Ordering::Relaxed); - if latest_message_id > 0 { - let _ = self - .load_remote_chat_messages(limit, None, Some(latest_message_id)) - .await; - } - } - pub async fn stop_stream_message(&self) { self .stop_stream @@ -340,7 +328,7 @@ impl Chat { /// - `before_message_id` is the first message ID in the current chat messages. pub async fn load_prev_chat_messages( &self, - limit: i64, + limit: u64, before_message_id: Option, ) -> Result { trace!( @@ -388,7 +376,7 @@ impl Chat { pub async fn load_latest_chat_messages( &self, - limit: i64, + limit: u64, after_message_id: Option, ) -> Result { trace!( @@ -420,7 +408,7 @@ impl Chat { async fn load_remote_chat_messages( &self, - limit: i64, + limit: u64, before_message_id: Option, after_message_id: Option, ) -> FlowyResult<()> { @@ -445,7 +433,7 @@ impl Chat { _ => MessageCursor::NextBack, }; match cloud_service - .get_chat_messages(&workspace_id, &chat_id, cursor.clone(), limit as u64) + .get_chat_messages(&workspace_id, &chat_id, cursor.clone(), limit) .await { Ok(resp) => { @@ -498,12 +486,14 @@ impl Chat { pub async fn get_question_id_from_answer_id( &self, + chat_id: &Uuid, answer_message_id: i64, ) -> Result { let conn = self.user_service.sqlite_connection(self.uid)?; - let local_result = select_message_where_match_reply_message_id(conn, answer_message_id)? - .map(|message| message.message_id); + let local_result = + select_message_where_match_reply_message_id(conn, &chat_id.to_string(), answer_message_id)? + .map(|message| message.message_id); if let Some(message_id) = local_result { return Ok(message_id); @@ -560,12 +550,12 @@ impl Chat { async fn load_local_chat_messages( &self, - limit: i64, + limit: u64, offset: MessageCursor, ) -> Result, FlowyError> { let conn = self.user_service.sqlite_connection(self.uid)?; - let records = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?; - let messages = records + let rows = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?.messages; + let messages = rows .into_iter() .map(|record| ChatMessagePB { message_id: record.message_id, diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index b8334ffe8d..6788685102 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -152,7 +152,7 @@ pub(crate) async fn load_prev_message_handler( let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .load_prev_chat_messages(&chat_id, data.limit, data.before_message_id) + .load_prev_chat_messages(&chat_id, data.limit as u64, data.before_message_id) .await?; data_result_ok(messages) } @@ -168,7 +168,7 @@ pub(crate) async fn load_next_message_handler( let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .load_latest_chat_messages(&chat_id, data.limit, data.after_message_id) + .load_latest_chat_messages(&chat_id, data.limit as u64, data.after_message_id) .await?; data_result_ok(messages) } diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 431d8ff681..3c4ccb1f9d 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -79,7 +79,6 @@ impl LocalAIController { // Create the core plugin and resource controller let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); let res_impl = LLMResourceServiceImpl { - user_service: user_service.clone(), store_preferences: store_preferences.clone(), }; let local_ai_resource = Arc::new(LocalAIResourceController::new( @@ -594,7 +593,6 @@ async fn initialize_ai_plugin( } pub struct LLMResourceServiceImpl { - user_service: Arc, store_preferences: Weak, } diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index a9a4022573..e3c3d671ec 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -5,14 +5,14 @@ use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; use af_plugin::error::PluginError; -use flowy_ai_pub::persistence::{select_message, select_message_content, ChatMessageTable}; +use flowy_ai_pub::persistence::select_message_content; use std::collections::HashMap; use flowy_ai_pub::cloud::{ AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, CompletionStream, MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, - StreamComplete, SubscriptionPlan, UpdateChatParams, + StreamComplete, UpdateChatParams, }; use flowy_error::{FlowyError, FlowyResult}; use futures::{stream, Sink, StreamExt, TryStreamExt}; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index c916157ff0..e4bffbfb69 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -16,7 +16,7 @@ use flowy_ai_pub::cloud::search_dto::{ use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, - StreamComplete, SubscriptionPlan, UpdateChatParams, + StreamComplete, UpdateChatParams, }; use flowy_database_pub::cloud::{ DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index 5225eb817d..c8710470b0 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -45,6 +45,7 @@ rand = "0.8.5" semver = "1.0.23" flowy-sqlite = { workspace = true } flowy-ai = { workspace = true } +chrono.workspace = true [dependencies.client-api] workspace = true diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 4b0611e497..3f8803c7fc 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -9,7 +9,7 @@ use client_api::entity::chat_dto::{ }; use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - ModelList, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + ModelList, StreamAnswer, StreamComplete, UpdateChatParams, }; use flowy_error::FlowyError; use futures_util::{StreamExt, TryStreamExt}; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 23562d2b07..09ef644bfc 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -1,25 +1,29 @@ use crate::af_cloud::define::ServerUser; +use chrono::{TimeZone, Utc}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::CompletionStream; use flowy_ai::local_ai::controller::LocalAIController; use flowy_ai::local_ai::stream_util::QuestionStream; +use flowy_ai_pub::cloud::chat_dto::{ChatAuthor, ChatAuthorType}; use flowy_ai_pub::cloud::{ - AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, - StreamComplete, SubscriptionPlan, UpdateChatParams, + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, + ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RelatedQuestion, + RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, }; use flowy_ai_pub::persistence::{ - deserialize_chat_metadata, deserialize_rag_ids, read_chat, select_message_content, - serialize_chat_metadata, serialize_rag_ids, update_chat, upsert_chat, ChatTable, - ChatTableChangeset, + deserialize_chat_metadata, deserialize_rag_ids, read_chat, select_chat_messages, + select_message_content, select_message_where_match_reply_message_id, serialize_chat_metadata, + serialize_rag_ids, update_chat, upsert_chat, ChatMessageTable, ChatTable, ChatTableChangeset, }; use flowy_error::{FlowyError, FlowyResult}; -use futures_util::{stream, FutureExt, StreamExt}; +use futures_util::{stream, StreamExt, TryStreamExt}; use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use tracing::trace; use uuid::Uuid; pub struct LocalServerChatServiceImpl { @@ -148,51 +152,119 @@ impl ChatCloudService for LocalServerChatServiceImpl { offset: MessageCursor, limit: u64, ) -> Result { + let chat_id = chat_id.to_string(); let uid = self.user.user_id()?; let db = self.user.get_sqlite_db(uid)?; + let result = select_chat_messages(db, &chat_id, limit, offset)?; - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + let messages = result + .messages + .into_iter() + .map(chat_message_from_row) + .collect(); + + Ok(RepeatedChatMessage { + messages, + has_more: result.has_more, + total: result.total_count, + }) } async fn get_question_from_answer_id( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, - _answer_message_id: i64, + chat_id: &Uuid, + answer_message_id: i64, ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + let chat_id = chat_id.to_string(); + let uid = self.user.user_id()?; + let db = self.user.get_sqlite_db(uid)?; + let row = select_message_where_match_reply_message_id(db, &chat_id, answer_message_id)? + .map(chat_message_from_row) + .ok_or_else(FlowyError::record_not_found)?; + Ok(row) } async fn get_related_message( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, + chat_id: &Uuid, message_id: i64, - ai_model: Option, + _ai_model: Option, ) -> Result { - Ok(RepeatedRelatedQuestion { - message_id, - items: vec![], - }) + if self.local_ai.is_running() { + let questions = self + .local_ai + .get_related_question(&chat_id.to_string()) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + trace!("LocalAI related questions: {:?}", questions); + + let items = questions + .into_iter() + .map(|content| RelatedQuestion { + content, + metadata: None, + }) + .collect::>(); + + Ok(RepeatedRelatedQuestion { message_id, items }) + } else { + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], + }) + } } async fn stream_complete( &self, _workspace_id: &Uuid, - _params: CompleteTextParams, + params: CompleteTextParams, _ai_model: Option, ) -> Result { - Err(FlowyError::not_support().with_context("complete text is not supported in local server.")) + if self.local_ai.is_running() { + match self + .local_ai + .complete_text_v2( + ¶ms.text, + params.completion_type.unwrap() as u8, + Some(json!(params.format)), + Some(json!(params.metadata)), + ) + .await + { + Ok(stream) => Ok( + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) + .boxed(), + ), + Err(_) => Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()), + } + } else { + Err(FlowyError::local_ai_not_ready()) + } } async fn embed_file( &self, _workspace_id: &Uuid, - _file_path: &Path, - _chat_id: &Uuid, - _metadata: Option>, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support().with_context("indexing file is not supported in local server.")) + if self.local_ai.is_running() { + self + .local_ai + .embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + Ok(()) + } else { + Err(FlowyError::local_ai_not_ready()) + } } async fn get_chat_settings( @@ -242,3 +314,36 @@ impl ChatCloudService for LocalServerChatServiceImpl { Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) } } + +fn chat_message_from_row(row: ChatMessageTable) -> ChatMessage { + let created_at = Utc + .timestamp_opt(row.created_at, 0) + .single() + .unwrap_or_else(Utc::now); + + let author_id = row.author_id.parse::().unwrap_or_default(); + let author_type = match row.author_type { + 1 => ChatAuthorType::Human, + 2 => ChatAuthorType::System, + 3 => ChatAuthorType::AI, + _ => ChatAuthorType::Unknown, + }; + + let metadata = row + .metadata + .map(|s| deserialize_chat_metadata::(&s)) + .unwrap_or_else(|| json!({})); + + ChatMessage { + author: ChatAuthor { + author_id, + author_type, + meta: None, + }, + message_id: row.message_id, + content: row.content, + created_at, + metadata, + reply_message_id: row.reply_message_id, + } +} diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index b747c0b2ae..3004ce2163 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -48,7 +48,7 @@ impl ServerUser for FakeServerUserImpl { todo!() } - fn get_sqlite_db(&self, uid: i64) -> Result { + fn get_sqlite_db(&self, _uid: i64) -> Result { todo!() } diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql index 6edd4a5113..8b07e6189d 100644 --- a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql @@ -6,4 +6,4 @@ ALTER TABLE chat_table ALTER TABLE chat_table ADD COLUMN local_files TEXT; -ALTER TABLE chat_table DROP COLUMN rag_ids; +ALTER TABLE chat_table DROP COLUMN rag_ids; \ No newline at end of file From 57e4d269eb097a527b5982fee2a7a5fd13bea0e4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 16:27:53 +0800 Subject: [PATCH 167/207] chore: enable local chat --- .../lib/plugins/ai_chat/chat_page.dart | 16 ++++++------- .../setting_ai_view/settings_ai_view.dart | 17 ------------- .../settings/settings_dialog.dart | 7 +++++- frontend/rust-lib/Cargo.lock | 24 +++++++++---------- frontend/rust-lib/Cargo.toml | 4 ++-- 5 files changed, 28 insertions(+), 40 deletions(-) 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 4f843d447b..52617f1292 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -51,14 +51,14 @@ class AIChatPage extends StatelessWidget { @override Widget build(BuildContext context) { - if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { - return Center( - child: FlowyText( - LocaleKeys.chat_unsupportedCloudPrompt.tr(), - fontSize: 20, - ), - ); - } + // if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + // return Center( + // child: FlowyText( + // LocaleKeys.chat_unsupportedCloudPrompt.tr(), + // fontSize: 20, + // ), + // ); + // } return MultiBlocProvider( providers: [ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart index efb969700e..c2e75ff2f2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart @@ -10,23 +10,6 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class AIFeatureOnlySupportedWhenUsingAppFlowyCloud extends StatelessWidget { - const AIFeatureOnlySupportedWhenUsingAppFlowyCloud({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 60, vertical: 30), - child: FlowyText( - LocaleKeys.settings_aiPage_keys_loginToEnableAIFeature.tr(), - maxLines: null, - fontSize: 16, - lineHeight: 1.6, - ), - ); - } -} - class SettingsAIView extends StatelessWidget { const SettingsAIView({ super.key, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 08747b95da..512d673407 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -32,6 +32,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'pages/setting_ai_view/local_settings_ai_view.dart'; import 'widgets/setting_cloud.dart'; @visibleForTesting @@ -147,7 +148,11 @@ class SettingsDialog extends StatelessWidget { workspaceId: workspaceId, ); } else { - return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud(); + return LocalSettingsAIView( + key: ValueKey(workspaceId), + userProfile: user, + workspaceId: workspaceId, + ); } case SettingsPage.member: return WorkspaceMembersPage( diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index cd06dc4870..ec114f205a 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "bytes", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "again", "anyhow", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "futures-channel", "futures-util", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "bincode", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "async-trait", @@ -1969,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "bincode", "bytes", @@ -3427,7 +3427,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "app-error", "jsonwebtoken", @@ -4066,7 +4066,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "bytes", @@ -6644,7 +6644,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3e98e6811f8e5cf1a5ab5a92dc9b35006e254579#3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 6e93b13a19..e4f0eab545 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -107,8 +107,8 @@ af-local-ai = { version = "0.1" } # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "3e98e6811f8e5cf1a5ab5a92dc9b35006e254579" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "72a71205ebb3ec227b44ed48473abe4f1c7663e8" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "72a71205ebb3ec227b44ed48473abe4f1c7663e8" } [profile.dev] opt-level = 0 From 3a4d17f054687c41854d2fcfe94b58c9b9f1f83e Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 16:53:52 +0800 Subject: [PATCH 168/207] chore: enable anon ai writer --- .../ai/ai_writer_toolbar_item.dart | 14 ++++---- .../local_settings_ai_view.dart | 34 +++++++++++++++++++ .../src/local_server/impls/chat.rs | 12 +++++-- 3 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index 467a847c53..a8071e9286 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -142,7 +142,7 @@ class _AiWriterToolbarActionListState extends State { ], ), onPressed: () { - if (_isAIEnabled(widget.editorState)) { + if (_isAIWriterEnabled(widget.editorState)) { keepEditorFocusNotifier.increase(); popoverController.show(); setState(() { @@ -159,7 +159,7 @@ class _AiWriterToolbarActionListState extends State { return widget.tooltipBuilder?.call( context, _aiWriterToolbarItemId, - _isAIEnabled(widget.editorState) + _isAIWriterEnabled(widget.editorState) ? LocaleKeys.document_plugins_aiWriter_userQuestion.tr() : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), child, @@ -191,7 +191,7 @@ class ImproveWritingButton extends StatelessWidget { color: theme.iconColorTheme.primary, ), onPressed: () { - if (_isAIEnabled(editorState)) { + if (_isAIWriterEnabled(editorState)) { keepEditorFocusNotifier.increase(); _insertAiNode(editorState, AiWriterCommand.improveWriting); } else { @@ -205,7 +205,7 @@ class ImproveWritingButton extends StatelessWidget { return tooltipBuilder?.call( context, _aiWriterToolbarItemId, - _isAIEnabled(editorState) + _isAIWriterEnabled(editorState) ? LocaleKeys.document_plugins_aiWriter_improveWriting.tr() : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), child, @@ -240,10 +240,8 @@ void _insertAiNode(EditorState editorState, AiWriterCommand command) async { ); } -bool _isAIEnabled(EditorState editorState) { - final documentContext = editorState.document.root.context; - return documentContext == null || - !documentContext.read().isLocalMode; +bool _isAIWriterEnabled(EditorState editorState) { + return true; } bool onlyShowInTextTypeAndExcludeTable( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart new file mode 100644 index 0000000000..e90c42444f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalSettingsAIView extends StatelessWidget { + const LocalSettingsAIView({ + super.key, + required this.userProfile, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final String workspaceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SettingsAIBloc(userProfile, workspaceId) + ..add(const SettingsAIEvent.started()), + child: SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: "", + children: [ + const LocalAISetting(), + ], + ), + ); + } +} diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 09ef644bfc..9057288f88 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -132,7 +132,11 @@ impl ChatCloudService for LocalServerChatServiceImpl { ), } } else { - Err(FlowyError::local_ai_not_ready()) + if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } } } @@ -244,7 +248,11 @@ impl ChatCloudService for LocalServerChatServiceImpl { Err(_) => Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()), } } else { - Err(FlowyError::local_ai_not_ready()) + if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } } } From ac659066c674bd1f95d3e637dac040767d5d3951 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 20:49:24 +0800 Subject: [PATCH 169/207] chore: return local model --- .../lib/plugins/ai_chat/chat_page.dart | 3 - .../ai/ai_writer_toolbar_item.dart | 2 - frontend/rust-lib/flowy-ai/src/ai_manager.rs | 178 ++++++++++-------- .../flowy-core/src/deps_resolve/chat_deps.rs | 7 +- frontend/rust-lib/flowy-core/src/lib.rs | 7 + .../rust-lib/flowy-core/src/server_layer.rs | 6 + .../flowy-server/src/af_cloud/define.rs | 3 + .../flowy-server/tests/af_cloud_test/util.rs | 6 + .../src/services/authenticate_user.rs | 24 +++ .../src/user_manager/manager_history_user.rs | 2 +- 10 files changed, 150 insertions(+), 88 deletions(-) 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 52617f1292..c154b0e3a0 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:appflowy/ai/ai.dart'; -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'; @@ -9,8 +8,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:desktop_drop/desktop_drop.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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index a8071e9286..ab695b31a6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -1,6 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -9,7 +8,6 @@ import 'package:appflowy_ui/appflowy_ui.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 'operations/ai_writer_entities.dart'; diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index a49771a544..c2f5b223c0 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -35,8 +35,10 @@ use tokio::sync::RwLock; use tracing::{error, info, instrument, trace}; use uuid::Uuid; +#[async_trait] pub trait AIUserService: Send + Sync + 'static { fn user_id(&self) -> Result; + async fn is_local_model(&self) -> FlowyResult; fn workspace_id(&self) -> Result; fn sqlite_connection(&self, uid: i64) -> Result; fn application_root_dir(&self) -> Result; @@ -437,99 +439,113 @@ impl AIManager { } pub async fn get_available_models(&self, source: String) -> FlowyResult { - // Build the models list from server models and mark them as non-local. - let mut models: Vec = self - .get_server_available_models() - .await? - .into_iter() - .map(AIModel::from) - .collect(); + let is_local_mode = self.user_service.is_local_model().await?; + if is_local_mode { + let mut selected_model = AIModel::default(); + let mut models = vec![]; + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + let model = AIModel::local(local_model, "".to_string()); + selected_model = model.clone(); + models.push(model); + } - trace!("[Model Selection]: Available models: {:?}", models); - - let mut current_active_local_ai_model = None; - - // If user enable local ai, then add local ai model to the list. - if let Some(local_model) = self.local_ai.get_plugin_chat_model() { - let model = AIModel::local(local_model, "".to_string()); - current_active_local_ai_model = Some(model.clone()); - trace!("[Model Selection] current local ai model: {}", model.name); - models.push(model); - } - - if models.is_empty() { - return Ok(AvailableModelsPB { + Ok(AvailableModelsPB { models: models.into_iter().map(|m| m.into()).collect(), - selected_model: AIModelPB::default(), - }); - } + selected_model: AIModelPB::from(selected_model), + }) + } else { + // Build the models list from server models and mark them as non-local. + let mut models: Vec = self + .get_server_available_models() + .await? + .into_iter() + .map(AIModel::from) + .collect(); - // Global active model is the model selected by the user in the workspace settings. - let mut server_active_model = self - .get_workspace_select_model() - .await - .map(|m| AIModel::server(m, "".to_string())) - .unwrap_or_else(|_| AIModel::default()); + trace!("[Model Selection]: Available models: {:?}", models); + let mut current_active_local_ai_model = None; - trace!( - "[Model Selection] server active model: {:?}", - server_active_model - ); + // If user enable local ai, then add local ai model to the list. + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + let model = AIModel::local(local_model, "".to_string()); + current_active_local_ai_model = Some(model.clone()); + trace!("[Model Selection] current local ai model: {}", model.name); + models.push(model); + } - let mut user_selected_model = server_active_model.clone(); - // when current select model is deprecated, reset the model to default - if !models.iter().any(|m| m.name == server_active_model.name) { - server_active_model = AIModel::default(); - } + if models.is_empty() { + return Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model: AIModelPB::default(), + }); + } - let source_key = ai_available_models_key(&source); + // Global active model is the model selected by the user in the workspace settings. + let mut server_active_model = self + .get_workspace_select_model() + .await + .map(|m| AIModel::server(m, "".to_string())) + .unwrap_or_else(|_| AIModel::default()); - // We use source to identify user selected model. source can be document id or chat id. - match self.store_preferences.get_object::(&source_key) { - None => { - // when there is selected model and current local ai is active, then use local ai - if let Some(local_ai_model) = models.iter().find(|m| m.is_local) { - user_selected_model = local_ai_model.clone(); - } - }, - Some(mut model) => { - trace!("[Model Selection] user previous select model: {:?}", model); - // If source is provided, try to get the user-selected model from the store. User selected - // model will be used as the active model if it exists. - if model.is_local { - if let Some(local_ai_model) = ¤t_active_local_ai_model { - if local_ai_model.name != model.name { - model = local_ai_model.clone(); + trace!( + "[Model Selection] server active model: {:?}", + server_active_model + ); + + let mut user_selected_model = server_active_model.clone(); + // when current select model is deprecated, reset the model to default + if !models.iter().any(|m| m.name == server_active_model.name) { + server_active_model = AIModel::default(); + } + + let source_key = ai_available_models_key(&source); + // We use source to identify user selected model. source can be document id or chat id. + match self.store_preferences.get_object::(&source_key) { + None => { + // when there is selected model and current local ai is active, then use local ai + if let Some(local_ai_model) = models.iter().find(|m| m.is_local) { + user_selected_model = local_ai_model.clone(); + } + }, + Some(mut model) => { + trace!("[Model Selection] user previous select model: {:?}", model); + // If source is provided, try to get the user-selected model from the store. User selected + // model will be used as the active model if it exists. + if model.is_local { + if let Some(local_ai_model) = ¤t_active_local_ai_model { + if local_ai_model.name != model.name { + model = local_ai_model.clone(); + } } } - } - user_selected_model = model; - }, - } - - // If user selected model is not available in the list, use the global active model. - let active_model = models - .iter() - .find(|m| m.name == user_selected_model.name) - .cloned() - .or(Some(server_active_model.clone())); - - // Update the stored preference if a different model is used. - if let Some(ref active_model) = active_model { - if active_model.name != user_selected_model.name { - self - .store_preferences - .set_object::(&source_key, &active_model.clone())?; + user_selected_model = model; + }, } - } - trace!("[Model Selection] final active model: {:?}", active_model); - let selected_model = AIModelPB::from(active_model.unwrap_or_default()); - Ok(AvailableModelsPB { - models: models.into_iter().map(|m| m.into()).collect(), - selected_model, - }) + // If user selected model is not available in the list, use the global active model. + let active_model = models + .iter() + .find(|m| m.name == user_selected_model.name) + .cloned() + .or(Some(server_active_model.clone())); + + // Update the stored preference if a different model is used. + if let Some(ref active_model) = active_model { + if active_model.name != user_selected_model.name { + self + .store_preferences + .set_object::(&source_key, &active_model.clone())?; + } + } + + trace!("[Model Selection] final active model: {:?}", active_model); + let selected_model = AIModelPB::from(active_model.unwrap_or_default()); + Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model, + }) + } } pub async fn get_or_create_chat_instance(&self, chat_id: &Uuid) -> Result, FlowyError> { diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs index a8280f9f66..c8c93a7f4c 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -8,7 +8,7 @@ use collab_integrate::persistence::collab_metadata_sql::AFCollabMetadata; use flowy_ai::ai_manager::{AIExternalService, AIManager, AIUserService}; use flowy_ai::local_ai::controller::LocalAIController; use flowy_ai_pub::cloud::ChatCloudService; -use flowy_error::FlowyError; +use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::ViewLayout; use flowy_folder_pub::cloud::{FolderCloudService, FullSyncCollabParams}; use flowy_folder_pub::query::FolderService; @@ -164,11 +164,16 @@ impl ChatUserServiceImpl { } } +#[async_trait] impl AIUserService for ChatUserServiceImpl { fn user_id(&self) -> Result { self.upgrade_user()?.user_id() } + async fn is_local_model(&self) -> FlowyResult { + self.upgrade_user()?.is_local_mode().await + } + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 7584628056..1cf748d089 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -37,6 +37,7 @@ use crate::log_filter::init_log; use crate::server_layer::{current_server_type, Server, ServerProvider}; use deps_resolve::reminder_deps::CollabInteractImpl; use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; use user_state_callback::UserStatusCallbackImpl; pub mod config; @@ -333,6 +334,8 @@ impl ServerUserImpl { Ok(user) } } + +#[async_trait] impl ServerUser for ServerUserImpl { fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() @@ -342,6 +345,10 @@ impl ServerUser for ServerUserImpl { self.upgrade_user()?.user_id() } + async fn is_local_mode(&self) -> FlowyResult { + self.upgrade_user()?.is_local_mode().await + } + fn get_sqlite_db(&self, uid: i64) -> Result { self.upgrade_user()?.get_sqlite_connection(uid) } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index e194f614cd..3f5d16b165 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -13,6 +13,7 @@ use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; use flowy_user_pub::entities::*; +use lib_infra::async_trait::async_trait; use serde_repr::*; use std::fmt::{Display, Formatter}; use std::path::PathBuf; @@ -184,11 +185,16 @@ pub fn current_server_type() -> Server { struct AIUserServiceImpl(Arc); +#[async_trait] impl AIUserService for AIUserServiceImpl { fn user_id(&self) -> Result { self.0.user_id() } + async fn is_local_model(&self) -> FlowyResult { + self.0.is_local_mode().await + } + fn workspace_id(&self) -> Result { self.0.workspace_id() } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index cf98cf5214..e6d8a7fac2 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,5 +1,6 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; use std::path::PathBuf; use uuid::Uuid; @@ -9,11 +10,13 @@ pub const USER_EMAIL: &str = "email"; pub const USER_DEVICE_ID: &str = "device_id"; /// Represents a user that is currently using the server. +#[async_trait] pub trait ServerUser: Send + Sync { /// different user might return different workspace id. fn workspace_id(&self) -> FlowyResult; fn user_id(&self) -> FlowyResult; + async fn is_local_mode(&self) -> FlowyResult; fn get_sqlite_db(&self, uid: i64) -> Result; fn application_root_dir(&self) -> Result; diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index 3004ce2163..11027028ff 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -12,6 +12,7 @@ use flowy_server::af_cloud::define::ServerUser; use flowy_server::af_cloud::AppFlowyCloudServer; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; /// To run the test, create a .env.ci file in the 'flowy-server' directory and set the following environment variables: /// @@ -38,6 +39,7 @@ pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc )) } +#[async_trait] struct FakeServerUserImpl; impl ServerUser for FakeServerUserImpl { fn workspace_id(&self) -> FlowyResult { @@ -48,6 +50,10 @@ impl ServerUser for FakeServerUserImpl { todo!() } + async fn is_local_mode(&self) -> FlowyResult { + Ok(true) + } + fn get_sqlite_db(&self, _uid: i64) -> Result { todo!() } diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index 7ec63f93f7..84c1e9afe9 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -3,6 +3,7 @@ use crate::services::db::UserDB; use crate::services::entities::{UserConfig, UserPaths}; use collab_integrate::CollabKVDB; +use crate::user_manager::manager_history_user::ANON_USER; use arc_swap::ArcSwapOption; use collab_plugins::local_storage::kv::doc::CollabKVAction; use collab_plugins::local_storage::kv::KVTransactionDB; @@ -46,6 +47,17 @@ impl AuthenticateUser { Ok(session.user_id) } + pub async fn is_local_mode(&self) -> FlowyResult { + let uid = self.user_id()?; + if let Ok(anon_user) = self.get_anon_user().await { + if anon_user == uid { + return Ok(true); + } + } + + Ok(false) + } + pub fn device_id(&self) -> FlowyResult { Ok(self.user_config.device_id.to_string()) } @@ -150,4 +162,16 @@ impl AuthenticateUser { }, } } + + async fn get_anon_user(&self) -> FlowyResult { + let anon_session = self + .store_preferences + .get_object::(ANON_USER) + .ok_or(FlowyError::new( + ErrorCode::RecordNotFound, + "Anon user not found", + ))?; + + Ok(anon_session.user_id) + } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs index 8d20bae427..b7f4789f9a 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs @@ -9,7 +9,7 @@ use flowy_user_pub::entities::Authenticator; use crate::migrations::AnonUser; use flowy_user_pub::session::Session; -const ANON_USER: &str = "anon_user"; +pub const ANON_USER: &str = "anon_user"; impl UserManager { #[instrument(skip_all)] pub async fn get_migration_user( From ed64719560e71c399db522b4165f02836f3eaafe Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 21:09:24 +0800 Subject: [PATCH 170/207] chore: clippy --- .../lib/user/presentation/screens/workspace_error_screen.dart | 1 - frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart index bd32696514..af6d4ad770 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart @@ -1,7 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index 11027028ff..f196e44ea9 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -39,8 +39,9 @@ pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc )) } -#[async_trait] struct FakeServerUserImpl; + +#[async_trait] impl ServerUser for FakeServerUserImpl { fn workspace_id(&self) -> FlowyResult { todo!() From 5436277ada39450fd9cac00068bc2338ed455139 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 17 Apr 2025 21:39:49 +0800 Subject: [PATCH 171/207] chore: fmt --- frontend/rust-lib/flowy-sqlite/src/schema.rs | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index eda95c29f7..9504d126ed 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -126,15 +126,15 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, ); From 31d8653ba61f5885630f7bad13e2d08f4e0968d1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 13:20:05 +0800 Subject: [PATCH 172/207] refactor: save chat and chat message --- .../tests/chat/chat_message_test.rs | 2 - .../tests/sql_test/chat_message_test.rs | 43 ++- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 5 +- .../src/persistence/chat_message_sql.rs | 36 ++- .../flowy-ai-pub/src/persistence/chat_sql.rs | 53 ++-- frontend/rust-lib/flowy-ai/src/ai_manager.rs | 38 +-- frontend/rust-lib/flowy-ai/src/chat.rs | 37 +-- frontend/rust-lib/flowy-ai/src/lib.rs | 1 + .../src/middleware/chat_service_mw.rs | 23 +- frontend/rust-lib/flowy-ai/src/offline/mod.rs | 1 + .../src/offline/offline_message_sync.rs | 258 ++++++++++++++++++ .../src/deps_resolve/cloud_service_impl.rs | 17 +- frontend/rust-lib/flowy-core/src/lib.rs | 4 +- .../rust-lib/flowy-core/src/server_layer.rs | 36 +-- .../flowy-server/src/af_cloud/define.rs | 29 +- .../flowy-server/src/af_cloud/impls/chat.rs | 17 +- .../src/af_cloud/impls/database.rs | 8 +- .../src/af_cloud/impls/document.rs | 8 +- .../flowy-server/src/af_cloud/impls/folder.rs | 8 +- .../af_cloud/impls/user/cloud_service_impl.rs | 6 +- .../flowy-server/src/af_cloud/impls/util.rs | 4 +- .../flowy-server/src/af_cloud/server.rs | 34 ++- .../src/local_server/impls/chat.rs | 102 ++++--- .../flowy-server/src/local_server/server.rs | 13 +- .../flowy-server/tests/af_cloud_test/util.rs | 4 +- .../down.sql | 3 + .../up.sql | 5 + frontend/rust-lib/flowy-sqlite/src/schema.rs | 24 +- 28 files changed, 548 insertions(+), 271 deletions(-) create mode 100644 frontend/rust-lib/flowy-ai/src/offline/mod.rs create mode 100644 frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs create mode 100644 frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql create mode 100644 frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs index c5b30d68c3..5b29258142 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -27,7 +27,6 @@ async fn af_cloud_create_chat_message_test() { &Uuid::from_str(&chat_id).unwrap(), &format!("hello world {}", i), ChatMessageType::System, - &[], ) .await .unwrap(); @@ -83,7 +82,6 @@ async fn af_cloud_load_remote_system_message_test() { &Uuid::from_str(&chat_id).unwrap(), &format!("hello server {}", i), ChatMessageType::System, - &[], ) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs index 549548ccf1..3294ad26db 100644 --- a/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs @@ -2,8 +2,8 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_ai_pub::cloud::MessageCursor; use flowy_ai_pub::persistence::{ - insert_chat_messages, select_chat_messages, select_message, select_message_content, - select_message_where_match_reply_message_id, total_message_count, ChatMessageTable, + select_answer_where_match_reply_message_id, select_chat_messages, select_message, + select_message_content, total_message_count, upsert_chat_messages, ChatMessageTable, }; use uuid::Uuid; @@ -31,6 +31,7 @@ async fn chat_message_table_insert_select_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }, ChatMessageTable { message_id: message_id_2, @@ -41,11 +42,12 @@ async fn chat_message_table_insert_select_test() { author_id: "ai".to_string(), reply_message_id: Some(message_id_1), metadata: Some(r#"{"source": "test"}"#.to_string()), + is_sync: false, }, ]; // Test insert_chat_messages - let result = insert_chat_messages(db_conn, &messages); + let result = upsert_chat_messages(db_conn, &messages); assert!( result.is_ok(), "Failed to insert chat messages: {:?}", @@ -105,11 +107,12 @@ async fn chat_message_table_cursor_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }); } // Insert messages - insert_chat_messages(db_conn, &messages).unwrap(); + upsert_chat_messages(db_conn, &messages).unwrap(); // Test MessageCursor::Offset let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -173,6 +176,7 @@ async fn chat_message_total_count_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }, ChatMessageTable { message_id: 1002, @@ -183,11 +187,12 @@ async fn chat_message_total_count_test() { author_id: "ai".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }, ]; // Insert messages - insert_chat_messages(db_conn, &messages).unwrap(); + upsert_chat_messages(db_conn, &messages).unwrap(); // Test total_message_count let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -205,9 +210,10 @@ async fn chat_message_total_count_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }; - insert_chat_messages(db_conn, &[additional_message]).unwrap(); + upsert_chat_messages(db_conn, &[additional_message]).unwrap(); // Verify count increased let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -242,10 +248,11 @@ async fn chat_message_select_message_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: Some(r#"{"test_key": "test_value"}"#.to_string()), + is_sync: false, }; // Insert message - insert_chat_messages(db_conn, &[message]).unwrap(); + upsert_chat_messages(db_conn, &[message]).unwrap(); // Test select_message let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -294,10 +301,11 @@ async fn chat_message_select_content_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }; // Insert message - insert_chat_messages(db_conn, &[message]).unwrap(); + upsert_chat_messages(db_conn, &[message]).unwrap(); // Test select_message_content let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -334,6 +342,7 @@ async fn chat_message_reply_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }; let answer = ChatMessageTable { @@ -345,14 +354,15 @@ async fn chat_message_reply_test() { author_id: "ai".to_string(), reply_message_id: Some(question_id), // Link to question metadata: None, + is_sync: false, }; // Insert messages - insert_chat_messages(db_conn, &[question, answer]).unwrap(); + upsert_chat_messages(db_conn, &[question, answer]).unwrap(); // Test select_message_where_match_reply_message_id let db_conn = test.user_manager.db_connection(uid).unwrap(); - let result = select_message_where_match_reply_message_id(db_conn, &chat_id, question_id).unwrap(); + let result = select_answer_where_match_reply_message_id(db_conn, &chat_id, question_id).unwrap(); assert!(result.is_some()); let reply = result.unwrap(); @@ -362,7 +372,7 @@ async fn chat_message_reply_test() { // Test with non-existent reply relation let db_conn = test.user_manager.db_connection(uid).unwrap(); - let no_reply = select_message_where_match_reply_message_id( + let no_reply = select_answer_where_match_reply_message_id( db_conn, &chat_id, 9999, // Non-existent question ID ) .unwrap(); @@ -372,7 +382,7 @@ async fn chat_message_reply_test() { // Test with wrong chat_id let db_conn = test.user_manager.db_connection(uid).unwrap(); let wrong_chat = - select_message_where_match_reply_message_id(db_conn, "wrong_chat_id", question_id).unwrap(); + select_answer_where_match_reply_message_id(db_conn, "wrong_chat_id", question_id).unwrap(); assert!(wrong_chat.is_none()); } @@ -399,10 +409,11 @@ async fn chat_message_upsert_test() { author_id: "user_1".to_string(), reply_message_id: None, metadata: None, + is_sync: false, }; // Insert message - insert_chat_messages(db_conn, &[message]).unwrap(); + upsert_chat_messages(db_conn, &[message]).unwrap(); // Check original content let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -420,10 +431,11 @@ async fn chat_message_upsert_test() { author_id: "user_1".to_string(), reply_message_id: Some(1000), // Added reply ID metadata: Some(r#"{"updated": true}"#.to_string()), + is_sync: false, }; // Upsert message - insert_chat_messages(db_conn, &[updated_message]).unwrap(); + upsert_chat_messages(db_conn, &[updated_message]).unwrap(); // Verify update let db_conn = test.user_manager.db_connection(uid).unwrap(); @@ -474,11 +486,12 @@ async fn chat_message_select_with_large_dataset() { } else { None }, + is_sync: false, }); } // Insert all 100 messages - insert_chat_messages(db_conn, &messages).unwrap(); + upsert_chat_messages(db_conn, &messages).unwrap(); // Verify total count let db_conn = test.user_manager.db_connection(uid).unwrap(); diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index 0acf3c5bd3..b91021142e 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -95,7 +95,6 @@ pub trait ChatCloudService: Send + Sync + 'static { chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result; async fn create_answer( @@ -111,7 +110,7 @@ pub trait ChatCloudService: Send + Sync + 'static { &self, workspace_id: &Uuid, chat_id: &Uuid, - message_id: i64, + question_id: i64, format: ResponseFormat, ai_model: Option, ) -> Result; @@ -120,7 +119,7 @@ pub trait ChatCloudService: Send + Sync + 'static { &self, workspace_id: &Uuid, chat_id: &Uuid, - question_message_id: i64, + question_id: i64, ) -> Result; async fn get_chat_messages( diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs index f5f985575a..230e5761d2 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs @@ -1,4 +1,5 @@ use crate::cloud::MessageCursor; +use client_api::entity::chat_dto::ChatMessage; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::upsert::excluded; use flowy_sqlite::{ @@ -21,9 +22,40 @@ pub struct ChatMessageTable { pub author_id: String, pub reply_message_id: Option, pub metadata: Option, + pub is_sync: bool, +} +impl ChatMessageTable { + pub fn from_message(chat_id: String, message: ChatMessage, is_sync: bool) -> Self { + ChatMessageTable { + message_id: message.message_id, + chat_id, + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), + is_sync, + } + } } -pub fn insert_chat_messages( +pub fn update_chat_message_is_sync( + mut conn: DBConnection, + chat_id_val: &str, + message_id_val: i64, + is_sync_val: bool, +) -> FlowyResult<()> { + diesel::update(chat_message_table::table) + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .filter(chat_message_table::message_id.eq(message_id_val)) + .set(chat_message_table::is_sync.eq(is_sync_val)) + .execute(&mut *conn)?; + + Ok(()) +} + +pub fn upsert_chat_messages( mut conn: DBConnection, new_messages: &[ChatMessageTable], ) -> FlowyResult<()> { @@ -143,7 +175,7 @@ pub fn select_message_content( Ok(message) } -pub fn select_message_where_match_reply_message_id( +pub fn select_answer_where_match_reply_message_id( mut conn: DBConnection, chat_id: &str, answer_message_id_val: i64, diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs index 218f6222c3..f5398c48c0 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs @@ -7,7 +7,10 @@ use flowy_sqlite::{ schema::{chat_table, chat_table::dsl}, AsChangeset, DBConnection, ExpressionMethods, Identifiable, Insertable, QueryResult, Queryable, }; +use lib_infra::util::timestamp; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; #[derive(Clone, Default, Queryable, Insertable, Identifiable)] #[diesel(table_name = chat_table)] @@ -18,6 +21,23 @@ pub struct ChatTable { pub name: String, pub metadata: String, pub rag_ids: Option, + pub is_sync: bool, +} + +impl ChatTable { + pub fn new(chat_id: String, metadata: Value, rag_ids: Vec, is_sync: bool) -> Self { + let rag_ids = rag_ids.iter().map(|v| v.to_string()).collect::>(); + let metadata = serialize_chat_metadata(&metadata); + let rag_ids = Some(serialize_rag_ids(&rag_ids)); + Self { + chat_id, + created_at: timestamp(), + name: "".to_string(), + metadata, + rag_ids, + is_sync, + } + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -49,27 +69,7 @@ pub struct ChatTableChangeset { pub name: Option, pub metadata: Option, pub rag_ids: Option, -} - -impl ChatTableChangeset { - pub fn from_metadata(metadata: ChatTableMetadata) -> Self { - ChatTableChangeset { - chat_id: Default::default(), - metadata: serde_json::to_string(&metadata).ok(), - name: None, - rag_ids: None, - } - } - - pub fn from_rag_ids(rag_ids: Vec) -> Self { - ChatTableChangeset { - chat_id: Default::default(), - // Serialize the Vec to a JSON array string - rag_ids: Some(serde_json::to_string(&rag_ids).unwrap_or_default()), - name: None, - metadata: None, - } - } + pub is_sync: Option, } pub fn serialize_rag_ids(rag_ids: &[String]) -> String { @@ -107,6 +107,7 @@ pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult< chat_table::name.eq(excluded(chat_table::name)), chat_table::metadata.eq(excluded(chat_table::metadata)), chat_table::rag_ids.eq(excluded(chat_table::rag_ids)), + chat_table::is_sync.eq(excluded(chat_table::is_sync)), )) .execute(&mut *conn) } @@ -120,6 +121,16 @@ pub fn update_chat( Ok(affected_row) } +pub fn update_chat_is_sync( + mut conn: DBConnection, + chat_id_val: &str, + is_sync_val: bool, +) -> QueryResult { + diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))) + .set(chat_table::is_sync.eq(is_sync_val)) + .execute(&mut *conn) +} + pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { let row = dsl::chat_table .filter(chat_table::chat_id.eq(chat_id_val)) diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index c2f5b223c0..7602ff8040 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -4,10 +4,8 @@ use crate::entities::{ FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, }; use crate::local_ai::controller::{LocalAIController, LocalAISetting}; -use crate::middleware::chat_service_mw::AICloudServiceMiddleware; -use flowy_ai_pub::persistence::{ - read_chat_metadata, serialize_chat_metadata, serialize_rag_ids, upsert_chat, ChatTable, -}; +use crate::middleware::chat_service_mw::ChatServiceMiddleware; +use flowy_ai_pub::persistence::read_chat_metadata; use std::collections::HashMap; use dashmap::DashMap; @@ -72,7 +70,7 @@ struct ServerModelsCache { pub const GLOBAL_ACTIVE_MODEL_KEY: &str = "global_active_model"; pub struct AIManager { - pub cloud_service_wm: Arc, + pub cloud_service_wm: Arc, pub user_service: Arc, pub external_service: Arc, chats: Arc>>, @@ -97,7 +95,7 @@ impl AIManager { }); let external_service = Arc::new(query_service); - let cloud_service_wm = Arc::new(AICloudServiceMiddleware::new( + let cloud_service_wm = Arc::new(ChatServiceMiddleware::new( user_service.clone(), chat_cloud_service, local_ai.clone(), @@ -226,13 +224,6 @@ impl AIManager { .unwrap_or_default(); info!("[Chat] create chat with rag_ids: {:?}", rag_ids); - save_chat( - self.user_service.sqlite_connection(*uid)?, - chat_id, - "", - rag_ids.iter().map(|v| v.to_string()).collect(), - json!({}), - )?; self .cloud_service_wm .create_chat(uid, &workspace_id, chat_id, rag_ids, "", json!({})) @@ -730,28 +721,9 @@ async fn sync_chat_documents( Ok(()) } -fn save_chat( - conn: DBConnection, - chat_id: &Uuid, - name: &str, - rag_ids: Vec, - metadata: serde_json::Value, -) -> FlowyResult<()> { - let row = ChatTable { - chat_id: chat_id.to_string(), - created_at: timestamp(), - name: name.to_string(), - metadata: serialize_chat_metadata(&metadata), - rag_ids: Some(serialize_rag_ids(&rag_ids)), - }; - - upsert_chat(conn, &row)?; - Ok(()) -} - async fn refresh_chat_setting( user_service: &Arc, - cloud_service: &Arc, + cloud_service: &Arc, store_preferences: &Arc, chat_id: &Uuid, ) -> FlowyResult { diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index 976acee3e4..ba5ff431e9 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -3,7 +3,7 @@ use crate::entities::{ ChatMessageErrorPB, ChatMessageListPB, ChatMessagePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, }; -use crate::middleware::chat_service_mw::AICloudServiceMiddleware; +use crate::middleware::chat_service_mw::ChatServiceMiddleware; use crate::notification::{chat_notification_builder, ChatNotification}; use crate::stream_message::StreamMessage; use allo_isolate::Isolate; @@ -11,7 +11,7 @@ use flowy_ai_pub::cloud::{ AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, }; use flowy_ai_pub::persistence::{ - insert_chat_messages, select_chat_messages, select_message_where_match_reply_message_id, + select_answer_where_match_reply_message_id, select_chat_messages, upsert_chat_messages, ChatMessageTable, }; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; @@ -35,7 +35,7 @@ pub struct Chat { chat_id: Uuid, uid: i64, user_service: Arc, - chat_service: Arc, + chat_service: Arc, prev_message_state: Arc>, latest_message_id: Arc, stop_stream: Arc, @@ -47,7 +47,7 @@ impl Chat { uid: i64, chat_id: Uuid, user_service: Arc, - chat_service: Arc, + chat_service: Arc, ) -> Chat { Chat { uid, @@ -105,7 +105,6 @@ impl Chat { &self.chat_id, ¶ms.message, params.message_type.clone(), - &[], ) .await .map_err(|err| { @@ -126,7 +125,7 @@ impl Chat { } // Save message to disk - save_and_notify_message(uid, &self.chat_id, &self.user_service, question.clone())?; + notify_message(&self.chat_id, question.clone())?; let format = params.format.clone().map(Into::into).unwrap_or_default(); self.stream_response( params.answer_stream_port, @@ -185,7 +184,7 @@ impl Chat { &self, answer_stream_port: i64, answer_stream_buffer: Arc>, - uid: i64, + _uid: i64, workspace_id: Uuid, question_id: i64, format: ResponseFormat, @@ -194,7 +193,6 @@ impl Chat { let stop_stream = self.stop_stream.clone(); let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); - let user_service = self.user_service.clone(); tokio::spawn(async move { let mut answer_sink = IsolateSink::new(Isolate::new(answer_stream_port)); match cloud_service @@ -309,7 +307,7 @@ impl Chat { metadata, ) .await?; - save_and_notify_message(uid, &chat_id, &user_service, answer)?; + notify_message(&chat_id, answer)?; Ok::<(), FlowyError>(()) }); } @@ -442,6 +440,7 @@ impl Chat { user_service.sqlite_connection(uid)?, &chat_id, resp.messages.clone(), + true, ) { error!("Failed to save chat:{} messages: {}", chat_id, err); } @@ -492,7 +491,7 @@ impl Chat { let conn = self.user_service.sqlite_connection(self.uid)?; let local_result = - select_message_where_match_reply_message_id(conn, &chat_id.to_string(), answer_message_id)? + select_answer_where_match_reply_message_id(conn, &chat_id.to_string(), answer_message_id)? .map(|message| message.message_id); if let Some(message_id) = local_result { @@ -543,7 +542,7 @@ impl Chat { .get_answer(&workspace_id, &self.chat_id, question_message_id) .await?; - save_and_notify_message(self.uid, &self.chat_id, &self.user_service, answer.clone())?; + notify_message(&self.chat_id, answer.clone())?; let pb = ChatMessagePB::from(answer); Ok(pb) } @@ -614,6 +613,7 @@ fn save_chat_message_disk( conn: DBConnection, chat_id: &Uuid, messages: Vec, + is_sync: bool, ) -> FlowyResult<()> { let records = messages .into_iter() @@ -626,9 +626,10 @@ fn save_chat_message_disk( author_id: message.author.author_id.to_string(), reply_message_id: message.reply_message_id, metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), + is_sync, }) .collect::>(); - insert_chat_messages(conn, &records)?; + upsert_chat_messages(conn, &records)?; Ok(()) } @@ -665,18 +666,8 @@ impl StringBuffer { } } -pub(crate) fn save_and_notify_message( - uid: i64, - chat_id: &Uuid, - user_service: &Arc, - message: ChatMessage, -) -> Result<(), FlowyError> { +pub(crate) fn notify_message(chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { trace!("[Chat] save answer: answer={:?}", message); - save_chat_message_disk( - user_service.sqlite_connection(uid)?, - chat_id, - vec![message.clone()], - )?; let pb = ChatMessagePB::from(message); chat_notification_builder(chat_id, ChatNotification::DidReceiveChatMessage) .payload(pb) diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index 400afd1507..5b582b2577 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -12,6 +12,7 @@ pub mod local_ai; mod middleware; pub mod notification; +pub mod offline; mod protobuf; mod stream_message; mod util; diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index e3c3d671ec..ff5c608f22 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -28,14 +28,14 @@ use std::sync::{Arc, Weak}; use tracing::{info, trace}; use uuid::Uuid; -pub struct AICloudServiceMiddleware { +pub struct ChatServiceMiddleware { cloud_service: Arc, user_service: Arc, local_ai: Arc, storage_service: Weak, } -impl AICloudServiceMiddleware { +impl ChatServiceMiddleware { pub fn new( user_service: Arc, cloud_service: Arc, @@ -106,7 +106,7 @@ impl AICloudServiceMiddleware { } #[async_trait] -impl ChatCloudService for AICloudServiceMiddleware { +impl ChatCloudService for ChatServiceMiddleware { async fn create_chat( &self, uid: &i64, @@ -128,11 +128,10 @@ impl ChatCloudService for AICloudServiceMiddleware { chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result { self .cloud_service - .create_question(workspace_id, chat_id, message, message_type, metadata) + .create_question(workspace_id, chat_id, message, message_type) .await } @@ -154,7 +153,7 @@ impl ChatCloudService for AICloudServiceMiddleware { &self, workspace_id: &Uuid, chat_id: &Uuid, - message_id: i64, + question_id: i64, format: ResponseFormat, ai_model: Option, ) -> Result { @@ -166,7 +165,7 @@ impl ChatCloudService for AICloudServiceMiddleware { info!("stream_answer use model: {:?}", ai_model); if use_local_ai { if self.local_ai.is_running() { - let content = self.get_message_content(message_id)?; + let content = self.get_message_content(question_id)?; match self .local_ai .stream_question( @@ -191,7 +190,7 @@ impl ChatCloudService for AICloudServiceMiddleware { } else { self .cloud_service - .stream_answer(workspace_id, chat_id, message_id, format, ai_model) + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) .await } } @@ -200,10 +199,10 @@ impl ChatCloudService for AICloudServiceMiddleware { &self, workspace_id: &Uuid, chat_id: &Uuid, - question_message_id: i64, + question_id: i64, ) -> Result { if self.local_ai.is_running() { - let content = self.get_message_content(question_message_id)?; + let content = self.get_message_content(question_id)?; match self .local_ai .ask_question(&chat_id.to_string(), &content) @@ -212,7 +211,7 @@ impl ChatCloudService for AICloudServiceMiddleware { Ok(answer) => { let message = self .cloud_service - .create_answer(workspace_id, chat_id, &answer, question_message_id, None) + .create_answer(workspace_id, chat_id, &answer, question_id, None) .await?; Ok(message) }, @@ -224,7 +223,7 @@ impl ChatCloudService for AICloudServiceMiddleware { } else { self .cloud_service - .get_answer(workspace_id, chat_id, question_message_id) + .get_answer(workspace_id, chat_id, question_id) .await } } diff --git a/frontend/rust-lib/flowy-ai/src/offline/mod.rs b/frontend/rust-lib/flowy-ai/src/offline/mod.rs new file mode 100644 index 0000000000..e55b43fdb2 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/offline/mod.rs @@ -0,0 +1 @@ +pub mod offline_message_sync; diff --git a/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs b/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs new file mode 100644 index 0000000000..8d7e8d2e42 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs @@ -0,0 +1,258 @@ +use crate::ai_manager::AIUserService; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, + MessageCursor, ModelList, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, + StreamAnswer, StreamComplete, UpdateChatParams, +}; +use flowy_ai_pub::persistence::{ + update_chat_is_sync, update_chat_message_is_sync, upsert_chat, upsert_chat_messages, + ChatMessageTable, ChatTable, +}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use uuid::Uuid; + +pub struct AutoSyncChatService { + cloud_service: Arc, + user_service: Arc, +} + +impl AutoSyncChatService { + pub fn new( + cloud_service: Arc, + user_service: Arc, + ) -> Self { + Self { + cloud_service, + user_service, + } + } + + async fn upsert_message( + &self, + chat_id: &Uuid, + message: ChatMessage, + is_sync: bool, + ) -> Result<(), FlowyError> { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + let row = ChatMessageTable::from_message(chat_id.to_string(), message, is_sync); + upsert_chat_messages(conn, &[row])?; + Ok(()) + } + + #[allow(dead_code)] + async fn update_message_is_sync( + &self, + chat_id: &Uuid, + message_id: i64, + ) -> Result<(), FlowyError> { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + update_chat_message_is_sync(conn, &chat_id.to_string(), message_id, true)?; + Ok(()) + } +} + +#[async_trait] +impl ChatCloudService for AutoSyncChatService { + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: Value, + ) -> Result<(), FlowyError> { + let conn = self.user_service.sqlite_connection(*uid)?; + let chat = ChatTable::new( + chat_id.to_string(), + metadata.clone(), + rag_ids.clone(), + false, + ); + upsert_chat(conn, &chat)?; + + if self + .cloud_service + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) + .await + .is_ok() + { + let conn = self.user_service.sqlite_connection(*uid)?; + update_chat_is_sync(conn, &chat_id.to_string(), true)?; + } + Ok(()) + } + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let message = self + .cloud_service + .create_question(workspace_id, chat_id, message, message_type) + .await?; + self.upsert_message(chat_id, message.clone(), true).await?; + // TODO: implement background sync + // self + // .update_message_is_sync(chat_id, message.message_id) + // .await?; + Ok(message) + } + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let message = self + .cloud_service + .create_answer(workspace_id, chat_id, message, question_id, metadata) + .await?; + + // TODO: implement background sync + self.upsert_message(chat_id, message.clone(), true).await?; + Ok(message) + } + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result { + self + .cloud_service + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) + .await + } + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let message = self + .cloud_service + .get_answer(workspace_id, chat_id, question_id) + .await?; + + // TODO: implement background sync + self.upsert_message(chat_id, message.clone(), true).await?; + Ok(message) + } + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + self + .cloud_service + .get_chat_messages(workspace_id, chat_id, offset, limit) + .await + } + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + self + .cloud_service + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) + .await + } + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result { + self + .cloud_service + .get_related_message(workspace_id, chat_id, message_id, ai_model) + .await + } + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result { + self + .cloud_service + .stream_complete(workspace_id, params, ai_model) + .await + } + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + self + .cloud_service + .embed_file(workspace_id, file_path, chat_id, metadata) + .await + } + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + // TODO: implement background sync + self + .cloud_service + .get_chat_settings(workspace_id, chat_id) + .await + } + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError> { + // TODO: implement background sync + self + .cloud_service + .update_chat_settings(workspace_id, chat_id, params) + .await + } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self.cloud_service.get_available_models(workspace_id).await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .cloud_service + .get_workspace_default_model(workspace_id) + .await + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index e4bffbfb69..3a7d648133 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -14,9 +14,9 @@ use flowy_ai_pub::cloud::search_dto::{ SearchDocumentResponseItem, SearchResult, SearchSummaryResult, }; use flowy_ai_pub::cloud::{ - AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, - StreamComplete, UpdateChatParams, + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, + MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, + UpdateChatParams, }; use flowy_database_pub::cloud::{ DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, @@ -683,13 +683,12 @@ impl ChatCloudService for ServerProvider { chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result { let message = message.to_string(); self .get_server()? .chat_service() - .create_question(workspace_id, chat_id, &message, message_type, metadata) + .create_question(workspace_id, chat_id, &message, message_type) .await } @@ -712,14 +711,14 @@ impl ChatCloudService for ServerProvider { &self, workspace_id: &Uuid, chat_id: &Uuid, - message_id: i64, + question_id: i64, format: ResponseFormat, ai_model: Option, ) -> Result { let server = self.get_server()?; server .chat_service() - .stream_answer(workspace_id, chat_id, message_id, format, ai_model) + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) .await } @@ -768,12 +767,12 @@ impl ChatCloudService for ServerProvider { &self, workspace_id: &Uuid, chat_id: &Uuid, - question_message_id: i64, + question_id: i64, ) -> Result { let server = self.get_server(); server? .chat_service() - .get_answer(workspace_id, chat_id, question_message_id) + .get_answer(workspace_id, chat_id, question_id) .await } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 1cf748d089..dbbce02136 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -8,7 +8,7 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::manager::FolderManager; use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_search::services::manager::SearchManager; -use flowy_server::af_cloud::define::ServerUser; +use flowy_server::af_cloud::define::LoginUserService; use std::path::PathBuf; use std::sync::{Arc, Weak}; use std::time::Duration; @@ -336,7 +336,7 @@ impl ServerUserImpl { } #[async_trait] -impl ServerUser for ServerUserImpl { +impl LoginUserService for ServerUserImpl { fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 3f5d16b165..a3d6de7003 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -2,24 +2,19 @@ use crate::AppFlowyCoreConfig; use af_plugin::manager::PluginManager; use arc_swap::ArcSwapOption; use dashmap::DashMap; -use flowy_ai::ai_manager::AIUserService; use flowy_ai::local_ai::controller::LocalAIController; use flowy_error::{FlowyError, FlowyResult}; -use flowy_server::af_cloud::define::ServerUser; +use flowy_server::af_cloud::define::{AIUserServiceImpl, LoginUserService}; use flowy_server::af_cloud::AppFlowyCloudServer; use flowy_server::local_server::LocalServer; use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; -use flowy_sqlite::DBConnection; use flowy_user_pub::entities::*; -use lib_infra::async_trait::async_trait; use serde_repr::*; use std::fmt::{Display, Formatter}; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Weak}; -use uuid::Uuid; #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] #[repr(u8)] @@ -61,7 +56,7 @@ pub struct ServerProvider { /// The authenticator type of the user. authenticator: AtomicU8, - user: Arc, + user: Arc, pub(crate) uid: Arc>, pub local_ai: Arc, } @@ -71,7 +66,7 @@ impl ServerProvider { config: AppFlowyCoreConfig, server: Server, store_preferences: Weak, - server_user: impl ServerUser + 'static, + server_user: impl LoginUserService + 'static, ) -> Self { let user = Arc::new(server_user); let encryption = EncryptionImpl::new(None); @@ -182,28 +177,3 @@ pub fn current_server_type() -> Server { AuthenticatorType::AppFlowyCloud => Server::AppFlowyCloud, } } - -struct AIUserServiceImpl(Arc); - -#[async_trait] -impl AIUserService for AIUserServiceImpl { - fn user_id(&self) -> Result { - self.0.user_id() - } - - async fn is_local_model(&self) -> FlowyResult { - self.0.is_local_mode().await - } - - fn workspace_id(&self) -> Result { - self.0.workspace_id() - } - - fn sqlite_connection(&self, uid: i64) -> Result { - self.0.get_sqlite_db(uid) - } - - fn application_root_dir(&self) -> Result { - self.0.application_root_dir() - } -} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index e6d8a7fac2..0cebe3371f 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,7 +1,9 @@ +use flowy_ai::ai_manager::AIUserService; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; use lib_infra::async_trait::async_trait; use std::path::PathBuf; +use std::sync::Arc; use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; @@ -11,7 +13,7 @@ pub const USER_DEVICE_ID: &str = "device_id"; /// Represents a user that is currently using the server. #[async_trait] -pub trait ServerUser: Send + Sync { +pub trait LoginUserService: Send + Sync { /// different user might return different workspace id. fn workspace_id(&self) -> FlowyResult; @@ -21,3 +23,28 @@ pub trait ServerUser: Send + Sync { fn get_sqlite_db(&self, uid: i64) -> Result; fn application_root_dir(&self) -> Result; } + +pub struct AIUserServiceImpl(pub Arc); + +#[async_trait] +impl AIUserService for AIUserServiceImpl { + fn user_id(&self) -> Result { + self.0.user_id() + } + + async fn is_local_model(&self) -> FlowyResult { + self.0.is_local_mode().await + } + + fn workspace_id(&self) -> Result { + self.0.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.0.get_sqlite_db(uid) + } + + fn application_root_dir(&self) -> Result { + self.0.application_root_dir() + } +} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 3f8803c7fc..14a26078f5 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -8,8 +8,8 @@ use client_api::entity::chat_dto::{ RepeatedChatMessage, }; use flowy_ai_pub::cloud::{ - AIModel, ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - ModelList, StreamAnswer, StreamComplete, UpdateChatParams, + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, ModelList, StreamAnswer, + StreamComplete, UpdateChatParams, }; use flowy_error::FlowyError; use futures_util::{StreamExt, TryStreamExt}; @@ -20,12 +20,12 @@ use std::path::Path; use tracing::trace; use uuid::Uuid; -pub(crate) struct AFCloudChatCloudServiceImpl { +pub(crate) struct CloudChatServiceImpl { pub inner: T, } #[async_trait] -impl ChatCloudService for AFCloudChatCloudServiceImpl +impl ChatCloudService for CloudChatServiceImpl where T: AFServer, { @@ -59,7 +59,6 @@ where chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result { let chat_id = chat_id.to_string(); let try_get_client = self.inner.try_get_client(); @@ -132,15 +131,11 @@ where &self, workspace_id: &Uuid, chat_id: &Uuid, - question_message_id: i64, + question_id: i64, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_answer( - workspace_id, - chat_id.to_string().as_str(), - question_message_id, - ) + .get_answer(workspace_id, chat_id.to_string().as_str(), question_id) .await .map_err(FlowyError::from)?; Ok(resp) diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index c493dda344..d6a22a2f73 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -1,5 +1,5 @@ #![allow(unused_variables)] -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoginUserService; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; use client_api::entity::ai_dto::{ @@ -23,7 +23,7 @@ use uuid::Uuid; pub(crate) struct AFCloudDatabaseCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Arc, } #[async_trait] @@ -40,7 +40,7 @@ where workspace_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); + let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*object_id, collab_type), @@ -95,7 +95,7 @@ where workspace_id: &Uuid, ) -> Result { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); + let cloned_user = self.logged_user.clone(); let client = try_get_client?; let params = object_ids .into_iter() diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index 4909d96fef..ae67fc6d26 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -13,13 +13,13 @@ use std::sync::Arc; use tracing::instrument; use uuid::Uuid; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoginUserService; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudDocumentCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Arc, } #[async_trait] @@ -49,7 +49,7 @@ where check_request_workspace_id_is_match( workspace_id, - &self.user, + &self.logged_user, format!("get document doc state:{}", document_id), )?; @@ -85,7 +85,7 @@ where .to_vec(); check_request_workspace_id_is_match( workspace_id, - &self.user, + &self.logged_user, format!("Get {} document", document_id), )?; let collab = Collab::new_with_source( diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index 5b8efa4b32..116651a734 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -22,13 +22,13 @@ use flowy_folder_pub::cloud::{ use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoginUserService; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudFolderCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Arc, } #[async_trait] @@ -91,7 +91,7 @@ where ) -> Result, FlowyError> { let uid = *uid; let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); + let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*workspace_id, CollabType::Folder), @@ -131,7 +131,7 @@ where object_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); + let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*object_id, collab_type), diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 6d7d9d743b..0ac4555d6e 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -31,7 +31,7 @@ use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; use uuid::Uuid; -use crate::af_cloud::define::{ServerUser, USER_SIGN_IN_URL}; +use crate::af_cloud::define::{LoginUserService, USER_SIGN_IN_URL}; use crate::af_cloud::impls::user::dto::{ af_update_from_update_params, from_af_workspace_member, to_af_role, user_profile_from_af_profile, }; @@ -44,14 +44,14 @@ use super::dto::{from_af_workspace_invitation_status, to_workspace_invitation_st pub(crate) struct AFCloudUserAuthServiceImpl { server: T, user_change_recv: ArcSwapOption>, - user: Arc, + user: Arc, } impl AFCloudUserAuthServiceImpl { pub(crate) fn new( server: T, user_change_recv: tokio::sync::mpsc::Receiver, - user: Arc, + user: Arc, ) -> Self { Self { server, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs index 0d91de8412..bedcc90ca0 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs @@ -1,4 +1,4 @@ -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoginUserService; use flowy_error::{FlowyError, FlowyResult}; use std::sync::Arc; use tracing::warn; @@ -9,7 +9,7 @@ use uuid::Uuid; /// This ensures that the operation is being performed in the correct workspace context, enhancing security. pub fn check_request_workspace_id_is_match( expected_workspace_id: &Uuid, - user: &Arc, + user: &Arc, action: impl AsRef, ) -> FlowyResult<()> { let actual_workspace_id = user.workspace_id()?; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs index 06e56a8c05..bb2d11cdbd 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::{AIUserServiceImpl, LoginUserService}; use anyhow::Error; use arc_swap::ArcSwap; use client_api::collab_sync::ServerCollabMessage; @@ -24,6 +24,11 @@ use flowy_storage_pub::cloud::StorageCloudService; use flowy_user_pub::cloud::{UserCloudService, UserUpdate}; use flowy_user_pub::entities::UserTokenState; +use crate::af_cloud::impls::{ + AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, AFCloudFileStorageServiceImpl, + AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, CloudChatServiceImpl, +}; +use flowy_ai::offline::offline_message_sync::AutoSyncChatService; use rand::Rng; use semver::Version; use tokio::select; @@ -34,11 +39,6 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use uuid::Uuid; -use crate::af_cloud::impls::{ - AFCloudChatCloudServiceImpl, AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, - AFCloudFileStorageServiceImpl, AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, -}; - use crate::AppFlowyServer; use super::impls::AFCloudSearchCloudServiceImpl; @@ -53,7 +53,7 @@ pub struct AppFlowyCloudServer { network_reachable: Arc, pub device_id: String, ws_client: Arc, - user: Arc, + logged_user: Arc, } impl AppFlowyCloudServer { @@ -62,7 +62,7 @@ impl AppFlowyCloudServer { enable_sync: bool, mut device_id: String, client_version: Version, - user: Arc, + auth_user_service: Arc, ) -> Self { // The device id can't be empty, so we generate a new one if it is. if device_id.is_empty() { @@ -100,7 +100,7 @@ impl AppFlowyCloudServer { network_reachable, device_id, ws_client, - user, + logged_user: auth_user_service, } } @@ -187,7 +187,7 @@ impl AppFlowyServer for AppFlowyCloudServer { Arc::new(AFCloudUserAuthServiceImpl::new( server, rx, - self.user.clone(), + self.logged_user.clone(), )) } @@ -197,7 +197,7 @@ impl AppFlowyServer for AppFlowyCloudServer { }; Arc::new(AFCloudFolderCloudServiceImpl { inner: server, - user: self.user.clone(), + logged_user: self.logged_user.clone(), }) } @@ -207,7 +207,7 @@ impl AppFlowyServer for AppFlowyCloudServer { }; Arc::new(AFCloudDatabaseCloudServiceImpl { inner: server, - user: self.user.clone(), + logged_user: self.logged_user.clone(), }) } @@ -217,7 +217,7 @@ impl AppFlowyServer for AppFlowyCloudServer { }; Some(Arc::new(AFCloudDatabaseCloudServiceImpl { inner: server, - user: self.user.clone(), + logged_user: self.logged_user.clone(), })) } @@ -227,7 +227,7 @@ impl AppFlowyServer for AppFlowyCloudServer { }; Arc::new(AFCloudDocumentCloudServiceImpl { inner: server, - user: self.user.clone(), + logged_user: self.logged_user.clone(), }) } @@ -235,7 +235,11 @@ impl AppFlowyServer for AppFlowyCloudServer { let server = AFServerImpl { client: self.get_client(), }; - Arc::new(AFCloudChatCloudServiceImpl { inner: server }) + + Arc::new(AutoSyncChatService::new( + Arc::new(CloudChatServiceImpl { inner: server }), + Arc::new(AIUserServiceImpl(self.logged_user.clone())), + )) } fn subscribe_ws_state(&self) -> Option { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 9057288f88..8e731edb84 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -1,4 +1,4 @@ -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoginUserService; use chrono::{TimeZone, Utc}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; use client_api::entity::CompletionStream; @@ -6,14 +6,15 @@ use flowy_ai::local_ai::controller::LocalAIController; use flowy_ai::local_ai::stream_util::QuestionStream; use flowy_ai_pub::cloud::chat_dto::{ChatAuthor, ChatAuthorType}; use flowy_ai_pub::cloud::{ - AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, - ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RelatedQuestion, - RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, + ChatSettings, CompleteTextParams, MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, + ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, }; use flowy_ai_pub::persistence::{ - deserialize_chat_metadata, deserialize_rag_ids, read_chat, select_chat_messages, - select_message_content, select_message_where_match_reply_message_id, serialize_chat_metadata, - serialize_rag_ids, update_chat, upsert_chat, ChatMessageTable, ChatTable, ChatTableChangeset, + deserialize_chat_metadata, deserialize_rag_ids, read_chat, + select_answer_where_match_reply_message_id, select_chat_messages, select_message_content, + serialize_chat_metadata, serialize_rag_ids, update_chat, upsert_chat, upsert_chat_messages, + ChatMessageTable, ChatTable, ChatTableChangeset, }; use flowy_error::{FlowyError, FlowyResult}; use futures_util::{stream, StreamExt, TryStreamExt}; @@ -26,12 +27,12 @@ use std::sync::Arc; use tracing::trace; use uuid::Uuid; -pub struct LocalServerChatServiceImpl { - pub user: Arc, +pub struct LocalChatServiceImpl { + pub user: Arc, pub local_ai: Arc, } -impl LocalServerChatServiceImpl { +impl LocalChatServiceImpl { fn get_message_content(&self, message_id: i64) -> FlowyResult { let uid = self.user.user_id()?; let db = self.user.get_sqlite_db(uid)?; @@ -40,35 +41,30 @@ impl LocalServerChatServiceImpl { })?; Ok(content) } + + async fn upsert_message(&self, chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { + let uid = self.user.user_id()?; + let conn = self.user.get_sqlite_db(uid)?; + let row = ChatMessageTable::from_message(chat_id.to_string(), message, true); + upsert_chat_messages(conn, &[row])?; + Ok(()) + } } #[async_trait] -impl ChatCloudService for LocalServerChatServiceImpl { +impl ChatCloudService for LocalChatServiceImpl { async fn create_chat( &self, _uid: &i64, _workspace_id: &Uuid, chat_id: &Uuid, rag_ids: Vec, - name: &str, + _name: &str, metadata: Value, ) -> Result<(), FlowyError> { let uid = self.user.user_id()?; let db = self.user.get_sqlite_db(uid)?; - - let rag_ids = rag_ids - .iter() - .map(|v| v.to_string()) - .collect::>(); - - let row = ChatTable { - chat_id: chat_id.to_string(), - created_at: timestamp(), - name: name.to_string(), - metadata: serialize_chat_metadata(&metadata), - rag_ids: Some(serialize_rag_ids(&rag_ids)), - }; - + let row = ChatTable::new(chat_id.to_string(), metadata, rag_ids, true); upsert_chat(db, &row)?; Ok(()) } @@ -76,25 +72,23 @@ impl ChatCloudService for LocalServerChatServiceImpl { async fn create_question( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, - _metadata: &[ChatMessageMetadata], ) -> Result { - match message_type { - ChatMessageType::System => Ok(ChatMessage::new_system(timestamp(), message.to_string())), - ChatMessageType::User => Ok(ChatMessage::new_human( - timestamp(), - message.to_string(), - None, - )), - } + let message = match message_type { + ChatMessageType::System => ChatMessage::new_system(timestamp(), message.to_string()), + ChatMessageType::User => ChatMessage::new_human(timestamp(), message.to_string(), None), + }; + + self.upsert_message(chat_id, message.clone()).await?; + Ok(message) } async fn create_answer( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -103,6 +97,7 @@ impl ChatCloudService for LocalServerChatServiceImpl { if let Some(metadata) = metadata { message.metadata = metadata; } + self.upsert_message(chat_id, message.clone()).await?; Ok(message) } @@ -131,22 +126,26 @@ impl ChatCloudService for LocalServerChatServiceImpl { stream::once(async { Err(FlowyError::local_ai_unavailable().with_context(err)) }).boxed(), ), } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) } else { - if self.local_ai.is_enabled() { - Err(FlowyError::local_ai_not_ready()) - } else { - Err(FlowyError::local_ai_disabled()) - } + Err(FlowyError::local_ai_disabled()) } } async fn get_answer( &self, _workspace_id: &Uuid, - _chat_id: &Uuid, - _question_message_id: i64, + chat_id: &Uuid, + question_id: i64, ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + let uid = self.user.user_id()?; + let db = self.user.get_sqlite_db(uid)?; + + match select_answer_where_match_reply_message_id(db, &chat_id.to_string(), question_id)? { + None => Err(FlowyError::record_not_found()), + Some(message) => Ok(chat_message_from_row(message)), + } } async fn get_chat_messages( @@ -183,7 +182,7 @@ impl ChatCloudService for LocalServerChatServiceImpl { let chat_id = chat_id.to_string(); let uid = self.user.user_id()?; let db = self.user.get_sqlite_db(uid)?; - let row = select_message_where_match_reply_message_id(db, &chat_id, answer_message_id)? + let row = select_answer_where_match_reply_message_id(db, &chat_id, answer_message_id)? .map(chat_message_from_row) .ok_or_else(FlowyError::record_not_found)?; Ok(row) @@ -247,12 +246,10 @@ impl ChatCloudService for LocalServerChatServiceImpl { ), Err(_) => Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()), } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) } else { - if self.local_ai.is_enabled() { - Err(FlowyError::local_ai_not_ready()) - } else { - Err(FlowyError::local_ai_disabled()) - } + Err(FlowyError::local_ai_disabled()) } } @@ -285,7 +282,7 @@ impl ChatCloudService for LocalServerChatServiceImpl { let db = self.user.get_sqlite_db(uid)?; let row = read_chat(db, &chat_id)?; let rag_ids = deserialize_rag_ids(&row.rag_ids); - let metadata = deserialize_chat_metadata::(&row.metadata); + let metadata = deserialize_chat_metadata::(&row.metadata); let setting = ChatSettings { name: row.name, rag_ids, @@ -308,6 +305,7 @@ impl ChatCloudService for LocalServerChatServiceImpl { name: s.name, metadata: s.metadata.map(|s| serialize_chat_metadata(&s)), rag_ids: s.rag_ids.map(|s| serialize_rag_ids(&s)), + is_sync: None, }; update_chat(&mut db, changeset)?; diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 0ef930320e..719dd59c95 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -1,11 +1,10 @@ use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoginUserService; use crate::local_server::impls::{ - LocalServerChatServiceImpl, LocalServerDatabaseCloudServiceImpl, - LocalServerDocumentCloudServiceImpl, LocalServerFolderCloudServiceImpl, - LocalServerUserServiceImpl, + LocalChatServiceImpl, LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, + LocalServerFolderCloudServiceImpl, LocalServerUserServiceImpl, }; use crate::AppFlowyServer; use flowy_ai::local_ai::controller::LocalAIController; @@ -18,13 +17,13 @@ use flowy_user_pub::cloud::UserCloudService; use tokio::sync::mpsc; pub struct LocalServer { - user: Arc, + user: Arc, local_ai: Arc, stop_tx: Option>, } impl LocalServer { - pub fn new(user: Arc, local_ai: Arc) -> Self { + pub fn new(user: Arc, local_ai: Arc) -> Self { Self { user, local_ai, @@ -62,7 +61,7 @@ impl AppFlowyServer for LocalServer { } fn chat_service(&self) -> Arc { - Arc::new(LocalServerChatServiceImpl { + Arc::new(LocalChatServiceImpl { user: self.user.clone(), local_ai: self.local_ai.clone(), }) diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index f196e44ea9..a8dcdb507f 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -8,7 +8,7 @@ use flowy_error::{FlowyError, FlowyResult}; use uuid::Uuid; use crate::setup_log; -use flowy_server::af_cloud::define::ServerUser; +use flowy_server::af_cloud::define::LoginUserService; use flowy_server::af_cloud::AppFlowyCloudServer; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_sqlite::DBConnection; @@ -42,7 +42,7 @@ pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc struct FakeServerUserImpl; #[async_trait] -impl ServerUser for FakeServerUserImpl { +impl LoginUserService for FakeServerUserImpl { fn workspace_id(&self) -> FlowyResult { todo!() } diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql new file mode 100644 index 0000000000..65dec0f30a --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_table DROP COLUMN is_sync; +ALTER TABLE chat_message_table DROP COLUMN is_sync; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql new file mode 100644 index 0000000000..ff8dce94bc --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +ALTER TABLE chat_table + ADD COLUMN is_sync BOOLEAN DEFAULT TRUE NOT NULL; +ALTER TABLE chat_message_table + ADD COLUMN is_sync BOOLEAN DEFAULT TRUE NOT NULL; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 9504d126ed..f3caa86e66 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -27,6 +27,7 @@ diesel::table! { author_id -> Text, reply_message_id -> Nullable, metadata -> Nullable, + is_sync -> Bool, } } @@ -37,6 +38,7 @@ diesel::table! { name -> Text, metadata -> Text, rag_ids -> Nullable, + is_sync -> Bool, } } @@ -126,15 +128,15 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, ); From 165e95c480663f989b32a1be7d59dbc878f58525 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 14:36:47 +0800 Subject: [PATCH 173/207] chore: fix test --- .../event-integration-test/tests/chat/chat_message_test.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs index 5b29258142..a9c928db5b 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -91,10 +91,8 @@ async fn af_cloud_load_remote_system_message_test() { .notification_sender .subscribe::(&chat_id, ChatNotification::DidLoadLatestChatMessage); - // Previous messages were created by the server, so there are no messages in the local cache. - // It will try to load messages in the background. let all = test.load_next_message(&chat_id, 5, None).await; - assert!(all.messages.is_empty()); + assert_eq!(all.messages.len(), 5); // Wait for the messages to be loaded. let next_back_five = receive_with_timeout(rx, Duration::from_secs(60)) @@ -119,7 +117,6 @@ async fn af_cloud_load_remote_system_message_test() { let first_five_messages = receive_with_timeout(rx, Duration::from_secs(60)) .await .unwrap(); - assert!(!first_five_messages.has_more); assert_eq!(first_five_messages.messages[0].content, "hello server 4"); assert_eq!(first_five_messages.messages[1].content, "hello server 3"); assert_eq!(first_five_messages.messages[2].content, "hello server 2"); From 59efb7d9e54d2c044721e2be6b884a86914cbce7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 14:43:01 +0800 Subject: [PATCH 174/207] chore: fix test --- frontend/rust-lib/Cargo.lock | 24 ++++++++++++------------ frontend/rust-lib/Cargo.toml | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index ec114f205a..fd0307fca7 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -493,7 +493,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bincode", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bytes", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "again", "anyhow", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "collab-entity", "collab-rt-entity", @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "futures-channel", "futures-util", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bincode", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "async-trait", @@ -1969,7 +1969,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "bincode", "bytes", @@ -3427,7 +3427,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "getrandom 0.2.10", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "app-error", "jsonwebtoken", @@ -4066,7 +4066,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bytes", @@ -6644,7 +6644,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=72a71205ebb3ec227b44ed48473abe4f1c7663e8#72a71205ebb3ec227b44ed48473abe4f1c7663e8" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index e4f0eab545..ee99fcacf1 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -107,8 +107,8 @@ af-local-ai = { version = "0.1" } # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "72a71205ebb3ec227b44ed48473abe4f1c7663e8" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "72a71205ebb3ec227b44ed48473abe4f1c7663e8" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" } [profile.dev] opt-level = 0 From 3bb5075a9830354965a2551feece6039f2f56c3f Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 18 Apr 2025 14:46:46 +0800 Subject: [PATCH 175/207] feat: setup/change password in settings page (#7752) * feat: change password in settings page * feat: add change password api * feat: add password service * feat: add setup password * feat: refacotor account page * chor: update i18n * chore: i18n * chore: i18n * feat: add error message under text field * fix: flutter tests * chore: remove long password test * fix: cloud integration test * fix: cargo clippy * fix: replace border color --- .../integration_test/shared/settings.dart | 2 +- .../personal_info_setting_group.dart | 2 +- .../application/password/password_bloc.dart | 241 +++++++++++++ .../password/password_http_service.dart | 183 ++++++++++ .../lib/user/application/sign_in_bloc.dart | 2 + .../lib/user/application/user_service.dart | 11 + .../continue_with_email_and_password.dart | 89 ++--- ...inue_with_magic_link_or_passcode_page.dart | 4 +- .../continue_with_password_page.dart | 43 ++- .../widgets/flowy_logo_title.dart | 2 +- .../application/user/settings_user_bloc.dart | 37 +- .../menu/sidebar/footer/sidebar_toast.dart | 6 +- .../menu/sidebar/shared/sidebar_setting.dart | 67 +++- .../settings/pages/about/app_version.dart | 30 +- .../pages/account/account_deletion.dart | 43 +-- .../pages/account/account_sign_in_out.dart | 105 +++++- .../pages/account/account_user_profile.dart | 21 +- .../pages/account/email/email_section.dart | 38 ++ .../account/password/change_password.dart | 330 ++++++++++++++++++ .../password/password_suffix_icon.dart | 30 ++ .../account/password/setup_password.dart | 254 ++++++++++++++ .../settings/pages/settings_account_view.dart | 17 +- .../settings/shared/settings_category.dart | 10 +- .../shared/settings_category_spacer.dart | 10 +- .../settings/shared/settings_header.dart | 12 +- frontend/appflowy_flutter/macos/Podfile.lock | 46 +-- .../component/button/base_button/base.dart | 2 +- .../button/base_button/base_text_button.dart | 4 + .../filled_button/filled_text_button.dart | 10 +- .../outlined_button/outlined_text_button.dart | 10 +- .../src/component/textfield/textfield.dart | 31 ++ .../text_style/base/default_text_style.dart | 295 ++++++++++++++-- .../lib/src/theme/text_style/text_style.dart | 10 +- .../flowy_icons/20x/hide_password.svg | 5 + .../flowy_icons/20x/password_close.svg | 5 + .../flowy_icons/20x/show_password.svg | 4 + frontend/resources/translations/en.json | 36 +- frontend/rust-lib/Cargo.lock | 18 +- .../tests/user/local_test/auth_test.rs | 23 -- .../src/local_server/impls/chat.rs | 16 +- .../rust-lib/flowy-user/src/entities/auth.rs | 7 +- .../flowy-user/src/entities/user_profile.rs | 7 +- 42 files changed, 1854 insertions(+), 264 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart create mode 100644 frontend/resources/flowy_icons/20x/hide_password.svg create mode 100644 frontend/resources/flowy_icons/20x/password_close.svg create mode 100644 frontend/resources/flowy_icons/20x/show_password.svg diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index aade7bb4c9..bfc5efedde 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -79,7 +79,7 @@ extension AppFlowySettings on WidgetTester { // Enable editing username final editUsernameFinder = find.descendant( of: find.byType(AccountUserProfile), - matching: find.byFlowySvg(FlowySvgs.edit_s), + matching: find.byFlowySvg(FlowySvgs.toolbar_link_edit_m), ); await tap(editUsernameFinder, warnIfMissed: false); await pumpAndSettle(); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index 37191a2ae2..28ebdb750e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -58,7 +58,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { userName: userName, onSubmitted: (value) => context .read() - .add(SettingsUserEvent.updateUserName(value)), + .add(SettingsUserEvent.updateUserName(name: value)), ); }, ); diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart new file mode 100644 index 0000000000..34e8514e4c --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart @@ -0,0 +1,241 @@ +import 'dart:convert'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/user/application/password/password_http_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'password_bloc.freezed.dart'; + +class PasswordBloc extends Bloc { + PasswordBloc(this.userProfile) : super(PasswordState.initial()) { + on( + (event, emit) async { + await event.when( + init: () async => _init(), + changePassword: (oldPassword, newPassword) async => _onChangePassword( + emit, + oldPassword: oldPassword, + newPassword: newPassword, + ), + setupPassword: (newPassword) async => _onSetupPassword( + emit, + newPassword: newPassword, + ), + forgotPassword: (email) async => _onForgotPassword( + emit, + email: email, + ), + checkHasPassword: () async => _onCheckHasPassword( + emit, + ), + cancel: () {}, + ); + }, + ); + } + + final UserProfilePB userProfile; + late final PasswordHttpService passwordHttpService; + + bool _isInitialized = false; + + Future _init() async { + if (userProfile.authenticator == AuthenticatorPB.Local) { + Log.debug('PasswordBloc: skip init because user is local authenticator'); + return; + } + + final baseUrl = await getAppFlowyCloudUrl(); + try { + final authToken = jsonDecode(userProfile.token)['access_token']; + passwordHttpService = PasswordHttpService( + baseUrl: baseUrl, + authToken: authToken, + ); + _isInitialized = true; + } catch (e) { + Log.error('PasswordBloc: _init: error: $e'); + } + } + + Future _onChangePassword( + Emitter emit, { + required String oldPassword, + required String newPassword, + }) async { + if (!_isInitialized) { + Log.info('changePassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('changePassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.changePassword( + currentPassword: oldPassword, + newPassword: newPassword, + ); + + emit( + state.copyWith( + isSubmitting: false, + changePasswordResult: result, + ), + ); + } + + Future _onSetupPassword( + Emitter emit, { + required String newPassword, + }) async { + if (!_isInitialized) { + Log.info('setupPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('setupPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.setupPassword( + newPassword: newPassword, + ); + + emit( + state.copyWith( + isSubmitting: false, + hasPassword: result.fold( + (success) => true, + (error) => false, + ), + setupPasswordResult: result, + ), + ); + } + + Future _onForgotPassword( + Emitter emit, { + required String email, + }) async { + if (!_isInitialized) { + Log.info('forgotPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('forgotPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.forgotPassword(email: email); + + emit( + state.copyWith( + isSubmitting: false, + forgotPasswordResult: result, + ), + ); + } + + Future _onCheckHasPassword(Emitter emit) async { + if (!_isInitialized) { + Log.info('checkHasPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('checkHasPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.checkHasPassword(); + + emit( + state.copyWith( + isSubmitting: false, + hasPassword: result.fold( + (success) => success, + (error) => false, + ), + checkHasPasswordResult: result, + ), + ); + } + + void _clearState(Emitter emit, bool isSubmitting) { + emit( + state.copyWith( + isSubmitting: isSubmitting, + changePasswordResult: null, + setupPasswordResult: null, + forgotPasswordResult: null, + checkHasPasswordResult: null, + ), + ); + } +} + +@freezed +class PasswordEvent with _$PasswordEvent { + const factory PasswordEvent.init() = Init; + + // Change password + const factory PasswordEvent.changePassword({ + required String oldPassword, + required String newPassword, + }) = ChangePassword; + + // Setup password + const factory PasswordEvent.setupPassword({ + required String newPassword, + }) = SetupPassword; + + // Forgot password + const factory PasswordEvent.forgotPassword({ + required String email, + }) = ForgotPassword; + + // Check has password + const factory PasswordEvent.checkHasPassword() = CheckHasPassword; + + // Cancel operation + const factory PasswordEvent.cancel() = Cancel; +} + +@freezed +class PasswordState with _$PasswordState { + const factory PasswordState({ + required bool isSubmitting, + required bool hasPassword, + required FlowyResult? changePasswordResult, + required FlowyResult? setupPasswordResult, + required FlowyResult? forgotPasswordResult, + required FlowyResult? checkHasPasswordResult, + }) = _PasswordState; + + factory PasswordState.initial() => const PasswordState( + isSubmitting: false, + hasPassword: false, + changePasswordResult: null, + setupPasswordResult: null, + forgotPasswordResult: null, + checkHasPasswordResult: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart b/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart new file mode 100644 index 0000000000..723ded57e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:http/http.dart' as http; + +enum PasswordEndpoint { + changePassword, + forgotPassword, + setupPassword, + checkHasPassword; + + String get path { + switch (this) { + case PasswordEndpoint.changePassword: + return '/gotrue/user/change-password'; + case PasswordEndpoint.forgotPassword: + return '/gotrue/user/recover'; + case PasswordEndpoint.setupPassword: + return '/gotrue/user/change-password'; + case PasswordEndpoint.checkHasPassword: + return '/gotrue/user/auth-info'; + } + } + + String get method { + switch (this) { + case PasswordEndpoint.changePassword: + case PasswordEndpoint.setupPassword: + case PasswordEndpoint.forgotPassword: + return 'POST'; + case PasswordEndpoint.checkHasPassword: + return 'GET'; + } + } + + Uri uri(String baseUrl) => Uri.parse('$baseUrl$path'); +} + +class PasswordHttpService { + PasswordHttpService({ + required this.baseUrl, + required this.authToken, + }); + + final String baseUrl; + final String authToken; + + final http.Client client = http.Client(); + + Map get headers => { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $authToken', + }; + + /// Changes the user's password + /// + /// [currentPassword] - The user's current password + /// [newPassword] - The new password to set + Future> changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.changePassword, + body: { + 'current_password': currentPassword, + 'password': newPassword, + }, + errorMessage: 'Failed to change password', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Sends a password reset email to the user + /// + /// [email] - The email address of the user + Future> forgotPassword({ + required String email, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.forgotPassword, + body: {'email': email}, + errorMessage: 'Failed to send password reset email', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Sets up a password for a user that doesn't have one + /// + /// [newPassword] - The new password to set + Future> setupPassword({ + required String newPassword, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.setupPassword, + body: {'password': newPassword}, + errorMessage: 'Failed to setup password', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Checks if the user has a password set + Future> checkHasPassword() async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.checkHasPassword, + errorMessage: 'Failed to check password status', + ); + + return result.fold( + (data) => FlowyResult.success(data['has_password'] ?? false), + (error) => FlowyResult.failure(error), + ); + } + + /// Makes a request to the specified endpoint with the given body + Future> _makeRequest({ + required PasswordEndpoint endpoint, + Map? body, + String errorMessage = 'Request failed', + }) async { + try { + final uri = endpoint.uri(baseUrl); + http.Response response; + + if (endpoint.method == 'POST') { + response = await client.post( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + } else if (endpoint.method == 'GET') { + response = await client.get( + uri, + headers: headers, + ); + } else { + return FlowyResult.failure( + FlowyError(msg: 'Invalid request method: ${endpoint.method}'), + ); + } + + if (response.statusCode == 200) { + if (response.body.isNotEmpty) { + return FlowyResult.success(jsonDecode(response.body)); + } + return FlowyResult.success(true); + } else { + final errorBody = + response.body.isNotEmpty ? jsonDecode(response.body) : {}; + + Log.info( + '${endpoint.name} request failed: ${response.statusCode}, $errorBody ', + ); + + return FlowyResult.failure( + FlowyError( + msg: errorBody['msg'] ?? errorMessage, + ), + ); + } + } catch (e) { + Log.error('${endpoint.name} request failed: error: $e'); + + return FlowyResult.failure( + FlowyError(msg: 'Network error: ${e.toString()}'), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 339af51f9f..9691a1269b 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -303,6 +303,8 @@ class SignInBloc extends Bloc { msg = LocaleKeys.signIn_tooFrequentVerificationCodeRequest.tr(); } else if (errorMsg.contains('invalid')) { msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(); + } else if (errorMsg.contains('Invalid login credentials')) { + msg = LocaleKeys.signIn_invalidLoginCredentials.tr(); } return state.copyWith( isSubmitting: false, diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 644a115641..4359a23753 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -95,6 +95,17 @@ class UserBackendService implements IUserBackendService { return UserEventPasscodeSignIn(payload).send(); } + Future> signInWithPassword( + String email, + String password, + ) { + final payload = SignInPayloadPB( + email: email, + password: password, + ); + return UserEventSignInWithEmailPassword(payload).send(); + } + static Future> signOut() { return UserEventSignOut().send(); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart index 8034dccd32..349dd7e0d4 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -2,6 +2,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -50,11 +52,6 @@ class _ContinueWithEmailAndPasswordState ); } else if (successOrFail == null && !state.isSubmitting) { emailKey.currentState?.clearError(); - - // _pushContinueWithMagicLinkOrPasscodePage( - // context, - // controller.text, - // ); } }, child: Column( @@ -76,13 +73,24 @@ class _ContinueWithEmailAndPasswordState controller.text, ), ), - // VSpace(theme.spacing.l), - // ContinueWithPassword( - // onTap: () => _pushContinueWithPasswordPage( - // context, - // controller.text, - // ), - // ), + VSpace(theme.spacing.l), + ContinueWithPassword( + onTap: () { + final email = controller.text; + + if (!isEmail(email)) { + emailKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidEmail.tr(), + ); + return; + } + + _pushContinueWithPasswordPage( + context, + email, + ); + }, + ), ], ), ); @@ -147,31 +155,34 @@ class _ContinueWithEmailAndPasswordState _hasPushedContinueWithMagicLinkOrPasscodePage = true; } - // void _pushContinueWithPasswordPage( - // BuildContext context, - // String email, - // ) { - // final signInBloc = context.read(); - // Navigator.push( - // context, - // MaterialPageRoute( - // builder: (context) => BlocProvider.value( - // value: signInBloc, - // child: ContinueWithPasswordPage( - // email: email, - // backToLogin: () => Navigator.pop(context), - // onEnterPassword: (password) => signInBloc.add( - // SignInEvent.signInWithEmailAndPassword( - // email: email, - // password: password, - // ), - // ), - // onForgotPassword: () { - // // todo: implement forgot password - // }, - // ), - // ), - // ), - // ); - // } + void _pushContinueWithPasswordPage( + BuildContext context, + String email, + ) { + final signInBloc = context.read(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: signInBloc, + child: ContinueWithPasswordPage( + email: email, + backToLogin: () { + emailKey.currentState?.clearError(); + Navigator.pop(context); + }, + onEnterPassword: (password) => signInBloc.add( + SignInEvent.signInWithEmailAndPassword( + email: email, + password: password, + ), + ), + onForgotPassword: () { + // todo: implement forgot password + }, + ), + ), + ), + ); + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart index 5be30ef84c..8cfc4c1157 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -167,7 +167,7 @@ class _ContinueWithMagicLinkOrPasscodePageState // title Text( LocaleKeys.signIn_checkYourEmail.tr(), - style: theme.textStyle.heading.h3( + style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.primary, ), ), @@ -199,7 +199,7 @@ class _ContinueWithMagicLinkOrPasscodePageState // title Text( LocaleKeys.signIn_enterCode.tr(), - style: theme.textStyle.heading.h3( + style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.primary, ), ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart index 4ab40011d2..1e2ed6e100 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -1,6 +1,9 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; @@ -43,9 +46,16 @@ class _ContinueWithPasswordPageState extends State { width: 320, child: BlocListener( listener: (context, state) { - if (state.passwordError != null) { + final successOrFail = state.successOrFail; + if (successOrFail != null && successOrFail.isFailure) { + successOrFail.onFailure((error) { + inputPasswordKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), + ); + }); + } else if (state.passwordError != null) { inputPasswordKey.currentState?.syncError( - errorText: 'Incorrect password. Please try again.', + errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), ); } else { inputPasswordKey.currentState?.clearError(); @@ -80,8 +90,8 @@ class _ContinueWithPasswordPageState extends State { // title Text( - 'Enter password', - style: theme.textStyle.heading.h3( + LocaleKeys.signIn_enterPassword.tr(), + style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.primary, ), ), @@ -92,13 +102,13 @@ class _ContinueWithPasswordPageState extends State { text: TextSpan( children: [ TextSpan( - text: 'Login as ', + text: LocaleKeys.signIn_loginAs.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), TextSpan( - text: widget.email, + text: ' ${widget.email}', style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), @@ -111,13 +121,26 @@ class _ContinueWithPasswordPageState extends State { } List _buildPasswordSection() { + final theme = AppFlowyTheme.of(context); + final iconSize = 20.0; return [ // Password input AFTextField( key: inputPasswordKey, controller: passwordController, - hintText: 'Enter password', + hintText: LocaleKeys.signIn_enterPassword.tr(), autoFocus: true, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + inputPasswordKey.currentState?.syncObscured(!isObscured); + }, + ), onSubmitted: widget.onEnterPassword, ), // todo: ask designer to provide the spacing @@ -127,7 +150,7 @@ class _ContinueWithPasswordPageState extends State { Align( alignment: Alignment.centerLeft, child: AFGhostTextButton( - text: 'Forget password?', + text: LocaleKeys.signIn_forgotPassword.tr(), size: AFButtonSize.s, padding: EdgeInsets.zero, onTap: widget.onForgotPassword, @@ -144,7 +167,7 @@ class _ContinueWithPasswordPageState extends State { // Continue button AFFilledTextButton.primary( - text: 'Continue', + text: LocaleKeys.web_continue.tr(), onTap: () => widget.onEnterPassword(passwordController.text), size: AFButtonSize.l, alignment: Alignment.center, @@ -156,7 +179,7 @@ class _ContinueWithPasswordPageState extends State { List _buildBackToLogin() { return [ AFGhostTextButton( - text: 'Back to Login', + text: LocaleKeys.signIn_backToLogin.tr(), size: AFButtonSize.s, onTap: widget.backToLogin, padding: EdgeInsets.zero, diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart index 93ccea25d0..14b1c896a9 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart @@ -25,7 +25,7 @@ class FlowyLogoTitle extends StatelessWidget { const VSpace(20), Text( title, - style: theme.textStyle.heading.h3( + style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.primary, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart index 56faa9f8d8..2f62177661 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -54,17 +54,27 @@ class SettingsUserViewBloc extends Bloc { ); }); }, - removeUserIcon: () { - // Empty Icon URL = No icon - _userService.updateUserProfile(iconUrl: "").then((result) { + updateUserEmail: (String email) { + _userService.updateUserProfile(email: email).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, - updateUserEmail: (String email) { - _userService.updateUserProfile(email: email).then((result) { + updateUserPassword: (String oldPassword, String newPassword) { + _userService + .updateUserProfile(password: newPassword) + .then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + removeUserIcon: () { + // Empty Icon URL = No icon + _userService.updateUserProfile(iconUrl: "").then((result) { result.fold( (l) => null, (err) => Log.error(err), @@ -104,10 +114,19 @@ class SettingsUserViewBloc extends Bloc { @freezed class SettingsUserEvent with _$SettingsUserEvent { const factory SettingsUserEvent.initial() = _Initial; - const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName; - const factory SettingsUserEvent.updateUserEmail(String email) = _UpdateEmail; - const factory SettingsUserEvent.updateUserIcon({required String iconUrl}) = - _UpdateUserIcon; + const factory SettingsUserEvent.updateUserName({ + required String name, + }) = _UpdateUserName; + const factory SettingsUserEvent.updateUserEmail({ + required String email, + }) = _UpdateEmail; + const factory SettingsUserEvent.updateUserIcon({ + required String iconUrl, + }) = _UpdateUserIcon; + const factory SettingsUserEvent.updateUserPassword({ + required String oldPassword, + required String newPassword, + }) = _UpdateUserPassword; const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon; const factory SettingsUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart index 5e1a6f90e0..05e6d46957 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart @@ -105,9 +105,9 @@ class SidebarToast extends StatelessWidget { if (role.isOwner) { showSettingsDialog( context, - userProfile, - userWorkspaceBloc, - SettingsPage.plan, + userProfile: userProfile, + userWorkspaceBloc: userWorkspaceBloc, + initPage: SettingsPage.plan, ); } else { final String message; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart index 84a76cfe83..0bd5dafe91 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; @@ -33,7 +34,7 @@ HotKeyItem openSettingsHotKey( ), keyDownHandler: (_) { if (_settingsDialogKey.currentContext == null) { - showSettingsDialog(context, userProfile); + showSettingsDialog(context, userProfile: userProfile); } else { Navigator.of(context, rootNavigator: true) .popUntil((route) => route.isFirst); @@ -57,37 +58,55 @@ class UserSettingButton extends StatefulWidget { class _UserSettingButtonState extends State { late UserWorkspaceBloc _userWorkspaceBloc; + late PasswordBloc _passwordBloc; @override void initState() { super.initState(); + _userWorkspaceBloc = context.read(); + _passwordBloc = PasswordBloc(widget.userProfile) + ..add(PasswordEvent.init()) + ..add(PasswordEvent.checkHasPassword()); } @override void didChangeDependencies() { _userWorkspaceBloc = context.read(); + super.didChangeDependencies(); } + @override + void dispose() { + _passwordBloc.close(); + + super.dispose(); + } + @override Widget build(BuildContext context) { return SizedBox.square( dimension: 24.0, child: FlowyTooltip( message: LocaleKeys.settings_menu_open.tr(), - child: FlowyButton( - onTap: () => showSettingsDialog( - context, - widget.userProfile, - _userWorkspaceBloc, - ), - margin: EdgeInsets.zero, - text: FlowySvg( - FlowySvgs.settings_s, - color: - widget.isHover ? Theme.of(context).colorScheme.onSurface : null, - opacity: 0.7, + child: BlocProvider.value( + value: _passwordBloc, + child: FlowyButton( + onTap: () => showSettingsDialog( + context, + userProfile: widget.userProfile, + userWorkspaceBloc: _userWorkspaceBloc, + passwordBloc: _passwordBloc, + ), + margin: EdgeInsets.zero, + text: FlowySvg( + FlowySvgs.settings_s, + color: widget.isHover + ? Theme.of(context).colorScheme.onSurface + : null, + opacity: 0.7, + ), ), ), ), @@ -96,21 +115,33 @@ class _UserSettingButtonState extends State { } void showSettingsDialog( - BuildContext context, - UserProfilePB userProfile, [ - UserWorkspaceBloc? bloc, + BuildContext context, { + required UserProfilePB userProfile, + UserWorkspaceBloc? userWorkspaceBloc, + PasswordBloc? passwordBloc, SettingsPage? initPage, -]) { +}) { AFFocusManager.maybeOf(context)?.notifyLoseFocus(); showDialog( context: context, builder: (dialogContext) => MultiBlocProvider( key: _settingsDialogKey, providers: [ + passwordBloc != null + ? BlocProvider.value( + value: passwordBloc, + ) + : BlocProvider( + create: (context) => PasswordBloc(userProfile) + ..add(PasswordEvent.init()) + ..add(PasswordEvent.checkHasPassword()), + ), BlocProvider.value( value: BlocProvider.of(dialogContext), ), - BlocProvider.value(value: bloc ?? context.read()), + BlocProvider.value( + value: userWorkspaceBloc ?? context.read(), + ), ], child: SettingsDialog( userProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart index 71dfdde7a9..2125ea4b66 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart @@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/version_checker/version_checker.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -16,28 +17,29 @@ class SettingsAppVersion extends StatelessWidget { Widget build(BuildContext context) { return ApplicationInfo.isUpdateAvailable ? const _UpdateAppSection() - : _buildIsUpToDate(); + : _buildIsUpToDate(context); } - Widget _buildIsUpToDate() { + Widget _buildIsUpToDate(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText.regular( + Text( LocaleKeys.settings_accountPage_isUpToDate.tr(), - figmaLineHeight: 17, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), ), const VSpace(4), - Opacity( - opacity: 0.7, - child: FlowyText.regular( - LocaleKeys.settings_accountPage_officialVersion.tr( - namedArgs: { - 'version': ApplicationInfo.applicationVersion, - }, - ), - fontSize: 12, - figmaLineHeight: 13, + Text( + LocaleKeys.settings_accountPage_officialVersion.tr( + namedArgs: { + 'version': ApplicationInfo.applicationVersion, + }, + ), + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, ), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart index e6c011156b..04d078ec0d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -8,8 +8,8 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_w import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -43,43 +43,36 @@ class _AccountDeletionButtonState extends State { @override Widget build(BuildContext context) { - final textColor = Theme.of(context).brightness == Brightness.light - ? const Color(0xFF4F4F4F) - : const Color(0xFFB0B0B0); + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText( + Text( LocaleKeys.button_deleteAccount.tr(), - fontSize: 14.0, - fontWeight: FontWeight.w500, - figmaLineHeight: 21.0, - color: textColor, + style: theme.textStyle.heading4.enhanced( + color: theme.textColorScheme.primary, + ), ), const VSpace(8), Row( children: [ Expanded( - child: FlowyText.regular( + child: Text( LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), - fontSize: 12.0, - figmaLineHeight: 13.0, maxLines: 2, - color: textColor, + overflow: TextOverflow.ellipsis, + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ), ), ), - FlowyTextButton( - LocaleKeys.button_deleteAccount.tr(), - constraints: const BoxConstraints(minHeight: 32), - padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), - fillColor: Colors.transparent, - radius: Corners.s8Border, - hoverColor: - Theme.of(context).colorScheme.error.withValues(alpha: 0.1), - fontColor: Theme.of(context).colorScheme.error, - fontSize: 12, - isDangerous: true, - onPressed: () { + AFOutlinedTextButton.destructive( + text: LocaleKeys.button_deleteAccount.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.error, + weight: FontWeight.w400, + ), + onTap: () { isCheckedNotifier.value = false; textEditingController.clear(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart index 984598f29c..89066ea649 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -3,12 +3,16 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -28,9 +32,15 @@ class AccountSignInOutSection extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Row( children: [ - FlowyText.regular(LocaleKeys.settings_accountPage_login_title.tr()), + Text( + LocaleKeys.settings_accountPage_login_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), const Spacer(), AccountSignInOutButton( userProfile: userProfile, @@ -56,13 +66,10 @@ class AccountSignInOutButton extends StatelessWidget { @override Widget build(BuildContext context) { - return PrimaryRoundedButton( + return AFFilledTextButton.primary( text: signIn ? LocaleKeys.settings_accountPage_login_loginLabel.tr() : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), - fontWeight: FontWeight.w500, - radius: 8.0, onTap: () => signIn ? _showSignInDialog(context) : _showLogoutDialog(context), ); @@ -96,6 +103,94 @@ class AccountSignInOutButton extends StatelessWidget { } } +class ChangePasswordSection extends StatelessWidget { + const ChangePasswordSection({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + Text( + LocaleKeys.newSettings_myAccount_password_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + state.hasPassword + ? AFFilledTextButton.primary( + text: LocaleKeys + .newSettings_myAccount_password_changePassword + .tr(), + onTap: () => _showChangePasswordDialog(context), + ) + : AFFilledTextButton.primary( + text: LocaleKeys + .newSettings_myAccount_password_setupPassword + .tr(), + onTap: () => _showSetPasswordDialog(context), + ), + ], + ); + }, + ); + } + + Future _showChangePasswordDialog(BuildContext context) async { + final theme = AppFlowyTheme.of(context); + await showDialog( + context: context, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: getIt(), + ), + ], + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + ), + child: ChangePasswordDialogContent( + userProfile: userProfile, + ), + ), + ), + ); + } + + Future _showSetPasswordDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: getIt(), + ), + ], + child: Dialog( + child: SetupPasswordDialogContent( + userProfile: userProfile, + ), + ), + ), + ); + } +} + class _SignInDialogContent extends StatelessWidget { const _SignInDialogContent(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart index bd08501ae4..62a6232c4a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart @@ -4,6 +4,7 @@ import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -96,27 +97,29 @@ class _AccountUserProfileState extends State { } Widget _buildNameDisplay() { + final theme = AppFlowyTheme.of(context); return Padding( padding: const EdgeInsets.only(top: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: FlowyText.medium( + child: Text( widget.name, overflow: TextOverflow.ellipsis, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), ), ), const HSpace(4), - GestureDetector( - behavior: HitTestBehavior.opaque, + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), onTap: () => setState(() => isEditing = true), - child: const FlowyHover( - resetHoverOnRebuild: false, - child: Padding( - padding: EdgeInsets.all(4), - child: FlowySvg(FlowySvgs.edit_s), - ), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.toolbar_link_edit_m, + size: const Size.square(20), ), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart new file mode 100644 index 0000000000..d606f870ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +class SettingsEmailSection extends StatelessWidget { + const SettingsEmailSection({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.settings_accountPage_email_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + VSpace(theme.spacing.s), + Text( + userProfile.email, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.secondary, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart new file mode 100644 index 0000000000..194254c869 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart @@ -0,0 +1,330 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ChangePasswordDialogContent extends StatefulWidget { + const ChangePasswordDialogContent({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => + _ChangePasswordDialogContentState(); +} + +class _ChangePasswordDialogContentState + extends State { + final currentPasswordTextFieldKey = GlobalKey(); + final newPasswordTextFieldKey = GlobalKey(); + final confirmPasswordTextFieldKey = GlobalKey(); + + final currentPasswordController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + final iconSize = 20.0; + + @override + void dispose() { + currentPasswordController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocListener( + listener: _onPasswordStateChanged, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context), + VSpace(theme.spacing.l), + ..._buildCurrentPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildNewPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildConfirmPasswordFields(context), + VSpace(theme.spacing.l), + _buildSubmitButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Change password', + style: theme.textStyle.heading4.prominent( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.password_close_m, + size: const Size.square(20), + ), + ), + ], + ); + } + + List _buildCurrentPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_currentPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: currentPasswordTextFieldKey, + controller: currentPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_enterYourCurrentPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + currentPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildNewPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_newPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: newPasswordTextFieldKey, + controller: newPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_enterYourNewPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + newPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildConfirmPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_confirmNewPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: confirmPasswordTextFieldKey, + controller: confirmPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_confirmYourNewPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + Widget _buildSubmitButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AFOutlinedTextButton.normal( + text: LocaleKeys.button_cancel.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + weight: FontWeight.w400, + ), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(16), + AFFilledTextButton.primary( + text: LocaleKeys.button_save.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.onFill, + weight: FontWeight.w400, + ), + onTap: () => _save(context), + ), + ], + ); + } + + void _save(BuildContext context) async { + _resetError(); + + final currentPassword = currentPasswordController.text; + final newPassword = newPasswordController.text; + final confirmPassword = confirmPasswordController.text; + + if (newPassword.isEmpty) { + newPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsRequired + .tr(), + ); + return; + } + + if (confirmPassword.isEmpty) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_confirmPasswordIsRequired + .tr(), + ); + return; + } + + if (newPassword != confirmPassword) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_passwordsDoNotMatch + .tr(), + ); + return; + } + + if (newPassword == currentPassword) { + newPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsSameAsCurrent + .tr(), + ); + return; + } + + // all the verification passed, save the new password + context.read().add( + PasswordEvent.changePassword( + oldPassword: currentPassword, + newPassword: newPassword, + ), + ); + } + + void _resetError() { + currentPasswordTextFieldKey.currentState?.clearError(); + newPasswordTextFieldKey.currentState?.clearError(); + confirmPasswordTextFieldKey.currentState?.clearError(); + } + + void _onPasswordStateChanged(BuildContext context, PasswordState state) { + bool hasError = false; + String message = ''; + String description = ''; + + final changePasswordResult = state.changePasswordResult; + final setPasswordResult = state.setupPasswordResult; + + if (changePasswordResult != null) { + changePasswordResult.fold( + (success) { + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordUpdatedSuccessfully + .tr(); + }, + (error) { + hasError = true; + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordUpdatedFailed + .tr(); + description = error.msg; + }, + ); + } else if (setPasswordResult != null) { + setPasswordResult.fold( + (success) { + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordSetupSuccessfully + .tr(); + }, + (error) { + hasError = true; + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordSetupFailed + .tr(); + description = error.msg; + }, + ); + } + + if (!state.isSubmitting && message.isNotEmpty) { + showToastNotification( + message: message, + description: description, + type: hasError ? ToastificationType.error : ToastificationType.success, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart new file mode 100644 index 0000000000..5417b1a0eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart @@ -0,0 +1,30 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class PasswordSuffixIcon extends StatelessWidget { + const PasswordSuffixIcon({ + super.key, + required this.isObscured, + required this.onTap, + }); + + final bool isObscured; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Padding( + padding: EdgeInsets.only(right: theme.spacing.m), + child: GestureDetector( + onTap: onTap, + child: FlowySvg( + isObscured ? FlowySvgs.show_s : FlowySvgs.hide_s, + color: theme.textColorScheme.secondary, + size: const Size.square(20), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart new file mode 100644 index 0000000000..2fdfd8b934 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart @@ -0,0 +1,254 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SetupPasswordDialogContent extends StatefulWidget { + const SetupPasswordDialogContent({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => + _SetupPasswordDialogContentState(); +} + +class _SetupPasswordDialogContentState + extends State { + final passwordTextFieldKey = GlobalKey(); + final confirmPasswordTextFieldKey = GlobalKey(); + + final passwordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + final iconSize = 20.0; + + @override + void dispose() { + passwordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocListener( + listener: _onPasswordStateChanged, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context), + VSpace(theme.spacing.l), + ..._buildPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildConfirmPasswordFields(context), + VSpace(theme.spacing.l), + _buildSubmitButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + LocaleKeys.newSettings_myAccount_password_setupPassword.tr(), + style: theme.textStyle.heading4.prominent( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.password_close_m, + size: const Size.square(20), + ), + ), + ], + ); + } + + List _buildPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + 'Password', + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: passwordTextFieldKey, + controller: passwordController, + hintText: 'Enter your password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + passwordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildConfirmPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + 'Confirm password', + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: confirmPasswordTextFieldKey, + controller: confirmPasswordController, + hintText: 'Confirm your password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + Widget _buildSubmitButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AFOutlinedTextButton.normal( + text: 'Cancel', + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + weight: FontWeight.w400, + ), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(16), + AFFilledTextButton.primary( + text: 'Save', + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.onFill, + weight: FontWeight.w400, + ), + onTap: () => _save(context), + ), + ], + ); + } + + void _save(BuildContext context) async { + _resetError(); + + final password = passwordController.text; + final confirmPassword = confirmPasswordController.text; + + if (password.isEmpty) { + passwordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsRequired + .tr(), + ); + return; + } + + if (confirmPassword.isEmpty) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_confirmPasswordIsRequired + .tr(), + ); + return; + } + + if (password != confirmPassword) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_passwordsDoNotMatch + .tr(), + ); + return; + } + + // all the verification passed, save the password + context.read().add( + PasswordEvent.setupPassword( + newPassword: password, + ), + ); + } + + void _resetError() { + passwordTextFieldKey.currentState?.clearError(); + confirmPasswordTextFieldKey.currentState?.clearError(); + } + + void _onPasswordStateChanged(BuildContext context, PasswordState state) { + bool hasError = false; + String message = ''; + String description = ''; + + final setPasswordResult = state.setupPasswordResult; + + if (setPasswordResult != null) { + setPasswordResult.fold( + (success) { + message = 'Password set'; + description = 'Your password has been set'; + }, + (error) { + hasError = true; + message = 'Failed to set password'; + description = error.msg; + }, + ); + } + + if (!state.isSubmitting && message.isNotEmpty) { + showToastNotification( + message: message, + description: description, + type: hasError ? ToastificationType.error : ToastificationType.success, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index 701d1cb565..e223b2d063 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -4,12 +4,12 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/about/app_version.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/email/email_section.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.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'; @@ -45,11 +45,11 @@ class _SettingsAccountViewState extends State { child: BlocBuilder( builder: (context, state) { return SettingsBody( - title: LocaleKeys.settings_accountPage_title.tr(), + title: LocaleKeys.newSettings_myAccount_title.tr(), children: [ // user profile SettingsCategory( - title: LocaleKeys.settings_accountPage_general_title.tr(), + title: LocaleKeys.newSettings_myAccount_myProfile.tr(), children: [ AccountUserProfile( name: userName, @@ -61,7 +61,7 @@ class _SettingsAccountViewState extends State { setState(() => userName = newName); context .read() - .add(SettingsUserEvent.updateUserName(newName)); + .add(SettingsUserEvent.updateUserName(name: newName)); }, ), ], @@ -72,9 +72,14 @@ class _SettingsAccountViewState extends State { if (isAuthEnabled && state.userProfile.authenticator != AuthenticatorPB.Local) ...[ SettingsCategory( - title: LocaleKeys.settings_accountPage_email_title.tr(), + title: LocaleKeys.newSettings_myAccount_myAccount.tr(), children: [ - FlowyText.regular(state.userProfile.email), + SettingsEmailSection( + userProfile: state.userProfile, + ), + ChangePasswordSection( + userProfile: state.userProfile, + ), AccountSignInOutSection( userProfile: state.userProfile, onAction: state.userProfile.authenticator == diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart index a111fa2626..33c81b99e8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -25,15 +26,18 @@ class SettingsCategory extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - FlowyText.semibold( + Text( title, + style: theme.textStyle.heading4.enhanced( + color: theme.textColorScheme.primary, + ), maxLines: 2, - fontSize: 16, overflow: TextOverflow.ellipsis, ), if (tooltip != null) ...[ @@ -47,7 +51,7 @@ class SettingsCategory extends StatelessWidget { if (actions != null) ...actions!, ], ), - const VSpace(8), + const VSpace(16), if (description?.isNotEmpty ?? false) ...[ FlowyText.regular( description!, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart index 5637fdd20c..deec09c1d8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// This is used to create a uniform space and divider @@ -7,6 +8,11 @@ class SettingsCategorySpacer extends StatelessWidget { const SettingsCategorySpacer({super.key}); @override - Widget build(BuildContext context) => - const Divider(height: 32, color: Color(0xFFF2F2F2)); + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Divider( + height: 32, + color: theme.borderColorScheme.greyPrimary, + ); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart index c028e6886d..7409070ba9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; - +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; /// Renders a simple header for the settings view /// @@ -13,10 +13,16 @@ class SettingsHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText.semibold(title, fontSize: 24), + Text( + title, + style: theme.textStyle.heading2.enhanced( + color: theme.textColorScheme.primary, + ), + ), if (description?.isNotEmpty == true) ...[ const VSpace(8), FlowyText( diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index b4a1a3d20d..30ee626f09 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 - auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c - irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 - local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart index dc85a3ee55..39d5175af1 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart @@ -27,7 +27,7 @@ enum AFButtonSize { vertical: theme.spacing.xs, ), AFButtonSize.m => EdgeInsets.symmetric( - horizontal: theme.spacing.l, + horizontal: theme.spacing.xl, vertical: theme.spacing.s, ), AFButtonSize.l => EdgeInsets.symmetric( diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart index ced936cf0a..035307d10b 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart @@ -13,6 +13,7 @@ class AFBaseTextButton extends StatelessWidget { this.textColor, this.backgroundColor, this.alignment, + this.textStyle, }); /// The text of the button. @@ -44,6 +45,9 @@ class AFBaseTextButton extends StatelessWidget { /// If it's null, the button size will be the size of the text with padding. final Alignment? alignment; + /// The text style of the button. + final TextStyle? textStyle; + @override Widget build(BuildContext context) { throw UnimplementedError(); diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart index 353d5ac785..889ad1e429 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart @@ -14,6 +14,7 @@ class AFFilledTextButton extends AFBaseTextButton { super.borderRadius, super.disabled = false, super.alignment, + super.textStyle, }); /// Primary text button. @@ -26,6 +27,7 @@ class AFFilledTextButton extends AFBaseTextButton { double? borderRadius, bool disabled = false, Alignment? alignment, + TextStyle? textStyle, }) { return AFFilledTextButton( key: key, @@ -36,6 +38,7 @@ class AFFilledTextButton extends AFBaseTextButton { borderRadius: borderRadius, disabled: disabled, alignment: alignment, + textStyle: textStyle, textColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).textColorScheme.onFill, backgroundColor: (context, isHovering, disabled) { @@ -60,6 +63,7 @@ class AFFilledTextButton extends AFBaseTextButton { double? borderRadius, bool disabled = false, Alignment? alignment, + TextStyle? textStyle, }) { return AFFilledTextButton( key: key, @@ -70,6 +74,7 @@ class AFFilledTextButton extends AFBaseTextButton { borderRadius: borderRadius, disabled: disabled, alignment: alignment, + textStyle: textStyle, textColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).textColorScheme.onFill, backgroundColor: (context, isHovering, disabled) { @@ -92,6 +97,7 @@ class AFFilledTextButton extends AFBaseTextButton { EdgeInsetsGeometry? padding, double? borderRadius, Alignment? alignment, + TextStyle? textStyle, }) { return AFFilledTextButton( key: key, @@ -102,6 +108,7 @@ class AFFilledTextButton extends AFBaseTextButton { borderRadius: borderRadius, disabled: true, alignment: alignment, + textStyle: textStyle, textColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).textColorScheme.tertiary, backgroundColor: (context, isHovering, disabled) => @@ -123,7 +130,8 @@ class AFFilledTextButton extends AFBaseTextButton { AppFlowyTheme.of(context).textColorScheme.onFill; Widget child = Text( text, - style: size.buildTextStyle(context).copyWith(color: textColor), + style: textStyle ?? + size.buildTextStyle(context).copyWith(color: textColor), ); final alignment = this.alignment; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart index 7cb5f2d609..d5ae580583 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart @@ -8,6 +8,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { required super.text, required super.onTap, this.borderColor, + super.textStyle, super.textColor, super.backgroundColor, super.size = AFButtonSize.m, @@ -27,6 +28,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { double? borderRadius, bool disabled = false, Alignment? alignment, + TextStyle? textStyle, }) { return AFOutlinedTextButton._( key: key, @@ -37,6 +39,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { borderRadius: borderRadius, disabled: disabled, alignment: alignment, + textStyle: textStyle, borderColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { @@ -80,6 +83,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { double? borderRadius, bool disabled = false, Alignment? alignment, + TextStyle? textStyle, }) { return AFOutlinedTextButton._( key: key, @@ -90,6 +94,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { borderRadius: borderRadius, disabled: disabled, alignment: alignment, + textStyle: textStyle, borderColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { @@ -127,6 +132,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { EdgeInsetsGeometry? padding, double? borderRadius, Alignment? alignment, + TextStyle? textStyle, }) { return AFOutlinedTextButton._( key: key, @@ -137,6 +143,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { borderRadius: borderRadius, disabled: true, alignment: alignment, + textStyle: textStyle, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return disabled @@ -185,7 +192,8 @@ class AFOutlinedTextButton extends AFBaseTextButton { Widget child = Text( text, - style: size.buildTextStyle(context).copyWith(color: textColor), + style: textStyle ?? + size.buildTextStyle(context).copyWith(color: textColor), ); final alignment = this.alignment; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart index 595d4bb859..a934734983 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -6,8 +6,12 @@ typedef AFTextFieldValidator = (bool result, String errorText) Function( ); abstract class AFTextFieldState extends State { + // Error handler void syncError({required String errorText}) {} void clearError() {} + + /// Obscure the text. + void syncObscured(bool isObscured) {} } class AFTextField extends StatefulWidget { @@ -23,6 +27,9 @@ class AFTextField extends StatefulWidget { this.onSubmitted, this.autoFocus, this.height = 40.0, + this.obscureText = false, + this.suffixIconBuilder, + this.suffixIconConstraints, }); /// The height of the text field. @@ -57,6 +64,16 @@ class AFTextField extends StatefulWidget { /// Enable auto focus. final bool? autoFocus; + /// Obscure the text. + final bool obscureText; + + /// The trailing widget to display. + final Widget Function(BuildContext context, bool isObscured)? + suffixIconBuilder; + + /// The size of the suffix icon. + final BoxConstraints? suffixIconConstraints; + @override State createState() => _AFTextFieldState(); } @@ -67,6 +84,8 @@ class _AFTextFieldState extends AFTextFieldState { bool hasError = false; String errorText = ''; + bool isObscured = false; + @override void initState() { super.initState(); @@ -79,6 +98,8 @@ class _AFTextFieldState extends AFTextFieldState { } effectiveController.addListener(_validate); + + isObscured = widget.obscureText; } @override @@ -107,6 +128,7 @@ class _AFTextFieldState extends AFTextFieldState { style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), + obscureText: isObscured, onChanged: widget.onChanged, onSubmitted: widget.onSubmitted, autofocus: widget.autoFocus ?? false, @@ -152,6 +174,8 @@ class _AFTextFieldState extends AFTextFieldState { borderRadius: borderRadius, ), hoverColor: theme.borderColorScheme.greyTertiaryHover, + suffixIcon: widget.suffixIconBuilder?.call(context, isObscured), + suffixIconConstraints: widget.suffixIconConstraints, ), ); @@ -204,4 +228,11 @@ class _AFTextFieldState extends AFTextFieldState { errorText = ''; }); } + + @override + void syncObscured(bool isObscured) { + setState(() { + this.isObscured = isObscured; + }); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart index a85ffbb6cb..3cdf267fe0 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart @@ -6,66 +6,86 @@ abstract class TextThemeType { TextStyle standard({ String family = '', Color? color, + FontWeight? weight, }); + TextStyle enhanced({ String family = '', Color? color, + FontWeight? weight, }); + TextStyle prominent({ String family = '', Color? color, + FontWeight? weight, }); + TextStyle underline({ String family = '', Color? color, + FontWeight? weight, }); } -class TextThemeHeading { - const TextThemeHeading(); +class TextThemeHeading1 extends TextThemeType { + const TextThemeHeading1(); - TextStyle h1({ + @override + TextStyle standard({ String family = '', Color? color, + FontWeight? weight, }) => _defaultTextStyle( family: family, fontSize: 36, height: 40 / 36, color: color, + weight: weight ?? FontWeight.w400, ); - TextStyle h2({ + @override + TextStyle enhanced({ String family = '', Color? color, + FontWeight? weight, }) => _defaultTextStyle( family: family, - fontSize: 24, - height: 32 / 24, + fontSize: 36, + height: 40 / 36, color: color, + weight: weight ?? FontWeight.w600, ); - TextStyle h3({ + @override + TextStyle prominent({ String family = '', Color? color, + FontWeight? weight, }) => _defaultTextStyle( family: family, - fontSize: 20, - height: 28 / 20, + fontSize: 36, + height: 40 / 36, color: color, + weight: weight ?? FontWeight.w700, ); - TextStyle h4({ + @override + TextStyle underline({ String family = '', Color? color, + FontWeight? weight, }) => _defaultTextStyle( family: family, - fontSize: 16, - height: 22 / 16, + fontSize: 36, + height: 40 / 36, color: color, + weight: weight ?? FontWeight.bold, + decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ @@ -74,13 +94,188 @@ class TextThemeHeading { required double height, TextDecoration decoration = TextDecoration.none, Color? color, + FontWeight weight = FontWeight.bold, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, - fontWeight: FontWeight.bold, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading2 extends TextThemeType { + const TextThemeHeading2(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 24, + double height = 32 / 24, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading3 extends TextThemeType { + const TextThemeHeading3(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 20, + double height = 28 / 20, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading4 extends TextThemeType { + const TextThemeHeading4(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 16, + double height = 22 / 16, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, height: height, fontFamily: family, color: color, @@ -93,29 +288,35 @@ class TextThemeHeadline extends TextThemeType { const TextThemeHeadline(); @override - TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, ); @override - TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.w600, + weight: weight ?? FontWeight.w600, ); @override - TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.bold, + weight: weight ?? FontWeight.bold, ); @override - TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); @@ -123,8 +324,8 @@ class TextThemeHeadline extends TextThemeType { required String family, double fontSize = 24, double height = 36 / 24, - FontWeight weight = FontWeight.normal, TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.normal, Color? color, }) => TextStyle( @@ -145,29 +346,35 @@ class TextThemeTitle extends TextThemeType { const TextThemeTitle(); @override - TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, ); @override - TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.w600, + weight: weight ?? FontWeight.w600, ); @override - TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.bold, + weight: weight ?? FontWeight.bold, ); @override - TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); @@ -197,29 +404,35 @@ class TextThemeBody extends TextThemeType { const TextThemeBody(); @override - TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, ); @override - TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.w600, + weight: weight ?? FontWeight.w600, ); @override - TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.bold, + weight: weight ?? FontWeight.bold, ); @override - TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); @@ -249,29 +462,35 @@ class TextThemeCaption extends TextThemeType { const TextThemeCaption(); @override - TextStyle standard({String family = '', Color? color}) => _defaultTextStyle( + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, ); @override - TextStyle enhanced({String family = '', Color? color}) => _defaultTextStyle( + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.w600, + weight: weight ?? FontWeight.w600, ); @override - TextStyle prominent({String family = '', Color? color}) => _defaultTextStyle( + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, - weight: FontWeight.bold, + weight: weight ?? FontWeight.bold, ); @override - TextStyle underline({String family = '', Color? color}) => _defaultTextStyle( + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( family: family, color: color, + weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart index 64daae2370..1caaa288e8 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart @@ -2,14 +2,20 @@ import 'package:appflowy_ui/src/theme/text_style/base/default_text_style.dart'; class AppFlowyBaseTextStyle { const AppFlowyBaseTextStyle({ - this.heading = const TextThemeHeading(), + this.heading1 = const TextThemeHeading1(), + this.heading2 = const TextThemeHeading2(), + this.heading3 = const TextThemeHeading3(), + this.heading4 = const TextThemeHeading4(), this.headline = const TextThemeHeadline(), this.title = const TextThemeTitle(), this.body = const TextThemeBody(), this.caption = const TextThemeCaption(), }); - final TextThemeHeading heading; + final TextThemeType heading1; + final TextThemeType heading2; + final TextThemeType heading3; + final TextThemeType heading4; final TextThemeType headline; final TextThemeType title; final TextThemeType body; diff --git a/frontend/resources/flowy_icons/20x/hide_password.svg b/frontend/resources/flowy_icons/20x/hide_password.svg new file mode 100644 index 0000000000..2ebd274866 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/hide_password.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/password_close.svg b/frontend/resources/flowy_icons/20x/password_close.svg new file mode 100644 index 0000000000..52a44e1a8e --- /dev/null +++ b/frontend/resources/flowy_icons/20x/password_close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/show_password.svg b/frontend/resources/flowy_icons/20x/show_password.svg new file mode 100644 index 0000000000..ac8d092b37 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/show_password.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 6274540914..30e8c476ae 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -81,8 +81,11 @@ "enterCode": "Enter code", "enterCodeManually": "Enter code manually", "continueWithEmail": "Continue with email", + "enterPassword": "Enter password", + "loginAs": "Login as", "invalidVerificationCode": "Please enter a valid verification code", - "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later." + "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later.", + "invalidLoginCredentials": "Your password is incorrect, please try again" }, "workspace": { "chooseWorkspace": "Choose your workspace", @@ -2618,7 +2621,7 @@ "noLogFiles": "There're no log files", "newSettings": { "myAccount": { - "title": "My account", + "title": "Account & App", "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", "profileLabel": "Account name & Profile image", "profileNamePlaceholder": "Enter your name", @@ -2644,7 +2647,34 @@ "failedToGetCurrentUser": "Failed to get current user email", "confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Account deleted successfully" - } + }, + "password": { + "title": "Password", + "changePassword": "Change password", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmNewPassword": "Confirm new password", + "setupPassword": "Setup password", + "error": { + "newPasswordIsRequired": "New password is required", + "confirmPasswordIsRequired": "Confirm password is required", + "passwordsDoNotMatch": "Passwords do not match", + "newPasswordIsSameAsCurrent": "New password is same as current password" + }, + "toast": { + "passwordUpdatedSuccessfully": "Password updated successfully", + "passwordUpdatedFailed": "Failed to update password", + "passwordSetupSuccessfully": "Password setup successfully", + "passwordSetupFailed": "Failed to setup password" + }, + "hint": { + "enterYourCurrentPassword": "Enter your current password", + "enterYourNewPassword": "Enter your new password", + "confirmYourNewPassword": "Confirm your new password" + } + }, + "myAccount": "My Account", + "myProfile": "My Profile" }, "workplace": { "name": "Workplace", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index ec114f205a..b2bb89e3b9 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1786,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -5148,7 +5148,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros", + "phf_macros 0.8.0", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -5168,6 +5168,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.2", ] @@ -5235,6 +5236,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "phf_shared" version = "0.8.0" diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs index 3cd3733837..3c73fd01eb 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs @@ -31,29 +31,6 @@ async fn sign_up_with_invalid_email() { ); } } -#[tokio::test] -async fn sign_up_with_long_password() { - let sdk = EventIntegrationTest::new().await; - let request = SignUpPayloadPB { - email: unique_email(), - name: valid_name(), - password: "1234".repeat(100).as_str().to_string(), - auth_type: AuthenticatorPB::Local, - device_id: "".to_string(), - }; - - assert_eq!( - EventBuilder::new(sdk) - .event(SignUp) - .payload(request) - .async_send() - .await - .error() - .unwrap() - .code, - ErrorCode::PasswordTooLong - ); -} #[tokio::test] async fn sign_in_with_invalid_email() { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 9057288f88..98ed822901 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -131,12 +131,10 @@ impl ChatCloudService for LocalServerChatServiceImpl { stream::once(async { Err(FlowyError::local_ai_unavailable().with_context(err)) }).boxed(), ), } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) } else { - if self.local_ai.is_enabled() { - Err(FlowyError::local_ai_not_ready()) - } else { - Err(FlowyError::local_ai_disabled()) - } + Err(FlowyError::local_ai_disabled()) } } @@ -247,12 +245,10 @@ impl ChatCloudService for LocalServerChatServiceImpl { ), Err(_) => Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()), } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) } else { - if self.local_ai.is_enabled() { - Err(FlowyError::local_ai_not_ready()) - } else { - Err(FlowyError::local_ai_disabled()) - } + Err(FlowyError::local_ai_disabled()) } } diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index 02ae333aa0..87e828199c 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -31,11 +31,10 @@ impl TryInto for SignInPayloadPB { fn try_into(self) -> Result { let email = UserEmail::parse(self.email)?; - let password = UserPassword::parse(self.password)?; Ok(SignInParams { email: email.0, - password: password.0, + password: self.password, name: self.name, auth_type: self.auth_type.into(), }) @@ -65,13 +64,13 @@ impl TryInto for SignUpPayloadPB { fn try_into(self) -> Result { let email = UserEmail::parse(self.email)?; - let password = UserPassword::parse(self.password)?; + let password = self.password; let name = UserName::parse(self.name)?; Ok(SignUpParams { email: email.0, name: name.0, - password: password.0, + password, auth_type: self.auth_type.into(), device_id: self.device_id, }) diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 36d5232bd2..7e62958c68 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -4,7 +4,7 @@ use lib_infra::validator_fn::required_not_empty_str; use std::convert::TryInto; use validator::Validate; -use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey, UserPassword}; +use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey}; use crate::entities::AuthenticatorPB; use crate::errors::ErrorCode; @@ -171,10 +171,7 @@ impl TryInto for UpdateUserProfilePayloadPB { Some(email) => Some(UserEmail::parse(email)?.0), }; - let password = match self.password { - None => None, - Some(password) => Some(UserPassword::parse(password)?.0), - }; + let password = self.password; let icon_url = match self.icon_url { None => None, From 4925e991660a5a6d89ada9705473051553e06f8b Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 15:02:02 +0800 Subject: [PATCH 176/207] chore: fix compile --- .../ai_chat/application/chat_bloc.dart | 2 +- frontend/rust-lib/flowy-ai-pub/src/cloud.rs | 4 +- frontend/rust-lib/flowy-ai/src/chat.rs | 11 +- frontend/rust-lib/flowy-ai/src/entities.rs | 9 +- .../rust-lib/flowy-ai/src/event_handler.rs | 33 +----- .../flowy-ai/src/local_ai/controller.rs | 107 +++++++++--------- .../src/middleware/chat_service_mw.rs | 41 +------ .../rust-lib/flowy-ai/src/stream_message.rs | 1 + frontend/rust-lib/flowy-sqlite/src/schema.rs | 22 ++-- 9 files changed, 78 insertions(+), 152 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 4924a42c0d..602b46f97a 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -435,7 +435,7 @@ class ChatBloc extends Bloc { messageType: ChatMessageTypePB.User, questionStreamPort: Int64(questionStream.nativePort), answerStreamPort: Int64(answerStream!.nativePort), - metadata: await metadataPBFromMetadata(metadata), + //metadata: await metadataPBFromMetadata(metadata), ); if (format != null) { payload.format = format.toPB(); diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index b91021142e..2292e0f332 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -6,8 +6,8 @@ pub use client_api::entity::ai_dto::{ }; pub use client_api::entity::billing_dto::SubscriptionPlan; pub use client_api::entity::chat_dto::{ - ChatMessage, ChatMessageMetadata, ChatMessageType, ChatRAGData, ChatSettings, ContextLoader, - MessageCursor, RepeatedChatMessage, UpdateChatParams, + ChatMessage, ChatMessageType, ChatRAGData, ChatSettings, ContextLoader, MessageCursor, + RepeatedChatMessage, UpdateChatParams, }; pub use client_api::entity::QuestionStreamValue; pub use client_api::entity::*; diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index ba5ff431e9..3180227ed0 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -76,11 +76,10 @@ impl Chat { preferred_ai_model: Option, ) -> Result { trace!( - "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, metadata={:?}, format={:?}", + "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, format={:?}", self.chat_id, params.message, params.message_type, - params.metadata, params.format, ); @@ -116,14 +115,6 @@ impl Chat { .send(StreamMessage::MessageId(question.message_id).to_string()) .await; - if let Err(err) = self - .chat_service - .index_message_metadata(&self.chat_id, ¶ms.metadata, &mut question_sink) - .await - { - error!("Failed to index file: {}", err); - } - // Save message to disk notify_message(&self.chat_id, question.clone())?; let format = params.format.clone().map(Into::into).unwrap_or_default(); diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index b62899eca3..5a4aecbbd7 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -2,9 +2,8 @@ use crate::local_ai::controller::LocalAISetting; use crate::local_ai::resource::PendingResource; use af_plugin::core::plugin::RunningState; use flowy_ai_pub::cloud::{ - AIModel, ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel, - OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, - ResponseFormat, + AIModel, ChatMessage, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, + RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use lib_infra::validator_fn::required_not_empty_str; @@ -71,9 +70,6 @@ pub struct StreamChatPayloadPB { #[pb(index = 6, one_of)] pub format: Option, - - #[pb(index = 7)] - pub metadata: Vec, } #[derive(Default, Debug)] @@ -84,7 +80,6 @@ pub struct StreamMessageParams { pub answer_stream_port: i64, pub question_stream_port: i64, pub format: Option, - pub metadata: Vec, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 6788685102..85f2dc8306 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -2,16 +2,13 @@ use crate::ai_manager::{AIManager, GLOBAL_ACTIVE_MODEL_KEY}; use crate::completion::AICompletion; use crate::entities::*; use crate::util::ai_available_models_key; -use flowy_ai_pub::cloud::{ - AIModel, ChatMessageMetadata, ChatMessageType, ChatRAGData, ContextLoader, -}; +use flowy_ai_pub::cloud::{AIModel, ChatMessageType}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; use std::fs; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, Weak}; -use tracing::trace; use uuid::Uuid; use validator::Validate; @@ -37,7 +34,6 @@ pub(crate) async fn stream_chat_message_handler( answer_stream_port, question_stream_port, format, - metadata, } = data; let message_type = match message_type { @@ -45,32 +41,6 @@ pub(crate) async fn stream_chat_message_handler( ChatMessageTypePB::User => ChatMessageType::User, }; - let metadata = metadata - .into_iter() - .map(|metadata| { - let (content_type, content_len) = match metadata.loader_type { - ContextLoaderTypePB::Txt => (ContextLoader::Text, metadata.data.len()), - ContextLoaderTypePB::Markdown => (ContextLoader::Markdown, metadata.data.len()), - ContextLoaderTypePB::PDF => (ContextLoader::PDF, 0), - ContextLoaderTypePB::UnknownLoaderType => (ContextLoader::Unknown, 0), - }; - - ChatMessageMetadata { - data: ChatRAGData { - content: metadata.data, - content_type, - size: content_len as i64, - }, - id: metadata.id, - name: metadata.name.clone(), - source: metadata.source, - extra: None, - } - }) - .collect::>(); - - trace!("Stream chat message with metadata: {:?}", metadata); - let chat_id = Uuid::from_str(&chat_id)?; let params = StreamMessageParams { chat_id, @@ -79,7 +49,6 @@ pub(crate) async fn stream_chat_message_handler( answer_stream_port, question_stream_port, format, - metadata, }; let ai_manager = upgrade_ai_manager(ai_manager)?; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index 3c4ccb1f9d..b9dc7a73c1 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -6,7 +6,6 @@ use crate::notification::{ }; use af_plugin::manager::PluginManager; use anyhow::Error; -use flowy_ai_pub::cloud::{ChatMessageMetadata, ContextLoader}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; use futures::Sink; @@ -21,9 +20,8 @@ use arc_swap::ArcSwapOption; use futures_util::SinkExt; use lib_infra::util::get_operating_system; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::ops::Deref; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Arc, Weak}; use tokio::select; use tokio_stream::StreamExt; @@ -383,58 +381,59 @@ impl LocalAIController { Ok(enabled) } - #[instrument(level = "debug", skip_all)] - pub async fn index_message_metadata( - &self, - chat_id: &Uuid, - metadata_list: &[ChatMessageMetadata], - index_process_sink: &mut (impl Sink + Unpin), - ) -> FlowyResult<()> { - if !self.is_enabled() { - info!("[AI Plugin] local ai is disabled, skip indexing"); - return Ok(()); - } - - for metadata in metadata_list { - let mut file_metadata = HashMap::new(); - file_metadata.insert("id".to_string(), json!(&metadata.id)); - file_metadata.insert("name".to_string(), json!(&metadata.name)); - file_metadata.insert("source".to_string(), json!(&metadata.source)); - - let file_path = Path::new(&metadata.data.content); - if !file_path.exists() { - return Err( - FlowyError::record_not_found().with_context(format!("File not found: {:?}", file_path)), - ); - } - info!( - "[AI Plugin] embed file: {:?}, with metadata: {:?}", - file_path, file_metadata - ); - - match &metadata.data.content_type { - ContextLoader::Unknown => { - error!( - "[AI Plugin] unsupported content type: {:?}", - metadata.data.content_type - ); - }, - ContextLoader::Text | ContextLoader::Markdown | ContextLoader::PDF => { - self - .process_index_file( - chat_id, - file_path.to_path_buf(), - &file_metadata, - index_process_sink, - ) - .await?; - }, - } - } - - Ok(()) - } + // #[instrument(level = "debug", skip_all)] + // pub async fn index_message_metadata( + // &self, + // chat_id: &Uuid, + // metadata_list: &[ChatMessageMetadata], + // index_process_sink: &mut (impl Sink + Unpin), + // ) -> FlowyResult<()> { + // if !self.is_enabled() { + // info!("[AI Plugin] local ai is disabled, skip indexing"); + // return Ok(()); + // } + // + // for metadata in metadata_list { + // let mut file_metadata = HashMap::new(); + // file_metadata.insert("id".to_string(), json!(&metadata.id)); + // file_metadata.insert("name".to_string(), json!(&metadata.name)); + // file_metadata.insert("source".to_string(), json!(&metadata.source)); + // + // let file_path = Path::new(&metadata.data.content); + // if !file_path.exists() { + // return Err( + // FlowyError::record_not_found().with_context(format!("File not found: {:?}", file_path)), + // ); + // } + // info!( + // "[AI Plugin] embed file: {:?}, with metadata: {:?}", + // file_path, file_metadata + // ); + // + // match &metadata.data.content_type { + // ContextLoader::Unknown => { + // error!( + // "[AI Plugin] unsupported content type: {:?}", + // metadata.data.content_type + // ); + // }, + // ContextLoader::Text | ContextLoader::Markdown | ContextLoader::PDF => { + // self + // .process_index_file( + // chat_id, + // file_path.to_path_buf(), + // &file_metadata, + // index_process_sink, + // ) + // .await?; + // }, + // } + // } + // + // Ok(()) + // } + #[allow(dead_code)] async fn process_index_file( &self, chat_id: &Uuid, diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index ff5c608f22..22a2bec674 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -9,19 +9,17 @@ use flowy_ai_pub::persistence::select_message_content; use std::collections::HashMap; use flowy_ai_pub::cloud::{ - AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageMetadata, - ChatMessageType, ChatSettings, CompleteTextParams, CompletionStream, MessageCursor, ModelList, - RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, - StreamComplete, UpdateChatParams, + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, + ChatSettings, CompleteTextParams, CompletionStream, MessageCursor, ModelList, RelatedQuestion, + RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, + UpdateChatParams, }; use flowy_error::{FlowyError, FlowyResult}; -use futures::{stream, Sink, StreamExt, TryStreamExt}; +use futures::{stream, StreamExt, TryStreamExt}; use lib_infra::async_trait::async_trait; use crate::local_ai::stream_util::QuestionStream; -use crate::stream_message::StreamMessage; use flowy_storage_pub::storage::StorageService; -use futures_util::SinkExt; use serde_json::{json, Value}; use std::path::Path; use std::sync::{Arc, Weak}; @@ -32,6 +30,7 @@ pub struct ChatServiceMiddleware { cloud_service: Arc, user_service: Arc, local_ai: Arc, + #[allow(dead_code)] storage_service: Weak, } @@ -50,34 +49,6 @@ impl ChatServiceMiddleware { } } - pub async fn index_message_metadata( - &self, - chat_id: &Uuid, - metadata_list: &[ChatMessageMetadata], - index_process_sink: &mut (impl Sink + Unpin), - ) -> Result<(), FlowyError> { - if metadata_list.is_empty() { - return Ok(()); - } - if self.local_ai.is_enabled() { - let _ = index_process_sink - .send(StreamMessage::IndexStart.to_string()) - .await; - let result = self - .local_ai - .index_message_metadata(chat_id, metadata_list, index_process_sink) - .await; - let _ = index_process_sink - .send(StreamMessage::IndexEnd.to_string()) - .await; - - result? - } else if let Some(_storage_service) = self.storage_service.upgrade() { - // - } - Ok(()) - } - fn get_message_content(&self, message_id: i64) -> FlowyResult { let uid = self.user_service.user_id()?; let conn = self.user_service.sqlite_connection(uid)?; diff --git a/frontend/rust-lib/flowy-ai/src/stream_message.rs b/frontend/rust-lib/flowy-ai/src/stream_message.rs index 3e76282aa8..3f7b37bd34 100644 --- a/frontend/rust-lib/flowy-ai/src/stream_message.rs +++ b/frontend/rust-lib/flowy-ai/src/stream_message.rs @@ -1,5 +1,6 @@ use std::fmt::Display; +#[allow(dead_code)] pub enum StreamMessage { MessageId(i64), IndexStart, diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index f3caa86e66..91c9aa8162 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -128,15 +128,15 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, ); From 394ac85c3265ae4c071206dc629ea84a8b9a6ae2 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 15:31:50 +0800 Subject: [PATCH 177/207] refactor: server type name --- .../src/deps_resolve/cloud_service_impl.rs | 24 +-- frontend/rust-lib/flowy-core/src/lib.rs | 10 +- .../rust-lib/flowy-core/src/server_layer.rs | 199 ++++++++---------- .../flowy-core/src/user_state_callback.rs | 34 ++- .../src/af_cloud/impls/user/dto.rs | 7 +- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 10 +- .../rust-lib/flowy-user-pub/src/entities.rs | 34 +-- .../rust-lib/flowy-user/src/entities/auth.rs | 14 +- .../rust-lib/flowy-user/src/event_handler.rs | 22 +- frontend/rust-lib/flowy-user/src/event_map.rs | 22 +- .../src/migrations/doc_key_with_workspace.rs | 4 +- .../src/migrations/document_empty_content.rs | 6 +- .../flowy-user/src/migrations/migration.rs | 6 +- .../migrations/workspace_and_favorite_v1.rs | 4 +- .../src/migrations/workspace_trash_v1.rs | 4 +- .../data_import/appflowy_data_import.rs | 4 +- .../src/services/sqlite_sql/user_sql.rs | 6 +- .../flowy-user/src/user_manager/manager.rs | 60 +++--- .../src/user_manager/manager_history_user.rs | 7 +- .../user_manager/manager_user_awareness.rs | 15 +- .../src/user_manager/user_login_state.rs | 4 +- 21 files changed, 224 insertions(+), 272 deletions(-) diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 3a7d648133..9c1ee9462c 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -1,4 +1,4 @@ -use crate::server_layer::{Server, ServerProvider}; +use crate::server_layer::{ServerProvider, ServerType}; use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; use client_api::entity::workspace_dto::PublishInfoView; @@ -35,7 +35,7 @@ use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserTokenState}; +use flowy_user_pub::entities::{AuthType, UserTokenState}; use lib_infra::async_trait::async_trait; use serde_json::Value; use std::collections::HashMap; @@ -186,18 +186,18 @@ impl UserCloudServiceProvider for ServerProvider { } } - /// When user login, the provider type is set by the [Authenticator] and save to disk for next use. + /// When user login, the provider type is set by the [AuthType] and save to disk for next use. /// - /// Each [Authenticator] has a corresponding [Server]. The [Server] is used - /// to create a new [AppFlowyServer] if it doesn't exist. Once the [Server] is set, + /// Each [AuthType] has a corresponding [ServerType]. The [ServerType] is used + /// to create a new [AppFlowyServer] if it doesn't exist. Once the [ServerType] is set, /// it will be used when user open the app again. /// - fn set_user_authenticator(&self, authenticator: &Authenticator) { - self.set_authenticator(authenticator.clone()); + fn set_auth_type(&self, auth_type: &AuthType) { + self.set_auth_type(*auth_type); } - fn get_user_authenticator(&self) -> Authenticator { - self.get_authenticator() + fn get_auth_type(&self) -> AuthType { + self.get_auth_type() } fn set_network_reachable(&self, reachable: bool) { @@ -211,7 +211,7 @@ impl UserCloudServiceProvider for ServerProvider { self.encryption.set_secret(secret); } - /// Returns the [UserCloudService] base on the current [Server]. + /// Returns the [UserCloudService] base on the current [ServerType]. /// Creates a new [AppFlowyServer] if it doesn't exist. fn get_user_service(&self) -> Result, FlowyError> { let user_service = self.get_server()?.user_service(); @@ -220,8 +220,8 @@ impl UserCloudServiceProvider for ServerProvider { fn service_url(&self) -> String { match self.get_server_type() { - Server::Local => "".to_string(), - Server::AppFlowyCloud => AFCloudConfiguration::from_env() + ServerType::Local => "".to_string(), + ServerType::AppFlowyCloud => AFCloudConfiguration::from_env() .map(|config| config.base_url) .unwrap_or_default(), } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index dbbce02136..bd55c8db1d 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -34,7 +34,7 @@ use crate::config::AppFlowyCoreConfig; use crate::deps_resolve::file_storage_deps::FileStorageResolver; use crate::deps_resolve::*; use crate::log_filter::init_log; -use crate::server_layer::{current_server_type, Server, ServerProvider}; +use crate::server_layer::{current_server_type, ServerProvider, ServerType}; use deps_resolve::reminder_deps::CollabInteractImpl; use flowy_sqlite::DBConnection; use lib_infra::async_trait::async_trait; @@ -314,11 +314,11 @@ impl AppFlowyCore { } } -impl From for CollabPluginProviderType { - fn from(server_type: Server) -> Self { +impl From for CollabPluginProviderType { + fn from(server_type: ServerType) -> Self { match server_type { - Server::Local => CollabPluginProviderType::Local, - Server::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, + ServerType::Local => CollabPluginProviderType::Local, + ServerType::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, } } } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index a3d6de7003..c0bf043d27 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -1,179 +1,156 @@ use crate::AppFlowyCoreConfig; use af_plugin::manager::PluginManager; -use arc_swap::ArcSwapOption; +use arc_swap::{ArcSwap, ArcSwapOption}; use dashmap::DashMap; use flowy_ai::local_ai::controller::LocalAIController; use flowy_error::{FlowyError, FlowyResult}; -use flowy_server::af_cloud::define::{AIUserServiceImpl, LoginUserService}; -use flowy_server::af_cloud::AppFlowyCloudServer; +use flowy_server::af_cloud::{ + define::{AIUserServiceImpl, LoginUserService}, + AppFlowyCloudServer, +}; use flowy_server::local_server::LocalServer; use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::entities::*; -use serde_repr::*; -use std::fmt::{Display, Formatter}; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; +/// ServerType: local or cloud #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] #[repr(u8)] -pub enum Server { - /// Local server provider. - /// Offline mode, no user authentication and the data is stored locally. +pub enum ServerType { Local = 0, - /// AppFlowy Cloud server provider. - /// See: https://github.com/AppFlowy-IO/AppFlowy-Cloud AppFlowyCloud = 1, } -impl Server { +impl ServerType { pub fn is_local(&self) -> bool { - matches!(self, Server::Local) + matches!(self, Self::Local) } } -impl Display for Server { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Server::Local => write!(f, "Local"), - Server::AppFlowyCloud => write!(f, "AppFlowyCloud"), +impl Display for ServerType { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "{:?}", self) + } +} + +/// Conversion between AuthType and ServerType +impl From<&AuthType> for ServerType { + fn from(a: &AuthType) -> Self { + match a { + AuthType::Local => ServerType::Local, + AuthType::AppFlowyCloud => ServerType::AppFlowyCloud, + } + } +} +impl From for AuthType { + fn from(s: ServerType) -> Self { + match s { + ServerType::Local => AuthType::Local, + ServerType::AppFlowyCloud => AuthType::AppFlowyCloud, } } } -/// The [ServerProvider] provides list of [AppFlowyServer] base on the [Authenticator]. Using -/// the auth type, the [ServerProvider] will create a new [AppFlowyServer] if it doesn't -/// exist. -/// Each server implements the [AppFlowyServer] trait, which provides the [UserCloudService], etc. pub struct ServerProvider { config: AppFlowyCoreConfig, - providers: DashMap>, - pub(crate) encryption: Arc, - #[allow(dead_code)] - pub(crate) store_preferences: Weak, - pub(crate) user_enable_sync: AtomicBool, - - /// The authenticator type of the user. - authenticator: AtomicU8, + providers: DashMap>, + auth_type: ArcSwap, user: Arc, - pub(crate) uid: Arc>, pub local_ai: Arc, + pub uid: Arc>, + pub user_enable_sync: Arc, + pub encryption: Arc, } impl ServerProvider { pub fn new( config: AppFlowyCoreConfig, - server: Server, + initial: ServerType, store_preferences: Weak, - server_user: impl LoginUserService + 'static, + user_service: impl LoginUserService + 'static, ) -> Self { - let user = Arc::new(server_user); - let encryption = EncryptionImpl::new(None); - let user_service = Arc::new(AIUserServiceImpl(user.clone())); - let plugin_manager = Arc::new(PluginManager::new()); + let user = Arc::new(user_service); + let initial_auth = AuthType::from(initial); + let auth_type = ArcSwap::from(Arc::new(initial_auth)); + let encryption = Arc::new(EncryptionImpl::new(None)) as Arc; + let ai_user = Arc::new(AIUserServiceImpl(user.clone())); + let plugins = Arc::new(PluginManager::new()); let local_ai = Arc::new(LocalAIController::new( - plugin_manager.clone(), - store_preferences.clone(), - user_service.clone(), + plugins, + store_preferences, + ai_user.clone(), )); - Self { + ServerProvider { config, providers: DashMap::new(), - user_enable_sync: AtomicBool::new(true), - authenticator: AtomicU8::new(Authenticator::from(server) as u8), - encryption: Arc::new(encryption), - store_preferences, - uid: Default::default(), + encryption, + user_enable_sync: Arc::new(AtomicBool::new(true)), + auth_type, user, + uid: Default::default(), local_ai, } } - pub fn get_server_type(&self) -> Server { - match Authenticator::from(self.authenticator.load(Ordering::Acquire) as i32) { - Authenticator::Local => Server::Local, - Authenticator::AppFlowyCloud => Server::AppFlowyCloud, + /// Reads current type + pub fn get_server_type(&self) -> ServerType { + let auth_type = self.auth_type.load_full(); + ServerType::from(auth_type.as_ref()) + } + + pub fn set_auth_type(&self, a: AuthType) { + let old_type = self.get_server_type(); + self.auth_type.store(Arc::new(a)); + let new_type = self.get_server_type(); + if old_type != new_type { + self.providers.remove(&old_type); } } - pub fn set_authenticator(&self, authenticator: Authenticator) { - let old_server_type = self.get_server_type(); - self - .authenticator - .store(authenticator as u8, Ordering::Release); - let new_server_type = self.get_server_type(); - - if old_server_type != new_server_type { - self.providers.remove(&old_server_type); - } + pub fn get_auth_type(&self) -> AuthType { + *self.auth_type.load_full().as_ref() } - pub fn get_authenticator(&self) -> Authenticator { - Authenticator::from(self.authenticator.load(Ordering::Acquire) as i32) - } - - /// Returns a [AppFlowyServer] trait implementation base on the provider_type. + /// Lazily create or fetch an AppFlowyServer instance pub fn get_server(&self) -> FlowyResult> { - let server_type = self.get_server_type(); - - if let Some(provider) = self.providers.get(&server_type) { - return Ok(provider.value().clone()); + let key = self.get_server_type(); + if let Some(entry) = self.providers.get(&key) { + return Ok(entry.clone()); } - let server = match server_type { - Server::Local => { - let server = Arc::new(LocalServer::new(self.user.clone(), self.local_ai.clone())); - Ok::, FlowyError>(server) - }, - Server::AppFlowyCloud => { - let config = self.config.cloud_config.clone().ok_or_else(|| { - FlowyError::internal().with_context("AppFlowyCloud configuration is missing") - })?; - let server = Arc::new(AppFlowyCloudServer::new( - config, + let server: Arc = match key { + ServerType::Local => Arc::new(LocalServer::new(self.user.clone(), self.local_ai.clone())), + ServerType::AppFlowyCloud => { + let cfg = self + .config + .cloud_config + .clone() + .ok_or_else(|| FlowyError::internal().with_context("Missing cloud config"))?; + Arc::new(AppFlowyCloudServer::new( + cfg, self.user_enable_sync.load(Ordering::Acquire), self.config.device_id.clone(), self.config.app_version.clone(), self.user.clone(), - )); - - Ok::, FlowyError>(server) + )) }, - }?; + }; - self.providers.insert(server_type.clone(), server.clone()); + self.providers.insert(key.clone(), server.clone()); Ok(server) } } -impl From for Server { - fn from(auth_provider: Authenticator) -> Self { - match auth_provider { - Authenticator::Local => Server::Local, - Authenticator::AppFlowyCloud => Server::AppFlowyCloud, - } - } -} - -impl From for Authenticator { - fn from(ty: Server) -> Self { - match ty { - Server::Local => Authenticator::Local, - Server::AppFlowyCloud => Authenticator::AppFlowyCloud, - } - } -} -impl From<&Authenticator> for Server { - fn from(auth_provider: &Authenticator) -> Self { - Self::from(auth_provider.clone()) - } -} - -pub fn current_server_type() -> Server { +/// Determine current server type from ENV +pub fn current_server_type() -> ServerType { match AuthenticatorType::from_env() { - AuthenticatorType::Local => Server::Local, - AuthenticatorType::AppFlowyCloud => Server::AppFlowyCloud, + AuthenticatorType::Local => ServerType::Local, + AuthenticatorType::AppFlowyCloud => ServerType::AppFlowyCloud, } } diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index 2882b9b050..89ba14e6d2 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -14,11 +14,11 @@ use flowy_folder::manager::{FolderInitDataSource, FolderManager}; use flowy_storage::manager::StorageManager; use flowy_user::event_map::UserStatusCallback; use flowy_user_pub::cloud::{UserCloudConfig, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserProfile, UserWorkspace}; +use flowy_user_pub::entities::{AuthType, UserProfile, UserWorkspace}; use lib_dispatch::runtime::AFPluginRuntime; use lib_infra::async_trait::async_trait; -use crate::server_layer::{Server, ServerProvider}; +use crate::server_layer::{ServerProvider, ServerType}; pub(crate) struct UserStatusCallbackImpl { pub(crate) collab_builder: Arc, @@ -49,16 +49,14 @@ impl UserStatusCallback for UserStatusCallbackImpl { async fn did_init( &self, user_id: i64, - user_authenticator: &Authenticator, + auth_type: &AuthType, cloud_config: &Option, user_workspace: &UserWorkspace, _device_id: &str, - authenticator: &Authenticator, + authenticator: &AuthType, ) -> FlowyResult<()> { let workspace_id = user_workspace.workspace_id()?; - self - .server_provider - .set_user_authenticator(user_authenticator); + self.server_provider.set_auth_type(*auth_type); if let Some(cloud_config) = cloud_config { self @@ -83,7 +81,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { .await?; self .database_manager - .initialize(user_id, authenticator == &Authenticator::Local) + .initialize(user_id, authenticator == &AuthType::Local) .await?; self.document_manager.initialize(user_id).await?; @@ -97,7 +95,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_id: i64, user_workspace: &UserWorkspace, device_id: &str, - authenticator: &Authenticator, + authenticator: &AuthType, ) -> FlowyResult<()> { event!( tracing::Level::TRACE, @@ -127,11 +125,9 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_profile: &UserProfile, user_workspace: &UserWorkspace, device_id: &str, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { - self - .server_provider - .set_user_authenticator(&user_profile.authenticator); + self.server_provider.set_auth_type(*auth_type); let server_type = self.server_provider.get_server_type(); event!( @@ -159,16 +155,16 @@ impl UserStatusCallback for UserStatusCallbackImpl { .await { Ok(doc_state) => match server_type { - Server::Local => FolderInitDataSource::LocalDisk { + ServerType::Local => FolderInitDataSource::LocalDisk { create_if_not_exist: true, }, - Server::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), + ServerType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), }, Err(err) => match server_type { - Server::Local => FolderInitDataSource::LocalDisk { + ServerType::Local => FolderInitDataSource::LocalDisk { create_if_not_exist: true, }, - Server::AppFlowyCloud => { + ServerType::AppFlowyCloud => { return Err(err); }, }, @@ -188,7 +184,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { self .database_manager - .initialize_with_new_user(user_profile.uid, authenticator.is_local()) + .initialize_with_new_user(user_profile.uid, auth_type.is_local()) .await .context("DatabaseManager error")?; @@ -212,7 +208,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { &self, user_id: i64, user_workspace: &UserWorkspace, - authenticator: &Authenticator, + authenticator: &AuthType, ) -> FlowyResult<()> { self .folder_manager diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index 0710bcc2b2..eb2bf26698 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -3,9 +3,8 @@ use client_api::entity::auth_dto::{UpdateUserParams, UserMetaData}; use client_api::entity::{AFRole, AFUserProfile, AFWorkspaceInvitationStatus, AFWorkspaceMember}; use flowy_user_pub::entities::{ - Authenticator, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, - WorkspaceMember, USER_METADATA_ICON_URL, USER_METADATA_OPEN_AI_KEY, - USER_METADATA_STABILITY_AI_KEY, + AuthType, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, WorkspaceMember, + USER_METADATA_ICON_URL, USER_METADATA_OPEN_AI_KEY, USER_METADATA_STABILITY_AI_KEY, }; use crate::af_cloud::impls::user::util::encryption_type_from_profile; @@ -60,7 +59,7 @@ pub fn user_profile_from_af_profile( icon_url: icon_url.unwrap_or_default(), openai_key: openai_key.unwrap_or_default(), stability_ai_key: stability_ai_key.unwrap_or_default(), - authenticator: Authenticator::AppFlowyCloud, + authenticator: AuthType::AppFlowyCloud, encryption_type, uid: profile.uid, updated_at: profile.updated_at, diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index dde94aebdc..fd283360de 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -20,7 +20,7 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::entities::{ - AuthResponse, Authenticator, Role, UpdateUserProfileParams, UserCredentials, UserProfile, + AuthResponse, AuthType, Role, UpdateUserProfileParams, UserCredentials, UserProfile, UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; @@ -84,13 +84,9 @@ pub trait UserCloudServiceProvider: Send + Sync { /// * `enable_sync`: A boolean indicating whether synchronization should be enabled or disabled. fn set_enable_sync(&self, uid: i64, enable_sync: bool); - /// Sets the authenticator when user sign in or sign up. - /// - /// # Arguments - /// * `authenticator`: An `Authenticator` object. - fn set_user_authenticator(&self, authenticator: &Authenticator); + fn set_auth_type(&self, auth_type: &AuthType); - fn get_user_authenticator(&self) -> Authenticator; + fn get_auth_type(&self) -> AuthType; /// Sets the network reachability /// diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 857a735edb..c09b536ccf 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -32,7 +32,7 @@ pub struct SignInParams { pub email: String, pub password: String, pub name: String, - pub auth_type: Authenticator, + pub auth_type: AuthType, } #[derive(Serialize, Deserialize, Default, Debug)] @@ -40,7 +40,7 @@ pub struct SignUpParams { pub email: String, pub name: String, pub password: String, - pub auth_type: Authenticator, + pub auth_type: AuthType, pub device_id: String, } @@ -103,7 +103,7 @@ impl UserAuthResponse for AuthResponse { #[derive(Clone, Debug)] pub struct UserCredentials { - /// Currently, the token is only used when the [Authenticator] is AppFlowyCloud + /// Currently, the token is only used when the [AuthType] is AppFlowyCloud pub token: Option, /// The user id @@ -180,7 +180,7 @@ pub struct UserProfile { pub icon_url: String, pub openai_key: String, pub stability_ai_key: String, - pub authenticator: Authenticator, + pub authenticator: AuthType, // If the encryption_sign is not empty, which means the user has enabled the encryption. pub encryption_type: EncryptionType, pub updated_at: i64, @@ -226,11 +226,11 @@ impl FromStr for EncryptionType { } } -impl From<(&T, &Authenticator)> for UserProfile +impl From<(&T, &AuthType)> for UserProfile where T: UserAuthResponse, { - fn from(params: (&T, &Authenticator)) -> Self { + fn from(params: (&T, &AuthType)) -> Self { let (value, auth_type) = params; let (icon_url, openai_key, stability_ai_key) = { value @@ -258,7 +258,7 @@ where token: value.user_token().unwrap_or_default(), icon_url, openai_key, - authenticator: auth_type.clone(), + authenticator: *auth_type, encryption_type: value.encryption_type(), stability_ai_key, updated_at: value.updated_at(), @@ -349,9 +349,9 @@ impl UpdateUserProfileParams { } } -#[derive(Debug, Clone, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] #[repr(u8)] -pub enum Authenticator { +pub enum AuthType { /// It's a local server, we do fake sign in default. Local = 0, /// Currently not supported. It will be supported in the future when the @@ -359,28 +359,28 @@ pub enum Authenticator { AppFlowyCloud = 1, } -impl Default for Authenticator { +impl Default for AuthType { fn default() -> Self { Self::Local } } -impl Authenticator { +impl AuthType { pub fn is_local(&self) -> bool { - matches!(self, Authenticator::Local) + matches!(self, AuthType::Local) } pub fn is_appflowy_cloud(&self) -> bool { - matches!(self, Authenticator::AppFlowyCloud) + matches!(self, AuthType::AppFlowyCloud) } } -impl From for Authenticator { +impl From for AuthType { fn from(value: i32) -> Self { match value { - 0 => Authenticator::Local, - 1 => Authenticator::AppFlowyCloud, - _ => Authenticator::Local, + 0 => AuthType::Local, + 1 => AuthType::AppFlowyCloud, + _ => AuthType::Local, } } } diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index 87e828199c..6a889359c1 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -235,20 +235,20 @@ pub enum AuthenticatorPB { AppFlowyCloud = 2, } -impl From for AuthenticatorPB { - fn from(auth_type: Authenticator) -> Self { +impl From for AuthenticatorPB { + fn from(auth_type: AuthType) -> Self { match auth_type { - Authenticator::Local => AuthenticatorPB::Local, - Authenticator::AppFlowyCloud => AuthenticatorPB::AppFlowyCloud, + AuthType::Local => AuthenticatorPB::Local, + AuthType::AppFlowyCloud => AuthenticatorPB::AppFlowyCloud, } } } -impl From for Authenticator { +impl From for AuthType { fn from(pb: AuthenticatorPB) -> Self { match pb { - AuthenticatorPB::Local => Authenticator::Local, - AuthenticatorPB::AppFlowyCloud => Authenticator::AppFlowyCloud, + AuthenticatorPB::Local => AuthType::Local, + AuthenticatorPB::AppFlowyCloud => AuthType::AppFlowyCloud, } } } diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index c0b7a8d6c2..a20229b6d0 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -45,16 +45,14 @@ pub async fn sign_in_with_email_password_handler( let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let old_authenticator = manager.cloud_services.get_user_authenticator(); + let old_authenticator = manager.cloud_services.get_auth_type(); match manager .sign_in_with_password(¶ms.email, ¶ms.password) .await { Ok(token) => data_result_ok(token.into()), Err(err) => { - manager - .cloud_services - .set_user_authenticator(&old_authenticator); + manager.cloud_services.set_auth_type(&old_authenticator); return Err(err); }, } @@ -76,15 +74,13 @@ pub async fn sign_up( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: SignUpParams = data.into_inner().try_into()?; - let authenticator = params.auth_type.clone(); + let auth_type = params.auth_type; - let prev_authenticator = manager.cloud_services.get_user_authenticator(); - match manager.sign_up(authenticator, BoxAny::new(params)).await { + let prev_auth_type = manager.cloud_services.get_auth_type(); + match manager.sign_up(auth_type, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), Err(err) => { - manager - .cloud_services - .set_user_authenticator(&prev_authenticator); + manager.cloud_services.set_auth_type(&prev_auth_type); Err(err) }, } @@ -119,7 +115,7 @@ pub async fn get_user_profile_handler( // When the user is logged in with a local account, the email field is a placeholder and should // not be exposed to the client. So we set the email field to an empty string. - if user_profile.authenticator == Authenticator::Local { + if user_profile.authenticator == AuthType::Local { user_profile.email = "".to_string(); } @@ -341,7 +337,7 @@ pub async fn oauth_sign_in_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let authenticator: Authenticator = params.authenticator.into(); + let authenticator: AuthType = params.authenticator.into(); let user_profile = manager .sign_up(authenticator, BoxAny::new(params.map)) .await?; @@ -355,7 +351,7 @@ pub async fn gen_sign_in_url_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let authenticator: Authenticator = params.authenticator.into(); + let authenticator: AuthType = params.authenticator.into(); let sign_in_url = manager .generate_sign_in_url_with_email(&authenticator, ¶ms.email) .await?; diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 7ed4948771..953011bc1c 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -86,12 +86,12 @@ pub fn init(user_manager: Weak) -> AFPlugin { #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] #[event_err = "FlowyError"] pub enum UserEvent { - /// Only use when the [Authenticator] is Local or SelfHosted + /// Only use when the [AuthType] is Local or SelfHosted /// Logging into an account using a register email and password #[event(input = "SignInPayloadPB", output = "GotrueTokenResponsePB")] SignInWithEmailPassword = 0, - /// Only use when the [Authenticator] is Local or SelfHosted + /// Only use when the [AuthType] is Local or SelfHosted /// Creating a new account #[event(input = "SignUpPayloadPB", output = "UserProfilePB")] SignUp = 1, @@ -129,7 +129,7 @@ pub enum UserEvent { OauthSignIn = 10, /// Get the OAuth callback url - /// Only use when the [Authenticator] is AFCloud + /// Only use when the [AuthType] is AFCloud #[event(input = "SignInUrlPayloadPB", output = "SignInUrlPB")] GenerateSignInURL = 11, @@ -165,7 +165,7 @@ pub enum UserEvent { OpenAnonUser = 26, /// Push a realtime event to the user. Currently, the realtime event - /// is only used when the auth type is: [Authenticator::Supabase]. + /// is only used when the auth type is: [AuthType::Supabase]. /// #[event(input = "RealtimePayloadPB")] PushRealtimeEvent = 27, @@ -281,19 +281,19 @@ pub enum UserEvent { #[async_trait] pub trait UserStatusCallback: Send + Sync + 'static { - /// When the [Authenticator] changed, this method will be called. Currently, the auth type + /// When the [AuthType] changed, this method will be called. Currently, the auth type /// will be changed when the user sign in or sign up. - fn authenticator_did_changed(&self, _authenticator: Authenticator) {} + fn authenticator_did_changed(&self, _authenticator: AuthType) {} /// This will be called after the application launches if the user is already signed in. /// If the user is not signed in, this method will not be called async fn did_init( &self, _user_id: i64, - _user_authenticator: &Authenticator, + _user_authenticator: &AuthType, _cloud_config: &Option, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &Authenticator, + _authenticator: &AuthType, ) -> FlowyResult<()> { Ok(()) } @@ -303,7 +303,7 @@ pub trait UserStatusCallback: Send + Sync + 'static { _user_id: i64, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &Authenticator, + _authenticator: &AuthType, ) -> FlowyResult<()> { Ok(()) } @@ -314,7 +314,7 @@ pub trait UserStatusCallback: Send + Sync + 'static { _user_profile: &UserProfile, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &Authenticator, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } @@ -326,7 +326,7 @@ pub trait UserStatusCallback: Send + Sync + 'static { &self, _user_id: i64, _user_workspace: &UserWorkspace, - _authenticator: &Authenticator, + _authenticator: &AuthType, ) -> FlowyResult<()> { Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs index 3056f4d945..5a8bb4d516 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs @@ -7,7 +7,7 @@ use tracing::{instrument, trace}; use collab_integrate::CollabKVDB; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use flowy_user_pub::session::Session; @@ -39,7 +39,7 @@ impl UserDataMigration for CollabDocKeyWithWorkspaceIdMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, ) -> FlowyResult<()> { trace!( "migrate key with workspace id:{}", diff --git a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs index e557c22450..ab828e18d8 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs @@ -11,7 +11,7 @@ use tracing::{event, instrument}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; use flowy_error::{FlowyError, FlowyResult}; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -41,12 +41,12 @@ impl UserDataMigration for HistoricalEmptyDocumentMigration { &self, session: &Session, collab_db: &Arc, - authenticator: &Authenticator, + authenticator: &AuthType, ) -> FlowyResult<()> { // - The `empty document` struct has already undergone refactoring prior to the launch of the AppFlowy cloud version. // - Consequently, if a user is utilizing the AppFlowy cloud version, there is no need to perform any migration for the `empty document` struct. // - This migration step is only necessary for users who are transitioning from a local version of AppFlowy to the cloud version. - if !matches!(authenticator, Authenticator::Local) { + if !matches!(authenticator, AuthType::Local) { return Ok(()); } collab_db.with_write_txn(|write_txn| { diff --git a/frontend/rust-lib/flowy-user/src/migrations/migration.rs b/frontend/rust-lib/flowy-user/src/migrations/migration.rs index c604e47e8d..1330d1995b 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/migration.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/migration.rs @@ -7,7 +7,7 @@ use flowy_error::FlowyResult; use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::schema::user_data_migration_records; use flowy_sqlite::ConnectionPool; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use flowy_user_pub::session::Session; use semver::Version; use tracing::info; @@ -54,7 +54,7 @@ impl UserLocalDataMigration { pub fn run( self, migrations: Vec>, - authenticator: &Authenticator, + authenticator: &AuthType, app_version: &Version, ) -> FlowyResult> { let mut applied_migrations = vec![]; @@ -98,7 +98,7 @@ pub trait UserDataMigration { &self, user: &Session, collab_db: &Arc, - authenticator: &Authenticator, + authenticator: &AuthType, ) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs index ec55b5fe29..51894f9a04 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs @@ -7,7 +7,7 @@ use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -39,7 +39,7 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { if let Ok(collab) = load_collab( diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs index d631e32e78..70123edb2c 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs @@ -7,7 +7,7 @@ use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -37,7 +37,7 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { if let Ok(collab) = load_collab( diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index 9efbf81932..106e911c1e 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -30,7 +30,7 @@ use flowy_folder_pub::entities::{ }; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; -use flowy_user_pub::entities::{user_awareness_object_id, Authenticator}; +use flowy_user_pub::entities::{user_awareness_object_id, AuthType}; use flowy_user_pub::session::Session; use rayon::prelude::*; use std::collections::{HashMap, HashSet}; @@ -1175,7 +1175,7 @@ pub async fn upload_collab_objects_data( uid: i64, user_collab_db: Weak, workspace_id: &Uuid, - user_authenticator: &Authenticator, + user_authenticator: &AuthType, collab_data: ImportedCollabData, user_cloud_service: Arc, ) -> Result<(), FlowyError> { diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs index e2b6628832..1138efd092 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs @@ -31,8 +31,8 @@ pub struct UserTable { } #[allow(deprecated)] -impl From<(UserProfile, Authenticator)> for UserTable { - fn from(value: (UserProfile, Authenticator)) -> Self { +impl From<(UserProfile, AuthType)> for UserTable { + fn from(value: (UserProfile, AuthType)) -> Self { let (user_profile, auth_type) = value; let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); UserTable { @@ -62,7 +62,7 @@ impl From for UserProfile { token: table.token, icon_url: table.icon_url, openai_key: table.openai_key, - authenticator: Authenticator::from(table.auth_type), + authenticator: AuthType::from(table.auth_type), encryption_type: EncryptionType::from_str(&table.encryption_type).unwrap_or_default(), stability_ai_key: table.stability_ai_key, updated_at: table.updated_at, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 5b5d060539..89a05539b6 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -141,7 +141,7 @@ impl UserManager { // If the current authenticator is different from the authenticator in the session and it's // not a local authenticator, we need to sign out the user. - if user.authenticator != Authenticator::Local && user.authenticator != current_authenticator { + if user.authenticator != AuthType::Local && user.authenticator != current_authenticator { event!( tracing::Level::INFO, "Authenticator changed from {:?} to {:?}", @@ -349,9 +349,9 @@ impl UserManager { pub async fn sign_in( &self, params: SignInParams, - authenticator: Authenticator, + authenticator: AuthType, ) -> Result { - self.cloud_services.set_user_authenticator(&authenticator); + self.cloud_services.set_auth_type(&authenticator); let response: AuthResponse = self .cloud_services @@ -398,25 +398,25 @@ impl UserManager { #[tracing::instrument(level = "info", skip(self, params))] pub async fn sign_up( &self, - authenticator: Authenticator, + auth_type: AuthType, params: BoxAny, ) -> Result { // sign out the current user if there is one - let migration_user = self.get_migration_user(&authenticator).await; - self.cloud_services.set_user_authenticator(&authenticator); + let migration_user = self.get_migration_user(&auth_type).await; + self.cloud_services.set_auth_type(&auth_type); let auth_service = self.cloud_services.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; - let new_user_profile = UserProfile::from((&response, &authenticator)); + let new_user_profile = UserProfile::from((&response, &auth_type)); if new_user_profile.encryption_type.require_encrypt_secret() { self.auth_process.lock().await.replace(UserAuthProcess { user_profile: new_user_profile.clone(), migration_user, response, - authenticator, + authenticator: auth_type, }); } else { self - .continue_sign_up(&new_user_profile, migration_user, response, &authenticator) + .continue_sign_up(&new_user_profile, migration_user, response, &auth_type) .await?; } Ok(new_user_profile) @@ -450,12 +450,12 @@ impl UserManager { new_user_profile: &UserProfile, migration_user: Option, response: AuthResponse, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { let new_session = Session::from(&response); self.prepare_user(&new_session).await; self - .save_auth_data(&response, authenticator, &new_session) + .save_auth_data(&response, auth_type, &new_session) .await?; let _ = self .initial_user_awareness(&new_session, &new_user_profile.authenticator) @@ -469,7 +469,7 @@ impl UserManager { new_user_profile, &new_session.user_workspace, &self.authenticate_user.user_config.device_id, - authenticator, + auth_type, ) .await?; @@ -493,7 +493,7 @@ impl UserManager { new_user_profile.uid ); self - .migrate_anon_user_data_to_cloud(&old_user, &new_session, authenticator) + .migrate_anon_user_data_to_cloud(&old_user, &new_session, auth_type) .await?; self.remove_anon_user(); let _ = self @@ -709,10 +709,10 @@ impl UserManager { pub(crate) async fn generate_sign_in_url_with_email( &self, - authenticator: &Authenticator, + authenticator: &AuthType, email: &str, ) -> Result { - self.cloud_services.set_user_authenticator(authenticator); + self.cloud_services.set_auth_type(authenticator); let auth_service = self.cloud_services.get_user_service()?; let url = auth_service.generate_sign_in_url_with_email(email).await?; @@ -724,9 +724,7 @@ impl UserManager { email: &str, password: &str, ) -> Result { - self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); + self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; let response = auth_service.sign_in_with_password(email, password).await?; Ok(response) @@ -737,9 +735,7 @@ impl UserManager { email: &str, redirect_to: &str, ) -> Result<(), FlowyError> { - self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); + self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; auth_service .sign_in_with_magic_link(email, redirect_to) @@ -752,9 +748,7 @@ impl UserManager { email: &str, passcode: &str, ) -> Result { - self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); + self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; let response = auth_service.sign_in_with_passcode(email, passcode).await?; Ok(response) @@ -764,9 +758,7 @@ impl UserManager { &self, oauth_provider: &str, ) -> Result { - self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); + self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) @@ -778,7 +770,7 @@ impl UserManager { async fn save_auth_data( &self, response: &impl UserAuthResponse, - authenticator: &Authenticator, + authenticator: &AuthType, session: &Session, ) -> Result<(), FlowyError> { let user_profile = UserProfile::from((response, authenticator)); @@ -798,7 +790,7 @@ impl UserManager { .authenticate_user .set_session(Some(session.clone().into()))?; self - .save_user(uid, (user_profile, authenticator.clone()).into()) + .save_user(uid, (user_profile, *authenticator).into()) .await?; Ok(()) } @@ -827,14 +819,14 @@ impl UserManager { &self, old_user: &AnonUser, _new_user_session: &Session, - authenticator: &Authenticator, + authenticator: &AuthType, ) -> Result<(), FlowyError> { let old_collab_db = self .authenticate_user .database .get_collab_db(old_user.session.user_id)?; - if authenticator == &Authenticator::AppFlowyCloud { + if authenticator == &AuthType::AppFlowyCloud { self .migration_anon_user_on_appflowy_cloud_sign_up(old_user, &old_collab_db) .await?; @@ -853,10 +845,10 @@ impl UserManager { } } -fn current_authenticator() -> Authenticator { +fn current_authenticator() -> AuthType { match AuthenticatorType::from_env() { - AuthenticatorType::Local => Authenticator::Local, - AuthenticatorType::AppFlowyCloud => Authenticator::AppFlowyCloud, + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs index b7f4789f9a..ddb73667c8 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs @@ -4,7 +4,7 @@ use tracing::instrument; use crate::entities::UserProfilePB; use crate::user_manager::UserManager; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::AnonUser; use flowy_user_pub::session::Session; @@ -12,10 +12,7 @@ use flowy_user_pub::session::Session; pub const ANON_USER: &str = "anon_user"; impl UserManager { #[instrument(skip_all)] - pub async fn get_migration_user( - &self, - current_authenticator: &Authenticator, - ) -> Option { + pub async fn get_migration_user(&self, current_authenticator: &AuthType) -> Option { // No need to migrate if the user is already local if current_authenticator.is_local() { return None; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index d055621398..7de09995a0 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -12,7 +12,7 @@ use collab_integrate::CollabKVDB; use collab_user::core::{UserAwareness, UserAwarenessNotifier}; use dashmap::try_result::TryResult; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::{user_awareness_object_id, Authenticator}; +use flowy_user_pub::entities::{user_awareness_object_id, AuthType}; use tracing::{error, info, instrument, trace}; use uuid::Uuid; @@ -119,9 +119,8 @@ impl UserManager { pub(crate) async fn initial_user_awareness( &self, session: &Session, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { - let authenticator = authenticator.clone(); let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); // Try to acquire mutable access to `is_loading_awareness`. @@ -156,11 +155,11 @@ impl UserManager { let is_exist_on_disk = self .authenticate_user .is_collab_on_disk(session.user_id, &object_id.to_string())?; - if authenticator.is_local() || is_exist_on_disk { + if auth_type.is_local() || is_exist_on_disk { trace!( "Initializing new user awareness from disk:{}, {:?}", object_id, - authenticator + auth_type ); let collab_db = self.get_collab_db(session.user_id)?; let workspace_id = session.user_workspace.workspace_id()?; @@ -185,9 +184,9 @@ impl UserManager { } else { info!( "Initializing new user awareness from server:{}, {:?}", - object_id, authenticator + object_id, auth_type ); - self.load_awareness_from_server(session, object_id, authenticator.clone())?; + self.load_awareness_from_server(session, object_id, *auth_type)?; } } else { return Err(FlowyError::new( @@ -209,7 +208,7 @@ impl UserManager { &self, session: &Session, object_id: Uuid, - authenticator: Authenticator, + authenticator: AuthType, ) -> FlowyResult<()> { // Clone necessary data let session = session.clone(); diff --git a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs b/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs index 906002ad10..ed7fdacf8d 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs @@ -1,11 +1,11 @@ use crate::migrations::AnonUser; -use flowy_user_pub::entities::{AuthResponse, Authenticator, UserProfile}; +use flowy_user_pub::entities::{AuthResponse, AuthType, UserProfile}; /// recording the intermediate state of the sign-in/sign-up process #[derive(Clone)] pub struct UserAuthProcess { pub user_profile: UserProfile, pub response: AuthResponse, - pub authenticator: Authenticator, + pub authenticator: AuthType, pub migration_user: Option, } From d8401e09c9c48468cb9c61b0c77809dc74c19a0c Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:10:17 +0800 Subject: [PATCH 178/207] feat: implement keyboard triggering on buttons and add focus state (#7724) * feat: implement keyboard triggering on buttons and add focus state * chore: pass isFocused to builders --- .../button/base_button/base_button.dart | 116 +++++++++++++++--- .../button/filled_button/filled_button.dart | 2 +- .../filled_button/filled_text_button.dart | 2 +- .../button/ghost_button/ghost_button.dart | 2 +- .../ghost_button/ghost_icon_text_button.dart | 2 +- .../ghost_button/ghost_text_button.dart | 2 +- .../outlined_button/outlined_button.dart | 8 +- .../outlined_icon_text_button.dart | 8 +- .../outlined_button/outlined_text_button.dart | 8 +- 9 files changed, 113 insertions(+), 37 deletions(-) diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart index 22c5325681..b62f2cce9b 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart @@ -7,6 +7,13 @@ typedef AFBaseButtonColorBuilder = Color Function( bool disabled, ); +typedef AFBaseButtonBorderColorBuilder = Color Function( + BuildContext context, + bool isHovering, + bool disabled, + bool isFocused, +); + class AFBaseButton extends StatefulWidget { const AFBaseButton({ super.key, @@ -16,49 +23,99 @@ class AFBaseButton extends StatefulWidget { required this.borderRadius, this.borderColor, this.backgroundColor, + this.ringColor, this.disabled = false, }); final VoidCallback? onTap; - final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? ringColor; final AFBaseButtonColorBuilder? backgroundColor; final EdgeInsetsGeometry padding; final double borderRadius; final bool disabled; - final Widget Function(BuildContext context, bool isHovering, bool disabled) - builder; + final Widget Function( + BuildContext context, + bool isHovering, + bool disabled, + ) builder; @override State createState() => _AFBaseButtonState(); } class _AFBaseButtonState extends State { + final FocusNode focusNode = FocusNode(); + bool isHovering = false; + bool isFocused = false; + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final Color borderColor = _buildBorderColor(context); final Color backgroundColor = _buildBackgroundColor(context); + final Color ringColor = _buildRingColor(context); - return MouseRegion( - cursor: - widget.disabled ? SystemMouseCursors.basic : SystemMouseCursors.click, - onEnter: (_) => setState(() => isHovering = true), - onExit: (_) => setState(() => isHovering = false), - child: GestureDetector( - onTap: widget.disabled ? null : widget.onTap, - child: DecoratedBox( - decoration: BoxDecoration( - color: backgroundColor, - border: Border.all(color: borderColor), - borderRadius: BorderRadius.circular(widget.borderRadius), - ), - child: Padding( - padding: widget.padding, - child: widget.builder(context, isHovering, widget.disabled), + return Actions( + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + if (!widget.disabled) { + widget.onTap?.call(); + } + return; + }, + ), + }, + child: Focus( + focusNode: focusNode, + onFocusChange: (isFocused) { + setState(() => this.isFocused = isFocused); + }, + child: MouseRegion( + cursor: widget.disabled + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + onTap: widget.disabled ? null : widget.onTap, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + border: isFocused + ? Border.all( + color: ringColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ) + : null, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Padding( + padding: widget.padding, + child: widget.builder( + context, + isHovering, + widget.disabled, + ), + ), + ), + ), ), ), ), @@ -67,7 +124,8 @@ class _AFBaseButtonState extends State { Color _buildBorderColor(BuildContext context) { final theme = AppFlowyTheme.of(context); - return widget.borderColor?.call(context, isHovering, widget.disabled) ?? + return widget.borderColor + ?.call(context, isHovering, widget.disabled, isFocused) ?? theme.borderColorScheme.greyTertiary; } @@ -76,4 +134,22 @@ class _AFBaseButtonState extends State { return widget.backgroundColor?.call(context, isHovering, widget.disabled) ?? theme.fillColorScheme.transparent; } + + Color _buildRingColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + if (widget.ringColor != null) { + return widget.ringColor! + .call(context, isHovering, widget.disabled, isFocused); + } + + if (isFocused) { + return AppFlowyTheme.of(context) + .borderColorScheme + .themeThick + .withAlpha(128); + } + + return theme.borderColorScheme.transparent; + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart index 68fb341827..e871626b59 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart @@ -115,7 +115,7 @@ class AFFilledButton extends StatelessWidget { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, - borderColor: (_, __, ___) => Colors.transparent, + borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart index 889ad1e429..d1b1d868d0 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart @@ -121,7 +121,7 @@ class AFFilledTextButton extends AFBaseTextButton { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, - borderColor: (_, __, ___) => Colors.transparent, + borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart index 47ff96e878..6300c6f5a8 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart @@ -86,7 +86,7 @@ class AFGhostButton extends StatelessWidget { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, - borderColor: (_, __, ___) => Colors.transparent, + borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart index e65eb2dd7e..af65599ea3 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart @@ -109,7 +109,7 @@ class AFGhostIconTextButton extends StatelessWidget { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { return Colors.transparent; }, padding: padding ?? size.buildPadding(context), diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart index 441b544f8a..d154d67dbd 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart @@ -88,7 +88,7 @@ class AFGhostTextButton extends AFBaseTextButton { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, - borderColor: (_, __, ___) => Colors.transparent, + borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart index 3b0ea7a06d..205d9931d6 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart @@ -38,7 +38,7 @@ class AFOutlinedButton extends StatelessWidget { padding: padding, borderRadius: borderRadius, disabled: disabled, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.greyTertiary; @@ -79,7 +79,7 @@ class AFOutlinedButton extends StatelessWidget { padding: padding, borderRadius: borderRadius, disabled: disabled, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; @@ -118,7 +118,7 @@ class AFOutlinedButton extends StatelessWidget { padding: padding, borderRadius: borderRadius, disabled: true, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.greyTertiary; @@ -148,7 +148,7 @@ class AFOutlinedButton extends StatelessWidget { final EdgeInsetsGeometry? padding; final double? borderRadius; - final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? borderColor; final AFBaseButtonColorBuilder? backgroundColor; final AFOutlinedButtonWidgetBuilder builder; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart index 710a4ccca5..350594cd46 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart @@ -46,7 +46,7 @@ class AFOutlinedIconTextButton extends StatelessWidget { borderRadius: borderRadius, disabled: disabled, alignment: alignment, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.greyTertiary; @@ -101,7 +101,7 @@ class AFOutlinedIconTextButton extends StatelessWidget { borderRadius: borderRadius, disabled: disabled, alignment: alignment, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; @@ -156,7 +156,7 @@ class AFOutlinedIconTextButton extends StatelessWidget { ? theme.textColorScheme.tertiary : theme.textColorScheme.primary; }, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.greyTertiary; @@ -190,7 +190,7 @@ class AFOutlinedIconTextButton extends StatelessWidget { final AFOutlinedIconBuilder iconBuilder; final AFBaseButtonColorBuilder? textColor; - final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? borderColor; final AFBaseButtonColorBuilder? backgroundColor; @override diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart index d5ae580583..d809d981b0 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart @@ -40,7 +40,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { disabled: disabled, alignment: alignment, textStyle: textStyle, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.greyTertiary; @@ -95,7 +95,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { disabled: disabled, alignment: alignment, textStyle: textStyle, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; @@ -150,7 +150,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { ? theme.textColorScheme.tertiary : theme.textColorScheme.primary; }, - borderColor: (context, isHovering, disabled) { + borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.greyTertiary; @@ -173,7 +173,7 @@ class AFOutlinedTextButton extends AFBaseTextButton { ); } - final AFBaseButtonColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? borderColor; @override Widget build(BuildContext context) { From 068f93c258eaad91d9a4025598d673fd1e1cacf5 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:14:28 +0800 Subject: [PATCH 179/207] feat: add shadow tokens (#7726) --- .../document/presentation/editor_page.dart | 2 +- .../link/link_create_menu.dart | 2 +- .../link_preview/paste_as/paste_as_menu.dart | 2 +- .../lib/src/theme/data/builder.dart | 68 +++++++++++-------- .../appflowy_ui/lib/src/theme/data/data.dart | 22 +++--- .../lib/src/theme/shadow/shadow.dart | 9 ++- 6 files changed, 60 insertions(+), 45 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 0ffb7de73a..edb19232be 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -445,7 +445,7 @@ class _AppFlowyEditorPageState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(appTheme.borderRadius.l), color: appTheme.surfaceColorScheme.primary, - boxShadow: [appTheme.shadow.small], + boxShadow: appTheme.shadow.small, ), toolbarBuilder: (_, child, onDismiss, isMetricsChanged) => BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart index 6213896feb..002d569c7b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -315,6 +315,6 @@ ShapeDecoration buildToolbarLinkDecoration( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radius), ), - shadows: [theme.shadow.small], + shadows: theme.shadow.small, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart index d31b2f8fd9..fb51cdcf47 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -148,7 +148,7 @@ class _PasteAsMenuState extends State { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: theme.surfaceColorScheme.primary, - boxShadow: [theme.shadow.medium], + boxShadow: theme.shadow.medium, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index 8923f61e1f..06c6eb5d8e 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -93,35 +93,6 @@ class AppFlowyThemeBuilder { }; } - AppFlowyShadow buildShadow(Brightness brightness) { - return switch (brightness) { - Brightness.light => AppFlowyShadow( - small: const BoxShadow( - offset: Offset(0.0, 2.0), - blurRadius: 16.0, - color: Color(0x1F000000), - ), - medium: const BoxShadow( - offset: Offset(0.0, 4.0), - blurRadius: 32.0, - color: Color(0x1F000000), - ), - ), - Brightness.dark => AppFlowyShadow( - small: BoxShadow( - offset: Offset(0.0, 2.0), - blurRadius: 16.0, - color: Color(0x7A000000), - ), - medium: BoxShadow( - offset: Offset(0.0, 4.0), - blurRadius: 32.0, - color: Color(0x7A000000), - ), - ), - }; - } - AppFlowyBorderColorScheme buildBorderColorScheme( AppFlowyBaseColorScheme colorScheme, Brightness brightness, @@ -351,4 +322,43 @@ class AppFlowyThemeBuilder { xxl: AppFlowySpacingConstant.spacing600, ); } + + AppFlowyShadow buildShadow( + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyShadow( + small: [ + BoxShadow( + offset: Offset(0, 2), + blurRadius: 16, + color: Color(0x1F000000), + ), + ], + medium: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 32, + color: Color(0x1F000000), + ), + ], + ), + Brightness.dark => AppFlowyShadow( + small: [ + BoxShadow( + offset: Offset(0, 2), + blurRadius: 16, + color: Color(0x7A000000), + ), + ], + medium: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 32, + color: Color(0x7A000000), + ), + ], + ), + }; + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart index 9494bdf0e2..60f7d1f1a4 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart @@ -36,9 +36,9 @@ abstract class AppFlowyBaseTheme { AppFlowySpacing get spacing; - AppFlowyBrandColorScheme get brandColorScheme; - AppFlowyShadow get shadow; + + AppFlowyBrandColorScheme get brandColorScheme; } class AppFlowyThemeData extends AppFlowyBaseTheme { @@ -70,10 +70,11 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { colorScheme, Brightness.light, ); - final shadow = themeBuilder.buildShadow(Brightness.light); final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); final borderRadius = themeBuilder.buildBorderRadius(colorScheme); final spacing = themeBuilder.buildSpacing(colorScheme); + final shadow = themeBuilder.buildShadow(Brightness.light); + return AppFlowyThemeData( colorScheme: colorScheme, textColorScheme: textColorScheme, @@ -85,8 +86,8 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { surfaceColorScheme: surfaceColorScheme, borderRadius: borderRadius, spacing: spacing, - brandColorScheme: brandColorScheme, shadow: shadow, + brandColorScheme: brandColorScheme, ); } @@ -117,10 +118,11 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { colorScheme, Brightness.dark, ); - final shadow = themeBuilder.buildShadow(Brightness.dark); final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); final borderRadius = themeBuilder.buildBorderRadius(colorScheme); final spacing = themeBuilder.buildSpacing(colorScheme); + final shadow = themeBuilder.buildShadow(Brightness.dark); + return AppFlowyThemeData( colorScheme: colorScheme, textColorScheme: textColorScheme, @@ -132,8 +134,8 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { surfaceColorScheme: surfaceColorScheme, borderRadius: borderRadius, spacing: spacing, - brandColorScheme: brandColorScheme, shadow: shadow, + brandColorScheme: brandColorScheme, ); } @@ -146,10 +148,10 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { required this.surfaceColorScheme, required this.borderRadius, required this.spacing, + required this.shadow, required this.brandColorScheme, required this.iconColorTheme, required this.backgroundColorScheme, - required this.shadow, this.brightness = Brightness.light, }); @@ -181,6 +183,9 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { @override final AppFlowySpacing spacing; + @override + final AppFlowyShadow shadow; + @override final AppFlowyBrandColorScheme brandColorScheme; @@ -190,9 +195,6 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { @override final AppFlowyBackgroundColorScheme backgroundColorScheme; - @override - final AppFlowyShadow shadow; - static AppFlowyTextColorScheme buildTextColorScheme( AppFlowyBaseColorScheme colorScheme, Brightness brightness, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart index 9bb2ac1116..457b86265e 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart @@ -1,8 +1,11 @@ import 'package:flutter/widgets.dart'; class AppFlowyShadow { - AppFlowyShadow({required this.small, required this.medium}); + AppFlowyShadow({ + required this.small, + required this.medium, + }); - final BoxShadow small; - final BoxShadow medium; + final List small; + final List medium; } From e1bfb7095b7d296350ef3a9d0bcb6232960a4f43 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 14 Apr 2025 11:20:25 +0800 Subject: [PATCH 180/207] feat: improve select modal button (#7736) --- .../prompt_input/select_model_menu.dart | 78 ++++++++++--------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart index eef2663370..a611d84310 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -31,9 +31,6 @@ class _SelectModelMenuState extends State { ), child: BlocBuilder( builder: (context, state) { - if (state.selectedModel == null) { - return const SizedBox.shrink(); - } return AppFlowyPopover( offset: Offset(-12.0, 0.0), constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), @@ -55,8 +52,12 @@ class _SelectModelMenuState extends State { ); }, child: _CurrentModelButton( - model: state.selectedModel!, - onTap: () => popoverController.show(), + model: state.selectedModel, + onTap: () { + if (state.selectedModel != null) { + popoverController.show(); + } + }, ), ); }, @@ -202,7 +203,7 @@ class _CurrentModelButton extends StatelessWidget { required this.onTap, }); - final AIModelPB model; + final AIModelPB? model; final VoidCallback onTap; @override @@ -214,40 +215,45 @@ class _CurrentModelButton extends StatelessWidget { behavior: HitTestBehavior.opaque, child: SizedBox( height: DesktopAIPromptSizes.actionBarButtonSize, - child: FlowyHover( - style: const HoverStyle( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - child: Padding( - padding: const EdgeInsetsDirectional.all(4.0), - child: Row( - children: [ - Padding( - // TODO: remove this after change icon to 20px - padding: EdgeInsets.all(2), - child: FlowySvg( - FlowySvgs.ai_sparks_s, - color: Theme.of(context).hintColor, - size: Size.square(16), - ), - ), - if (!model.isDefault) + child: AnimatedSize( + duration: const Duration(milliseconds: 50), + curve: Curves.easeInOut, + alignment: AlignmentDirectional.centerStart, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.all(4.0), + child: Row( + children: [ Padding( - padding: EdgeInsetsDirectional.only(end: 2.0), - child: FlowyText( - model.i18n, - fontSize: 12, - figmaLineHeight: 16, + // TODO: remove this after change icon to 20px + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.ai_sparks_s, color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, + size: Size.square(16), ), ), - FlowySvg( - FlowySvgs.ai_source_drop_down_s, - color: Theme.of(context).hintColor, - size: const Size.square(8), - ), - ], + if (model != null && !model!.isDefault) + Padding( + padding: EdgeInsetsDirectional.only(end: 2.0), + child: FlowyText( + model!.i18n, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), ), ), ), From b12bd8ee8562bfffcea0778f296ff2f5eac7c7fd Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 14 Apr 2025 13:03:49 +0800 Subject: [PATCH 181/207] feat: add medium sized text field (#7737) * feat: add medium sized text field * chore: remove height --- .../continue_with_email_and_password.dart | 1 - ...inue_with_magic_link_or_passcode_page.dart | 1 - .../appflowy_ui/example/lib/main.dart | 6 +-- .../lib/src/textfield/textfield_page.dart | 13 +++++ .../src/component/textfield/textfield.dart | 50 ++++++++++++------- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart index 349dd7e0d4..5027874418 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -60,7 +60,6 @@ class _ContinueWithEmailAndPasswordState key: emailKey, controller: controller, hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), - radius: 10, onSubmitted: (value) => _signInWithEmail( context, value, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart index 8cfc4c1157..ec4fd1bbee 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -101,7 +101,6 @@ class _ContinueWithMagicLinkOrPasscodePageState controller: passcodeController, hintText: LocaleKeys.signIn_enterCode.tr(), keyboardType: TextInputType.number, - radius: 10, autoFocus: true, onSubmitted: (passcode) { if (passcode.isEmpty) { diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart index 067e42858b..41548abd08 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -24,6 +24,8 @@ class MyApp extends StatelessWidget { return ValueListenableBuilder( valueListenable: themeMode, builder: (context, themeMode, child) { + final themeData = + themeMode == ThemeMode.light ? ThemeData.light() : ThemeData.dark(); return AppFlowyTheme( data: themeMode == ThemeMode.light ? AppFlowyThemeData.light() @@ -31,9 +33,7 @@ class MyApp extends StatelessWidget { child: MaterialApp( debugShowCheckedModeBanner: false, title: 'AppFlowy UI Example', - theme: themeMode == ThemeMode.light - ? ThemeData.light() - : ThemeData.dark(), + theme: themeData.copyWith(visualDensity: VisualDensity.standard), home: const MyHomePage( title: 'AppFlowy UI', ), diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart index 280e43818c..9e3436ecd4 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart @@ -11,6 +11,19 @@ class TextFieldPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildSection( + 'TextField Sizes', + [ + AFTextField( + hintText: 'Please enter your name', + size: AFTextFieldSize.m, + ), + AFTextField( + hintText: 'Please enter your name', + ), + ], + ), + const SizedBox(height: 32), _buildSection( 'TextField with hint text', [ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart index a934734983..3f5ad4cfed 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -1,4 +1,4 @@ -import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:appflowy_ui/src/theme/theme.dart'; import 'package:flutter/material.dart'; typedef AFTextFieldValidator = (bool result, String errorText) Function( @@ -20,21 +20,17 @@ class AFTextField extends StatefulWidget { this.hintText, this.initialText, this.keyboardType, - this.radius, + this.size = AFTextFieldSize.l, this.validator, this.controller, this.onChanged, this.onSubmitted, this.autoFocus, - this.height = 40.0, this.obscureText = false, this.suffixIconBuilder, this.suffixIconConstraints, }); - /// The height of the text field. - final double height; - /// The hint text to display when the text field is empty. final String? hintText; @@ -44,8 +40,8 @@ class AFTextField extends StatefulWidget { /// The type of keyboard to display. final TextInputType? keyboardType; - /// The radius of the text field. - final double? radius; + /// The size variant of the text field. + final AFTextFieldSize size; /// The validator to use for the text field. final AFTextFieldValidator? validator; @@ -115,9 +111,8 @@ class _AFTextFieldState extends AFTextFieldState { @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); - final borderRadius = BorderRadius.circular( - widget.radius ?? theme.borderRadius.l, - ); + final borderRadius = widget.size.borderRadius(theme); + final contentPadding = widget.size.contentPadding(theme); final errorBorderColor = theme.borderColorScheme.errorThick; final defaultBorderColor = theme.borderColorScheme.greyTertiary; @@ -137,10 +132,9 @@ class _AFTextFieldState extends AFTextFieldState { hintStyle: theme.textStyle.body.standard( color: theme.textColorScheme.tertiary, ), - contentPadding: EdgeInsets.symmetric( - horizontal: theme.spacing.m, - vertical: 10, - ), + isDense: true, + constraints: BoxConstraints(), + contentPadding: contentPadding, border: OutlineInputBorder( borderSide: BorderSide( color: hasError ? errorBorderColor : defaultBorderColor, @@ -179,8 +173,6 @@ class _AFTextFieldState extends AFTextFieldState { ), ); - child = SizedBox(height: widget.height, child: child); - if (hasError && errorText.isNotEmpty) { child = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -236,3 +228,27 @@ class _AFTextFieldState extends AFTextFieldState { }); } } + +enum AFTextFieldSize { + m, + l; + + EdgeInsetsGeometry contentPadding(AppFlowyThemeData theme) { + return EdgeInsets.symmetric( + vertical: switch (this) { + AFTextFieldSize.m => theme.spacing.s, + AFTextFieldSize.l => 10.0, + }, + horizontal: theme.spacing.m, + ); + } + + BorderRadius borderRadius(AppFlowyThemeData theme) { + return BorderRadius.circular( + switch (this) { + AFTextFieldSize.m => theme.borderRadius.m, + AFTextFieldSize.l => 10.0, + }, + ); + } +} From 0906febe957ffd982fb3283087c11651457c168a Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 15:48:17 +0800 Subject: [PATCH 182/207] refactor: remove server type --- .../src/deps_resolve/cloud_service_impl.rs | 21 ++--- frontend/rust-lib/flowy-core/src/lib.rs | 19 ++--- .../rust-lib/flowy-core/src/server_layer.rs | 78 ++++--------------- .../flowy-core/src/user_state_callback.rs | 15 ++-- .../rust-lib/flowy-user-pub/src/entities.rs | 10 +++ 5 files changed, 49 insertions(+), 94 deletions(-) diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 9c1ee9462c..56bf4e965c 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -1,4 +1,4 @@ -use crate::server_layer::{ServerProvider, ServerType}; +use crate::server_layer::ServerProvider; use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; use client_api::entity::workspace_dto::PublishInfoView; @@ -188,8 +188,8 @@ impl UserCloudServiceProvider for ServerProvider { /// When user login, the provider type is set by the [AuthType] and save to disk for next use. /// - /// Each [AuthType] has a corresponding [ServerType]. The [ServerType] is used - /// to create a new [AppFlowyServer] if it doesn't exist. Once the [ServerType] is set, + /// Each [AuthType] has a corresponding [AuthType]. The [AuthType] is used + /// to create a new [AppFlowyServer] if it doesn't exist. Once the [AuthType] is set, /// it will be used when user open the app again. /// fn set_auth_type(&self, auth_type: &AuthType) { @@ -211,7 +211,7 @@ impl UserCloudServiceProvider for ServerProvider { self.encryption.set_secret(secret); } - /// Returns the [UserCloudService] base on the current [ServerType]. + /// Returns the [UserCloudService] base on the current [AuthType]. /// Creates a new [AppFlowyServer] if it doesn't exist. fn get_user_service(&self) -> Result, FlowyError> { let user_service = self.get_server()?.user_service(); @@ -219,9 +219,9 @@ impl UserCloudServiceProvider for ServerProvider { } fn service_url(&self) -> String { - match self.get_server_type() { - ServerType::Local => "".to_string(), - ServerType::AppFlowyCloud => AFCloudConfiguration::from_env() + match self.get_auth_type() { + AuthType::Local => "".to_string(), + AuthType::AppFlowyCloud => AFCloudConfiguration::from_env() .map(|config| config.base_url) .unwrap_or_default(), } @@ -578,12 +578,15 @@ impl DocumentCloudService for ServerProvider { impl CollabCloudPluginProvider for ServerProvider { fn provider_type(&self) -> CollabPluginProviderType { - self.get_server_type().into() + match self.get_auth_type() { + AuthType::Local => CollabPluginProviderType::Local, + AuthType::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, + } } fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { // If the user is local, we don't need to create a sync plugin. - if self.get_server_type().is_local() { + if self.get_auth_type().is_local() { debug!( "User authenticator is local, skip create sync plugin for: {}", context diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index bd55c8db1d..d217f52785 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -1,6 +1,6 @@ #![allow(unused_doc_comments)] -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabPluginProviderType}; +use collab_integrate::collab_builder::AppFlowyCollabBuilder; use flowy_ai::ai_manager::AIManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; @@ -34,7 +34,7 @@ use crate::config::AppFlowyCoreConfig; use crate::deps_resolve::file_storage_deps::FileStorageResolver; use crate::deps_resolve::*; use crate::log_filter::init_log; -use crate::server_layer::{current_server_type, ServerProvider, ServerType}; +use crate::server_layer::{current_server_type, ServerProvider}; use deps_resolve::reminder_deps::CollabInteractImpl; use flowy_sqlite::DBConnection; use lib_infra::async_trait::async_trait; @@ -131,12 +131,12 @@ impl AppFlowyCore { store_preference.clone(), )); - let server_type = current_server_type(); - debug!("🔥runtime:{}, server:{}", runtime, server_type); + let auth_type = current_server_type(); + debug!("🔥runtime:{}, server:{}", runtime, auth_type); let server_provider = Arc::new(ServerProvider::new( config.clone(), - server_type, + auth_type, Arc::downgrade(&store_preference), ServerUserImpl(Arc::downgrade(&authenticate_user)), )); @@ -314,15 +314,6 @@ impl AppFlowyCore { } } -impl From for CollabPluginProviderType { - fn from(server_type: ServerType) -> Self { - match server_type { - ServerType::Local => CollabPluginProviderType::Local, - ServerType::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, - } - } -} - struct ServerUserImpl(Weak); impl ServerUserImpl { diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index c0bf043d27..ebb86c4417 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -13,52 +13,12 @@ use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::entities::*; -use serde_repr::{Deserialize_repr, Serialize_repr}; -use std::fmt::{Display, Formatter, Result as FmtResult}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; -/// ServerType: local or cloud -#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum ServerType { - Local = 0, - AppFlowyCloud = 1, -} - -impl ServerType { - pub fn is_local(&self) -> bool { - matches!(self, Self::Local) - } -} - -impl Display for ServerType { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, "{:?}", self) - } -} - -/// Conversion between AuthType and ServerType -impl From<&AuthType> for ServerType { - fn from(a: &AuthType) -> Self { - match a { - AuthType::Local => ServerType::Local, - AuthType::AppFlowyCloud => ServerType::AppFlowyCloud, - } - } -} -impl From for AuthType { - fn from(s: ServerType) -> Self { - match s { - ServerType::Local => AuthType::Local, - ServerType::AppFlowyCloud => AuthType::AppFlowyCloud, - } - } -} - pub struct ServerProvider { config: AppFlowyCoreConfig, - providers: DashMap>, + providers: DashMap>, auth_type: ArcSwap, user: Arc, pub local_ai: Arc, @@ -70,12 +30,11 @@ pub struct ServerProvider { impl ServerProvider { pub fn new( config: AppFlowyCoreConfig, - initial: ServerType, + initial_auth: AuthType, store_preferences: Weak, user_service: impl LoginUserService + 'static, ) -> Self { let user = Arc::new(user_service); - let initial_auth = AuthType::from(initial); let auth_type = ArcSwap::from(Arc::new(initial_auth)); let encryption = Arc::new(EncryptionImpl::new(None)) as Arc; let ai_user = Arc::new(AIUserServiceImpl(user.clone())); @@ -98,17 +57,10 @@ impl ServerProvider { } } - /// Reads current type - pub fn get_server_type(&self) -> ServerType { - let auth_type = self.auth_type.load_full(); - ServerType::from(auth_type.as_ref()) - } - - pub fn set_auth_type(&self, a: AuthType) { - let old_type = self.get_server_type(); - self.auth_type.store(Arc::new(a)); - let new_type = self.get_server_type(); - if old_type != new_type { + pub fn set_auth_type(&self, new_auth_type: AuthType) { + let old_type = self.get_auth_type(); + if old_type != new_auth_type { + self.auth_type.store(Arc::new(new_auth_type)); self.providers.remove(&old_type); } } @@ -119,14 +71,14 @@ impl ServerProvider { /// Lazily create or fetch an AppFlowyServer instance pub fn get_server(&self) -> FlowyResult> { - let key = self.get_server_type(); - if let Some(entry) = self.providers.get(&key) { + let auth_type = self.get_auth_type(); + if let Some(entry) = self.providers.get(&auth_type) { return Ok(entry.clone()); } - let server: Arc = match key { - ServerType::Local => Arc::new(LocalServer::new(self.user.clone(), self.local_ai.clone())), - ServerType::AppFlowyCloud => { + let server: Arc = match auth_type { + AuthType::Local => Arc::new(LocalServer::new(self.user.clone(), self.local_ai.clone())), + AuthType::AppFlowyCloud => { let cfg = self .config .cloud_config @@ -142,15 +94,15 @@ impl ServerProvider { }, }; - self.providers.insert(key.clone(), server.clone()); + self.providers.insert(auth_type, server.clone()); Ok(server) } } /// Determine current server type from ENV -pub fn current_server_type() -> ServerType { +pub fn current_server_type() -> AuthType { match AuthenticatorType::from_env() { - AuthenticatorType::Local => ServerType::Local, - AuthenticatorType::AppFlowyCloud => ServerType::AppFlowyCloud, + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, } } diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index 89ba14e6d2..ff1c6b6699 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -18,7 +18,7 @@ use flowy_user_pub::entities::{AuthType, UserProfile, UserWorkspace}; use lib_dispatch::runtime::AFPluginRuntime; use lib_infra::async_trait::async_trait; -use crate::server_layer::{ServerProvider, ServerType}; +use crate::server_layer::ServerProvider; pub(crate) struct UserStatusCallbackImpl { pub(crate) collab_builder: Arc, @@ -128,7 +128,6 @@ impl UserStatusCallback for UserStatusCallbackImpl { auth_type: &AuthType, ) -> FlowyResult<()> { self.server_provider.set_auth_type(*auth_type); - let server_type = self.server_provider.get_server_type(); event!( tracing::Level::TRACE, @@ -154,17 +153,17 @@ impl UserStatusCallback for UserStatusCallbackImpl { ) .await { - Ok(doc_state) => match server_type { - ServerType::Local => FolderInitDataSource::LocalDisk { + Ok(doc_state) => match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { create_if_not_exist: true, }, - ServerType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), + AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), }, - Err(err) => match server_type { - ServerType::Local => FolderInitDataSource::LocalDisk { + Err(err) => match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { create_if_not_exist: true, }, - ServerType::AppFlowyCloud => { + AuthType::AppFlowyCloud => { return Err(err); }, }, diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index c09b536ccf..d59f9cab47 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -1,3 +1,4 @@ +use std::fmt::{Display, Formatter}; use std::str::FromStr; use chrono::{DateTime, Utc}; @@ -359,6 +360,15 @@ pub enum AuthType { AppFlowyCloud = 1, } +impl Display for AuthType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AuthType::Local => write!(f, "Local"), + AuthType::AppFlowyCloud => write!(f, "AppFlowyCloud"), + } + } +} + impl Default for AuthType { fn default() -> Self { Self::Local From 2dc22004a1e249994f4569c058b77130b6723767 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 18 Apr 2025 15:57:33 +0800 Subject: [PATCH 183/207] refactor: remove server type --- .../src/deps_resolve/cloud_service_impl.rs | 4 ++-- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 4 ++-- .../rust-lib/flowy-user/src/event_handler.rs | 10 +++++---- .../flowy-user/src/user_manager/manager.rs | 22 +++++++++++++------ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index 56bf4e965c..35300563d7 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -192,11 +192,11 @@ impl UserCloudServiceProvider for ServerProvider { /// to create a new [AppFlowyServer] if it doesn't exist. Once the [AuthType] is set, /// it will be used when user open the app again. /// - fn set_auth_type(&self, auth_type: &AuthType) { + fn set_server_auth_type(&self, auth_type: &AuthType) { self.set_auth_type(*auth_type); } - fn get_auth_type(&self) -> AuthType { + fn get_server_auth_type(&self) -> AuthType { self.get_auth_type() } diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index fd283360de..dd69e4aa37 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -84,9 +84,9 @@ pub trait UserCloudServiceProvider: Send + Sync { /// * `enable_sync`: A boolean indicating whether synchronization should be enabled or disabled. fn set_enable_sync(&self, uid: i64, enable_sync: bool); - fn set_auth_type(&self, auth_type: &AuthType); + fn set_server_auth_type(&self, auth_type: &AuthType); - fn get_auth_type(&self) -> AuthType; + fn get_server_auth_type(&self) -> AuthType; /// Sets the network reachability /// diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index a20229b6d0..7a64c20e06 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -45,14 +45,16 @@ pub async fn sign_in_with_email_password_handler( let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let old_authenticator = manager.cloud_services.get_auth_type(); + let old_authenticator = manager.cloud_services.get_server_auth_type(); match manager .sign_in_with_password(¶ms.email, ¶ms.password) .await { Ok(token) => data_result_ok(token.into()), Err(err) => { - manager.cloud_services.set_auth_type(&old_authenticator); + manager + .cloud_services + .set_server_auth_type(&old_authenticator); return Err(err); }, } @@ -76,11 +78,11 @@ pub async fn sign_up( let params: SignUpParams = data.into_inner().try_into()?; let auth_type = params.auth_type; - let prev_auth_type = manager.cloud_services.get_auth_type(); + let prev_auth_type = manager.cloud_services.get_server_auth_type(); match manager.sign_up(auth_type, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), Err(err) => { - manager.cloud_services.set_auth_type(&prev_auth_type); + manager.cloud_services.set_server_auth_type(&prev_auth_type); Err(err) }, } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 89a05539b6..8fb4009991 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -351,7 +351,7 @@ impl UserManager { params: SignInParams, authenticator: AuthType, ) -> Result { - self.cloud_services.set_auth_type(&authenticator); + self.cloud_services.set_server_auth_type(&authenticator); let response: AuthResponse = self .cloud_services @@ -403,7 +403,7 @@ impl UserManager { ) -> Result { // sign out the current user if there is one let migration_user = self.get_migration_user(&auth_type).await; - self.cloud_services.set_auth_type(&auth_type); + self.cloud_services.set_server_auth_type(&auth_type); let auth_service = self.cloud_services.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; let new_user_profile = UserProfile::from((&response, &auth_type)); @@ -712,7 +712,7 @@ impl UserManager { authenticator: &AuthType, email: &str, ) -> Result { - self.cloud_services.set_auth_type(authenticator); + self.cloud_services.set_server_auth_type(authenticator); let auth_service = self.cloud_services.get_user_service()?; let url = auth_service.generate_sign_in_url_with_email(email).await?; @@ -724,7 +724,9 @@ impl UserManager { email: &str, password: &str, ) -> Result { - self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); + self + .cloud_services + .set_server_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; let response = auth_service.sign_in_with_password(email, password).await?; Ok(response) @@ -735,7 +737,9 @@ impl UserManager { email: &str, redirect_to: &str, ) -> Result<(), FlowyError> { - self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); + self + .cloud_services + .set_server_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; auth_service .sign_in_with_magic_link(email, redirect_to) @@ -748,7 +752,9 @@ impl UserManager { email: &str, passcode: &str, ) -> Result { - self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); + self + .cloud_services + .set_server_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; let response = auth_service.sign_in_with_passcode(email, passcode).await?; Ok(response) @@ -758,7 +764,9 @@ impl UserManager { &self, oauth_provider: &str, ) -> Result { - self.cloud_services.set_auth_type(&AuthType::AppFlowyCloud); + self + .cloud_services + .set_server_auth_type(&AuthType::AppFlowyCloud); let auth_service = self.cloud_services.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) From 54b5e248e3aa7eccfdcff79b1de2abdea1a83b39 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:02:08 +0800 Subject: [PATCH 184/207] feat: implement modal (#7750) --- .../presentation/widgets/dialog_v2.dart | 109 +++++++++++++ .../appflowy_ui/example/lib/main.dart | 8 +- .../example/lib/src/modal/modal_page.dart | 153 ++++++++++++++++++ .../button/base_button/base_button.dart | 5 +- .../lib/src/component/component.dart | 1 + .../lib/src/component/modal/dimension.dart | 9 ++ .../lib/src/component/modal/modal.dart | 125 ++++++++++++++ 7 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart new file mode 100644 index 0000000000..43ab8897e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +typedef SimpleAFDialogAction = (String, void Function(BuildContext)?); + +/// A simple dialog with a title, content, and actions. +/// +/// The primary button is a filled button and colored using theme or destructive +/// color depending on the [isDestructive] parameter. The secondary button is an +/// outlined button. +/// +Future showSimpleAFDialog({ + required BuildContext context, + required String title, + required String content, + bool isDestructive = false, + required SimpleAFDialogAction primaryAction, + SimpleAFDialogAction? secondaryAction, + bool barrierDismissible = true, +}) { + final theme = AppFlowyTheme.of(context); + + return showDialog( + context: context, + barrierColor: theme.surfaceColorScheme.overlay, + barrierDismissible: barrierDismissible, + builder: (_) { + return AFModal( + constraints: BoxConstraints( + maxWidth: AFModalDimension.S, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AFModalHeader( + leading: Text( + title, + style: theme.textStyle.heading4.standard( + color: theme.textColorScheme.primary, + ), + ), + trailing: [ + AFGhostButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.close_s, + size: Size.square(20), + ); + }, + ), + ], + ), + Flexible( + child: ConstrainedBox( + // AFModalDimension.dialogHeight - header - footer + constraints: BoxConstraints(minHeight: 108.0), + child: AFModalBody( + child: Text(content), + ), + ), + ), + AFModalFooter( + trailing: [ + if (secondaryAction != null) + AFOutlinedButton.normal( + onTap: () { + secondaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text(secondaryAction.$1); + }, + ), + isDestructive + ? AFFilledButton.destructive( + onTap: () { + primaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text( + primaryAction.$1, + style: TextStyle( + color: AppFlowyTheme.of(context) + .textColorScheme + .onFill, + ), + ); + }, + ) + : AFFilledButton.primary( + onTap: () { + primaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text(primaryAction.$1); + }, + ), + ], + ), + ], + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart index 41548abd08..1dfc60b314 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -1,8 +1,10 @@ import 'package:appflowy_ui/appflowy_ui.dart'; -import 'package:appflowy_ui_example/src/buttons/buttons_page.dart'; -import 'package:appflowy_ui_example/src/textfield/textfield_page.dart'; import 'package:flutter/material.dart'; +import 'src/buttons/buttons_page.dart'; +import 'src/modal/modal_page.dart'; +import 'src/textfield/textfield_page.dart'; + enum ThemeMode { light, dark, @@ -60,6 +62,7 @@ class _MyHomePageState extends State { final tabs = [ Tab(text: 'Button'), Tab(text: 'TextField'), + Tab(text: 'Modal'), ]; @override @@ -92,6 +95,7 @@ class _MyHomePageState extends State { children: [ ButtonsPage(), TextFieldPage(), + ModalPage(), ], ), bottomNavigationBar: TabBar( diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart new file mode 100644 index 0000000000..4a9480d1b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart @@ -0,0 +1,153 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ModalPage extends StatefulWidget { + const ModalPage({super.key}); + + @override + State createState() => _ModalPageState(); +} + +class _ModalPageState extends State { + double width = AFModalDimension.M; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: Container( + constraints: BoxConstraints(maxWidth: 600), + padding: EdgeInsets.symmetric(horizontal: theme.spacing.xl), + child: Column( + spacing: theme.spacing.l, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + spacing: theme.spacing.m, + mainAxisSize: MainAxisSize.min, + children: [ + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.S), + builder: (context, isHovering, disabled) { + return Text( + 'S', + style: TextStyle( + color: width == AFModalDimension.S + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.M), + builder: (context, isHovering, disabled) { + return Text( + 'M', + style: TextStyle( + color: width == AFModalDimension.M + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.L), + builder: (context, isHovering, disabled) { + return Text( + 'L', + style: TextStyle( + color: width == AFModalDimension.L + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + ], + ), + AFFilledButton.primary( + builder: (context, isHovering, disabled) { + return Text( + 'Show Modal', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ); + }, + onTap: () { + showDialog( + context: context, + barrierColor: theme.surfaceColorScheme.overlay, + builder: (context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: AFModal( + constraints: BoxConstraints( + maxWidth: width, + maxHeight: AFModalDimension.dialogHeight, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AFModalHeader( + leading: Text( + 'Header', + style: theme.textStyle.heading4.standard( + color: theme.textColorScheme.primary, + ), + ), + trailing: [ + AFGhostButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return const Icon(Icons.close); + }, + ) + ], + ), + Expanded( + child: AFModalBody( + child: Text( + 'A dialog briefly presents information or requests confirmation, allowing users to continue their workflow after interaction.'), + ), + ), + AFModalFooter( + trailing: [ + AFOutlinedButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return const Text('Cancel'); + }, + ), + AFFilledButton.primary( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return Text( + 'Apply', + style: TextStyle( + color: AppFlowyTheme.of(context) + .textColorScheme + .onFill, + ), + ); + }, + ), + ], + ) + ], + )), + ); + }, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart index b62f2cce9b..9bb36507e8 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart @@ -144,10 +144,7 @@ class _AFBaseButtonState extends State { } if (isFocused) { - return AppFlowyTheme.of(context) - .borderColorScheme - .themeThick - .withAlpha(128); + return theme.borderColorScheme.themeThick.withAlpha(128); } return theme.borderColorScheme.transparent; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart index d01d64109c..584d50c07b 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart @@ -1,2 +1,3 @@ export 'button/button.dart'; +export 'modal/modal.dart'; export 'textfield/textfield.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart new file mode 100644 index 0000000000..72a7dbb5cf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart @@ -0,0 +1,9 @@ +class AFModalDimension { + const AFModalDimension._(); + + static const double S = 400.0; + static const double M = 560.0; + static const double L = 720.0; + + static const double dialogHeight = 200.0; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart new file mode 100644 index 0000000000..4b40aebcbd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +export 'dimension.dart'; + +class AFModal extends StatelessWidget { + const AFModal({ + super.key, + this.constraints = const BoxConstraints(), + required this.child, + }); + + final BoxConstraints constraints; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: Padding( + padding: EdgeInsets.all(theme.spacing.xl), + child: ConstrainedBox( + constraints: constraints, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: theme.shadow.medium, + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + color: theme.surfaceColorScheme.primary, + ), + child: Material( + color: Colors.transparent, + child: child, + ), + ), + ), + ), + ); + } +} + +class AFModalHeader extends StatelessWidget { + const AFModalHeader({ + super.key, + required this.leading, + this.trailing = const [], + }); + + final Widget leading; + final List trailing; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.only( + top: theme.spacing.xl, + left: theme.spacing.xxl, + right: theme.spacing.xxl, + ), + child: Row( + spacing: theme.spacing.s, + children: [ + Expanded(child: leading), + ...trailing, + ], + ), + ); + } +} + +class AFModalFooter extends StatelessWidget { + const AFModalFooter({ + super.key, + this.leading = const [], + this.trailing = const [], + }); + + final List leading; + final List trailing; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.only( + bottom: theme.spacing.xl, + left: theme.spacing.xxl, + right: theme.spacing.xxl, + ), + child: Row( + spacing: theme.spacing.l, + children: [ + ...leading, + Spacer(), + ...trailing, + ], + ), + ); + } +} + +class AFModalBody extends StatelessWidget { + const AFModalBody({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.symmetric( + vertical: theme.spacing.l, + horizontal: theme.spacing.xxl, + ), + child: child, + ); + } +} From 889756ebb09272447aec8e00b7a7d4b29435d288 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:54:28 +0800 Subject: [PATCH 185/207] refactor: use script to generate design tokens (#7751) * refactor: use script to generate design tokens * chore: improve code readaility * refactor: make builder reusable to built in themes * chore: improve code readability --- .../ai/ai_writer_toolbar_item.dart | 4 +- .../link_embed/link_embed_menu.dart | 6 +- .../link_preview/custom_link_preview.dart | 2 +- .../custom_format_toolbar_items.dart | 2 +- .../custom_hightlight_color_toolbar_item.dart | 2 +- .../custom_link_toolbar_item.dart | 2 +- .../custom_text_align_toolbar_item.dart | 2 +- .../custom_text_color_toolbar_item.dart | 2 +- .../text_heading_toolbar_item.dart | 2 +- .../text_suggestions_toolbar_item.dart | 2 +- .../appflowy_ui/example/lib/main.dart | 2 +- .../src/component/textfield/textfield.dart | 4 +- .../lib/src/theme/appflowy_theme.dart | 21 +- .../theme/color_scheme/base/base_scheme.dart | 33 - .../lib/src/theme/color_scheme/base/blue.dart | 27 - .../src/theme/color_scheme/base/green.dart | 24 - .../src/theme/color_scheme/base/magenta.dart | 25 - .../src/theme/color_scheme/base/neutral.dart | 49 - .../src/theme/color_scheme/base/orange.dart | 25 - .../src/theme/color_scheme/base/purple.dart | 25 - .../lib/src/theme/color_scheme/base/red.dart | 27 - .../src/theme/color_scheme/base/subtle.dart | 265 ----- .../src/theme/color_scheme/base/yellow.dart | 24 - .../src/theme/color_scheme/border/border.dart | 49 - .../src/theme/color_scheme/color_scheme.dart | 8 - .../data/appflowy_default/primitive.dart | 298 +++++ .../theme/data/appflowy_default/semantic.dart | 377 ++++++ .../lib/src/theme/data/builder.dart | 323 +---- .../lib/src/theme/data/built_in_themes.dart | 1 + .../appflowy_ui/lib/src/theme/data/data.dart | 249 ---- .../lib/src/theme/definition/base_theme.dart | 33 + .../border_radius/border_radius.dart | 0 .../background_color_scheme.dart | 0 .../color_scheme}/border_color_scheme.dart | 0 .../color_scheme}/brand_color_scheme.dart | 0 .../definition/color_scheme/color_scheme.dart | 8 + .../color_scheme}/fill_color_scheme.dart | 0 .../color_scheme/icon_color_scheme.dart} | 4 +- .../color_scheme/other_color_scheme.dart | 9 + .../color_scheme}/surface_color_scheme.dart | 0 .../color_scheme}/text_color_scheme.dart | 0 .../theme/{ => definition}/shadow/shadow.dart | 0 .../{ => definition}/spacing/spacing.dart | 0 .../text_style/base/default_text_style.dart | 0 .../text_style/text_style.dart | 2 +- .../appflowy_ui/lib/src/theme/dimensions.dart | 17 - .../appflowy_ui/lib/src/theme/theme.dart | 13 +- .../script/Primitive.Mode 1.tokens.json | 984 ++++++++++++++++ .../script/Semantic.Dark Mode.tokens.json | 1039 +++++++++++++++++ .../script/Semantic.Light Mode.tokens.json | 1039 +++++++++++++++++ .../appflowy_ui/script/generate_theme.dart | 269 +++++ 51 files changed, 4116 insertions(+), 1183 deletions(-) delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{ => definition}/border_radius/border_radius.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/background => definition/color_scheme}/background_color_scheme.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/border => definition/color_scheme}/border_color_scheme.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/brand => definition/color_scheme}/brand_color_scheme.dart (100%) create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/fill => definition/color_scheme}/fill_color_scheme.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/icon/icon_color_theme.dart => definition/color_scheme/icon_color_scheme.dart} (86%) create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/surface => definition/color_scheme}/surface_color_scheme.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{color_scheme/text => definition/color_scheme}/text_color_scheme.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{ => definition}/shadow/shadow.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{ => definition}/spacing/spacing.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{ => definition}/text_style/base/default_text_style.dart (100%) rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/{ => definition}/text_style/text_style.dart (88%) delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index ab695b31a6..70d627d327 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -117,7 +117,7 @@ class _AiWriterToolbarActionListState extends State { } Widget buildChild(BuildContext context) { - final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; final child = FlowyIconButton( width: 48, height: 32, @@ -186,7 +186,7 @@ class ImproveWritingButton extends StatelessWidget { icon: FlowySvg( FlowySvgs.toolbar_ai_improve_writing_m, size: Size.square(20.0), - color: theme.iconColorTheme.primary, + color: theme.iconColorScheme.primary, ), onPressed: () { if (_isAIWriterEnabled(editorState)) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart index bddfcb9b54..c3d2aebbcc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -63,7 +63,7 @@ class _LinkEmbedMenuState extends State { Widget buildChild() { final theme = AppFlowyTheme.of(context), - iconScheme = theme.iconColorTheme, + iconScheme = theme.iconColorScheme, fillScheme = theme.fillColorScheme; return Container( @@ -102,7 +102,7 @@ class _LinkEmbedMenuState extends State { } Widget buildconvertBotton() { - final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; return AppFlowyPopover( offset: Offset(0, 6), direction: PopoverDirection.bottomWithRightAligned, @@ -170,7 +170,7 @@ class _LinkEmbedMenuState extends State { } Widget buildMoreOptionBotton() { - final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme; + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; return AppFlowyPopover( offset: Offset(0, 6), direction: PopoverDirection.bottomWithRightAligned, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index 880a33b817..d7f3e26302 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -163,7 +163,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { Widget buildImage(BuildContext context) { final theme = AppFlowyTheme.of(context), fillScheme = theme.fillColorScheme, - iconScheme = theme.iconColorTheme; + iconScheme = theme.iconColorScheme; final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0; Widget child; if (imageUrl?.isNotEmpty ?? false) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart index 7dbf192bae..d4f3d21f46 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart @@ -82,7 +82,7 @@ class _FormatToolbarItem extends ToolbarItem { size: Size.square(20.0), color: (isDark && isHighlight) ? Color(0xFF282E3A) - : theme.iconColorTheme.primary, + : theme.iconColorScheme.primary, ), onPressed: () => editorState.toggleAttribute( name, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart index 2e115d240d..46f2c02c5a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -83,7 +83,7 @@ class _HighlightColorPickerWidgetState Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), - iconColor = theme.iconColorTheme.primary; + iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 36, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index cbbce9c943..8c9e6b69da 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -45,7 +45,7 @@ final customLinkItem = ToolbarItem( size: Size.square(20.0), color: (isDark && isHref) ? Color(0xFF282E3A) - : theme.iconColorTheme.primary, + : theme.iconColorScheme.primary, ), onPressed: () { getIt().hideToolbar(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart index 2a1688db19..efaff532f4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart @@ -97,7 +97,7 @@ class _TextAlignActionListState extends State { Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), - iconColor = theme.iconColorTheme.primary; + iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 48, height: 32, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart index 80f2d3138d..9f5a917b89 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart @@ -82,7 +82,7 @@ class _TextColorPickerWidgetState extends State { Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), - iconColor = theme.iconColorTheme.primary; + iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 36, height: 32, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart index 8140a7b7f3..5778b6b8a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart @@ -79,7 +79,7 @@ class _TextHeadingActionListState extends State { Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), - iconColor = theme.iconColorTheme.primary; + iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 48, height: 32, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart index 3c8f55caef..48f5d3f403 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -120,7 +120,7 @@ class _SuggestionsActionListState extends State { Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), - iconColor = theme.iconColorTheme.primary; + iconColor = theme.iconColorScheme.primary; final child = FlowyHover( isSelected: () => isSelected, style: HoverStyle( diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart index 1dfc60b314..c02001745d 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -82,7 +82,7 @@ class _MyHomePageState extends State { actions: [ IconButton( icon: Icon( - theme.brightness == Brightness.light + Theme.of(context).brightness == Brightness.light ? Icons.dark_mode : Icons.light_mode, ), diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart index 3f5ad4cfed..9e61b71709 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -233,7 +233,7 @@ enum AFTextFieldSize { m, l; - EdgeInsetsGeometry contentPadding(AppFlowyThemeData theme) { + EdgeInsetsGeometry contentPadding(AppFlowyBaseThemeData theme) { return EdgeInsets.symmetric( vertical: switch (this) { AFTextFieldSize.m => theme.spacing.s, @@ -243,7 +243,7 @@ enum AFTextFieldSize { ); } - BorderRadius borderRadius(AppFlowyThemeData theme) { + BorderRadius borderRadius(AppFlowyBaseThemeData theme) { return BorderRadius.circular( switch (this) { AFTextFieldSize.m => theme.borderRadius.m, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart index 49deecc178..8a99b737ec 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart @@ -1,4 +1,4 @@ -import 'package:appflowy_ui/src/theme/data/data.dart'; +import 'package:appflowy_ui/src/theme/definition/base_theme.dart'; import 'package:flutter/widgets.dart'; class AppFlowyTheme extends StatelessWidget { @@ -8,10 +8,10 @@ class AppFlowyTheme extends StatelessWidget { required this.child, }); - final AppFlowyThemeData data; + final AppFlowyBaseThemeData data; final Widget child; - static AppFlowyThemeData of(BuildContext context, {bool listen = true}) { + static AppFlowyBaseThemeData of(BuildContext context, {bool listen = true}) { final provider = maybeOf(context, listen: listen); if (provider == null) { throw FlutterError( @@ -26,27 +26,26 @@ class AppFlowyTheme extends StatelessWidget { return provider; } - static AppFlowyThemeData? maybeOf( + static AppFlowyBaseThemeData? maybeOf( BuildContext context, { bool listen = true, }) { if (listen) { return context .dependOnInheritedWidgetOfExactType() - ?.theme - .data; + ?.theme; } final provider = context .getElementForInheritedWidgetOfExactType() ?.widget; - return (provider as AppFlowyInheritedTheme?)?.theme.data; + return (provider as AppFlowyInheritedTheme?)?.theme; } @override Widget build(BuildContext context) { return AppFlowyInheritedTheme( - theme: this, + theme: data, child: child, ); } @@ -59,14 +58,14 @@ class AppFlowyInheritedTheme extends InheritedTheme { required super.child, }); - final AppFlowyTheme theme; + final AppFlowyBaseThemeData theme; @override Widget wrap(BuildContext context, Widget child) { - return AppFlowyTheme(data: theme.data, child: child); + return AppFlowyTheme(data: theme, child: child); } @override bool updateShouldNotify(AppFlowyInheritedTheme oldWidget) => - theme.data != oldWidget.theme.data; + theme != oldWidget.theme; } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart deleted file mode 100644 index 5b843d97e2..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/base_scheme.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:appflowy_ui/src/theme/color_scheme/base/blue.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/green.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/magenta.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/neutral.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/orange.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/purple.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/red.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/subtle.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/yellow.dart'; - -class AppFlowyBaseColorScheme { - const AppFlowyBaseColorScheme({ - this.blue = const BlueColors(), - this.green = const GreenColors(), - this.yellow = const YellowColors(), - this.red = const RedColors(), - this.orange = const OrangeColors(), - this.magenta = const MagentaColors(), - this.purple = const PurpleColors(), - this.neutral = const NeutralColors(), - this.subtle = const SubtleColors(), - }); - - final BlueColors blue; - final GreenColors green; - final YellowColors yellow; - final RedColors red; - final OrangeColors orange; - final MagentaColors magenta; - final PurpleColors purple; - final NeutralColors neutral; - final SubtleColors subtle; -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart deleted file mode 100644 index fb73b42f60..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/blue.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; - -class BlueColors { - const BlueColors(); - - Color get blue100 => const Color(0xFFE3F6FF); - - Color get blue200 => const Color(0xFFA9E2FF); - - Color get blue300 => const Color(0xFF80D2FF); - - Color get blue400 => const Color(0xFF4EC1FF); - - Color get blue500 => const Color(0xFF00B5FF); - - Color get blue600 => const Color(0xFF0092D6); - - Color get blue700 => const Color(0xFF0078C0); - - Color get blue800 => const Color(0xFF0065A9); - - Color get blue900 => const Color(0xFF00508F); - - Color get blue1000 => const Color(0xFF003C77); - - Color get alphaBlue50015 => const Color(0x2600B5FF); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart deleted file mode 100644 index 652f5f5932..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/green.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; - -class GreenColors { - const GreenColors(); - Color get green100 => const Color(0xFFECF9F5); - - Color get green200 => const Color(0xFFC3E5D8); - - Color get green300 => const Color(0xFF9AD1BC); - - Color get green400 => const Color(0xFF71BD9F); - - Color get green500 => const Color(0xFF48A982); - - Color get green600 => const Color(0xFF248569); - - Color get green700 => const Color(0xFF29725D); - - Color get green800 => const Color(0xFF2E6050); - - Color get green900 => const Color(0xFF305548); - - Color get green1000 => const Color(0xFF305244); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart deleted file mode 100644 index dec5617c67..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/magenta.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -class MagentaColors { - const MagentaColors(); - - Color get magenta100 => const Color(0xFFFFE5EF); - - Color get magenta200 => const Color(0xFFFFB8D1); - - Color get magenta300 => const Color(0xFFFF8AB2); - - Color get magenta400 => const Color(0xFFFF5C93); - - Color get magenta500 => const Color(0xFFFB006D); - - Color get magenta600 => const Color(0xFFD2005F); - - Color get magenta700 => const Color(0xFFD2005F); - - Color get magenta800 => const Color(0xFF850040); - - Color get magenta900 => const Color(0xFF610031); - - Color get magenta1000 => const Color(0xFF400022); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart deleted file mode 100644 index 4b6c08b595..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/neutral.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -class NeutralColors { - const NeutralColors(); - - Color get neutral100 => const Color(0xFFF8FAFF); - - Color get neutral200 => const Color(0xFFE4E8F5); - - Color get neutral300 => const Color(0xFFCED3E6); - - Color get neutral400 => const Color(0xFFB5BBD3); - - Color get neutral500 => const Color(0xFF989EB7); - - Color get neutral600 => const Color(0xFF6F748C); - - Color get neutral700 => const Color(0xFF54596E); - - Color get neutral800 => const Color(0xFF3C3F4E); - - Color get neutral900 => const Color(0xFF272930); - - Color get neutral1000 => const Color(0xFF21232A); - - Color get black => const Color(0xFF000000); - - Color get alphaBlack60 => const Color(0x99000000); - - Color get white => const Color(0xFFFFFFFF); - - Color get alphaWhite0 => const Color(0x00FFFFFF); - - Color get alphaWhite20 => const Color(0x33FFFFFF); - - Color get alphaWhite30 => const Color(0x4DFFFFFF); - - Color get alphaGrey10005 => const Color(0x0DF9FAFD); - - Color get alphaGrey10010 => const Color(0x1AF9FAFD); - - Color get alphaGrey100005 => const Color(0x0D1F2329); - - Color get alphaGrey100010 => const Color(0x1A1F2329); - - Color get alphaGrey100070 => const Color(0xB21F2329); - - Color get alphaGrey100080 => const Color(0xCC1F2329); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart deleted file mode 100644 index c9424bd960..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/orange.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -class OrangeColors { - const OrangeColors(); - - Color get orange100 => const Color(0xFFFFF3D5); - - Color get orange200 => const Color(0xFFFFE4AB); - - Color get orange300 => const Color(0xFFFFD181); - - Color get orange400 => const Color(0xFFFFBE62); - - Color get orange500 => const Color(0xFFFFA02E); - - Color get orange600 => const Color(0xFFDB7E21); - - Color get orange700 => const Color(0xFFB75F17); - - Color get orange800 => const Color(0xFF93450E); - - Color get orange900 => const Color(0xFF7A3108); - - Color get orange1000 => const Color(0xFF602706); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart deleted file mode 100644 index fa3b9e54cf..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/purple.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -class PurpleColors { - const PurpleColors(); - - Color get purple100 => const Color(0xFFF1E0FF); - - Color get purple200 => const Color(0xFFE1B3FF); - - Color get purple300 => const Color(0xFFD185FF); - - Color get purple400 => const Color(0xFFBC58FF); - - Color get purple500 => const Color(0xFF9327FF); - - Color get purple600 => const Color(0xFF7A1DCC); - - Color get purple700 => const Color(0xFF6617B3); - - Color get purple800 => const Color(0xFF55138F); - - Color get purple900 => const Color(0xFF470C72); - - Color get purple1000 => const Color(0xFF380758); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart deleted file mode 100644 index 030f2988bc..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/red.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; - -class RedColors { - const RedColors(); - - Color get red100 => const Color(0xFFFFD2DD); - - Color get red200 => const Color(0xFFFFA5B4); - - Color get red300 => const Color(0xFFFF7D87); - - Color get red400 => const Color(0xFFFF5050); - - Color get red500 => const Color(0xFFF33641); - - Color get red600 => const Color(0xFFE71D32); - - Color get red700 => const Color(0xFFAD1625); - - Color get red800 => const Color(0xFF8C101C); - - Color get red900 => const Color(0xFF6E0A1E); - - Color get red1000 => const Color(0xFF4C0A17); - - Color get alphaRed50010 => const Color(0x1AF33641); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart deleted file mode 100644 index 2fddd14f1a..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/subtle.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:flutter/material.dart'; - -class SubtleColors { - const SubtleColors(); - - // Rose colors - Color get rose100 => const Color(0xFFFCF2F2); - - Color get rose200 => const Color(0xFFFAE3E3); - - Color get rose300 => const Color(0xFFFAD9D9); - - Color get rose400 => const Color(0xFFEDADAD); - - Color get rose500 => const Color(0xFFCC4E4E); - - Color get rose600 => const Color(0xFF702828); - - // Papaya colors - Color get papaya100 => const Color(0xFFFCF4F0); - - Color get papaya200 => const Color(0xFFFAE8DE); - - Color get papaya300 => const Color(0xFFFADFD2); - - Color get papaya400 => const Color(0xFFF0BDA3); - - Color get papaya500 => const Color(0xFFD67240); - - Color get papaya600 => const Color(0xFF6B3215); - - // Tangerine colors - Color get tangerine100 => const Color(0xFFFFF7ED); - - Color get tangerine200 => const Color(0xFFFCEDD9); - - Color get tangerine300 => const Color(0xFFFAE5CA); - - Color get tangerine400 => const Color(0xFFF2CB99); - - Color get tangerine500 => const Color(0xFFDB8F2C); - - Color get tangerine600 => const Color(0xFF613B0A); - - // Mango colors - Color get mango100 => const Color(0xFFFFF9EC); - - Color get mango200 => const Color(0xFFFCF1D7); - - Color get mango300 => const Color(0xFFFAE9C3); - - Color get mango400 => const Color(0xFFF5D68E); - - Color get mango500 => const Color(0xFFE0A416); - - Color get mango600 => const Color(0xFF5C4102); - - // Lemon colors - Color get lemon100 => const Color(0xFFFFFBE8); - - Color get lemon200 => const Color(0xFFFCF5CF); - - Color get lemon300 => const Color(0xFFFAEFB9); - - Color get lemon400 => const Color(0xFFF5E282); - - Color get lemon500 => const Color(0xFFE0BB00); - - Color get lemon600 => const Color(0xFF574800); - - // Olive colors - Color get olive100 => const Color(0xFFF9FAE6); - - Color get olive200 => const Color(0xFFF6F7D0); - - Color get olive300 => const Color(0xFFF0F2B3); - - Color get olive400 => const Color(0xFFDBDE83); - - Color get olive500 => const Color(0xFFADB204); - - Color get olive600 => const Color(0xFF4A4C03); - - // Lime colors - Color get lime100 => const Color(0xFFF6F9E6); - - Color get lime200 => const Color(0xFFEEF5CE); - - Color get lime300 => const Color(0xFFE7F0BB); - - Color get lime400 => const Color(0xFFCFDB91); - - Color get lime500 => const Color(0xFF92A822); - - Color get lime600 => const Color(0xFF414D05); - - // Grass colors - Color get grass100 => const Color(0xFFF4FAEB); - - Color get grass200 => const Color(0xFFE9F5D7); - - Color get grass300 => const Color(0xFFDEF0C5); - - Color get grass400 => const Color(0xFFBFD998); - - Color get grass500 => const Color(0xFF75A828); - - Color get grass600 => const Color(0xFF334D0C); - - // Forest colors - Color get forest100 => const Color(0xFFF1FAF0); - - Color get forest200 => const Color(0xFFE2F5DF); - - Color get forest300 => const Color(0xFFD7F0D3); - - Color get forest400 => const Color(0xFFA8D6A1); - - Color get forest500 => const Color(0xFF49A33B); - - Color get forest600 => const Color(0xFF1E4F16); - - // Jade colors - Color get jade100 => const Color(0xFFF0FAF6); - - Color get jade200 => const Color(0xFFDFF5EB); - - Color get jade300 => const Color(0xFFCEF0E1); - - Color get jade400 => const Color(0xFF90D1B5); - - Color get jade500 => const Color(0xFF1C9963); - - Color get jade600 => const Color(0xFF075231); - - // Aqua colors - Color get aqua100 => const Color(0xFFF0F9FA); - - Color get aqua200 => const Color(0xFFDFF3F5); - - Color get aqua300 => const Color(0xFFCCECF0); - - Color get aqua400 => const Color(0xFF83CCD4); - - Color get aqua500 => const Color(0xFF008E9E); - - Color get aqua600 => const Color(0xFF004E57); - - // Azure colors - Color get azure100 => const Color(0xFFF0F6FA); - - Color get azure200 => const Color(0xFFE1EEF7); - - Color get azure300 => const Color(0xFFD3E6F5); - - Color get azure400 => const Color(0xFF88C0EB); - - Color get azure500 => const Color(0xFF0877CC); - - Color get azure600 => const Color(0xFF154469); - - // Denim colors - Color get denim100 => const Color(0xFFF0F3FA); - - Color get denim200 => const Color(0xFFE3EBFA); - - Color get denim300 => const Color(0xFFD7E2F7); - - Color get denim400 => const Color(0xFF9AB6ED); - - Color get denim500 => const Color(0xFF3267D1); - - Color get denim600 => const Color(0xFF223C70); - - // Mauve colors - Color get mauve100 => const Color(0xFFF2F2FC); - - Color get mauve200 => const Color(0xFFE6E6FA); - - Color get mauve300 => const Color(0xFFDCDCF7); - - Color get mauve400 => const Color(0xFFAEAEF5); - - Color get mauve500 => const Color(0xFF5555E0); - - Color get mauve600 => const Color(0xFF36366B); - - // Lavender colors - Color get lavender100 => const Color(0xFFF6F3FC); - - Color get lavender200 => const Color(0xFFEBE3FA); - - Color get lavender300 => const Color(0xFFE4DAF7); - - Color get lavender400 => const Color(0xFFC1AAF0); - - Color get lavender500 => const Color(0xFF8153DB); - - Color get lavender600 => const Color(0xFF462F75); - - // Lilac colors - Color get lilac100 => const Color(0xFFF7F0FA); - - Color get lilac200 => const Color(0xFFF0E1F7); - - Color get lilac300 => const Color(0xFFEDD7F7); - - Color get lilac400 => const Color(0xFFD3A9E8); - - Color get lilac500 => const Color(0xFF9E4CC7); - - Color get lilac600 => const Color(0xFF562D6B); - - // Mallow colors - Color get mallow100 => const Color(0xFFFAF0FA); - - Color get mallow200 => const Color(0xFFF5E1F4); - - Color get mallow300 => const Color(0xFFF5D7F4); - - Color get mallow400 => const Color(0xFFDEA4DC); - - Color get mallow500 => const Color(0xFFB240AF); - - Color get mallow600 => const Color(0xFF632861); - - // Camellia colors - Color get camellia100 => const Color(0xFFF9EFF3); - - Color get camellia200 => const Color(0xFFF7E1EB); - - Color get camellia300 => const Color(0xFFF7D7E5); - - Color get camellia400 => const Color(0xFFE5A3C0); - - Color get camellia500 => const Color(0xFFC24279); - - Color get camellia600 => const Color(0xFF6E2343); - - // Smoke colors - Color get smoke100 => const Color(0xFFF5F5F5); - - Color get smoke200 => const Color(0xFFE8E8E8); - - Color get smoke300 => const Color(0xFFDEDEDE); - - Color get smoke400 => const Color(0xFFB8B8B8); - - Color get smoke500 => const Color(0xFF6E6E6E); - - Color get smoke600 => const Color(0xFF404040); - - // Iron colors - Color get iron100 => const Color(0xFFF2F4F7); - - Color get iron200 => const Color(0xFFE6E9F0); - - Color get iron300 => const Color(0xFFDADEE5); - - Color get iron400 => const Color(0xFFB0B5BF); - - Color get iron500 => const Color(0xFF666F80); - - Color get iron600 => const Color(0xFF394152); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart deleted file mode 100644 index a38cf2bd78..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/base/yellow.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; - -class YellowColors { - const YellowColors(); - Color get yellow100 => const Color(0xFFFFF9B2); - - Color get yellow200 => const Color(0xFFFFEC66); - - Color get yellow300 => const Color(0xFFFFDF1A); - - Color get yellow400 => const Color(0xFFFFCC00); - - Color get yellow500 => const Color(0xFFFFCE00); - - Color get yellow600 => const Color(0xFFE6B800); - - Color get yellow700 => const Color(0xFFCC9F00); - - Color get yellow800 => const Color(0xFFB38A00); - - Color get yellow900 => const Color(0xFF9A7500); - - Color get yellow1000 => const Color(0xFF7F6200); -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart deleted file mode 100644 index d1618b6cff..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppFlowyBorderColorScheme { - const AppFlowyBorderColorScheme({ - required this.greyPrimary, - required this.greyPrimaryHover, - required this.greySecondary, - required this.greySecondaryHover, - required this.greyTertiary, - required this.greyTertiaryHover, - required this.greyQuaternary, - required this.greyQuaternaryHover, - required this.transparent, - required this.themeThick, - required this.themeThickHover, - required this.infoThick, - required this.infoThickHover, - required this.successThick, - required this.successThickHover, - required this.warningThick, - required this.warningThickHover, - required this.errorThick, - required this.errorThickHover, - required this.purpleThick, - required this.purpleThickHover, - }); - - final Color greyPrimary; - final Color greyPrimaryHover; - final Color greySecondary; - final Color greySecondaryHover; - final Color greyTertiary; - final Color greyTertiaryHover; - final Color greyQuaternary; - final Color greyQuaternaryHover; - final Color transparent; - final Color themeThick; - final Color themeThickHover; - final Color infoThick; - final Color infoThickHover; - final Color successThick; - final Color successThickHover; - final Color warningThick; - final Color warningThickHover; - final Color errorThick; - final Color errorThickHover; - final Color purpleThick; - final Color purpleThickHover; -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart deleted file mode 100644 index 5a1e7debeb..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/color_scheme.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'background/background_color_scheme.dart'; -export 'base/base_scheme.dart'; -export 'border/border_color_scheme.dart'; -export 'brand/brand_color_scheme.dart'; -export 'fill/fill_color_scheme.dart'; -export 'icon/icon_color_theme.dart'; -export 'surface/surface_color_scheme.dart'; -export 'text/text_color_scheme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart new file mode 100644 index 0000000000..1eee06953f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart @@ -0,0 +1,298 @@ +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: 2025-04-15T23:05:39.278903 +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:flutter/material.dart'; + +class AppFlowyPrimitiveTokens { + AppFlowyPrimitiveTokens._(); + + /// #f8faff + static Color get neutral100 => Color(0xFFF8FAFF); + + /// #e4e8f5 + static Color get neutral200 => Color(0xFFE4E8F5); + + /// #ced3e6 + static Color get neutral300 => Color(0xFFCED3E6); + + /// #b5bbd3 + static Color get neutral400 => Color(0xFFB5BBD3); + + /// #989eb7 + static Color get neutral500 => Color(0xFF989EB7); + + /// #6f748c + static Color get neutral600 => Color(0xFF6F748C); + + /// #54596e + static Color get neutral700 => Color(0xFF54596E); + + /// #3c3f4e + static Color get neutral800 => Color(0xFF3C3F4E); + + /// #272930 + static Color get neutral900 => Color(0xFF272930); + + /// #21232a + static Color get neutral1000 => Color(0xFF21232A); + + /// #000000 + static Color get neutralBlack => Color(0xFF000000); + + /// #00000099 + static Color get neutralAlphaBlack60 => Color(0x99000000); + + /// #ffffff + static Color get neutralWhite => Color(0xFFFFFFFF); + + /// #ffffff00 + static Color get neutralAlphaWhite0 => Color(0x00FFFFFF); + + /// #ffffff33 + static Color get neutralAlphaWhite20 => Color(0x33FFFFFF); + + /// #ffffff4d + static Color get neutralAlphaWhite30 => Color(0x4DFFFFFF); + + /// #f9fafd0d + static Color get neutralAlphaGrey10005 => Color(0x0DF9FAFD); + + /// #f9fafd1a + static Color get neutralAlphaGrey10010 => Color(0x1AF9FAFD); + + /// #1f23290d + static Color get neutralAlphaGrey100005 => Color(0x0D1F2329); + + /// #1f23291a + static Color get neutralAlphaGrey100010 => Color(0x1A1F2329); + + /// #1f2329b2 + static Color get neutralAlphaGrey100070 => Color(0xB21F2329); + + /// #1f2329cc + static Color get neutralAlphaGrey100080 => Color(0xCC1F2329); + + /// #e3f6ff + static Color get blue100 => Color(0xFFE3F6FF); + + /// #a9e2ff + static Color get blue200 => Color(0xFFA9E2FF); + + /// #80d2ff + static Color get blue300 => Color(0xFF80D2FF); + + /// #4ec1ff + static Color get blue400 => Color(0xFF4EC1FF); + + /// #00b5ff + static Color get blue500 => Color(0xFF00B5FF); + + /// #0092d6 + static Color get blue600 => Color(0xFF0092D6); + + /// #0078c0 + static Color get blue700 => Color(0xFF0078C0); + + /// #0065a9 + static Color get blue800 => Color(0xFF0065A9); + + /// #00508f + static Color get blue900 => Color(0xFF00508F); + + /// #003c77 + static Color get blue1000 => Color(0xFF003C77); + + /// #00b5ff26 + static Color get blueAlphaBlue50015 => Color(0x2600B5FF); + + /// #ecf9f5 + static Color get green100 => Color(0xFFECF9F5); + + /// #c3e5d8 + static Color get green200 => Color(0xFFC3E5D8); + + /// #9ad1bc + static Color get green300 => Color(0xFF9AD1BC); + + /// #71bd9f + static Color get green400 => Color(0xFF71BD9F); + + /// #48a982 + static Color get green500 => Color(0xFF48A982); + + /// #248569 + static Color get green600 => Color(0xFF248569); + + /// #29725d + static Color get green700 => Color(0xFF29725D); + + /// #2e6050 + static Color get green800 => Color(0xFF2E6050); + + /// #305548 + static Color get green900 => Color(0xFF305548); + + /// #305244 + static Color get green1000 => Color(0xFF305244); + + /// #f1e0ff + static Color get purple100 => Color(0xFFF1E0FF); + + /// #e1b3ff + static Color get purple200 => Color(0xFFE1B3FF); + + /// #d185ff + static Color get purple300 => Color(0xFFD185FF); + + /// #bc58ff + static Color get purple400 => Color(0xFFBC58FF); + + /// #9327ff + static Color get purple500 => Color(0xFF9327FF); + + /// #7a1dcc + static Color get purple600 => Color(0xFF7A1DCC); + + /// #6617b3 + static Color get purple700 => Color(0xFF6617B3); + + /// #55138f + static Color get purple800 => Color(0xFF55138F); + + /// #470c72 + static Color get purple900 => Color(0xFF470C72); + + /// #380758 + static Color get purple1000 => Color(0xFF380758); + + /// #ffe5ef + static Color get magenta100 => Color(0xFFFFE5EF); + + /// #ffb8d1 + static Color get magenta200 => Color(0xFFFFB8D1); + + /// #ff8ab2 + static Color get magenta300 => Color(0xFFFF8AB2); + + /// #ff5c93 + static Color get magenta400 => Color(0xFFFF5C93); + + /// #fb006d + static Color get magenta500 => Color(0xFFFB006D); + + /// #d2005f + static Color get magenta600 => Color(0xFFD2005F); + + /// #d2005f + static Color get magenta700 => Color(0xFFD2005F); + + /// #850040 + static Color get magenta800 => Color(0xFF850040); + + /// #610031 + static Color get magenta900 => Color(0xFF610031); + + /// #400022 + static Color get magenta1000 => Color(0xFF400022); + + /// #ffd2dd + static Color get red100 => Color(0xFFFFD2DD); + + /// #ffa5b4 + static Color get red200 => Color(0xFFFFA5B4); + + /// #ff7d87 + static Color get red300 => Color(0xFFFF7D87); + + /// #ff5050 + static Color get red400 => Color(0xFFFF5050); + + /// #f33641 + static Color get red500 => Color(0xFFF33641); + + /// #e71d32 + static Color get red600 => Color(0xFFE71D32); + + /// #ad1625 + static Color get red700 => Color(0xFFAD1625); + + /// #8c101c + static Color get red800 => Color(0xFF8C101C); + + /// #6e0a1e + static Color get red900 => Color(0xFF6E0A1E); + + /// #4c0a17 + static Color get red1000 => Color(0xFF4C0A17); + + /// #f336411a + static Color get redAlphaRed50010 => Color(0x1AF33641); + + /// #fff3d5 + static Color get orange100 => Color(0xFFFFF3D5); + + /// #ffe4ab + static Color get orange200 => Color(0xFFFFE4AB); + + /// #ffd181 + static Color get orange300 => Color(0xFFFFD181); + + /// #ffbe62 + static Color get orange400 => Color(0xFFFFBE62); + + /// #ffa02e + static Color get orange500 => Color(0xFFFFA02E); + + /// #db7e21 + static Color get orange600 => Color(0xFFDB7E21); + + /// #b75f17 + static Color get orange700 => Color(0xFFB75F17); + + /// #93450e + static Color get orange800 => Color(0xFF93450E); + + /// #7a3108 + static Color get orange900 => Color(0xFF7A3108); + + /// #602706 + static Color get orange1000 => Color(0xFF602706); + + /// #fff9b2 + static Color get yellow100 => Color(0xFFFFF9B2); + + /// #ffec66 + static Color get yellow200 => Color(0xFFFFEC66); + + /// #ffdf1a + static Color get yellow300 => Color(0xFFFFDF1A); + + /// #ffcc00 + static Color get yellow400 => Color(0xFFFFCC00); + + /// #ffce00 + static Color get yellow500 => Color(0xFFFFCE00); + + /// #e6b800 + static Color get yellow600 => Color(0xFFE6B800); + + /// #cc9f00 + static Color get yellow700 => Color(0xFFCC9F00); + + /// #b38a00 + static Color get yellow800 => Color(0xFFB38A00); + + /// #9a7500 + static Color get yellow900 => Color(0xFF9A7500); + + /// #7f6200 + static Color get yellow1000 => Color(0xFF7F6200); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart new file mode 100644 index 0000000000..08206a6572 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart @@ -0,0 +1,377 @@ +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: 2025-04-15T23:05:39.288085 +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import '../builder.dart'; +import 'primitive.dart'; + +class AppFlowyThemeData implements AppFlowyBaseThemeData { + const AppFlowyThemeData._({ + required this.textStyle, + required this.textColorScheme, + required this.borderColorScheme, + required this.fillColorScheme, + required this.surfaceColorScheme, + required this.borderRadius, + required this.spacing, + required this.shadow, + required this.brandColorScheme, + required this.iconColorScheme, + required this.backgroundColorScheme, + required this.otherColorsColorScheme, + }); + + factory AppFlowyThemeData.light() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = themeBuilder.buildBorderRadius(); + final spacing = themeBuilder.buildSpacing(); + final shadow = themeBuilder.buildShadow(Brightness.light); + + final textColorScheme = AppFlowyTextColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral600, + tertiary: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral200, + inverse: AppFlowyPrimitiveTokens.neutralWhite, + onFill: AppFlowyPrimitiveTokens.neutralWhite, + theme: AppFlowyPrimitiveTokens.blue500, + themeHover: AppFlowyPrimitiveTokens.blue600, + action: AppFlowyPrimitiveTokens.blue500, + actionHover: AppFlowyPrimitiveTokens.blue600, + info: AppFlowyPrimitiveTokens.blue500, + infoHover: AppFlowyPrimitiveTokens.blue600, + success: AppFlowyPrimitiveTokens.green600, + successHover: AppFlowyPrimitiveTokens.green700, + warning: AppFlowyPrimitiveTokens.orange600, + warningHover: AppFlowyPrimitiveTokens.orange700, + error: AppFlowyPrimitiveTokens.red600, + errorHover: AppFlowyPrimitiveTokens.red700, + purple: AppFlowyPrimitiveTokens.purple500, + purpleHover: AppFlowyPrimitiveTokens.purple600, + ); + + final iconColorScheme = AppFlowyIconColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral600, + tertiary: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral200, + white: AppFlowyPrimitiveTokens.neutralWhite, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final borderColorScheme = AppFlowyBorderColorScheme( + greyPrimary: AppFlowyPrimitiveTokens.neutral1000, + greyPrimaryHover: AppFlowyPrimitiveTokens.neutral900, + greySecondary: AppFlowyPrimitiveTokens.neutral800, + greySecondaryHover: AppFlowyPrimitiveTokens.neutral700, + greyTertiary: AppFlowyPrimitiveTokens.neutral300, + greyTertiaryHover: AppFlowyPrimitiveTokens.neutral400, + greyQuaternary: AppFlowyPrimitiveTokens.neutral100, + greyQuaternaryHover: AppFlowyPrimitiveTokens.neutral200, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red700, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final fillColorScheme = AppFlowyFillColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + primaryHover: AppFlowyPrimitiveTokens.neutral900, + secondary: AppFlowyPrimitiveTokens.neutral600, + secondaryHover: AppFlowyPrimitiveTokens.neutral500, + tertiary: AppFlowyPrimitiveTokens.neutral300, + tertiaryHover: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral100, + quaternaryHover: AppFlowyPrimitiveTokens.neutral200, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + primaryAlpha5: AppFlowyPrimitiveTokens.neutralAlphaGrey100005, + primaryAlpha5Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100010, + primaryAlpha80: AppFlowyPrimitiveTokens.neutralAlphaGrey100080, + primaryAlpha80Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100070, + white: AppFlowyPrimitiveTokens.neutralWhite, + whiteAlpha: AppFlowyPrimitiveTokens.neutralAlphaWhite20, + whiteAlphaHover: AppFlowyPrimitiveTokens.neutralAlphaWhite30, + black: AppFlowyPrimitiveTokens.neutralBlack, + themeLight: AppFlowyPrimitiveTokens.blue100, + themeLightHover: AppFlowyPrimitiveTokens.blue200, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, + infoLight: AppFlowyPrimitiveTokens.blue100, + infoLightHover: AppFlowyPrimitiveTokens.blue200, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successLight: AppFlowyPrimitiveTokens.green100, + successLightHover: AppFlowyPrimitiveTokens.green200, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningLight: AppFlowyPrimitiveTokens.orange100, + warningLightHover: AppFlowyPrimitiveTokens.orange200, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorLight: AppFlowyPrimitiveTokens.red100, + errorLightHover: AppFlowyPrimitiveTokens.red200, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red700, + errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, + purpleLight: AppFlowyPrimitiveTokens.purple100, + purpleLightHover: AppFlowyPrimitiveTokens.purple200, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + purpleThick: AppFlowyPrimitiveTokens.purple500, + ); + + final surfaceColorScheme = AppFlowySurfaceColorScheme( + primary: AppFlowyPrimitiveTokens.neutralWhite, + overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, + ); + + final backgroundColorScheme = AppFlowyBackgroundColorScheme( + primary: AppFlowyPrimitiveTokens.neutralWhite, + secondary: AppFlowyPrimitiveTokens.neutral100, + tertiary: AppFlowyPrimitiveTokens.neutral200, + quaternary: AppFlowyPrimitiveTokens.neutral300, + ); + + final brandColorScheme = AppFlowyBrandColorScheme( + skyline: Color(0xFF00B5FF), + aqua: Color(0xFF00C8FF), + violet: Color(0xFF9327FF), + amethyst: Color(0xFF8427E0), + berry: Color(0xFFE3006D), + coral: Color(0xFFFB006D), + golden: Color(0xFFF7931E), + amber: Color(0xFFFFBD00), + lemon: Color(0xFFFFCE00), + ); + + final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( + textHighlight: AppFlowyPrimitiveTokens.blue200, + ); + + return AppFlowyThemeData._( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + } + + factory AppFlowyThemeData.dark() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = themeBuilder.buildBorderRadius(); + final spacing = themeBuilder.buildSpacing(); + final shadow = themeBuilder.buildShadow(Brightness.dark); + + final textColorScheme = AppFlowyTextColorScheme( + primary: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + inverse: AppFlowyPrimitiveTokens.neutral1000, + onFill: AppFlowyPrimitiveTokens.neutralWhite, + theme: AppFlowyPrimitiveTokens.blue500, + themeHover: AppFlowyPrimitiveTokens.blue600, + action: AppFlowyPrimitiveTokens.blue500, + actionHover: AppFlowyPrimitiveTokens.blue600, + info: AppFlowyPrimitiveTokens.blue500, + infoHover: AppFlowyPrimitiveTokens.blue600, + success: AppFlowyPrimitiveTokens.green600, + successHover: AppFlowyPrimitiveTokens.green700, + warning: AppFlowyPrimitiveTokens.orange600, + warningHover: AppFlowyPrimitiveTokens.orange700, + error: AppFlowyPrimitiveTokens.red500, + errorHover: AppFlowyPrimitiveTokens.red400, + purple: AppFlowyPrimitiveTokens.purple500, + purpleHover: AppFlowyPrimitiveTokens.purple600, + ); + + final iconColorScheme = AppFlowyIconColorScheme( + primary: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + white: AppFlowyPrimitiveTokens.neutralWhite, + purpleThick: Color(0xFFFFFFFF), + purpleThickHover: Color(0xFFFFFFFF), + ); + + final borderColorScheme = AppFlowyBorderColorScheme( + greyPrimary: AppFlowyPrimitiveTokens.neutral100, + greyPrimaryHover: AppFlowyPrimitiveTokens.neutral200, + greySecondary: AppFlowyPrimitiveTokens.neutral300, + greySecondaryHover: AppFlowyPrimitiveTokens.neutral400, + greyTertiary: AppFlowyPrimitiveTokens.neutral800, + greyTertiaryHover: AppFlowyPrimitiveTokens.neutral700, + greyQuaternary: AppFlowyPrimitiveTokens.neutral1000, + greyQuaternaryHover: AppFlowyPrimitiveTokens.neutral900, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorThick: AppFlowyPrimitiveTokens.red500, + errorThickHover: AppFlowyPrimitiveTokens.red400, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final fillColorScheme = AppFlowyFillColorScheme( + primary: AppFlowyPrimitiveTokens.neutral100, + primaryHover: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral300, + secondaryHover: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + tertiaryHover: AppFlowyPrimitiveTokens.neutral500, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + quaternaryHover: AppFlowyPrimitiveTokens.neutral900, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + primaryAlpha5: AppFlowyPrimitiveTokens.neutralAlphaGrey10005, + primaryAlpha5Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey10010, + primaryAlpha80: AppFlowyPrimitiveTokens.neutralAlphaGrey100080, + primaryAlpha80Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100070, + white: AppFlowyPrimitiveTokens.neutralWhite, + whiteAlpha: AppFlowyPrimitiveTokens.neutralAlphaWhite20, + whiteAlphaHover: AppFlowyPrimitiveTokens.neutralAlphaWhite30, + black: AppFlowyPrimitiveTokens.neutralBlack, + themeLight: AppFlowyPrimitiveTokens.blue100, + themeLightHover: AppFlowyPrimitiveTokens.blue200, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue400, + themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, + infoLight: AppFlowyPrimitiveTokens.blue100, + infoLightHover: AppFlowyPrimitiveTokens.blue200, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successLight: AppFlowyPrimitiveTokens.green100, + successLightHover: AppFlowyPrimitiveTokens.green200, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningLight: AppFlowyPrimitiveTokens.orange100, + warningLightHover: AppFlowyPrimitiveTokens.orange200, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorLight: AppFlowyPrimitiveTokens.red100, + errorLightHover: AppFlowyPrimitiveTokens.red200, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red500, + errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, + purpleLight: AppFlowyPrimitiveTokens.purple100, + purpleLightHover: AppFlowyPrimitiveTokens.purple200, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + purpleThick: AppFlowyPrimitiveTokens.purple500, + ); + + final surfaceColorScheme = AppFlowySurfaceColorScheme( + primary: AppFlowyPrimitiveTokens.neutral900, + overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, + ); + + final backgroundColorScheme = AppFlowyBackgroundColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral900, + tertiary: AppFlowyPrimitiveTokens.neutral800, + quaternary: AppFlowyPrimitiveTokens.neutral700, + ); + + final brandColorScheme = AppFlowyBrandColorScheme( + skyline: Color(0xFF00B5FF), + aqua: Color(0xFF00C8FF), + violet: Color(0xFF9327FF), + amethyst: Color(0xFF8427E0), + berry: Color(0xFFE3006D), + coral: Color(0xFFFB006D), + golden: Color(0xFFF7931E), + amber: Color(0xFFFFBD00), + lemon: Color(0xFFFFCE00), + ); + + final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( + textHighlight: AppFlowyPrimitiveTokens.blue200, + ); + + return AppFlowyThemeData._( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + } + + static const AppFlowyThemeBuilder themeBuilder = AppFlowyThemeBuilder(); + + @override + final AppFlowyBaseTextStyle textStyle; + + @override + final AppFlowyTextColorScheme textColorScheme; + + @override + final AppFlowyBorderColorScheme borderColorScheme; + + @override + final AppFlowyFillColorScheme fillColorScheme; + + @override + final AppFlowySurfaceColorScheme surfaceColorScheme; + + @override + final AppFlowyBorderRadius borderRadius; + + @override + final AppFlowySpacing spacing; + + @override + final AppFlowyShadow shadow; + + @override + final AppFlowyBrandColorScheme brandColorScheme; + + @override + final AppFlowyIconColorScheme iconColorScheme; + + @override + final AppFlowyBackgroundColorScheme backgroundColorScheme; + + @override + final AppFlowyOtherColorsColorScheme otherColorsColorScheme; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index 06c6eb5d8e..a4f83109cb 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -1,305 +1,30 @@ -import 'package:appflowy_ui/src/theme/border_radius/border_radius.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/background/background_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/base_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/border/border.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/brand/brand_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/fill/fill_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/dimensions.dart'; -import 'package:appflowy_ui/src/theme/shadow/shadow.dart'; -import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; +import 'package:appflowy_ui/src/theme/definition/border_radius/border_radius.dart'; +import 'package:appflowy_ui/src/theme/definition/shadow/shadow.dart'; +import 'package:appflowy_ui/src/theme/definition/spacing/spacing.dart'; import 'package:flutter/material.dart'; +class AppFlowySpacingConstant { + static const double spacing100 = 4; + static const double spacing200 = 6; + static const double spacing300 = 8; + static const double spacing400 = 12; + static const double spacing500 = 16; + static const double spacing600 = 20; +} + +class AppFlowyBorderRadiusConstant { + static const double radius100 = 4; + static const double radius200 = 6; + static const double radius300 = 8; + static const double radius400 = 12; + static const double radius500 = 16; + static const double radius600 = 20; +} + class AppFlowyThemeBuilder { const AppFlowyThemeBuilder(); - AppFlowyTextColorScheme buildTextColorScheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.light => AppFlowyTextColorScheme( - primary: colorScheme.neutral.neutral1000, - secondary: colorScheme.neutral.neutral600, - tertiary: colorScheme.neutral.neutral400, - quaternary: colorScheme.neutral.neutral200, - inverse: colorScheme.neutral.white, - onFill: colorScheme.neutral.white, - theme: colorScheme.blue.blue500, - themeHover: colorScheme.blue.blue600, - action: colorScheme.blue.blue500, - actionHover: colorScheme.blue.blue600, - info: colorScheme.blue.blue500, - infoHover: colorScheme.blue.blue600, - success: colorScheme.green.green600, - successHover: colorScheme.green.green700, - warning: colorScheme.orange.orange600, - warningHover: colorScheme.orange.orange700, - error: colorScheme.red.red600, - errorHover: colorScheme.red.red700, - purple: colorScheme.purple.purple500, - purpleHover: colorScheme.purple.purple600, - ), - Brightness.dark => AppFlowyTextColorScheme( - primary: colorScheme.neutral.neutral200, - secondary: colorScheme.neutral.neutral400, - tertiary: colorScheme.neutral.neutral600, - quaternary: colorScheme.neutral.neutral1000, - inverse: colorScheme.neutral.neutral1000, - onFill: colorScheme.neutral.white, - theme: colorScheme.blue.blue500, - themeHover: colorScheme.blue.blue600, - action: colorScheme.blue.blue500, - actionHover: colorScheme.blue.blue600, - info: colorScheme.blue.blue500, - infoHover: colorScheme.blue.blue600, - success: colorScheme.green.green600, - successHover: colorScheme.green.green700, - warning: colorScheme.orange.orange600, - warningHover: colorScheme.orange.orange700, - error: colorScheme.red.red500, - errorHover: colorScheme.red.red400, - purple: colorScheme.purple.purple500, - purpleHover: colorScheme.purple.purple600, - ), - }; - } - - AppFlowyIconColorTheme buildIconColorTheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.light => AppFlowyIconColorTheme( - primary: colorScheme.neutral.neutral1000, - secondary: colorScheme.neutral.neutral600, - tertiary: colorScheme.neutral.neutral400, - quaternary: colorScheme.neutral.neutral200, - white: colorScheme.neutral.white, - purpleThick: colorScheme.purple.purple500, - purpleThickHover: colorScheme.purple.purple600, - ), - Brightness.dark => AppFlowyIconColorTheme( - primary: colorScheme.neutral.neutral200, - secondary: colorScheme.neutral.neutral400, - tertiary: colorScheme.neutral.neutral600, - quaternary: colorScheme.neutral.neutral1000, - white: colorScheme.neutral.white, - purpleThick: const Color(0xFFFFFFFF), - purpleThickHover: const Color(0xFFFFFFFF), - ), - }; - } - - AppFlowyBorderColorScheme buildBorderColorScheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.light => AppFlowyBorderColorScheme( - greyPrimary: colorScheme.neutral.neutral1000, - greyPrimaryHover: colorScheme.neutral.neutral900, - greySecondary: colorScheme.neutral.neutral800, - greySecondaryHover: colorScheme.neutral.neutral700, - greyTertiary: colorScheme.neutral.neutral300, - greyTertiaryHover: colorScheme.neutral.neutral400, - greyQuaternary: colorScheme.neutral.neutral100, - greyQuaternaryHover: colorScheme.neutral.neutral200, - transparent: colorScheme.neutral.alphaWhite0, - themeThick: colorScheme.blue.blue500, - themeThickHover: colorScheme.blue.blue600, - infoThick: colorScheme.blue.blue500, - infoThickHover: colorScheme.blue.blue600, - successThick: colorScheme.green.green600, - successThickHover: colorScheme.green.green700, - warningThick: colorScheme.orange.orange600, - warningThickHover: colorScheme.orange.orange700, - errorThick: colorScheme.red.red600, - errorThickHover: colorScheme.red.red700, - purpleThick: colorScheme.purple.purple500, - purpleThickHover: colorScheme.purple.purple600, - ), - Brightness.dark => AppFlowyBorderColorScheme( - greyPrimary: colorScheme.neutral.neutral100, - greyPrimaryHover: colorScheme.neutral.neutral200, - greySecondary: colorScheme.neutral.neutral300, - greySecondaryHover: colorScheme.neutral.neutral400, - greyTertiary: colorScheme.neutral.neutral800, - greyTertiaryHover: colorScheme.neutral.neutral700, - greyQuaternary: colorScheme.neutral.neutral1000, - greyQuaternaryHover: colorScheme.neutral.neutral900, - transparent: colorScheme.neutral.alphaWhite0, - themeThick: colorScheme.blue.blue500, - themeThickHover: colorScheme.blue.blue600, - infoThick: colorScheme.blue.blue500, - infoThickHover: colorScheme.blue.blue600, - successThick: colorScheme.green.green600, - successThickHover: colorScheme.green.green700, - warningThick: colorScheme.orange.orange600, - warningThickHover: colorScheme.orange.orange700, - errorThick: colorScheme.red.red500, - errorThickHover: colorScheme.red.red400, - purpleThick: colorScheme.purple.purple500, - purpleThickHover: colorScheme.purple.purple600, - ), - }; - } - - AppFlowyFillColorScheme buildFillColorScheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.dark => AppFlowyFillColorScheme( - primary: colorScheme.neutral.neutral100, - primaryHover: colorScheme.neutral.neutral200, - secondary: colorScheme.neutral.neutral300, - secondaryHover: colorScheme.neutral.neutral400, - tertiary: colorScheme.neutral.neutral600, - tertiaryHover: colorScheme.neutral.neutral500, - quaternary: colorScheme.neutral.neutral1000, - quaternaryHover: colorScheme.neutral.neutral900, - transparent: colorScheme.neutral.alphaWhite0, - primaryAlpha5: colorScheme.neutral.alphaGrey10005, - primaryAlpha5Hover: colorScheme.neutral.alphaGrey10010, - primaryAlpha80: colorScheme.neutral.alphaGrey100080, - primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, - white: colorScheme.neutral.white, - whiteAlpha: colorScheme.neutral.alphaWhite20, - whiteAlphaHover: colorScheme.neutral.alphaWhite30, - black: colorScheme.neutral.black, - themeLight: colorScheme.blue.blue100, - themeLightHover: colorScheme.blue.blue200, - themeThick: colorScheme.blue.blue500, - themeThickHover: colorScheme.blue.blue600, - themeSelect: colorScheme.blue.alphaBlue50015, - infoLight: colorScheme.blue.blue100, - infoLightHover: colorScheme.blue.blue200, - infoThick: colorScheme.blue.blue500, - infoThickHover: colorScheme.blue.blue600, - successLight: colorScheme.green.green100, - successLightHover: colorScheme.green.green200, - successThick: colorScheme.green.green600, - successThickHover: colorScheme.green.green700, - warningLight: colorScheme.orange.orange100, - warningLightHover: colorScheme.orange.orange200, - warningThick: colorScheme.orange.orange600, - warningThickHover: colorScheme.orange.orange700, - errorLight: colorScheme.red.red100, - errorLightHover: colorScheme.red.red200, - errorThick: colorScheme.red.red600, - errorThickHover: colorScheme.red.red700, - errorSelect: colorScheme.red.alphaRed50010, - purpleLight: colorScheme.purple.purple100, - purpleLightHover: colorScheme.purple.purple200, - purpleThick: colorScheme.purple.purple500, - purpleThickHover: colorScheme.purple.purple600, - ), - Brightness.light => AppFlowyFillColorScheme( - primary: colorScheme.neutral.neutral1000, - primaryHover: colorScheme.neutral.neutral900, - secondary: colorScheme.neutral.neutral600, - secondaryHover: colorScheme.neutral.neutral500, - tertiary: colorScheme.neutral.neutral300, - tertiaryHover: colorScheme.neutral.neutral400, - quaternary: colorScheme.neutral.neutral100, - quaternaryHover: colorScheme.neutral.neutral200, - transparent: colorScheme.neutral.alphaWhite0, - primaryAlpha5: colorScheme.neutral.alphaGrey100005, - primaryAlpha5Hover: colorScheme.neutral.alphaGrey100010, - primaryAlpha80: colorScheme.neutral.alphaGrey100080, - primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, - white: colorScheme.neutral.white, - whiteAlpha: colorScheme.neutral.alphaWhite20, - whiteAlphaHover: colorScheme.neutral.alphaWhite30, - black: colorScheme.neutral.black, - themeLight: colorScheme.blue.blue100, - themeLightHover: colorScheme.blue.blue200, - themeThick: colorScheme.blue.blue500, - themeThickHover: colorScheme.blue.blue600, - themeSelect: colorScheme.blue.alphaBlue50015, - infoLight: colorScheme.blue.blue100, - infoLightHover: colorScheme.blue.blue200, - infoThick: colorScheme.blue.blue500, - infoThickHover: colorScheme.blue.blue600, - successLight: colorScheme.green.green100, - successLightHover: colorScheme.green.green200, - successThick: colorScheme.green.green600, - successThickHover: colorScheme.green.green700, - warningLight: colorScheme.orange.orange100, - warningLightHover: colorScheme.orange.orange200, - warningThick: colorScheme.orange.orange600, - warningThickHover: colorScheme.orange.orange700, - errorLight: colorScheme.red.red100, - errorLightHover: colorScheme.red.red200, - errorThick: colorScheme.red.red600, - errorThickHover: colorScheme.red.red700, - errorSelect: colorScheme.red.alphaRed50010, - purpleLight: colorScheme.purple.purple100, - purpleLightHover: colorScheme.purple.purple200, - purpleThick: colorScheme.purple.purple500, - purpleThickHover: colorScheme.purple.purple600, - ), - }; - } - - AppFlowySurfaceColorScheme buildSurfaceColorScheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.light => AppFlowySurfaceColorScheme( - primary: colorScheme.neutral.white, - overlay: colorScheme.neutral.alphaBlack60, - ), - Brightness.dark => AppFlowySurfaceColorScheme( - primary: colorScheme.neutral.neutral900, - overlay: colorScheme.neutral.alphaBlack60, - ), - }; - } - - AppFlowyBackgroundColorScheme buildBackgroundColorScheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.light => AppFlowyBackgroundColorScheme( - primary: colorScheme.neutral.white, - secondary: colorScheme.neutral.neutral100, - tertiary: colorScheme.neutral.neutral200, - quaternary: colorScheme.neutral.neutral300, - ), - Brightness.dark => AppFlowyBackgroundColorScheme( - primary: colorScheme.neutral.neutral1000, - secondary: colorScheme.neutral.neutral900, - tertiary: colorScheme.neutral.neutral800, - quaternary: colorScheme.neutral.neutral700, - ), - }; - } - - AppFlowyBrandColorScheme buildBrandColorScheme( - AppFlowyBaseColorScheme colorScheme, - ) { - return AppFlowyBrandColorScheme( - skyline: const Color(0xFF00B5FF), - aqua: const Color(0xFF00C8FF), - violet: const Color(0xFF9327FF), - amethyst: const Color(0xFF8427E0), - berry: const Color(0xFFE3006D), - coral: const Color(0xFFFB006D), - golden: const Color(0xFFF7931E), - amber: const Color(0xFFFFBD00), - lemon: const Color(0xFFFFCE00), - ); - } - - AppFlowyBorderRadius buildBorderRadius( - AppFlowyBaseColorScheme colorScheme, - ) { + AppFlowyBorderRadius buildBorderRadius() { return AppFlowyBorderRadius( xs: AppFlowyBorderRadiusConstant.radius100, s: AppFlowyBorderRadiusConstant.radius200, @@ -310,9 +35,7 @@ class AppFlowyThemeBuilder { ); } - AppFlowySpacing buildSpacing( - AppFlowyBaseColorScheme colorScheme, - ) { + AppFlowySpacing buildSpacing() { return AppFlowySpacing( xs: AppFlowySpacingConstant.spacing100, s: AppFlowySpacingConstant.spacing200, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart new file mode 100644 index 0000000000..2b29371433 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart @@ -0,0 +1 @@ +export 'appflowy_default/semantic.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart deleted file mode 100644 index 60f7d1f1a4..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'package:appflowy_ui/src/theme/border_radius/border_radius.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/background/background_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/base/base_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/border/border.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/brand/brand_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/fill/fill_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; -import 'package:appflowy_ui/src/theme/data/builder.dart'; -import 'package:appflowy_ui/src/theme/shadow/shadow.dart'; -import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; -import 'package:appflowy_ui/src/theme/text_style/text_style.dart'; -import 'package:flutter/material.dart'; - -abstract class AppFlowyBaseTheme { - const AppFlowyBaseTheme(); - - AppFlowyBaseColorScheme get colorScheme; - - AppFlowyTextColorScheme get textColorScheme; - - AppFlowyBaseTextStyle get textStyle; - - AppFlowyIconColorTheme get iconColorTheme; - - AppFlowyBorderColorScheme get borderColorScheme; - - AppFlowyBackgroundColorScheme get backgroundColorScheme; - - AppFlowyFillColorScheme get fillColorScheme; - - AppFlowySurfaceColorScheme get surfaceColorScheme; - - AppFlowyBorderRadius get borderRadius; - - AppFlowySpacing get spacing; - - AppFlowyShadow get shadow; - - AppFlowyBrandColorScheme get brandColorScheme; -} - -class AppFlowyThemeData extends AppFlowyBaseTheme { - factory AppFlowyThemeData.light() { - final colorScheme = AppFlowyBaseColorScheme(); - - final textStyle = AppFlowyBaseTextStyle(); - final textColorScheme = themeBuilder.buildTextColorScheme( - colorScheme, - Brightness.light, - ); - final borderColorScheme = themeBuilder.buildBorderColorScheme( - colorScheme, - Brightness.light, - ); - final fillColorScheme = themeBuilder.buildFillColorScheme( - colorScheme, - Brightness.light, - ); - final surfaceColorScheme = themeBuilder.buildSurfaceColorScheme( - colorScheme, - Brightness.light, - ); - final backgroundColorScheme = themeBuilder.buildBackgroundColorScheme( - colorScheme, - Brightness.light, - ); - final iconColorTheme = themeBuilder.buildIconColorTheme( - colorScheme, - Brightness.light, - ); - final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); - final borderRadius = themeBuilder.buildBorderRadius(colorScheme); - final spacing = themeBuilder.buildSpacing(colorScheme); - final shadow = themeBuilder.buildShadow(Brightness.light); - - return AppFlowyThemeData( - colorScheme: colorScheme, - textColorScheme: textColorScheme, - textStyle: textStyle, - iconColorTheme: iconColorTheme, - backgroundColorScheme: backgroundColorScheme, - borderColorScheme: borderColorScheme, - fillColorScheme: fillColorScheme, - surfaceColorScheme: surfaceColorScheme, - borderRadius: borderRadius, - spacing: spacing, - shadow: shadow, - brandColorScheme: brandColorScheme, - ); - } - - factory AppFlowyThemeData.dark() { - final colorScheme = AppFlowyBaseColorScheme(); - final textStyle = AppFlowyBaseTextStyle(); - final textColorScheme = themeBuilder.buildTextColorScheme( - colorScheme, - Brightness.dark, - ); - final borderColorScheme = themeBuilder.buildBorderColorScheme( - colorScheme, - Brightness.dark, - ); - final fillColorScheme = themeBuilder.buildFillColorScheme( - colorScheme, - Brightness.dark, - ); - final surfaceColorScheme = themeBuilder.buildSurfaceColorScheme( - colorScheme, - Brightness.dark, - ); - final backgroundColorScheme = themeBuilder.buildBackgroundColorScheme( - colorScheme, - Brightness.dark, - ); - final iconColorTheme = themeBuilder.buildIconColorTheme( - colorScheme, - Brightness.dark, - ); - final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); - final borderRadius = themeBuilder.buildBorderRadius(colorScheme); - final spacing = themeBuilder.buildSpacing(colorScheme); - final shadow = themeBuilder.buildShadow(Brightness.dark); - - return AppFlowyThemeData( - colorScheme: colorScheme, - textColorScheme: textColorScheme, - textStyle: textStyle, - iconColorTheme: iconColorTheme, - backgroundColorScheme: backgroundColorScheme, - borderColorScheme: borderColorScheme, - fillColorScheme: fillColorScheme, - surfaceColorScheme: surfaceColorScheme, - borderRadius: borderRadius, - spacing: spacing, - shadow: shadow, - brandColorScheme: brandColorScheme, - ); - } - - const AppFlowyThemeData({ - required this.colorScheme, - required this.textStyle, - required this.textColorScheme, - required this.borderColorScheme, - required this.fillColorScheme, - required this.surfaceColorScheme, - required this.borderRadius, - required this.spacing, - required this.shadow, - required this.brandColorScheme, - required this.iconColorTheme, - required this.backgroundColorScheme, - this.brightness = Brightness.light, - }); - - static const AppFlowyThemeBuilder themeBuilder = AppFlowyThemeBuilder(); - - final Brightness brightness; - - @override - final AppFlowyBaseColorScheme colorScheme; - - @override - final AppFlowyBaseTextStyle textStyle; - - @override - final AppFlowyTextColorScheme textColorScheme; - - @override - final AppFlowyBorderColorScheme borderColorScheme; - - @override - final AppFlowyFillColorScheme fillColorScheme; - - @override - final AppFlowySurfaceColorScheme surfaceColorScheme; - - @override - final AppFlowyBorderRadius borderRadius; - - @override - final AppFlowySpacing spacing; - - @override - final AppFlowyShadow shadow; - - @override - final AppFlowyBrandColorScheme brandColorScheme; - - @override - final AppFlowyIconColorTheme iconColorTheme; - - @override - final AppFlowyBackgroundColorScheme backgroundColorScheme; - - static AppFlowyTextColorScheme buildTextColorScheme( - AppFlowyBaseColorScheme colorScheme, - Brightness brightness, - ) { - return switch (brightness) { - Brightness.light => AppFlowyTextColorScheme( - primary: colorScheme.neutral.neutral1000, - secondary: colorScheme.neutral.neutral600, - tertiary: colorScheme.neutral.neutral400, - quaternary: colorScheme.neutral.neutral200, - inverse: colorScheme.neutral.white, - onFill: colorScheme.neutral.white, - theme: colorScheme.blue.blue500, - themeHover: colorScheme.blue.blue600, - action: colorScheme.blue.blue500, - actionHover: colorScheme.blue.blue600, - info: colorScheme.blue.blue500, - infoHover: colorScheme.blue.blue600, - success: colorScheme.green.green600, - successHover: colorScheme.green.green700, - warning: colorScheme.orange.orange600, - warningHover: colorScheme.orange.orange700, - error: colorScheme.red.red600, - errorHover: colorScheme.red.red700, - purple: colorScheme.purple.purple500, - purpleHover: colorScheme.purple.purple600, - ), - Brightness.dark => AppFlowyTextColorScheme( - primary: colorScheme.neutral.neutral200, - secondary: colorScheme.neutral.neutral400, - tertiary: colorScheme.neutral.neutral600, - quaternary: colorScheme.neutral.neutral1000, - inverse: colorScheme.neutral.neutral1000, - onFill: colorScheme.neutral.white, - theme: colorScheme.blue.blue500, - themeHover: colorScheme.blue.blue600, - action: colorScheme.blue.blue500, - actionHover: colorScheme.blue.blue600, - info: colorScheme.blue.blue500, - infoHover: colorScheme.blue.blue600, - success: colorScheme.green.green600, - successHover: colorScheme.green.green700, - warning: colorScheme.orange.orange600, - warningHover: colorScheme.orange.orange700, - error: colorScheme.red.red500, - errorHover: colorScheme.red.red400, - purple: colorScheme.purple.purple500, - purpleHover: colorScheme.purple.purple600, - ), - }; - } -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart new file mode 100644 index 0000000000..ca06c8da1b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart @@ -0,0 +1,33 @@ +import 'border_radius/border_radius.dart'; +import 'color_scheme/color_scheme.dart'; +import 'shadow/shadow.dart'; +import 'spacing/spacing.dart'; +import 'text_style/text_style.dart'; + +abstract class AppFlowyBaseThemeData { + const AppFlowyBaseThemeData(); + + AppFlowyTextColorScheme get textColorScheme; + + AppFlowyBaseTextStyle get textStyle; + + AppFlowyIconColorScheme get iconColorScheme; + + AppFlowyBorderColorScheme get borderColorScheme; + + AppFlowyBackgroundColorScheme get backgroundColorScheme; + + AppFlowyFillColorScheme get fillColorScheme; + + AppFlowySurfaceColorScheme get surfaceColorScheme; + + AppFlowyBorderRadius get borderRadius; + + AppFlowySpacing get spacing; + + AppFlowyShadow get shadow; + + AppFlowyBrandColorScheme get brandColorScheme; + + AppFlowyOtherColorsColorScheme get otherColorsColorScheme; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/border_radius/border_radius.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/border_radius/border_radius.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/background/background_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/background/background_color_scheme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/border/border_color_scheme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/brand/brand_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/brand/brand_color_scheme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart new file mode 100644 index 0000000000..01952e1461 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart @@ -0,0 +1,8 @@ +export 'background_color_scheme.dart'; +export 'border_color_scheme.dart'; +export 'brand_color_scheme.dart'; +export 'fill_color_scheme.dart'; +export 'icon_color_scheme.dart'; +export 'other_color_scheme.dart'; +export 'surface_color_scheme.dart'; +export 'text_color_scheme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/fill/fill_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/fill/fill_color_scheme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart similarity index 86% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart index f9ece5339c..245a02aadb 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/icon/icon_color_theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -class AppFlowyIconColorTheme { - const AppFlowyIconColorTheme({ +class AppFlowyIconColorScheme { + const AppFlowyIconColorScheme({ required this.primary, required this.secondary, required this.tertiary, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart new file mode 100644 index 0000000000..ed9b94695c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart @@ -0,0 +1,9 @@ +import 'dart:ui'; + +class AppFlowyOtherColorsColorScheme { + const AppFlowyOtherColorsColorScheme({ + required this.textHighlight, + }); + + final Color textHighlight; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/surface/surface_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/surface/surface_color_scheme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/text/text_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/color_scheme/text/text_color_scheme.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/spacing/spacing.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/spacing/spacing.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart similarity index 100% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/base/default_text_style.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart similarity index 88% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart index 1caaa288e8..d96ca0f557 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/text_style/text_style.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart @@ -1,4 +1,4 @@ -import 'package:appflowy_ui/src/theme/text_style/base/default_text_style.dart'; +import 'package:appflowy_ui/src/theme/definition/text_style/base/default_text_style.dart'; class AppFlowyBaseTextStyle { const AppFlowyBaseTextStyle({ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart deleted file mode 100644 index e502b3b875..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/dimensions.dart +++ /dev/null @@ -1,17 +0,0 @@ -class AppFlowySpacingConstant { - static const double spacing100 = 4; - static const double spacing200 = 6; - static const double spacing300 = 8; - static const double spacing400 = 12; - static const double spacing500 = 16; - static const double spacing600 = 20; -} - -class AppFlowyBorderRadiusConstant { - static const double radius100 = 4; - static const double radius200 = 6; - static const double radius300 = 8; - static const double radius400 = 12; - static const double radius500 = 16; - static const double radius600 = 20; -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart index b800b1bb6c..5f9f66cd2d 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart @@ -1,7 +1,8 @@ export 'appflowy_theme.dart'; -export 'border_radius/border_radius.dart'; -export 'color_scheme/color_scheme.dart'; -export 'data/data.dart'; -export 'dimensions.dart'; -export 'spacing/spacing.dart'; -export 'text_style/text_style.dart'; +export 'data/built_in_themes.dart'; +export 'definition/border_radius/border_radius.dart'; +export 'definition/color_scheme/color_scheme.dart'; +export 'definition/base_theme.dart'; +export 'definition/spacing/spacing.dart'; +export 'definition/shadow/shadow.dart'; +export 'definition/text_style/text_style.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json new file mode 100644 index 0000000000..c46354b599 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json @@ -0,0 +1,984 @@ +{ + "Neutral": { + "100": { + "$type": "color", + "$value": "#f8faff" + }, + "200": { + "$type": "color", + "$value": "#e4e8f5" + }, + "300": { + "$type": "color", + "$value": "#ced3e6" + }, + "400": { + "$type": "color", + "$value": "#b5bbd3" + }, + "500": { + "$type": "color", + "$value": "#989eb7" + }, + "600": { + "$type": "color", + "$value": "#6f748c" + }, + "700": { + "$type": "color", + "$value": "#54596e" + }, + "800": { + "$type": "color", + "$value": "#3c3f4e" + }, + "900": { + "$type": "color", + "$value": "#272930" + }, + "1000": { + "$type": "color", + "$value": "#21232a" + }, + "black": { + "$type": "color", + "$value": "#000000" + }, + "alpha-black-60": { + "$type": "color", + "$value": "#00000099" + }, + "white": { + "$type": "color", + "$value": "#ffffff" + }, + "alpha-white-0": { + "$type": "color", + "$value": "#ffffff00" + }, + "alpha-white-20": { + "$type": "color", + "$value": "#ffffff33" + }, + "alpha-white-30": { + "$type": "color", + "$value": "#ffffff4d" + }, + "alpha-grey-100-05": { + "$type": "color", + "$value": "#f9fafd0d" + }, + "alpha-grey-100-10": { + "$type": "color", + "$value": "#f9fafd1a" + }, + "alpha-grey-1000-05": { + "$type": "color", + "$value": "#1f23290d" + }, + "alpha-grey-1000-10": { + "$type": "color", + "$value": "#1f23291a" + }, + "alpha-grey-1000-70": { + "$type": "color", + "$value": "#1f2329b2" + }, + "alpha-grey-1000-80": { + "$type": "color", + "$value": "#1f2329cc" + } + }, + "Blue": { + "100": { + "$type": "color", + "$value": "#e3f6ff" + }, + "200": { + "$type": "color", + "$value": "#a9e2ff" + }, + "300": { + "$type": "color", + "$value": "#80d2ff" + }, + "400": { + "$type": "color", + "$value": "#4ec1ff" + }, + "500": { + "$type": "color", + "$value": "#00b5ff" + }, + "600": { + "$type": "color", + "$value": "#0092d6" + }, + "700": { + "$type": "color", + "$value": "#0078c0" + }, + "800": { + "$type": "color", + "$value": "#0065a9" + }, + "900": { + "$type": "color", + "$value": "#00508f" + }, + "1000": { + "$type": "color", + "$value": "#003c77" + }, + "alpha-blue-500-15": { + "$type": "color", + "$value": "#00b5ff26" + } + }, + "Green": { + "100": { + "$type": "color", + "$value": "#ecf9f5" + }, + "200": { + "$type": "color", + "$value": "#c3e5d8" + }, + "300": { + "$type": "color", + "$value": "#9ad1bc" + }, + "400": { + "$type": "color", + "$value": "#71bd9f" + }, + "500": { + "$type": "color", + "$value": "#48a982" + }, + "600": { + "$type": "color", + "$value": "#248569" + }, + "700": { + "$type": "color", + "$value": "#29725d" + }, + "800": { + "$type": "color", + "$value": "#2e6050" + }, + "900": { + "$type": "color", + "$value": "#305548" + }, + "1000": { + "$type": "color", + "$value": "#305244" + } + }, + "Purple": { + "100": { + "$type": "color", + "$value": "#f1e0ff" + }, + "200": { + "$type": "color", + "$value": "#e1b3ff" + }, + "300": { + "$type": "color", + "$value": "#d185ff" + }, + "400": { + "$type": "color", + "$value": "#bc58ff" + }, + "500": { + "$type": "color", + "$value": "#9327ff" + }, + "600": { + "$type": "color", + "$value": "#7a1dcc" + }, + "700": { + "$type": "color", + "$value": "#6617b3" + }, + "800": { + "$type": "color", + "$value": "#55138f" + }, + "900": { + "$type": "color", + "$value": "#470c72" + }, + "1000": { + "$type": "color", + "$value": "#380758" + } + }, + "Magenta": { + "100": { + "$type": "color", + "$value": "#ffe5ef" + }, + "200": { + "$type": "color", + "$value": "#ffb8d1" + }, + "300": { + "$type": "color", + "$value": "#ff8ab2" + }, + "400": { + "$type": "color", + "$value": "#ff5c93" + }, + "500": { + "$type": "color", + "$value": "#fb006d" + }, + "600": { + "$type": "color", + "$value": "#d2005f" + }, + "700": { + "$type": "color", + "$value": "#d2005f" + }, + "800": { + "$type": "color", + "$value": "#850040" + }, + "900": { + "$type": "color", + "$value": "#610031" + }, + "1000": { + "$type": "color", + "$value": "#400022" + } + }, + "Red": { + "100": { + "$type": "color", + "$value": "#ffd2dd" + }, + "200": { + "$type": "color", + "$value": "#ffa5b4" + }, + "300": { + "$type": "color", + "$value": "#ff7d87" + }, + "400": { + "$type": "color", + "$value": "#ff5050" + }, + "500": { + "$type": "color", + "$value": "#f33641" + }, + "600": { + "$type": "color", + "$value": "#e71d32" + }, + "700": { + "$type": "color", + "$value": "#ad1625" + }, + "800": { + "$type": "color", + "$value": "#8c101c" + }, + "900": { + "$type": "color", + "$value": "#6e0a1e" + }, + "1000": { + "$type": "color", + "$value": "#4c0a17" + }, + "alpha-red-500-10": { + "$type": "color", + "$value": "#f336411a" + } + }, + "Orange": { + "100": { + "$type": "color", + "$value": "#fff3d5" + }, + "200": { + "$type": "color", + "$value": "#ffe4ab" + }, + "300": { + "$type": "color", + "$value": "#ffd181" + }, + "400": { + "$type": "color", + "$value": "#ffbe62" + }, + "500": { + "$type": "color", + "$value": "#ffa02e" + }, + "600": { + "$type": "color", + "$value": "#db7e21" + }, + "700": { + "$type": "color", + "$value": "#b75f17" + }, + "800": { + "$type": "color", + "$value": "#93450e" + }, + "900": { + "$type": "color", + "$value": "#7a3108" + }, + "1000": { + "$type": "color", + "$value": "#602706" + } + }, + "Yellow": { + "100": { + "$type": "color", + "$value": "#fff9b2" + }, + "200": { + "$type": "color", + "$value": "#ffec66" + }, + "300": { + "$type": "color", + "$value": "#ffdf1a" + }, + "400": { + "$type": "color", + "$value": "#ffcc00" + }, + "500": { + "$type": "color", + "$value": "#ffce00" + }, + "600": { + "$type": "color", + "$value": "#e6b800" + }, + "700": { + "$type": "color", + "$value": "#cc9f00" + }, + "800": { + "$type": "color", + "$value": "#b38a00" + }, + "900": { + "$type": "color", + "$value": "#9a7500" + }, + "1000": { + "$type": "color", + "$value": "#7f6200" + } + }, + "Subtle_Color": { + "Rose": { + "100": { + "$type": "color", + "$value": "#fcf2f2" + }, + "200": { + "$type": "color", + "$value": "#fae3e3" + }, + "300": { + "$type": "color", + "$value": "#fad9d9" + }, + "400": { + "$type": "color", + "$value": "#edadad" + }, + "500": { + "$type": "color", + "$value": "#cc4e4e" + }, + "600": { + "$type": "color", + "$value": "#702828" + } + }, + "Papaya": { + "100": { + "$type": "color", + "$value": "#fcf4f0" + }, + "200": { + "$type": "color", + "$value": "#fae8de" + }, + "300": { + "$type": "color", + "$value": "#fadfd2" + }, + "400": { + "$type": "color", + "$value": "#f0bda3" + }, + "500": { + "$type": "color", + "$value": "#d67240" + }, + "600": { + "$type": "color", + "$value": "#6b3215" + } + }, + "Tangerine": { + "100": { + "$type": "color", + "$value": "#fff7ed" + }, + "200": { + "$type": "color", + "$value": "#fcedd9" + }, + "300": { + "$type": "color", + "$value": "#fae5ca" + }, + "400": { + "$type": "color", + "$value": "#f2cb99" + }, + "500": { + "$type": "color", + "$value": "#db8f2c" + }, + "600": { + "$type": "color", + "$value": "#613b0a" + } + }, + "Mango": { + "100": { + "$type": "color", + "$value": "#fff9ec" + }, + "200": { + "$type": "color", + "$value": "#fcf1d7" + }, + "300": { + "$type": "color", + "$value": "#fae9c3" + }, + "400": { + "$type": "color", + "$value": "#f5d68e" + }, + "500": { + "$type": "color", + "$value": "#e0a416" + }, + "600": { + "$type": "color", + "$value": "#5c4102" + } + }, + "Lemon": { + "100": { + "$type": "color", + "$value": "#fffbe8" + }, + "200": { + "$type": "color", + "$value": "#fcf5cf" + }, + "300": { + "$type": "color", + "$value": "#faefb9" + }, + "400": { + "$type": "color", + "$value": "#f5e282" + }, + "500": { + "$type": "color", + "$value": "#e0bb00" + }, + "600": { + "$type": "color", + "$value": "#574800" + } + }, + "Olive": { + "100": { + "$type": "color", + "$value": "#f9fae6" + }, + "200": { + "$type": "color", + "$value": "#f6f7d0" + }, + "300": { + "$type": "color", + "$value": "#f0f2b3" + }, + "400": { + "$type": "color", + "$value": "#dbde83" + }, + "500": { + "$type": "color", + "$value": "#adb204" + }, + "600": { + "$type": "color", + "$value": "#4a4c03" + } + }, + "Lime": { + "100": { + "$type": "color", + "$value": "#f6f9e6" + }, + "200": { + "$type": "color", + "$value": "#eef5ce" + }, + "300": { + "$type": "color", + "$value": "#e7f0bb" + }, + "400": { + "$type": "color", + "$value": "#cfdb91" + }, + "500": { + "$type": "color", + "$value": "#92a822" + }, + "600": { + "$type": "color", + "$value": "#414d05" + } + }, + "Grass": { + "100": { + "$type": "color", + "$value": "#f4faeb" + }, + "200": { + "$type": "color", + "$value": "#e9f5d7" + }, + "300": { + "$type": "color", + "$value": "#def0c5" + }, + "400": { + "$type": "color", + "$value": "#bfd998" + }, + "500": { + "$type": "color", + "$value": "#75a828" + }, + "600": { + "$type": "color", + "$value": "#334d0c" + } + }, + "Forest": { + "100": { + "$type": "color", + "$value": "#f1faf0" + }, + "200": { + "$type": "color", + "$value": "#e2f5df" + }, + "300": { + "$type": "color", + "$value": "#d7f0d3" + }, + "400": { + "$type": "color", + "$value": "#a8d6a1" + }, + "500": { + "$type": "color", + "$value": "#49a33b" + }, + "600": { + "$type": "color", + "$value": "#1e4f16" + } + }, + "Jade": { + "100": { + "$type": "color", + "$value": "#f0faf6" + }, + "200": { + "$type": "color", + "$value": "#dff5eb" + }, + "300": { + "$type": "color", + "$value": "#cef0e1" + }, + "400": { + "$type": "color", + "$value": "#90d1b5" + }, + "500": { + "$type": "color", + "$value": "#1c9963" + }, + "600": { + "$type": "color", + "$value": "#075231" + } + }, + "Aqua": { + "100": { + "$type": "color", + "$value": "#f0f9fa" + }, + "200": { + "$type": "color", + "$value": "#dff3f5" + }, + "300": { + "$type": "color", + "$value": "#ccecf0" + }, + "400": { + "$type": "color", + "$value": "#83ccd4" + }, + "500": { + "$type": "color", + "$value": "#008e9e" + }, + "600": { + "$type": "color", + "$value": "#004e57" + } + }, + "Azure": { + "100": { + "$type": "color", + "$value": "#f0f6fa" + }, + "200": { + "$type": "color", + "$value": "#e1eef7" + }, + "300": { + "$type": "color", + "$value": "#d3e6f5" + }, + "400": { + "$type": "color", + "$value": "#88c0eb" + }, + "500": { + "$type": "color", + "$value": "#0877cc" + }, + "600": { + "$type": "color", + "$value": "#154469" + } + }, + "Denim": { + "100": { + "$type": "color", + "$value": "#f0f3fa" + }, + "200": { + "$type": "color", + "$value": "#e3ebfa" + }, + "300": { + "$type": "color", + "$value": "#d7e2f7" + }, + "400": { + "$type": "color", + "$value": "#9ab6ed" + }, + "500": { + "$type": "color", + "$value": "#3267d1" + }, + "600": { + "$type": "color", + "$value": "#223c70" + } + }, + "Mauve": { + "100": { + "$type": "color", + "$value": "#f2f2fc" + }, + "200": { + "$type": "color", + "$value": "#e6e6fa" + }, + "300": { + "$type": "color", + "$value": "#dcdcf7" + }, + "400": { + "$type": "color", + "$value": "#aeaef5" + }, + "500": { + "$type": "color", + "$value": "#5555e0" + }, + "600": { + "$type": "color", + "$value": "#36366b" + } + }, + "Lavender": { + "100": { + "$type": "color", + "$value": "#f6f3fc" + }, + "200": { + "$type": "color", + "$value": "#ebe3fa" + }, + "300": { + "$type": "color", + "$value": "#e4daf7" + }, + "400": { + "$type": "color", + "$value": "#c1aaf0" + }, + "500": { + "$type": "color", + "$value": "#8153db" + }, + "600": { + "$type": "color", + "$value": "#462f75" + } + }, + "Lilac": { + "100": { + "$type": "color", + "$value": "#f7f0fa" + }, + "200": { + "$type": "color", + "$value": "#f0e1f7" + }, + "300": { + "$type": "color", + "$value": "#edd7f7" + }, + "400": { + "$type": "color", + "$value": "#d3a9e8" + }, + "500": { + "$type": "color", + "$value": "#9e4cc7" + }, + "600": { + "$type": "color", + "$value": "#562d6b" + } + }, + "Mallow": { + "100": { + "$type": "color", + "$value": "#faf0fa" + }, + "200": { + "$type": "color", + "$value": "#f5e1f4" + }, + "300": { + "$type": "color", + "$value": "#f5d7f4" + }, + "400": { + "$type": "color", + "$value": "#dea4dc" + }, + "500": { + "$type": "color", + "$value": "#b240af" + }, + "600": { + "$type": "color", + "$value": "#632861" + } + }, + "Camellia": { + "100": { + "$type": "color", + "$value": "#f9eff3" + }, + "200": { + "$type": "color", + "$value": "#f7e1eb" + }, + "300": { + "$type": "color", + "$value": "#f7d7e5" + }, + "400": { + "$type": "color", + "$value": "#e5a3c0" + }, + "500": { + "$type": "color", + "$value": "#c24279" + }, + "600": { + "$type": "color", + "$value": "#6e2343" + } + }, + "Smoke": { + "100": { + "$type": "color", + "$value": "#f5f5f5" + }, + "200": { + "$type": "color", + "$value": "#e8e8e8" + }, + "300": { + "$type": "color", + "$value": "#dedede" + }, + "400": { + "$type": "color", + "$value": "#b8b8b8" + }, + "500": { + "$type": "color", + "$value": "#6e6e6e" + }, + "600": { + "$type": "color", + "$value": "#404040" + } + }, + "Iron": { + "100": { + "$type": "color", + "$value": "#f2f4f7" + }, + "200": { + "$type": "color", + "$value": "#e6e9f0" + }, + "300": { + "$type": "color", + "$value": "#dadee5" + }, + "400": { + "$type": "color", + "$value": "#b0b5bf" + }, + "500": { + "$type": "color", + "$value": "#666f80" + }, + "600": { + "$type": "color", + "$value": "#394152" + } + } + }, + "Spacing": { + "0": { + "$type": "dimension", + "$value": "0px" + }, + "100": { + "$type": "dimension", + "$value": "4px" + }, + "200": { + "$type": "dimension", + "$value": "6px" + }, + "300": { + "$type": "dimension", + "$value": "8px" + }, + "400": { + "$type": "dimension", + "$value": "12px" + }, + "500": { + "$type": "dimension", + "$value": "16px" + }, + "600": { + "$type": "dimension", + "$value": "20px" + }, + "1000": { + "$type": "dimension", + "$value": "1000px" + } + }, + "Border-Radius": { + "0": { + "$type": "dimension", + "$value": "0px" + }, + "100": { + "$type": "dimension", + "$value": "4px" + }, + "200": { + "$type": "dimension", + "$value": "6px" + }, + "300": { + "$type": "dimension", + "$value": "8px" + }, + "400": { + "$type": "dimension", + "$value": "12px" + }, + "500": { + "$type": "dimension", + "$value": "16px" + }, + "600": { + "$type": "dimension", + "$value": "20px" + }, + "1000": { + "$type": "dimension", + "$value": "1000px" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json new file mode 100644 index 0000000000..99d266c008 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json @@ -0,0 +1,1039 @@ +{ + "Text": { + "primary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "inverse": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "on-fill": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "theme": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "action": { + "$type": "color", + "$value": "{Blue.500}" + }, + "action-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-hover": { + "$type": "color", + "$value": "{Red.400}" + }, + "purple": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Icon": { + "primary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "purple-thick": { + "$type": "color", + "$value": "#ffffff" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "#ffffff" + } + }, + "Border": { + "grey-primary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "grey-primary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "grey-secondary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "grey-secondary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "grey-tertiary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "grey-tertiary-hover": { + "$type": "color", + "$value": "{Neutral.700}" + }, + "grey-quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "grey-quaternary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.400}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Fill": { + "primary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "primary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "secondary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary-hover": { + "$type": "color", + "$value": "{Neutral.500}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "quaternary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "primary-alpha-5": { + "$type": "color", + "$value": "{Neutral.alpha-grey-100-05}", + "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." + }, + "primary-alpha-5-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-100-10}" + }, + "primary-alpha-80": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-80}" + }, + "primary-alpha-80-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-70}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "white-alpha": { + "$type": "color", + "$value": "{Neutral.alpha-white-20}" + }, + "white-alpha-hover": { + "$type": "color", + "$value": "{Neutral.alpha-white-30}" + }, + "black": { + "$type": "color", + "$value": "{Neutral.black}" + }, + "theme-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "theme-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.400}" + }, + "theme-select": { + "$type": "color", + "$value": "{Blue.alpha-blue-500-15}" + }, + "info-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "info-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-light": { + "$type": "color", + "$value": "{Green.100}" + }, + "success-light-hover": { + "$type": "color", + "$value": "{Green.200}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-light": { + "$type": "color", + "$value": "{Orange.100}" + }, + "warning-light-hover": { + "$type": "color", + "$value": "{Orange.200}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-light": { + "$type": "color", + "$value": "{Red.100}" + }, + "error-light-hover": { + "$type": "color", + "$value": "{Red.200}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-select": { + "$type": "color", + "$value": "{Red.alpha-red-500-10}" + }, + "purple-light": { + "$type": "color", + "$value": "{Purple.100}" + }, + "purple-light-hover": { + "$type": "color", + "$value": "{Purple.200}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + } + }, + "Surface": { + "primary": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "overlay": { + "$type": "color", + "$value": "{Neutral.alpha-black-60}" + } + }, + "Background": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.700}" + } + }, + "Badge_Color": { + "Rose": { + "rose-light-1": { + "$type": "color", + "$value": "#fcf2f2" + }, + "rose-light-2": { + "$type": "color", + "$value": "#fae3e3" + }, + "rose-light-3": { + "$type": "color", + "$value": "#fad9d9" + }, + "rose-thick-1": { + "$type": "color", + "$value": "#edadad" + }, + "rose-thick-2": { + "$type": "color", + "$value": "#cc4e4e" + }, + "rose-thick-3": { + "$type": "color", + "$value": "#702828" + } + }, + "Papaya": { + "papaya-light-1": { + "$type": "color", + "$value": "#fcf4f0" + }, + "papaya-light-2": { + "$type": "color", + "$value": "#fae8de" + }, + "papaya-light-3": { + "$type": "color", + "$value": "#fadfd2" + }, + "papaya-thick-1": { + "$type": "color", + "$value": "#f0bda3" + }, + "papaya-thick-2": { + "$type": "color", + "$value": "#d67240" + }, + "papaya-thick-3": { + "$type": "color", + "$value": "#6b3215" + } + }, + "Tangerine": { + "tangerine-light-1": { + "$type": "color", + "$value": "#fff7ed" + }, + "tangerine-light-2": { + "$type": "color", + "$value": "#fcedd9" + }, + "tangerine-light-3": { + "$type": "color", + "$value": "#fae5ca" + }, + "tangerine-thick-1": { + "$type": "color", + "$value": "#f2cb99" + }, + "tangerine-thick-2": { + "$type": "color", + "$value": "#db8f2c" + }, + "tangerine-thick-3": { + "$type": "color", + "$value": "#613b0a" + } + }, + "Mango": { + "mango-light-1": { + "$type": "color", + "$value": "#fff9ec" + }, + "mango-light-2": { + "$type": "color", + "$value": "#fcf1d7" + }, + "mango-light-3": { + "$type": "color", + "$value": "#fae9c3" + }, + "mango-thick-1": { + "$type": "color", + "$value": "#f5d68e" + }, + "mango-thick-2": { + "$type": "color", + "$value": "#e0a416" + }, + "mango-thick-3": { + "$type": "color", + "$value": "#5c4102" + } + }, + "Lemon": { + "lemon-light-1": { + "$type": "color", + "$value": "#fffbe8" + }, + "lemon-light-2": { + "$type": "color", + "$value": "#fcf5cf" + }, + "lemon-light-3": { + "$type": "color", + "$value": "#faefb9" + }, + "lemon-thick-1": { + "$type": "color", + "$value": "#f5e282" + }, + "lemon-thick-2": { + "$type": "color", + "$value": "#e0bb00" + }, + "lemon-thick-3": { + "$type": "color", + "$value": "#574800" + } + }, + "Olive": { + "olive-light-1": { + "$type": "color", + "$value": "#f9fae6" + }, + "olive-light-2": { + "$type": "color", + "$value": "#f6f7d0" + }, + "olive-light-3": { + "$type": "color", + "$value": "#f0f2b3" + }, + "olive-thick-1": { + "$type": "color", + "$value": "#dbde83" + }, + "olive-thick-2": { + "$type": "color", + "$value": "#adb204" + }, + "olive-thick-3": { + "$type": "color", + "$value": "#4a4c03" + } + }, + "Lime": { + "lime-light-1": { + "$type": "color", + "$value": "#f6f9e6" + }, + "lime-light-2": { + "$type": "color", + "$value": "#eef5ce" + }, + "lime-light-3": { + "$type": "color", + "$value": "#e7f0bb" + }, + "lime-thick-1": { + "$type": "color", + "$value": "#cfdb91" + }, + "lime-thick-2": { + "$type": "color", + "$value": "#92a822" + }, + "lime-thick-3": { + "$type": "color", + "$value": "#414d05" + } + }, + "Grass": { + "grass-light-1": { + "$type": "color", + "$value": "#f4faeb" + }, + "grass-light-2": { + "$type": "color", + "$value": "#e9f5d7" + }, + "grass-light-3": { + "$type": "color", + "$value": "#def0c5" + }, + "grass-thick-1": { + "$type": "color", + "$value": "#bfd998" + }, + "grass-thick-2": { + "$type": "color", + "$value": "#75a828" + }, + "grass-thick-3": { + "$type": "color", + "$value": "#334d0c" + } + }, + "Forest": { + "forest-light-1": { + "$type": "color", + "$value": "#f1faf0" + }, + "forest-light-2": { + "$type": "color", + "$value": "#e2f5df" + }, + "forest-light-3": { + "$type": "color", + "$value": "#d7f0d3" + }, + "forest-thick-1": { + "$type": "color", + "$value": "#a8d6a1" + }, + "forest-thick-2": { + "$type": "color", + "$value": "#49a33b" + }, + "forest-thick-3": { + "$type": "color", + "$value": "#1e4f16" + } + }, + "Jade": { + "jade-light-1": { + "$type": "color", + "$value": "#f0faf6" + }, + "jade-light-2": { + "$type": "color", + "$value": "#dff5eb" + }, + "jade-light-3": { + "$type": "color", + "$value": "#cef0e1" + }, + "jade-thick-1": { + "$type": "color", + "$value": "#90d1b5" + }, + "jade-thick-2": { + "$type": "color", + "$value": "#1c9963" + }, + "jade-thick-3": { + "$type": "color", + "$value": "#075231" + } + }, + "Aqua": { + "aqua-light-1": { + "$type": "color", + "$value": "#f0f9fa" + }, + "aqua-light-2": { + "$type": "color", + "$value": "#dff3f5" + }, + "aqua-light-3": { + "$type": "color", + "$value": "#ccecf0" + }, + "aqua-thick-1": { + "$type": "color", + "$value": "#83ccd4" + }, + "aqua-thick-2": { + "$type": "color", + "$value": "#008e9e" + }, + "aqua-thick-3": { + "$type": "color", + "$value": "#004e57" + } + }, + "Azure": { + "azure-light-1": { + "$type": "color", + "$value": "#f0f6fa" + }, + "azure-light-2": { + "$type": "color", + "$value": "#e1eef7" + }, + "azure-light-3": { + "$type": "color", + "$value": "#d3e6f5" + }, + "azure-thick-1": { + "$type": "color", + "$value": "#88c0eb" + }, + "azure-thick-2": { + "$type": "color", + "$value": "#0877cc" + }, + "azure-thick-3": { + "$type": "color", + "$value": "#154469" + } + }, + "Denim": { + "denim-light-1": { + "$type": "color", + "$value": "#f0f3fa" + }, + "denim-light-2": { + "$type": "color", + "$value": "#e3ebfa" + }, + "denim-light-3": { + "$type": "color", + "$value": "#d7e2f7" + }, + "denim-thick-1": { + "$type": "color", + "$value": "#9ab6ed" + }, + "denim-thick-2": { + "$type": "color", + "$value": "#3267d1" + }, + "denim-thick-3": { + "$type": "color", + "$value": "#223c70" + } + }, + "Mauve": { + "mauve-light-1": { + "$type": "color", + "$value": "#f2f2fc" + }, + "mauve-thick-2": { + "$type": "color", + "$value": "#5555e0" + }, + "mauve-thick-3": { + "$type": "color", + "$value": "#36366b" + }, + "mauve-thick-1": { + "$type": "color", + "$value": "#aeaef5" + } + }, + "Lavender": { + "lavender-light-1": { + "$type": "color", + "$value": "#f6f3fc" + }, + "lavender-light-2": { + "$type": "color", + "$value": "#ebe3fa" + }, + "lavender-light-3": { + "$type": "color", + "$value": "#e4daf7" + }, + "lavender-thick-1": { + "$type": "color", + "$value": "#c1aaf0" + }, + "lavender-thick-2": { + "$type": "color", + "$value": "#8153db" + }, + "lavender-thick-3": { + "$type": "color", + "$value": "#462f75" + } + }, + "Lilac": { + "liliac-light-1": { + "$type": "color", + "$value": "#f7f0fa" + }, + "liliac-light-2": { + "$type": "color", + "$value": "#f0e1f7" + }, + "liliac-light-3": { + "$type": "color", + "$value": "#edd7f7" + }, + "liliac-thick-1": { + "$type": "color", + "$value": "#d3a9e8" + }, + "liliac-thick-2": { + "$type": "color", + "$value": "#9e4cc7" + }, + "liliac-thick-3": { + "$type": "color", + "$value": "#562d6b" + } + }, + "Mallow": { + "mallow-light-1": { + "$type": "color", + "$value": "#faf0fa" + }, + "mallow-light-2": { + "$type": "color", + "$value": "#f5e1f4" + }, + "mallow-light-3": { + "$type": "color", + "$value": "#f5d7f4" + }, + "mallow-thick-1": { + "$type": "color", + "$value": "#dea4dc" + }, + "mallow-thick-2": { + "$type": "color", + "$value": "#b240af" + }, + "mallow-thick-3": { + "$type": "color", + "$value": "#632861" + } + }, + "Camellia": { + "camellia-light-1": { + "$type": "color", + "$value": "#f9eff3" + }, + "camellia-light-2": { + "$type": "color", + "$value": "#f7e1eb" + }, + "camellia-light-3": { + "$type": "color", + "$value": "#f7d7e5" + }, + "camellia-thick-1": { + "$type": "color", + "$value": "#e5a3c0" + }, + "camellia-thick-2": { + "$type": "color", + "$value": "#c24279" + }, + "camellia-thick-3": { + "$type": "color", + "$value": "#6e2343" + } + }, + "Smoke": { + "smoke-light-1": { + "$type": "color", + "$value": "#f5f5f5" + }, + "smoke-light-2": { + "$type": "color", + "$value": "#e8e8e8" + }, + "smoke-light-3": { + "$type": "color", + "$value": "#dedede" + }, + "smoke-thick-1": { + "$type": "color", + "$value": "#b8b8b8" + }, + "smoke-thick-2": { + "$type": "color", + "$value": "#6e6e6e" + }, + "smoke-thick-3": { + "$type": "color", + "$value": "#404040" + } + }, + "Iron": { + "icon-light-1": { + "$type": "color", + "$value": "#f2f4f7" + }, + "icon-light-2": { + "$type": "color", + "$value": "#e6e9f0" + }, + "icon-light-3": { + "$type": "color", + "$value": "#dadee5" + }, + "icon-thick-1": { + "$type": "color", + "$value": "#b0b5bf" + }, + "icon-thick-2": { + "$type": "color", + "$value": "#666f80" + }, + "icon-thick-3": { + "$type": "color", + "$value": "#394152" + } + } + }, + "Shadow": { + "sm": { + "$type": "dimension", + "$value": "0px" + }, + "md": { + "$type": "dimension", + "$value": "0px" + } + }, + "Brand": { + "Skyline": { + "$type": "color", + "$value": "#00b5ff" + }, + "Aqua": { + "$type": "color", + "$value": "#00c8ff" + }, + "Violet": { + "$type": "color", + "$value": "#9327ff" + }, + "Amethyst": { + "$type": "color", + "$value": "#8427e0" + }, + "Berry": { + "$type": "color", + "$value": "#e3006d" + }, + "Coral": { + "$type": "color", + "$value": "#fb006d" + }, + "Golden": { + "$type": "color", + "$value": "#f7931e" + }, + "Amber": { + "$type": "color", + "$value": "#ffbd00" + }, + "Lemon": { + "$type": "color", + "$value": "#ffce00" + } + }, + "Other_Colors": { + "text-highlight": { + "$type": "color", + "$value": "{Blue.200}" + } + }, + "Spacing": { + "spacing-0": { + "$type": "dimension", + "$value": "{Spacing.0}" + }, + "spacing-xs": { + "$type": "dimension", + "$value": "{Spacing.100}" + }, + "spacing-s": { + "$type": "dimension", + "$value": "{Spacing.200}" + }, + "spacing-m": { + "$type": "dimension", + "$value": "{Spacing.300}" + }, + "spacing-l": { + "$type": "dimension", + "$value": "{Spacing.400}" + }, + "spacing-xl": { + "$type": "dimension", + "$value": "{Spacing.500}" + }, + "spacing-xxl": { + "$type": "dimension", + "$value": "{Spacing.600}" + }, + "spacing-full": { + "$type": "dimension", + "$value": "{Spacing.1000}" + } + }, + "Border_Radius": { + "border-radius-0": { + "$type": "dimension", + "$value": "{Border-Radius.0}" + }, + "border-radius-xs": { + "$type": "dimension", + "$value": "{Border-Radius.100}" + }, + "border-radius-s": { + "$type": "dimension", + "$value": "{Border-Radius.200}" + }, + "border-radius-m": { + "$type": "dimension", + "$value": "{Border-Radius.300}" + }, + "border-radius-l": { + "$type": "dimension", + "$value": "{Border-Radius.400}" + }, + "border-radius-xl": { + "$type": "dimension", + "$value": "{Border-Radius.500}" + }, + "border-radius-xxl": { + "$type": "dimension", + "$value": "{Border-Radius.600}" + }, + "border-radius-full": { + "$type": "dimension", + "$value": "{Border-Radius.1000}" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json new file mode 100644 index 0000000000..4e6b0543dc --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json @@ -0,0 +1,1039 @@ +{ + "Text": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "inverse": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "on-fill": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "theme": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "action": { + "$type": "color", + "$value": "{Blue.500}" + }, + "action-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "purple": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Icon": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Border": { + "grey-primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "grey-primary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "grey-secondary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "grey-secondary-hover": { + "$type": "color", + "$value": "{Neutral.700}" + }, + "grey-tertiary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "grey-tertiary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "grey-quaternary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "grey-quaternary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Fill": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "primary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "secondary-hover": { + "$type": "color", + "$value": "{Neutral.500}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "tertiary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "quaternary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "primary-alpha-5": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-05}", + "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." + }, + "primary-alpha-5-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-10}" + }, + "primary-alpha-80": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-80}" + }, + "primary-alpha-80-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-70}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "white-alpha": { + "$type": "color", + "$value": "{Neutral.alpha-white-20}" + }, + "white-alpha-hover": { + "$type": "color", + "$value": "{Neutral.alpha-white-30}" + }, + "black": { + "$type": "color", + "$value": "{Neutral.black}" + }, + "theme-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "theme-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "theme-select": { + "$type": "color", + "$value": "{Blue.alpha-blue-500-15}" + }, + "info-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "info-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-light": { + "$type": "color", + "$value": "{Green.100}" + }, + "success-light-hover": { + "$type": "color", + "$value": "{Green.200}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-light": { + "$type": "color", + "$value": "{Orange.100}" + }, + "warning-light-hover": { + "$type": "color", + "$value": "{Orange.200}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-light": { + "$type": "color", + "$value": "{Red.100}" + }, + "error-light-hover": { + "$type": "color", + "$value": "{Red.200}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "error-select": { + "$type": "color", + "$value": "{Red.alpha-red-500-10}" + }, + "purple-light": { + "$type": "color", + "$value": "{Purple.100}" + }, + "purple-light-hover": { + "$type": "color", + "$value": "{Purple.200}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + } + }, + "Surface": { + "primary": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "overlay": { + "$type": "color", + "$value": "{Neutral.alpha-black-60}" + } + }, + "Background": { + "primary": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.300}" + } + }, + "Badge_Color": { + "Rose": { + "rose-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Rose.100}" + }, + "rose-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Rose.200}" + }, + "rose-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Rose.300}" + }, + "rose-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Rose.400}" + }, + "rose-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Rose.500}" + }, + "rose-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Rose.600}" + } + }, + "Papaya": { + "papaya-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.100}" + }, + "papaya-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.200}" + }, + "papaya-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.300}" + }, + "papaya-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.400}" + }, + "papaya-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.500}" + }, + "papaya-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.600}" + } + }, + "Tangerine": { + "tangerine-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.100}" + }, + "tangerine-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.200}" + }, + "tangerine-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.300}" + }, + "tangerine-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.400}" + }, + "tangerine-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.500}" + }, + "tangerine-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.600}" + } + }, + "Mango": { + "mango-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mango.100}" + }, + "mango-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Mango.200}" + }, + "mango-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Mango.300}" + }, + "mango-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mango.400}" + }, + "mango-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mango.500}" + }, + "mango-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mango.600}" + } + }, + "Lemon": { + "lemon-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.100}" + }, + "lemon-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.200}" + }, + "lemon-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.300}" + }, + "lemon-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.400}" + }, + "lemon-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.500}" + }, + "lemon-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.600}" + } + }, + "Olive": { + "olive-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Olive.100}" + }, + "olive-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Olive.200}" + }, + "olive-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Olive.300}" + }, + "olive-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Olive.400}" + }, + "olive-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Olive.500}" + }, + "olive-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Olive.600}" + } + }, + "Lime": { + "lime-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lime.100}" + }, + "lime-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lime.200}" + }, + "lime-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lime.300}" + }, + "lime-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lime.400}" + }, + "lime-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lime.500}" + }, + "lime-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lime.600}" + } + }, + "Grass": { + "grass-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Grass.100}" + }, + "grass-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Grass.200}" + }, + "grass-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Grass.300}" + }, + "grass-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Grass.400}" + }, + "grass-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Grass.500}" + }, + "grass-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Grass.600}" + } + }, + "Forest": { + "forest-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Forest.100}" + }, + "forest-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Forest.200}" + }, + "forest-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Forest.300}" + }, + "forest-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Forest.400}" + }, + "forest-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Forest.500}" + }, + "forest-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Forest.600}" + } + }, + "Jade": { + "jade-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Jade.100}" + }, + "jade-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Jade.200}" + }, + "jade-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Jade.300}" + }, + "jade-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Jade.400}" + }, + "jade-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Jade.500}" + }, + "jade-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Jade.600}" + } + }, + "Aqua": { + "aqua-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.100}" + }, + "aqua-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.200}" + }, + "aqua-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.300}" + }, + "aqua-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.400}" + }, + "aqua-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.500}" + }, + "aqua-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.600}" + } + }, + "Azure": { + "azure-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Azure.100}" + }, + "azure-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Azure.200}" + }, + "azure-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Azure.300}" + }, + "azure-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Azure.400}" + }, + "azure-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Azure.500}" + }, + "azure-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Azure.600}" + } + }, + "Denim": { + "denim-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Denim.100}" + }, + "denim-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Denim.200}" + }, + "denim-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Denim.300}" + }, + "denim-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Denim.400}" + }, + "denim-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Denim.500}" + }, + "denim-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Denim.600}" + } + }, + "Mauve": { + "mauve-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.100}" + }, + "mauve-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.500}" + }, + "mauve-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.600}" + }, + "mauve-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.400}" + } + }, + "Lavender": { + "lavender-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.100}" + }, + "lavender-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.200}" + }, + "lavender-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.300}" + }, + "lavender-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.400}" + }, + "lavender-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.500}" + }, + "lavender-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.600}" + } + }, + "Lilac": { + "liliac-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.100}" + }, + "liliac-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.200}" + }, + "liliac-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.300}" + }, + "liliac-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.400}" + }, + "liliac-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.500}" + }, + "liliac-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.600}" + } + }, + "Mallow": { + "mallow-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.100}" + }, + "mallow-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.200}" + }, + "mallow-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.300}" + }, + "mallow-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.400}" + }, + "mallow-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.500}" + }, + "mallow-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.600}" + } + }, + "Camellia": { + "camellia-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.100}" + }, + "camellia-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.200}" + }, + "camellia-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.300}" + }, + "camellia-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.400}" + }, + "camellia-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.500}" + }, + "camellia-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.600}" + } + }, + "Smoke": { + "smoke-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.100}" + }, + "smoke-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.200}" + }, + "smoke-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.300}" + }, + "smoke-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.400}" + }, + "smoke-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.500}" + }, + "smoke-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.600}" + } + }, + "Iron": { + "icon-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Iron.100}" + }, + "icon-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Iron.200}" + }, + "icon-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Iron.300}" + }, + "icon-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Iron.400}" + }, + "icon-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Iron.500}" + }, + "icon-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Iron.600}" + } + } + }, + "Shadow": { + "sm": { + "$type": "dimension", + "$value": "0px" + }, + "md": { + "$type": "dimension", + "$value": "0px" + } + }, + "Brand": { + "Skyline": { + "$type": "color", + "$value": "#00b5ff" + }, + "Aqua": { + "$type": "color", + "$value": "#00c8ff" + }, + "Violet": { + "$type": "color", + "$value": "#9327ff" + }, + "Amethyst": { + "$type": "color", + "$value": "#8427e0" + }, + "Berry": { + "$type": "color", + "$value": "#e3006d" + }, + "Coral": { + "$type": "color", + "$value": "#fb006d" + }, + "Golden": { + "$type": "color", + "$value": "#f7931e" + }, + "Amber": { + "$type": "color", + "$value": "#ffbd00" + }, + "Lemon": { + "$type": "color", + "$value": "#ffce00" + } + }, + "Other_Colors": { + "text-highlight": { + "$type": "color", + "$value": "{Blue.200}" + } + }, + "Spacing": { + "spacing-0": { + "$type": "dimension", + "$value": "{Spacing.0}" + }, + "spacing-xs": { + "$type": "dimension", + "$value": "{Spacing.100}" + }, + "spacing-s": { + "$type": "dimension", + "$value": "{Spacing.200}" + }, + "spacing-m": { + "$type": "dimension", + "$value": "{Spacing.300}" + }, + "spacing-l": { + "$type": "dimension", + "$value": "{Spacing.400}" + }, + "spacing-xl": { + "$type": "dimension", + "$value": "{Spacing.500}" + }, + "spacing-xxl": { + "$type": "dimension", + "$value": "{Spacing.600}" + }, + "spacing-full": { + "$type": "dimension", + "$value": "{Spacing.1000}" + } + }, + "Border_Radius": { + "border-radius-0": { + "$type": "dimension", + "$value": "{Border-Radius.0}" + }, + "border-radius-xs": { + "$type": "dimension", + "$value": "{Border-Radius.100}" + }, + "border-radius-s": { + "$type": "dimension", + "$value": "{Border-Radius.200}" + }, + "border-radius-m": { + "$type": "dimension", + "$value": "{Border-Radius.300}" + }, + "border-radius-l": { + "$type": "dimension", + "$value": "{Border-Radius.400}" + }, + "border-radius-xl": { + "$type": "dimension", + "$value": "{Border-Radius.500}" + }, + "border-radius-xxl": { + "$type": "dimension", + "$value": "{Border-Radius.600}" + }, + "border-radius-full": { + "$type": "dimension", + "$value": "{Border-Radius.1000}" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart new file mode 100644 index 0000000000..9c9c8a45ff --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart @@ -0,0 +1,269 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; + +void main() { + generatePrimitive(); + generateSemantic(); +} + +void generatePrimitive() { + // 1. Load the JSON file. + final jsonString = + File('script/Primitive.Mode 1.tokens.json').readAsStringSync(); + final jsonData = jsonDecode(jsonString) as Map; + + // 2. Prepare the output code. + final buffer = StringBuffer(); + + buffer.writeln(''' +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: ${DateTime.now().toIso8601String()} +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:flutter/material.dart'; + +class AppFlowyPrimitiveTokens { + AppFlowyPrimitiveTokens._();'''); + + // 3. Process each color category. + jsonData.forEach((categoryName, categoryData) { + if (categoryData is Map) { + categoryData.forEach((tokenName, tokenData) { + if (tokenData is Map && + tokenData['\$type'] == 'color') { + final colorValue = tokenData['\$value'] as String; + final dartColorValue = convertColor(colorValue); + final dartTokenName = + '${categoryName}_$tokenName'.replaceAll('-', '_').toCamelCase(); + + buffer.writeln(''' + + /// $colorValue + static Color get $dartTokenName => Color(0x$dartColorValue);'''); + } + }); + } + }); + + buffer.writeln('}'); + + // 4. Write the output to a Dart file. + final outputFile = File('lib/src/theme/data/appflowy_default/primitive.dart'); + outputFile.writeAsStringSync(buffer.toString()); + + print('Successfully generated ${outputFile.path}'); +} + +void generateSemantic() { + // 1. Load the JSON file. + final lightJsonString = + File('script/Semantic.Light Mode.tokens.json').readAsStringSync(); + final darkJsonString = + File('script/Semantic.Dark Mode.tokens.json').readAsStringSync(); + final lightJsonData = jsonDecode(lightJsonString) as Map; + final darkJsonData = jsonDecode(darkJsonString) as Map; + + // 2. Prepare the output code. + final buffer = StringBuffer(); + + buffer.writeln(''' +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: ${DateTime.now().toIso8601String()} +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import '../builder.dart'; +import 'primitive.dart'; + +class AppFlowyThemeData implements AppFlowyBaseThemeData { + const AppFlowyThemeData._({ + required this.textStyle, + required this.textColorScheme, + required this.borderColorScheme, + required this.fillColorScheme, + required this.surfaceColorScheme, + required this.borderRadius, + required this.spacing, + required this.shadow, + required this.brandColorScheme, + required this.iconColorScheme, + required this.backgroundColorScheme, + required this.otherColorsColorScheme, + }); +'''); + + // 3. Process light mode semantic tokens + void writeThemeFactory(String brightness, Map jsonData) { + buffer.writeln(''' + factory AppFlowyThemeData.$brightness() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = themeBuilder.buildBorderRadius(); + final spacing = themeBuilder.buildSpacing(); + final shadow = themeBuilder.buildShadow(Brightness.$brightness);'''); + + jsonData.forEach((categoryName, categoryData) { + if (categoryData is Map) { + final hasNonColorType = categoryData.values.any( + (element) => + element is Map && element['\$type'] != 'color', + ); + if (hasNonColorType) { + return; + } + + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); + + categoryData.forEach((tokenName, tokenData) { + if (tokenData is Map && + tokenData['\$type'] == 'color') { + final semanticTokenName = + tokenName.replaceAll('-', '_').toCamelCase(); + + final value = tokenData['\$value'] as String; + final String colorOrPrimitiveToken; + if (value.isColor) { + colorOrPrimitiveToken = 'Color(0x${convertColor(value)})'; + } else { + final primitiveToken = value + .replaceAll('{', '') + .replaceAll('}', '') + .replaceAll('.', '_') + .replaceAll('-', '_') + .toCamelCase(); + colorOrPrimitiveToken = 'AppFlowyPrimitiveTokens.$primitiveToken'; + } + + buffer.writeln(' $semanticTokenName: $colorOrPrimitiveToken,'); + } + }); + buffer.writeln(' );'); + } + }); + + buffer.writeln(); + buffer.writeln(''' + return AppFlowyThemeData._( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + }'''); + } + + writeThemeFactory('light', lightJsonData); + buffer.writeln(); + writeThemeFactory('dark', darkJsonData); + + buffer.writeln(''' + + static const AppFlowyThemeBuilder themeBuilder = AppFlowyThemeBuilder(); + + @override + final AppFlowyBaseTextStyle textStyle; + + @override + final AppFlowyTextColorScheme textColorScheme; + + @override + final AppFlowyBorderColorScheme borderColorScheme; + + @override + final AppFlowyFillColorScheme fillColorScheme; + + @override + final AppFlowySurfaceColorScheme surfaceColorScheme; + + @override + final AppFlowyBorderRadius borderRadius; + + @override + final AppFlowySpacing spacing; + + @override + final AppFlowyShadow shadow; + + @override + final AppFlowyBrandColorScheme brandColorScheme; + + @override + final AppFlowyIconColorScheme iconColorScheme; + + @override + final AppFlowyBackgroundColorScheme backgroundColorScheme; + + @override + final AppFlowyOtherColorsColorScheme otherColorsColorScheme;'''); + buffer.writeln('}'); + + // 4. Write the output to a Dart file. + final outputFile = File('lib/src/theme/data/appflowy_default/semantic.dart'); + outputFile.writeAsStringSync(buffer.toString()); + + print('Successfully generated ${outputFile.path}'); +} + +String convertColor(String hexColor) { + String color = hexColor.toUpperCase().replaceAll('#', ''); + if (color.length == 6) { + color = 'FF$color'; // Add missing alpha channel + } else if (color.length == 8) { + color = color.substring(6) + color.substring(0, 6); // Rearrange to ARGB + } + return color; +} + +extension on String { + String toCamelCase() { + return split('_').mapIndexed((index, part) { + if (index == 0) { + return part.toLowerCase(); + } else { + return part[0].toUpperCase() + part.substring(1).toLowerCase(); + } + }).join(); + } + + String toCapitalize() { + if (isEmpty) { + return this; + } + return '${this[0].toUpperCase()}${substring(1)}'; + } + + bool get isColor => + startsWith('#') || + (startsWith('0x') && length == 10) || + (startsWith('0xFF') && length == 12); +} From 28e89beb432605d01c46db9829c866c056bf32a7 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 18 Apr 2025 14:14:38 +0800 Subject: [PATCH 186/207] feat: appflowy theme lerp (#7771) * feat: implement appflowy theme lerping * refactor: use theme builder and adjust script * chore: rename theme data * chore: add doc comments * chore: rename function * chore: don't use theme extension * chore: use the animated appflowy theme widget * chore: clean up inherited theme --- .../lib/startup/tasks/app_widget.dart | 47 ++++---- .../appearance/mobile_appearance.dart | 17 +-- .../appflowy_ui/example/lib/main.dart | 12 +- .../src/component/textfield/textfield.dart | 4 +- .../lib/src/theme/appflowy_theme.dart | 105 ++++++++++++++++-- .../data/appflowy_default/primitive.dart | 2 +- .../theme/data/appflowy_default/semantic.dart | 81 +++----------- .../src/theme/data/custom/custom_theme.dart | 23 ++++ .../theme/data/{builder.dart => shared.dart} | 10 +- .../lib/src/theme/definition/base_theme.dart | 33 ------ .../color_scheme/background_color_scheme.dart | 12 ++ .../color_scheme/border_color_scheme.dart | 36 ++++++ .../color_scheme/brand_color_scheme.dart | 17 +++ .../color_scheme/fill_color_scheme.dart | 59 ++++++++++ .../color_scheme/icon_color_scheme.dart | 16 +++ .../color_scheme/other_color_scheme.dart | 9 ++ .../color_scheme/surface_color_scheme.dart | 10 ++ .../color_scheme/text_color_scheme.dart | 28 +++++ .../lib/src/theme/definition/theme_data.dart | 86 ++++++++++++++ .../appflowy_ui/lib/src/theme/theme.dart | 2 +- .../appflowy_ui/script/generate_theme.dart | 78 +++---------- 21 files changed, 467 insertions(+), 220 deletions(-) create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart rename frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/{builder.dart => shared.dart} (92%) delete mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 0ef21267aa..98b76802d4 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -234,27 +234,34 @@ class _ApplicationWidgetState extends State { supportedLocales: context.supportedLocales, locale: state.locale, routerConfig: routerConfig, - builder: (context, child) => AppFlowyTheme( - data: Theme.of(context).brightness == Brightness.light - ? AppFlowyThemeData.light() - : AppFlowyThemeData.dark(), - child: MediaQuery( - // use the 1.0 as the textScaleFactor to avoid the text size - // affected by the system setting. - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(state.textScaleFactor), + builder: (context, child) { + final themeBuilder = AppFlowyDefaultTheme(); + final brightness = Theme.of(context).brightness; + + return AnimatedAppFlowyTheme( + data: brightness == Brightness.light + ? themeBuilder.light() + : themeBuilder.dark(), + child: MediaQuery( + // use the 1.0 as the textScaleFactor to avoid the text size + // affected by the system setting. + data: MediaQuery.of(context).copyWith( + textScaler: + TextScaler.linear(state.textScaleFactor), + ), + child: overlayManagerBuilder( + context, + !UniversalPlatform.isMobile && + FeatureFlag.search.isOn + ? CommandPalette( + notifier: _commandPaletteNotifier, + child: child, + ) + : child, + ), ), - child: overlayManagerBuilder( - context, - !UniversalPlatform.isMobile && FeatureFlag.search.isOn - ? CommandPalette( - notifier: _commandPaletteNotifier, - child: child, - ) - : child, - ), - ), - ), + ); + }, ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index eda3153459..46eddd53ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -28,13 +28,12 @@ class MobileAppearance extends BaseAppearance { fontWeight: FontWeight.w400, ); + final isLight = brightness == Brightness.light; final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); - final theme = brightness == Brightness.light - ? appTheme.lightTheme - : appTheme.darkTheme; + final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; - final colorTheme = brightness == Brightness.light + final colorTheme = isLight ? ColorScheme( brightness: brightness, primary: _primaryColor, @@ -71,13 +70,9 @@ class MobileAppearance extends BaseAppearance { onSurface: const Color(0xffC5C6C7), // text/body color surfaceContainerHighest: theme.sidebarBg, ); - final hintColor = brightness == Brightness.light - ? const Color(0x991F2329) - : _hintColorInDarkMode; - final onBackground = - brightness == Brightness.light ? _onBackgroundColor : Colors.white; - final background = - brightness == Brightness.light ? Colors.white : const Color(0xff121212); + final hintColor = isLight ? const Color(0x991F2329) : _hintColorInDarkMode; + final onBackground = isLight ? _onBackgroundColor : Colors.white; + final background = isLight ? Colors.white : const Color(0xff121212); return ThemeData( useMaterial3: false, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart index c02001745d..0d23746ebd 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -26,16 +26,20 @@ class MyApp extends StatelessWidget { return ValueListenableBuilder( valueListenable: themeMode, builder: (context, themeMode, child) { + final themeBuilder = AppFlowyDefaultTheme(); final themeData = themeMode == ThemeMode.light ? ThemeData.light() : ThemeData.dark(); - return AppFlowyTheme( + + return AnimatedAppFlowyTheme( data: themeMode == ThemeMode.light - ? AppFlowyThemeData.light() - : AppFlowyThemeData.dark(), + ? themeBuilder.light() + : themeBuilder.dark(), child: MaterialApp( debugShowCheckedModeBanner: false, title: 'AppFlowy UI Example', - theme: themeData.copyWith(visualDensity: VisualDensity.standard), + theme: themeData.copyWith( + visualDensity: VisualDensity.standard, + ), home: const MyHomePage( title: 'AppFlowy UI', ), diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart index 9e61b71709..3f5ad4cfed 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -233,7 +233,7 @@ enum AFTextFieldSize { m, l; - EdgeInsetsGeometry contentPadding(AppFlowyBaseThemeData theme) { + EdgeInsetsGeometry contentPadding(AppFlowyThemeData theme) { return EdgeInsets.symmetric( vertical: switch (this) { AFTextFieldSize.m => theme.spacing.s, @@ -243,7 +243,7 @@ enum AFTextFieldSize { ); } - BorderRadius borderRadius(AppFlowyBaseThemeData theme) { + BorderRadius borderRadius(AppFlowyThemeData theme) { return BorderRadius.circular( switch (this) { AFTextFieldSize.m => theme.borderRadius.m, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart index 8a99b737ec..26e45ca8f1 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart @@ -1,5 +1,6 @@ -import 'package:appflowy_ui/src/theme/definition/base_theme.dart'; -import 'package:flutter/widgets.dart'; +import 'package:appflowy_ui/src/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; class AppFlowyTheme extends StatelessWidget { const AppFlowyTheme({ @@ -8,10 +9,10 @@ class AppFlowyTheme extends StatelessWidget { required this.child, }); - final AppFlowyBaseThemeData data; + final AppFlowyThemeData data; final Widget child; - static AppFlowyBaseThemeData of(BuildContext context, {bool listen = true}) { + static AppFlowyThemeData of(BuildContext context, {bool listen = true}) { final provider = maybeOf(context, listen: listen); if (provider == null) { throw FlutterError( @@ -26,26 +27,26 @@ class AppFlowyTheme extends StatelessWidget { return provider; } - static AppFlowyBaseThemeData? maybeOf( + static AppFlowyThemeData? maybeOf( BuildContext context, { bool listen = true, }) { if (listen) { return context .dependOnInheritedWidgetOfExactType() - ?.theme; + ?.themeData; } final provider = context .getElementForInheritedWidgetOfExactType() ?.widget; - return (provider as AppFlowyInheritedTheme?)?.theme; + return (provider as AppFlowyInheritedTheme?)?.themeData; } @override Widget build(BuildContext context) { return AppFlowyInheritedTheme( - theme: data, + themeData: data, child: child, ); } @@ -54,18 +55,98 @@ class AppFlowyTheme extends StatelessWidget { class AppFlowyInheritedTheme extends InheritedTheme { const AppFlowyInheritedTheme({ super.key, - required this.theme, + required this.themeData, required super.child, }); - final AppFlowyBaseThemeData theme; + final AppFlowyThemeData themeData; @override Widget wrap(BuildContext context, Widget child) { - return AppFlowyTheme(data: theme, child: child); + return AppFlowyTheme(data: themeData, child: child); } @override bool updateShouldNotify(AppFlowyInheritedTheme oldWidget) => - theme != oldWidget.theme; + themeData != oldWidget.themeData; +} + +/// An interpolation between two [AppFlowyThemeData]s. +/// +/// This class specializes the interpolation of [Tween] to +/// call the [AppFlowyThemeData.lerp] method. +/// +/// See [Tween] for a discussion on how to use interpolation objects. +class AppFlowyThemeDataTween extends Tween { + /// Creates a [AppFlowyThemeData] tween. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + AppFlowyThemeDataTween({super.begin, super.end}); + + @override + AppFlowyThemeData lerp(double t) => AppFlowyThemeData.lerp(begin!, end!, t); +} + +class AnimatedAppFlowyTheme extends ImplicitlyAnimatedWidget { + /// Creates an animated theme. + /// + /// By default, the theme transition uses a linear curve. + const AnimatedAppFlowyTheme({ + super.key, + required this.data, + super.curve, + super.duration = kThemeAnimationDuration, + super.onEnd, + required this.child, + }); + + /// Specifies the color and typography values for descendant widgets. + final AppFlowyThemeData data; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + AnimatedWidgetBaseState createState() => + _AnimatedThemeState(); +} + +class _AnimatedThemeState + extends AnimatedWidgetBaseState { + AppFlowyThemeDataTween? data; + + @override + void forEachTween(TweenVisitor visitor) { + data = visitor( + data, + widget.data, + (dynamic value) => + AppFlowyThemeDataTween(begin: value as AppFlowyThemeData), + )! as AppFlowyThemeDataTween; + } + + @override + Widget build(BuildContext context) { + return AppFlowyTheme( + data: data!.evaluate(animation), + child: widget.child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add( + DiagnosticsProperty( + 'data', + data, + showName: false, + defaultValue: null, + ), + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart index 1eee06953f..790db31660 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart @@ -3,7 +3,7 @@ // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script -// Generation time: 2025-04-15T23:05:39.278903 +// Generation time: 2025-04-16T22:13:33.297893 // // To modify these colors, edit the source JSON files and run the script: // diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart index 08206a6572..03e80da962 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart @@ -3,7 +3,7 @@ // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script -// Generation time: 2025-04-15T23:05:39.288085 +// Generation time: 2025-04-16T22:13:33.307397 // // To modify these colors, edit the source JSON files and run the script: // @@ -12,30 +12,16 @@ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; -import '../builder.dart'; +import '../shared.dart'; import 'primitive.dart'; -class AppFlowyThemeData implements AppFlowyBaseThemeData { - const AppFlowyThemeData._({ - required this.textStyle, - required this.textColorScheme, - required this.borderColorScheme, - required this.fillColorScheme, - required this.surfaceColorScheme, - required this.borderRadius, - required this.spacing, - required this.shadow, - required this.brandColorScheme, - required this.iconColorScheme, - required this.backgroundColorScheme, - required this.otherColorsColorScheme, - }); - - factory AppFlowyThemeData.light() { +class AppFlowyDefaultTheme implements AppFlowyThemeBuilder { + @override + AppFlowyThemeData light() { final textStyle = AppFlowyBaseTextStyle(); - final borderRadius = themeBuilder.buildBorderRadius(); - final spacing = themeBuilder.buildSpacing(); - final shadow = themeBuilder.buildShadow(Brightness.light); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.light); final textColorScheme = AppFlowyTextColorScheme( primary: AppFlowyPrimitiveTokens.neutral1000, @@ -168,7 +154,7 @@ class AppFlowyThemeData implements AppFlowyBaseThemeData { textHighlight: AppFlowyPrimitiveTokens.blue200, ); - return AppFlowyThemeData._( + return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, @@ -184,11 +170,12 @@ class AppFlowyThemeData implements AppFlowyBaseThemeData { ); } - factory AppFlowyThemeData.dark() { + @override + AppFlowyThemeData dark() { final textStyle = AppFlowyBaseTextStyle(); - final borderRadius = themeBuilder.buildBorderRadius(); - final spacing = themeBuilder.buildSpacing(); - final shadow = themeBuilder.buildShadow(Brightness.dark); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark); final textColorScheme = AppFlowyTextColorScheme( primary: AppFlowyPrimitiveTokens.neutral200, @@ -321,7 +308,7 @@ class AppFlowyThemeData implements AppFlowyBaseThemeData { textHighlight: AppFlowyPrimitiveTokens.blue200, ); - return AppFlowyThemeData._( + return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, @@ -336,42 +323,4 @@ class AppFlowyThemeData implements AppFlowyBaseThemeData { shadow: shadow, ); } - - static const AppFlowyThemeBuilder themeBuilder = AppFlowyThemeBuilder(); - - @override - final AppFlowyBaseTextStyle textStyle; - - @override - final AppFlowyTextColorScheme textColorScheme; - - @override - final AppFlowyBorderColorScheme borderColorScheme; - - @override - final AppFlowyFillColorScheme fillColorScheme; - - @override - final AppFlowySurfaceColorScheme surfaceColorScheme; - - @override - final AppFlowyBorderRadius borderRadius; - - @override - final AppFlowySpacing spacing; - - @override - final AppFlowyShadow shadow; - - @override - final AppFlowyBrandColorScheme brandColorScheme; - - @override - final AppFlowyIconColorScheme iconColorScheme; - - @override - final AppFlowyBackgroundColorScheme backgroundColorScheme; - - @override - final AppFlowyOtherColorsColorScheme otherColorsColorScheme; } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart new file mode 100644 index 0000000000..6ef43076c5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart @@ -0,0 +1,23 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; + +class CustomTheme implements AppFlowyThemeBuilder { + const CustomTheme({ + required this.lightThemeJson, + required this.darkThemeJson, + }); + + final Map lightThemeJson; + final Map darkThemeJson; + + @override + AppFlowyThemeData light() { + // TODO: implement light + throw UnimplementedError(); + } + + @override + AppFlowyThemeData dark() { + // TODO: implement dark + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart similarity index 92% rename from frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart rename to frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart index a4f83109cb..c9c3c3adb0 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart @@ -21,10 +21,10 @@ class AppFlowyBorderRadiusConstant { static const double radius600 = 20; } -class AppFlowyThemeBuilder { - const AppFlowyThemeBuilder(); +class AppFlowySharedTokens { + const AppFlowySharedTokens(); - AppFlowyBorderRadius buildBorderRadius() { + static AppFlowyBorderRadius buildBorderRadius() { return AppFlowyBorderRadius( xs: AppFlowyBorderRadiusConstant.radius100, s: AppFlowyBorderRadiusConstant.radius200, @@ -35,7 +35,7 @@ class AppFlowyThemeBuilder { ); } - AppFlowySpacing buildSpacing() { + static AppFlowySpacing buildSpacing() { return AppFlowySpacing( xs: AppFlowySpacingConstant.spacing100, s: AppFlowySpacingConstant.spacing200, @@ -46,7 +46,7 @@ class AppFlowyThemeBuilder { ); } - AppFlowyShadow buildShadow( + static AppFlowyShadow buildShadow( Brightness brightness, ) { return switch (brightness) { diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart deleted file mode 100644 index ca06c8da1b..0000000000 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/base_theme.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'border_radius/border_radius.dart'; -import 'color_scheme/color_scheme.dart'; -import 'shadow/shadow.dart'; -import 'spacing/spacing.dart'; -import 'text_style/text_style.dart'; - -abstract class AppFlowyBaseThemeData { - const AppFlowyBaseThemeData(); - - AppFlowyTextColorScheme get textColorScheme; - - AppFlowyBaseTextStyle get textStyle; - - AppFlowyIconColorScheme get iconColorScheme; - - AppFlowyBorderColorScheme get borderColorScheme; - - AppFlowyBackgroundColorScheme get backgroundColorScheme; - - AppFlowyFillColorScheme get fillColorScheme; - - AppFlowySurfaceColorScheme get surfaceColorScheme; - - AppFlowyBorderRadius get borderRadius; - - AppFlowySpacing get spacing; - - AppFlowyShadow get shadow; - - AppFlowyBrandColorScheme get brandColorScheme; - - AppFlowyOtherColorsColorScheme get otherColorsColorScheme; -} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart index 547c7f1635..c7324c34fe 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart @@ -12,4 +12,16 @@ class AppFlowyBackgroundColorScheme { final Color secondary; final Color tertiary; final Color quaternary; + + AppFlowyBackgroundColorScheme lerp( + AppFlowyBackgroundColorScheme other, + double t, + ) { + return AppFlowyBackgroundColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart index d1618b6cff..28eee5b145 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart @@ -46,4 +46,40 @@ class AppFlowyBorderColorScheme { final Color errorThickHover; final Color purpleThick; final Color purpleThickHover; + + AppFlowyBorderColorScheme lerp( + AppFlowyBorderColorScheme other, + double t, + ) { + return AppFlowyBorderColorScheme( + greyPrimary: Color.lerp(greyPrimary, other.greyPrimary, t)!, + greyPrimaryHover: + Color.lerp(greyPrimaryHover, other.greyPrimaryHover, t)!, + greySecondary: Color.lerp(greySecondary, other.greySecondary, t)!, + greySecondaryHover: + Color.lerp(greySecondaryHover, other.greySecondaryHover, t)!, + greyTertiary: Color.lerp(greyTertiary, other.greyTertiary, t)!, + greyTertiaryHover: + Color.lerp(greyTertiaryHover, other.greyTertiaryHover, t)!, + greyQuaternary: Color.lerp(greyQuaternary, other.greyQuaternary, t)!, + greyQuaternaryHover: + Color.lerp(greyQuaternaryHover, other.greyQuaternaryHover, t)!, + transparent: Color.lerp(transparent, other.transparent, t)!, + themeThick: Color.lerp(themeThick, other.themeThick, t)!, + themeThickHover: Color.lerp(themeThickHover, other.themeThickHover, t)!, + infoThick: Color.lerp(infoThick, other.infoThick, t)!, + infoThickHover: Color.lerp(infoThickHover, other.infoThickHover, t)!, + successThick: Color.lerp(successThick, other.successThick, t)!, + successThickHover: + Color.lerp(successThickHover, other.successThickHover, t)!, + warningThick: Color.lerp(warningThick, other.warningThick, t)!, + warningThickHover: + Color.lerp(warningThickHover, other.warningThickHover, t)!, + errorThick: Color.lerp(errorThick, other.errorThick, t)!, + errorThickHover: Color.lerp(errorThickHover, other.errorThickHover, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart index 8374d2bbfd..4140f6924a 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart @@ -22,4 +22,21 @@ class AppFlowyBrandColorScheme { final Color golden; final Color amber; final Color lemon; + + AppFlowyBrandColorScheme lerp( + AppFlowyBrandColorScheme other, + double t, + ) { + return AppFlowyBrandColorScheme( + skyline: Color.lerp(skyline, other.skyline, t)!, + aqua: Color.lerp(aqua, other.aqua, t)!, + violet: Color.lerp(violet, other.violet, t)!, + amethyst: Color.lerp(amethyst, other.amethyst, t)!, + berry: Color.lerp(berry, other.berry, t)!, + coral: Color.lerp(coral, other.coral, t)!, + golden: Color.lerp(golden, other.golden, t)!, + amber: Color.lerp(amber, other.amber, t)!, + lemon: Color.lerp(lemon, other.lemon, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart index 8616bde4eb..3faac64dfc 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart @@ -90,4 +90,63 @@ class AppFlowyFillColorScheme { final Color purpleLightHover; final Color purpleThick; final Color purpleThickHover; + + AppFlowyFillColorScheme lerp( + AppFlowyFillColorScheme other, + double t, + ) { + return AppFlowyFillColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + tertiaryHover: Color.lerp(tertiaryHover, other.tertiaryHover, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + quaternaryHover: Color.lerp(quaternaryHover, other.quaternaryHover, t)!, + transparent: Color.lerp(transparent, other.transparent, t)!, + primaryAlpha5: Color.lerp(primaryAlpha5, other.primaryAlpha5, t)!, + primaryAlpha5Hover: + Color.lerp(primaryAlpha5Hover, other.primaryAlpha5Hover, t)!, + primaryAlpha80: Color.lerp(primaryAlpha80, other.primaryAlpha80, t)!, + primaryAlpha80Hover: + Color.lerp(primaryAlpha80Hover, other.primaryAlpha80Hover, t)!, + white: Color.lerp(white, other.white, t)!, + whiteAlpha: Color.lerp(whiteAlpha, other.whiteAlpha, t)!, + whiteAlphaHover: Color.lerp(whiteAlphaHover, other.whiteAlphaHover, t)!, + black: Color.lerp(black, other.black, t)!, + themeLight: Color.lerp(themeLight, other.themeLight, t)!, + themeLightHover: Color.lerp(themeLightHover, other.themeLightHover, t)!, + themeThick: Color.lerp(themeThick, other.themeThick, t)!, + themeThickHover: Color.lerp(themeThickHover, other.themeThickHover, t)!, + themeSelect: Color.lerp(themeSelect, other.themeSelect, t)!, + infoLight: Color.lerp(infoLight, other.infoLight, t)!, + infoLightHover: Color.lerp(infoLightHover, other.infoLightHover, t)!, + infoThick: Color.lerp(infoThick, other.infoThick, t)!, + infoThickHover: Color.lerp(infoThickHover, other.infoThickHover, t)!, + successLight: Color.lerp(successLight, other.successLight, t)!, + successLightHover: + Color.lerp(successLightHover, other.successLightHover, t)!, + successThick: Color.lerp(successThick, other.successThick, t)!, + successThickHover: + Color.lerp(successThickHover, other.successThickHover, t)!, + warningLight: Color.lerp(warningLight, other.warningLight, t)!, + warningLightHover: + Color.lerp(warningLightHover, other.warningLightHover, t)!, + warningThick: Color.lerp(warningThick, other.warningThick, t)!, + warningThickHover: + Color.lerp(warningThickHover, other.warningThickHover, t)!, + errorLight: Color.lerp(errorLight, other.errorLight, t)!, + errorLightHover: Color.lerp(errorLightHover, other.errorLightHover, t)!, + errorThick: Color.lerp(errorThick, other.errorThick, t)!, + errorThickHover: Color.lerp(errorThickHover, other.errorThickHover, t)!, + errorSelect: Color.lerp(errorSelect, other.errorSelect, t)!, + purpleLight: Color.lerp(purpleLight, other.purpleLight, t)!, + purpleLightHover: + Color.lerp(purpleLightHover, other.purpleLightHover, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart index 245a02aadb..efe59b8b99 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart @@ -18,4 +18,20 @@ class AppFlowyIconColorScheme { final Color white; final Color purpleThick; final Color purpleThickHover; + + AppFlowyIconColorScheme lerp( + AppFlowyIconColorScheme other, + double t, + ) { + return AppFlowyIconColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + white: Color.lerp(white, other.white, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart index ed9b94695c..9bb21e54e6 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart @@ -6,4 +6,13 @@ class AppFlowyOtherColorsColorScheme { }); final Color textHighlight; + + AppFlowyOtherColorsColorScheme lerp( + AppFlowyOtherColorsColorScheme other, + double t, + ) { + return AppFlowyOtherColorsColorScheme( + textHighlight: Color.lerp(textHighlight, other.textHighlight, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart index 8fdc21adef..67be450a04 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart @@ -8,4 +8,14 @@ class AppFlowySurfaceColorScheme { final Color primary; final Color overlay; + + AppFlowySurfaceColorScheme lerp( + AppFlowySurfaceColorScheme other, + double t, + ) { + return AppFlowySurfaceColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + overlay: Color.lerp(overlay, other.overlay, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart index 486378643f..17e1f057ce 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart @@ -44,4 +44,32 @@ class AppFlowyTextColorScheme { final Color errorHover; final Color purple; final Color purpleHover; + + AppFlowyTextColorScheme lerp( + AppFlowyTextColorScheme other, + double t, + ) { + return AppFlowyTextColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + inverse: Color.lerp(inverse, other.inverse, t)!, + onFill: Color.lerp(onFill, other.onFill, t)!, + theme: Color.lerp(theme, other.theme, t)!, + themeHover: Color.lerp(themeHover, other.themeHover, t)!, + action: Color.lerp(action, other.action, t)!, + actionHover: Color.lerp(actionHover, other.actionHover, t)!, + info: Color.lerp(info, other.info, t)!, + infoHover: Color.lerp(infoHover, other.infoHover, t)!, + success: Color.lerp(success, other.success, t)!, + successHover: Color.lerp(successHover, other.successHover, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningHover: Color.lerp(warningHover, other.warningHover, t)!, + error: Color.lerp(error, other.error, t)!, + errorHover: Color.lerp(errorHover, other.errorHover, t)!, + purple: Color.lerp(purple, other.purple, t)!, + purpleHover: Color.lerp(purpleHover, other.purpleHover, t)!, + ); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart new file mode 100644 index 0000000000..515e6b2ecf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart @@ -0,0 +1,86 @@ +import 'border_radius/border_radius.dart'; +import 'color_scheme/color_scheme.dart'; +import 'shadow/shadow.dart'; +import 'spacing/spacing.dart'; +import 'text_style/text_style.dart'; + +/// [AppFlowyThemeData] defines the structure of the design system, and contains +/// the data that all child widgets will have access to. +class AppFlowyThemeData { + const AppFlowyThemeData({ + required this.textColorScheme, + required this.textStyle, + required this.iconColorScheme, + required this.borderColorScheme, + required this.backgroundColorScheme, + required this.fillColorScheme, + required this.surfaceColorScheme, + required this.borderRadius, + required this.spacing, + required this.shadow, + required this.brandColorScheme, + required this.otherColorsColorScheme, + }); + + final AppFlowyTextColorScheme textColorScheme; + + final AppFlowyBaseTextStyle textStyle; + + final AppFlowyIconColorScheme iconColorScheme; + + final AppFlowyBorderColorScheme borderColorScheme; + + final AppFlowyBackgroundColorScheme backgroundColorScheme; + + final AppFlowyFillColorScheme fillColorScheme; + + final AppFlowySurfaceColorScheme surfaceColorScheme; + + final AppFlowyBorderRadius borderRadius; + + final AppFlowySpacing spacing; + + final AppFlowyShadow shadow; + + final AppFlowyBrandColorScheme brandColorScheme; + + final AppFlowyOtherColorsColorScheme otherColorsColorScheme; + + static AppFlowyThemeData lerp( + AppFlowyThemeData begin, + AppFlowyThemeData end, + double t, + ) { + return AppFlowyThemeData( + textColorScheme: begin.textColorScheme.lerp(end.textColorScheme, t), + textStyle: end.textStyle, + iconColorScheme: begin.iconColorScheme.lerp(end.iconColorScheme, t), + borderColorScheme: begin.borderColorScheme.lerp(end.borderColorScheme, t), + backgroundColorScheme: + begin.backgroundColorScheme.lerp(end.backgroundColorScheme, t), + fillColorScheme: begin.fillColorScheme.lerp(end.fillColorScheme, t), + surfaceColorScheme: + begin.surfaceColorScheme.lerp(end.surfaceColorScheme, t), + borderRadius: end.borderRadius, + spacing: end.spacing, + shadow: end.shadow, + brandColorScheme: begin.brandColorScheme.lerp(end.brandColorScheme, t), + otherColorsColorScheme: + begin.otherColorsColorScheme.lerp(end.otherColorsColorScheme, t), + ); + } +} + +/// [AppFlowyThemeBuilder] is used to build the light and dark themes. Extend +/// this class to create a built-in theme, or use the [CustomTheme] class to +/// create a custom theme from JSON data. +/// +/// See also: +/// +/// - [AppFlowyThemeData] for the main theme data class. +abstract class AppFlowyThemeBuilder { + const AppFlowyThemeBuilder(); + + AppFlowyThemeData light(); + AppFlowyThemeData dark(); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart index 5f9f66cd2d..000b7a0372 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart @@ -2,7 +2,7 @@ export 'appflowy_theme.dart'; export 'data/built_in_themes.dart'; export 'definition/border_radius/border_radius.dart'; export 'definition/color_scheme/color_scheme.dart'; -export 'definition/base_theme.dart'; +export 'definition/theme_data.dart'; export 'definition/spacing/spacing.dart'; export 'definition/shadow/shadow.dart'; export 'definition/text_style/text_style.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart index 9c9c8a45ff..9d429f537e 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print, depend_on_referenced_packages + import 'dart:convert'; import 'dart:io'; @@ -90,34 +92,20 @@ void generateSemantic() { import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; -import '../builder.dart'; +import '../shared.dart'; import 'primitive.dart'; -class AppFlowyThemeData implements AppFlowyBaseThemeData { - const AppFlowyThemeData._({ - required this.textStyle, - required this.textColorScheme, - required this.borderColorScheme, - required this.fillColorScheme, - required this.surfaceColorScheme, - required this.borderRadius, - required this.spacing, - required this.shadow, - required this.brandColorScheme, - required this.iconColorScheme, - required this.backgroundColorScheme, - required this.otherColorsColorScheme, - }); -'''); +class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); // 3. Process light mode semantic tokens - void writeThemeFactory(String brightness, Map jsonData) { + void writeThemeData(String brightness, Map jsonData) { buffer.writeln(''' - factory AppFlowyThemeData.$brightness() { + @override + AppFlowyThemeData $brightness() { final textStyle = AppFlowyBaseTextStyle(); - final borderRadius = themeBuilder.buildBorderRadius(); - final spacing = themeBuilder.buildSpacing(); - final shadow = themeBuilder.buildShadow(Brightness.$brightness);'''); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.$brightness);'''); jsonData.forEach((categoryName, categoryData) { if (categoryData is Map) { @@ -165,7 +153,7 @@ class AppFlowyThemeData implements AppFlowyBaseThemeData { buffer.writeln(); buffer.writeln(''' - return AppFlowyThemeData._( + return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, @@ -182,49 +170,9 @@ class AppFlowyThemeData implements AppFlowyBaseThemeData { }'''); } - writeThemeFactory('light', lightJsonData); + writeThemeData('light', lightJsonData); buffer.writeln(); - writeThemeFactory('dark', darkJsonData); - - buffer.writeln(''' - - static const AppFlowyThemeBuilder themeBuilder = AppFlowyThemeBuilder(); - - @override - final AppFlowyBaseTextStyle textStyle; - - @override - final AppFlowyTextColorScheme textColorScheme; - - @override - final AppFlowyBorderColorScheme borderColorScheme; - - @override - final AppFlowyFillColorScheme fillColorScheme; - - @override - final AppFlowySurfaceColorScheme surfaceColorScheme; - - @override - final AppFlowyBorderRadius borderRadius; - - @override - final AppFlowySpacing spacing; - - @override - final AppFlowyShadow shadow; - - @override - final AppFlowyBrandColorScheme brandColorScheme; - - @override - final AppFlowyIconColorScheme iconColorScheme; - - @override - final AppFlowyBackgroundColorScheme backgroundColorScheme; - - @override - final AppFlowyOtherColorsColorScheme otherColorsColorScheme;'''); + writeThemeData('dark', darkJsonData); buffer.writeln('}'); // 4. Write the output to a Dart file. From edc5710e323c2985c5b46ca8f7a7060b2a9cac9e Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 14:00:51 +0800 Subject: [PATCH 187/207] chore: auth type and remove unused code --- frontend/appflowy_flutter/ios/Podfile.lock | 46 +-- .../user_profile/user_profile_bloc.dart | 13 +- .../bottom_sheet/bottom_sheet_view_page.dart | 2 +- .../favorite/mobile_favorite_page.dart | 8 +- .../presentation/home/mobile_home_page.dart | 14 +- .../home/setting/settings_popup_menu.dart | 2 +- .../home/tab/mobile_space_tab.dart | 3 +- .../mobile_notifications_page.dart | 10 +- .../setting/user_session_setting_group.dart | 2 +- .../application/sync/database_sync_bloc.dart | 6 +- .../database/widgets/row/row_banner.dart | 2 +- .../document/application/document_bloc.dart | 2 +- .../document_collaborators_bloc.dart | 2 +- .../application/document_sync_bloc.dart | 2 +- .../editor_plugins/file/file_util.dart | 8 +- .../page_style/_page_style_cover_image.dart | 2 +- .../lib/plugins/shared/share/share_bloc.dart | 2 +- .../icon_emoji_picker/icon_uploader.dart | 2 +- .../lib/startup/tasks/generate_router.dart | 18 -- .../user/application/encrypt_secret_bloc.dart | 114 -------- .../application/password/password_bloc.dart | 2 +- .../lib/user/application/user_listener.dart | 27 +- .../lib/user/application/user_service.dart | 10 - .../lib/user/presentation/anon_user.dart | 5 +- .../helpers/handle_user_profile_result.dart | 6 +- .../lib/user/presentation/router.dart | 14 - .../screens/encrypt_secret_screen.dart | 130 --------- .../user/presentation/screens/screens.dart | 1 - .../sign_in_screen/sign_in_screen.dart | 7 +- .../presentation/screens/splash_screen.dart | 34 +-- .../workspace/application/home/home_bloc.dart | 17 +- .../application/home/home_setting_bloc.dart | 10 +- .../settings/ai/settings_ai_bloc.dart | 12 +- .../settings/settings_dialog_bloc.dart | 2 +- .../application/user/user_workspace_bloc.dart | 2 +- .../home/desktop_home_screen.dart | 16 +- .../home/menu/sidebar/sidebar.dart | 2 +- .../menu/view/view_more_action_button.dart | 2 +- .../pages/account/account_sign_in_out.dart | 4 +- .../settings/pages/settings_account_view.dart | 31 +- .../pages/settings_workspace_view.dart | 4 +- .../settings/settings_dialog.dart | 2 +- .../widgets/setting_third_party_login.dart | 9 +- .../settings/widgets/settings_menu.dart | 6 +- .../more_view_actions/more_view_actions.dart | 2 +- .../bloc_test/home_test/view_bloc_test.dart | 19 +- .../event-integration-test/src/lib.rs | 27 +- .../event-integration-test/src/user_event.rs | 4 +- .../tests/chat/chat_message_test.rs | 14 +- .../af_cloud_test/file_upload_test.rs | 1 + .../user/af_cloud_test/anon_user_test.rs | 2 +- .../tests/user/af_cloud_test/auth_test.rs | 27 -- .../user/af_cloud_test/workspace_test.rs | 16 +- .../user/local_test/user_profile_test.rs | 29 +- .../rust-lib/flowy-ai/src/event_handler.rs | 2 +- frontend/rust-lib/flowy-core/src/lib.rs | 10 +- .../rust-lib/flowy-core/src/server_layer.rs | 72 +++-- frontend/rust-lib/flowy-error/src/code.rs | 3 + frontend/rust-lib/flowy-error/src/errors.rs | 1 + .../flowy-error/src/impl_from/database.rs | 6 +- .../flowy-folder/src/entities/workspace.rs | 2 +- .../flowy-folder/src/event_handler.rs | 2 +- .../rust-lib/flowy-folder/src/event_map.rs | 2 +- frontend/rust-lib/flowy-folder/src/manager.rs | 8 +- .../flowy-server/src/af_cloud/define.rs | 25 +- .../src/af_cloud/impls/database.rs | 16 +- .../src/af_cloud/impls/document.rs | 6 +- .../flowy-server/src/af_cloud/impls/folder.rs | 12 +- .../af_cloud/impls/user/cloud_service_impl.rs | 67 +++-- .../src/af_cloud/impls/user/dto.rs | 30 +- .../flowy-server/src/af_cloud/impls/util.rs | 7 +- .../flowy-server/src/af_cloud/server.rs | 65 ++--- .../src/local_server/impls/chat.rs | 4 +- .../src/local_server/impls/user.rs | 10 +- .../flowy-server/src/local_server/server.rs | 6 +- .../flowy-server/tests/af_cloud_test/util.rs | 8 +- frontend/rust-lib/flowy-sqlite/src/schema.rs | 37 +-- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 22 +- .../rust-lib/flowy-user-pub/src/entities.rs | 84 +----- .../flowy-user/src/entities/user_profile.rs | 62 +--- .../flowy-user/src/entities/workspace.rs | 48 +++- .../rust-lib/flowy-user/src/event_handler.rs | 145 +++------- frontend/rust-lib/flowy-user/src/event_map.rs | 14 +- .../rust-lib/flowy-user/src/notification.rs | 2 +- .../rust-lib/flowy-user/src/services/db.rs | 17 +- .../flowy-user/src/services/sqlite_sql/mod.rs | 1 + .../src/services/sqlite_sql/user_sql.rs | 41 +-- .../src/services/sqlite_sql/workspace_sql.rs | 129 +++++---- .../flowy-user/src/user_manager/manager.rs | 176 +++++------- .../src/user_manager/manager_history_user.rs | 2 +- .../user_manager/manager_user_awareness.rs | 4 +- .../user_manager/manager_user_encryption.rs | 35 --- .../user_manager/manager_user_workspace.rs | 270 +++++++++--------- 93 files changed, 802 insertions(+), 1407 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 4b7ed5d639..92e52a1a79 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -181,37 +181,37 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 - appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a - connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf - device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0 + appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88 + connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 - flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 - keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 - open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 + keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 + open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490 + saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca diff --git a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart index 1480cc02e9..0527316860 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart @@ -19,14 +19,13 @@ class UserProfileBloc extends Bloc { Future _initialize(Emitter emit) async { emit(const UserProfileState.loading()); - - final workspaceOrFailure = + final latestOrFailure = await FolderEventGetCurrentWorkspaceSetting().send(); final userOrFailure = await getIt().getUser(); - final workspaceSetting = workspaceOrFailure.fold( - (workspaceSettingPB) => workspaceSettingPB, + final latest = latestOrFailure.fold( + (latestPB) => latestPB, (error) => null, ); @@ -35,13 +34,13 @@ class UserProfileBloc extends Bloc { (error) => null, ); - if (workspaceSetting == null || userProfile == null) { + if (latest == null || userProfile == null) { return emit(const UserProfileState.workspaceFailure()); } emit( UserProfileState.success( - workspaceSettings: workspaceSetting, + workspaceSettings: latest, userProfile: userProfile, ), ); @@ -59,7 +58,7 @@ class UserProfileState with _$UserProfileState { const factory UserProfileState.loading() = _Loading; const factory UserProfileState.workspaceFailure() = _WorkspaceFailure; const factory UserProfileState.success({ - required WorkspaceSettingPB workspaceSettings, + required WorkspaceLatestPB workspaceSettings, required UserProfilePB userProfile, }) = _Success; } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 9497774298..85a5e3cbfa 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -203,7 +203,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { final userProfile = context.read().state.userProfilePB; // the publish feature is only available for AppFlowy Cloud if (userProfile == null || - userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + userProfile.authType != AuthenticatorPB.AppFlowyCloud) { return []; } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart index e6d2d895b1..0e7a7cb4c6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart @@ -31,9 +31,9 @@ class MobileFavoriteScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator.adaptive()); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) { - return workspaceSettingPB as WorkspaceSettingPB?; + final latest = snapshots.data?[0].fold( + (latest) { + return latest as WorkspaceLatestPB?; }, (error) => null, ); @@ -46,7 +46,7 @@ class MobileFavoriteScreen extends StatelessWidget { // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. - if (workspaceSetting == null || userProfile == null) { + if (latest == null || userProfile == null) { return const WorkspaceFailedScreen(); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index 1ae5d881ce..fdea8322c3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -44,9 +44,9 @@ class MobileHomeScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator.adaptive()); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) { - return workspaceSettingPB as WorkspaceSettingPB?; + final workspaceLatest = snapshots.data?[0].fold( + (workspaceLatestPB) { + return workspaceLatestPB as WorkspaceLatestPB?; }, (error) => null, ); @@ -59,7 +59,7 @@ class MobileHomeScreen extends StatelessWidget { // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. - if (workspaceSetting == null || userProfile == null) { + if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } @@ -78,7 +78,7 @@ class MobileHomeScreen extends StatelessWidget { value: userProfile, child: MobileHomePage( userProfile: userProfile, - workspaceSetting: workspaceSetting, + workspaceLatest: workspaceLatest, ), ), ), @@ -95,11 +95,11 @@ class MobileHomePage extends StatefulWidget { const MobileHomePage({ super.key, required this.userProfile, - required this.workspaceSetting, + required this.workspaceLatest, }); final UserProfilePB userProfile; - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceLatest; @override State createState() => _MobileHomePageState(); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart index 521fca4fdf..11a82d2c7a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -48,7 +48,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget { text: LocaleKeys.settings_popupMenuItem_settings.tr(), ), // only show the member items in cloud mode - if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + if (userProfile.authType == AuthenticatorPB.AppFlowyCloud) ...[ const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.members, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart index 7ebfeefbbc..cc3f4c8c56 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart @@ -167,8 +167,7 @@ class _MobileSpaceTabState extends State children: [ MobileHomeSpace(userProfile: widget.userProfile), // only show ai chat button for cloud user - if (widget.userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + if (widget.userProfile.authType == AuthenticatorPB.AppFlowyCloud) Positioned( bottom: MediaQuery.of(context).padding.bottom + 16, left: 20, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart index a8055b8ba2..33c2eb3905 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -50,9 +50,9 @@ class _MobileNotificationsScreenState extends State orElse: () => const Center(child: CircularProgressIndicator.adaptive()), workspaceFailure: () => const WorkspaceFailedScreen(), - success: (workspaceSetting, userProfile) => + success: (workspaceLatest, userProfile) => _NotificationScreenContent( - workspaceSetting: workspaceSetting, + workspaceLatest: workspaceLatest, userProfile: userProfile, controller: controller, reminderBloc: reminderBloc, @@ -66,13 +66,13 @@ class _MobileNotificationsScreenState extends State class _NotificationScreenContent extends StatelessWidget { const _NotificationScreenContent({ - required this.workspaceSetting, + required this.workspaceLatest, required this.userProfile, required this.controller, required this.reminderBloc, }); - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceLatest; final UserProfilePB userProfile; final TabController controller; final ReminderBloc reminderBloc; @@ -84,7 +84,7 @@ class _NotificationScreenContent extends StatelessWidget { ..add( SidebarSectionsEvent.initial( userProfile, - workspaceSetting.workspaceId, + workspaceLatest.workspaceId, ), ), child: BlocBuilder( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index 617de1db50..09f38223f4 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -40,7 +40,7 @@ class UserSessionSettingGroup extends StatelessWidget { // delete account button // only show the delete account button in cloud mode - if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + if (userProfile.authType == AuthenticatorPB.AppFlowyCloud) ...[ const VSpace(16.0), MobileLogoutButton( text: LocaleKeys.button_deleteAccount.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart index ae0b9173c7..53dc2bd6d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart @@ -30,9 +30,9 @@ class DatabaseSyncBloc extends Bloc { .then((value) => value.fold((s) => s, (f) => null)); emit( state.copyWith( - shouldShowIndicator: userProfile?.authenticator == - AuthenticatorPB.AppFlowyCloud && - databaseId != null, + shouldShowIndicator: + userProfile?.authType == AuthenticatorPB.AppFlowyCloud && + databaseId != null, ), ); if (databaseId != null) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart index 8d64c537c3..5706167f1a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -69,7 +69,7 @@ class RowBanner extends StatefulWidget { class _RowBannerState extends State { final _isHovering = ValueNotifier(false); late final isLocalMode = - (widget.userProfile?.authenticator ?? AuthenticatorPB.Local) == + (widget.userProfile?.authType ?? AuthenticatorPB.Local) == AuthenticatorPB.Local; @override diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index 010dae1f12..252742aa5e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -101,7 +101,7 @@ class DocumentBloc extends Bloc { bool get isLocalMode { final userProfilePB = state.userProfilePB; - final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; + final type = userProfilePB?.authType ?? AuthenticatorPB.Local; return type == AuthenticatorPB.Local; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart index 74a6199b89..b593ccc6cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart @@ -32,7 +32,7 @@ class DocumentCollaboratorsBloc emit( state.copyWith( shouldShowIndicator: - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + userProfile?.authType == AuthenticatorPB.AppFlowyCloud, ), ); final deviceId = ApplicationInfo.deviceId; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart index 0fae90920d..1001aaef5e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart @@ -31,7 +31,7 @@ class DocumentSyncBloc extends Bloc { emit( state.copyWith( shouldShowIndicator: - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + userProfile?.authType == AuthenticatorPB.AppFlowyCloud, ), ); _syncStateListener.start( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index 83debdd71b..d3bbbd27d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -184,8 +184,8 @@ Future insertLocalFile( final fileType = file.fileType.toMediaFileTypePB(); // Check upload type - final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + final isLocalMode = + (userProfile?.authType ?? AuthenticatorPB.Local) == AuthenticatorPB.Local; String? path; String? errorMsg; @@ -229,8 +229,8 @@ Future insertLocalFiles( if (files.every((f) => f.path.isEmpty)) return; // Check upload type - final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + final isLocalMode = + (userProfile?.authType ?? AuthenticatorPB.Local) == AuthenticatorPB.Local; for (final file in files) { final fileType = file.fileType.toMediaFileTypePB(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart index 27498cc65e..601ba8fa20 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart @@ -226,7 +226,7 @@ class PageStyleCoverImage extends StatelessWidget { (f) => null, ); final isAppFlowyCloud = - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud; + userProfile?.authType == AuthenticatorPB.AppFlowyCloud; final PageStyleCoverImageType type; if (!isAppFlowyCloud) { result = await saveImageToLocalStorage(path); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart index a852fa5e38..c3f04a8837 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -193,7 +193,7 @@ class ShareBloc extends Bloc { Future _updatePublishStatus(Emitter emit) async { final publishInfo = await ViewBackendService.getPublishInfo(view); final enablePublish = await UserBackendService.getCurrentUserProfile().fold( - (v) => v.authenticator == AuthenticatorPB.AppFlowyCloud, + (v) => v.authType == AuthenticatorPB.AppFlowyCloud, (p) => false, ); diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart index 2974156a2a..f39fdb01dc 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart @@ -294,7 +294,7 @@ class _IconUploaderState extends State { (userProfile) => userProfile, (l) => null, ); - final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == + final isLocalMode = (userProfile?.authType ?? AuthenticatorPB.Local) == AuthenticatorPB.Local; if (isLocalMode) { result = await pickedImages.first.saveToLocal(); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index b326276c56..7f9a2df329 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -51,7 +51,6 @@ GoRouter generateRouter(Widget child) { // Routes in both desktop and mobile _signInScreenRoute(), _skipLogInScreenRoute(), - _encryptSecretScreenRoute(), _workspaceErrorScreenRoute(), // Desktop only if (UniversalPlatform.isDesktop) _desktopHomeScreenRoute(), @@ -471,23 +470,6 @@ GoRoute _workspaceErrorScreenRoute() { ); } -GoRoute _encryptSecretScreenRoute() { - return GoRoute( - path: EncryptSecretScreen.routeName, - pageBuilder: (context, state) { - final args = state.extra as Map; - return CustomTransitionPage( - child: EncryptSecretScreen( - user: args[EncryptSecretScreen.argUser], - key: args[EncryptSecretScreen.argKey], - ), - transitionsBuilder: _buildFadeTransition, - transitionDuration: _slowDuration, - ); - }, - ); -} - GoRoute _skipLogInScreenRoute() { return GoRoute( path: SkipLogInScreen.routeName, diff --git a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart deleted file mode 100644 index 19b8101ae8..0000000000 --- a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:appflowy/plugins/database/application/defines.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'auth/auth_service.dart'; - -part 'encrypt_secret_bloc.freezed.dart'; - -class EncryptSecretBloc extends Bloc { - EncryptSecretBloc({required this.user}) - : super(EncryptSecretState.initial()) { - _dispatch(); - } - - final UserProfilePB user; - - void _dispatch() { - on((event, emit) async { - await event.when( - setEncryptSecret: (secret) async { - if (isLoading()) { - return; - } - - final payload = UserSecretPB.create() - ..encryptionSecret = secret - ..encryptionSign = user.encryptionSign - ..encryptionType = user.encryptionType - ..userId = user.id; - final result = await UserEventSetEncryptionSecret(payload).send(); - if (!isClosed) { - add(EncryptSecretEvent.didFinishCheck(result)); - } - emit( - state.copyWith( - loadingState: const LoadingState.loading(), - successOrFail: null, - ), - ); - }, - cancelInputSecret: () async { - await getIt().signOut(); - emit( - state.copyWith( - successOrFail: null, - isSignOut: true, - ), - ); - }, - didFinishCheck: (result) { - result.fold( - (unit) { - emit( - state.copyWith( - loadingState: const LoadingState.loading(), - successOrFail: result, - ), - ); - }, - (err) { - emit( - state.copyWith( - loadingState: LoadingState.finish(FlowyResult.failure(err)), - successOrFail: result, - ), - ); - }, - ); - }, - ); - }); - } - - bool isLoading() { - final loadingState = state.loadingState; - if (loadingState != null) { - return loadingState.when( - loading: () => true, - finish: (_) => false, - idle: () => false, - ); - } - return false; - } -} - -@freezed -class EncryptSecretEvent with _$EncryptSecretEvent { - const factory EncryptSecretEvent.setEncryptSecret(String secret) = - _SetEncryptSecret; - const factory EncryptSecretEvent.didFinishCheck( - FlowyResult result, - ) = _DidFinishCheck; - const factory EncryptSecretEvent.cancelInputSecret() = _CancelInputSecret; -} - -@freezed -class EncryptSecretState with _$EncryptSecretState { - const factory EncryptSecretState({ - required FlowyResult? successOrFail, - required bool isSignOut, - LoadingState? loadingState, - }) = _EncryptSecretState; - - factory EncryptSecretState.initial() => const EncryptSecretState( - successOrFail: null, - isSignOut: false, - ); -} diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart index 34e8514e4c..d421440d08 100644 --- a/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart @@ -46,7 +46,7 @@ class PasswordBloc extends Bloc { bool _isInitialized = false; Future _init() async { - if (userProfile.authenticator == AuthenticatorPB.Local) { + if (userProfile.authType == AuthenticatorPB.Local) { Log.debug('PasswordBloc: skip init because user is local authenticator'); return; } diff --git a/frontend/appflowy_flutter/lib/user/application/user_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_listener.dart index 36d6039d40..d3ebe0201b 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_listener.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_listener.dart @@ -24,7 +24,7 @@ typedef DidUpdateUserWorkspacesCallback = void Function( ); typedef UserProfileNotifyValue = FlowyResult; typedef DidUpdateUserWorkspaceSetting = void Function( - UseAISettingPB settings, + WorkspaceSettingsPB settings, ); class UserListener { @@ -101,10 +101,10 @@ class UserListener { result.map( (r) => onUserWorkspaceUpdated?.call(UserWorkspacePB.fromBuffer(r)), ); - case user.UserNotification.DidUpdateAISetting: + case user.UserNotification.DidUpdateWorkspaceSetting: result.map( - (r) => - onUserWorkspaceSettingUpdated?.call(UseAISettingPB.fromBuffer(r)), + (r) => onUserWorkspaceSettingUpdated + ?.call(WorkspaceSettingsPB.fromBuffer(r)), ); break; default: @@ -113,22 +113,21 @@ class UserListener { } } -typedef WorkspaceSettingNotifyValue - = FlowyResult; +typedef WorkspaceLatestNotifyValue = FlowyResult; class FolderListener { FolderListener(); - final PublishNotifier _settingChangedNotifier = + final PublishNotifier _latestChangedNotifier = PublishNotifier(); FolderNotificationListener? _listener; void start({ - void Function(WorkspaceSettingNotifyValue)? onSettingUpdated, + void Function(WorkspaceLatestNotifyValue)? onLatestUpdated, }) { - if (onSettingUpdated != null) { - _settingChangedNotifier.addPublishListener(onSettingUpdated); + if (onLatestUpdated != null) { + _latestChangedNotifier.addPublishListener(onLatestUpdated); } // The "current-workspace" is predefined in the backend. Do not try to @@ -146,9 +145,9 @@ class FolderListener { switch (ty) { case FolderNotification.DidUpdateWorkspaceSetting: result.fold( - (payload) => _settingChangedNotifier.value = - FlowyResult.success(WorkspaceSettingPB.fromBuffer(payload)), - (error) => _settingChangedNotifier.value = FlowyResult.failure(error), + (payload) => _latestChangedNotifier.value = + FlowyResult.success(WorkspaceLatestPB.fromBuffer(payload)), + (error) => _latestChangedNotifier.value = FlowyResult.failure(error), ); break; default: @@ -158,6 +157,6 @@ class FolderListener { Future stop() async { await _listener?.stop(); - _settingChangedNotifier.dispose(); + _latestChangedNotifier.dispose(); } } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 4359a23753..18f89ebc14 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -40,8 +40,6 @@ class UserBackendService implements IUserBackendService { String? password, String? email, String? iconUrl, - String? openAIKey, - String? stabilityAiKey, }) { final payload = UpdateUserProfilePayloadPB.create()..id = userId; @@ -61,14 +59,6 @@ class UserBackendService implements IUserBackendService { payload.iconUrl = iconUrl; } - if (openAIKey != null) { - payload.openaiKey = openAIKey; - } - - if (stabilityAiKey != null) { - payload.stabilityAiKey = stabilityAiKey; - } - return UserEventUpdateUserProfile(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart index a9b11cb42e..014c7caaf8 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart @@ -74,9 +74,8 @@ class AnonUserItem extends StatelessWidget { @override Widget build(BuildContext context) { final icon = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; - final isDisabled = - isSelected || user.authenticator != AuthenticatorPB.Local; - final desc = "${user.name}\t ${user.authenticator}\t"; + final isDisabled = isSelected || user.authType != AuthenticatorPB.Local; + final desc = "${user.name}\t ${user.authType}\t"; final child = SizedBox( height: 30, child: FlowyButton( diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart index 9abd417df3..83007786f1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart @@ -12,11 +12,7 @@ void handleUserProfileResult( ) { userProfileResult.fold( (userProfile) { - if (userProfile.encryptionType == EncryptionTypePB.Symmetric) { - authRouter.pushEncryptionScreen(context, userProfile); - } else { - authRouter.goHomeScreen(context, userProfile); - } + authRouter.goHomeScreen(context, userProfile); }, (error) { handleOpenWorkspaceError(context, error); diff --git a/frontend/appflowy_flutter/lib/user/presentation/router.dart b/frontend/appflowy_flutter/lib/user/presentation/router.dart index 370d9c2062..f6f6ec3e3a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/router.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/router.dart @@ -61,20 +61,6 @@ class AuthRouter { ); } - void pushEncryptionScreen( - BuildContext context, - UserProfilePB userProfile, - ) { - // After log in,push EncryptionScreen on the top SignInScreen - context.push( - EncryptSecretScreen.routeName, - extra: { - EncryptSecretScreen.argUser: userProfile, - EncryptSecretScreen.argKey: ValueKey(userProfile.id), - }, - ); - } - Future pushWorkspaceErrorScreen( BuildContext context, UserFolderPB userFolder, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart deleted file mode 100644 index f0b79ed9d2..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/presentation/helpers/helpers.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../application/encrypt_secret_bloc.dart'; - -class EncryptSecretScreen extends StatefulWidget { - const EncryptSecretScreen({required this.user, super.key}); - - final UserProfilePB user; - - static const routeName = '/EncryptSecretScreen'; - - // arguments used in GoRouter - static const argUser = 'user'; - static const argKey = 'key'; - - @override - State createState() => _EncryptSecretScreenState(); -} - -class _EncryptSecretScreenState extends State { - final TextEditingController _textEditingController = TextEditingController(); - - @override - void dispose() { - _textEditingController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: BlocProvider( - create: (context) => EncryptSecretBloc(user: widget.user), - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (previous, current) => - previous.isSignOut != current.isSignOut, - listener: (context, state) async { - if (state.isSignOut) { - await runAppFlowy(); - } - }, - ), - BlocListener( - listenWhen: (previous, current) => - previous.successOrFail != current.successOrFail, - listener: (context, state) async { - await state.successOrFail?.fold( - (unit) async { - await runAppFlowy(); - }, - (error) { - handleOpenWorkspaceError(context, error); - }, - ); - }, - ), - ], - child: BlocBuilder( - builder: (context, state) { - final indicator = state.loadingState?.when( - loading: () => const Center( - child: CircularProgressIndicator.adaptive(), - ), - finish: (result) => const SizedBox.shrink(), - idle: () => const SizedBox.shrink(), - ) ?? - const SizedBox.shrink(); - return Center( - child: SizedBox( - width: 300, - height: 160, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Opacity( - opacity: 0.6, - child: FlowyText.medium( - "${LocaleKeys.settings_menu_inputEncryptPrompt.tr()} ${widget.user.email}", - fontSize: 14, - maxLines: 10, - ), - ), - const VSpace(6), - SizedBox( - width: 300, - child: FlowyTextField( - controller: _textEditingController, - hintText: - LocaleKeys.settings_menu_inputTextFieldHint.tr(), - onChanged: (_) {}, - ), - ), - OkCancelButton( - alignment: MainAxisAlignment.end, - onOkPressed: () => - context.read().add( - EncryptSecretEvent.setEncryptSecret( - _textEditingController.text, - ), - ), - onCancelPressed: () => context - .read() - .add(const EncryptSecretEvent.cancelInputSecret()), - mode: TextButtonMode.normal, - ), - const VSpace(6), - indicator, - ], - ), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart index 088da38978..540da8c2b4 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart @@ -2,6 +2,5 @@ export 'sign_in_screen/sign_in_screen.dart'; export 'skip_log_in_screen.dart'; export 'splash_screen.dart'; export 'sign_up_screen.dart'; -export 'encrypt_secret_screen.dart'; export 'workspace_error_screen.dart'; export 'workspace_start_screen/workspace_start_screen.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index afae06d50a..b359b2e217 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -4,7 +4,6 @@ import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -34,11 +33,7 @@ class SignInScreen extends StatelessWidget { if (successOrFail != null) { successOrFail.fold( (userProfile) { - if (userProfile.encryptionType == EncryptionTypePB.Symmetric) { - getIt().pushEncryptionScreen(context, userProfile); - } else { - getIt().goHomeScreen(context, userProfile); - } + getIt().goHomeScreen(context, userProfile); }, (error) { Log.error('Sign in error: $error'); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart index 71345aa8dd..4062cedf8e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -8,7 +8,6 @@ import 'package:appflowy/user/presentation/helpers/helpers.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -61,32 +60,15 @@ class SplashScreen extends StatelessWidget { BuildContext context, Authenticated authenticated, ) async { - final userProfile = authenticated.userProfile; - - /// After a user is authenticated, this function checks if encryption is required. - final result = await UserEventCheckEncryptionSign().send(); - await result.fold( - (check) async { - /// If encryption is needed, the user is navigated to the encryption screen. - /// Otherwise, it fetches the current workspace for the user and navigates them - if (check.requireSecret) { - getIt().pushEncryptionScreen(context, userProfile); - } else { - final result = await FolderEventGetCurrentWorkspaceSetting().send(); - result.fold( - (workspaceSetting) { - // After login, replace Splash screen by corresponding home screen - getIt().goHomeScreen( - context, - ); - }, - (error) => handleOpenWorkspaceError(context, error), - ); - } - }, - (err) { - Log.error(err); + final result = await FolderEventGetCurrentWorkspaceSetting().send(); + result.fold( + (workspaceSetting) { + // After login, replace Splash screen by corresponding home screen + getIt().goHomeScreen( + context, + ); }, + (error) => handleOpenWorkspaceError(context, error), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart index 1afc253ab7..531e797ff5 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart @@ -3,14 +3,14 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' - show WorkspaceSettingPB; + show WorkspaceLatestPB; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'home_bloc.freezed.dart'; class HomeBloc extends Bloc { - HomeBloc(WorkspaceSettingPB workspaceSetting) + HomeBloc(WorkspaceLatestPB workspaceSetting) : _workspaceListener = FolderListener(), super(HomeState.initial(workspaceSetting)) { _dispatch(workspaceSetting); @@ -24,7 +24,7 @@ class HomeBloc extends Bloc { return super.close(); } - void _dispatch(WorkspaceSettingPB workspaceSetting) { + void _dispatch(WorkspaceLatestPB workspaceSetting) { on( (event, emit) async { await event.map( @@ -36,10 +36,9 @@ class HomeBloc extends Bloc { }); _workspaceListener.start( - onSettingUpdated: (result) { + onLatestUpdated: (result) { result.fold( - (setting) => - add(HomeEvent.didReceiveWorkspaceSetting(setting)), + (latest) => add(HomeEvent.didReceiveWorkspaceSetting(latest)), (r) => Log.error(r), ); }, @@ -78,7 +77,7 @@ class HomeEvent with _$HomeEvent { const factory HomeEvent.initial() = _Initial; const factory HomeEvent.showLoading(bool isLoading) = _ShowLoading; const factory HomeEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting, + WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; } @@ -86,11 +85,11 @@ class HomeEvent with _$HomeEvent { class HomeState with _$HomeState { const factory HomeState({ required bool isLoading, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, ViewPB? latestView, }) = _HomeState; - factory HomeState.initial(WorkspaceSettingPB workspaceSetting) => HomeState( + factory HomeState.initial(WorkspaceLatestPB workspaceSetting) => HomeState( isLoading: false, workspaceSetting: workspaceSetting, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart index 657f2592d7..cde67045b9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart @@ -2,7 +2,7 @@ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' - show WorkspaceSettingPB; + show WorkspaceLatestPB; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -12,7 +12,7 @@ part 'home_setting_bloc.freezed.dart'; class HomeSettingBloc extends Bloc { HomeSettingBloc( - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, AppearanceSettingsCubit appearanceSettingsCubit, double screenWidthPx, ) : _listener = FolderListener(), @@ -124,7 +124,7 @@ class HomeSettingEvent with _$HomeSettingEvent { _ShowEditPanel; const factory HomeSettingEvent.dismissEditPanel() = _DismissEditPanel; const factory HomeSettingEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting, + WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; const factory HomeSettingEvent.collapseMenu() = _CollapseMenu; const factory HomeSettingEvent.checkScreenSize(double screenWidthPx) = @@ -139,7 +139,7 @@ class HomeSettingEvent with _$HomeSettingEvent { class HomeSettingState with _$HomeSettingState { const factory HomeSettingState({ required EditPanelContext? panelContext, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, required bool unauthorized, required bool isMenuCollapsed, required bool keepMenuCollapsed, @@ -150,7 +150,7 @@ class HomeSettingState with _$HomeSettingState { }) = _HomeSettingState; factory HomeSettingState.initial( - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, AppearanceSettingsState appearanceSettingsState, double screenWidthPx, ) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 4383e0dbef..0141283765 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -55,7 +55,7 @@ class SettingsAIBloc extends Bloc { onProfileUpdated: _onProfileUpdated, onUserWorkspaceSettingUpdated: (settings) { if (!isClosed) { - add(SettingsAIEvent.didLoadAISetting(settings)); + add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); } }, ); @@ -85,7 +85,7 @@ class SettingsAIBloc extends Bloc { ), ).send(); }, - didLoadAISetting: (UseAISettingPB settings) { + didLoadWorkspaceSetting: (WorkspaceSettingsPB settings) { emit( state.copyWith( aiSettings: settings, @@ -150,7 +150,7 @@ class SettingsAIBloc extends Bloc { UserEventGetWorkspaceSetting(payload).send().then((result) { result.fold((settings) { if (!isClosed) { - add(SettingsAIEvent.didLoadAISetting(settings)); + add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); } }, (err) { Log.error(err); @@ -162,8 +162,8 @@ class SettingsAIBloc extends Bloc { @freezed class SettingsAIEvent with _$SettingsAIEvent { const factory SettingsAIEvent.started() = _Started; - const factory SettingsAIEvent.didLoadAISetting( - UseAISettingPB settings, + const factory SettingsAIEvent.didLoadWorkspaceSetting( + WorkspaceSettingsPB settings, ) = _DidLoadWorkspaceSetting; const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; @@ -183,7 +183,7 @@ class SettingsAIEvent with _$SettingsAIEvent { class SettingsAIState with _$SettingsAIState { const factory SettingsAIState({ required UserProfilePB userProfile, - UseAISettingPB? aiSettings, + WorkspaceSettingsPB? aiSettings, AvailableModelsPB? availableModels, @Default(true) bool enableSearchIndexing, }) = _SettingsAIState; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 0578d9808b..66277cf30b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -92,7 +92,7 @@ class SettingsDialogBloc ]) async { if ([ AuthenticatorPB.Local, - ].contains(userProfile.authenticator)) { + ].contains(userProfile.authType)) { return false; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 7f32a86d1c..e6a8c4d921 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -44,7 +44,7 @@ class UserWorkspaceBloc extends Bloc { final currentWorkspace = result.$1; final workspaces = result.$2; final isCollabWorkspaceOn = - userProfile.authenticator == AuthenticatorPB.AppFlowyCloud && + userProfile.authType == AuthenticatorPB.AppFlowyCloud && FeatureFlag.collaborativeWorkspace.isOn; Log.info( 'init workspace, current workspace: ${currentWorkspace?.workspaceId}, ' diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart index a8d768aa79..619ee4e229 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -52,8 +52,8 @@ class DesktopHomeScreen extends StatelessWidget { return _buildLoading(); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) => workspaceSettingPB as WorkspaceSettingPB, + final workspaceLatest = snapshots.data?[0].fold( + (workspaceLatestPB) => workspaceLatestPB as WorkspaceLatestPB, (error) => null, ); @@ -64,7 +64,7 @@ class DesktopHomeScreen extends StatelessWidget { // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. - if (workspaceSetting == null || userProfile == null) { + if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } @@ -86,11 +86,11 @@ class DesktopHomeScreen extends StatelessWidget { BlocProvider.value(value: getIt()), BlocProvider( create: (_) => - HomeBloc(workspaceSetting)..add(const HomeEvent.initial()), + HomeBloc(workspaceLatest)..add(const HomeEvent.initial()), ), BlocProvider( create: (_) => HomeSettingBloc( - workspaceSetting, + workspaceLatest, context.read(), context.widthPx, )..add(const HomeSettingEvent.initial()), @@ -137,7 +137,7 @@ class DesktopHomeScreen extends StatelessWidget { child: _buildBody( context, userProfile, - workspaceSetting, + workspaceLatest, ), ), ), @@ -157,7 +157,7 @@ class DesktopHomeScreen extends StatelessWidget { Widget _buildBody( BuildContext context, UserProfilePB userProfile, - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, ) { final layout = HomeLayout(context); final homeStack = HomeStack( @@ -190,7 +190,7 @@ class DesktopHomeScreen extends StatelessWidget { BuildContext context, { required HomeLayout layout, required UserProfilePB userProfile, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, }) { final homeMenu = HomeSideBar( userProfile: userProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index ea55c72f16..9c19184217 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -60,7 +60,7 @@ class HomeSideBar extends StatelessWidget { final UserProfilePB userProfile; - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceSetting; @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart index d792c54f04..576ff07cb4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -211,7 +211,7 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { ) { final userProfile = context.read().userProfile; // move to feature doesn't support in local mode - if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + if (userProfile.authType != AuthenticatorPB.AppFlowyCloud) { return const SizedBox.shrink(); } return BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart index 89066ea649..78f1aaf16e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -79,9 +79,7 @@ class AccountSignInOutButton extends StatelessWidget { showConfirmDialog( context: context, title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - description: userProfile.encryptionType == EncryptionTypePB.Symmetric - ? LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr() - : LocaleKeys.settings_menu_logoutPrompt.tr(), + description: LocaleKeys.settings_menu_logoutPrompt.tr(), onConfirm: () async { await getIt().signOut(); onAction(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index e223b2d063..f1ecfda8ad 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -70,7 +70,7 @@ class _SettingsAccountViewState extends State { // user email // Only show email if the user is authenticated and not using local auth if (isAuthEnabled && - state.userProfile.authenticator != AuthenticatorPB.Local) ...[ + state.userProfile.authType != AuthenticatorPB.Local) ...[ SettingsCategory( title: LocaleKeys.newSettings_myAccount_myAccount.tr(), children: [ @@ -82,30 +82,30 @@ class _SettingsAccountViewState extends State { ), AccountSignInOutSection( userProfile: state.userProfile, - onAction: state.userProfile.authenticator == - AuthenticatorPB.Local - ? widget.didLogin - : widget.didLogout, - signIn: state.userProfile.authenticator == - AuthenticatorPB.Local, + onAction: + state.userProfile.authType == AuthenticatorPB.Local + ? widget.didLogin + : widget.didLogout, + signIn: + state.userProfile.authType == AuthenticatorPB.Local, ), ], ), ], if (isAuthEnabled && - state.userProfile.authenticator == AuthenticatorPB.Local) ...[ + state.userProfile.authType == AuthenticatorPB.Local) ...[ SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ AccountSignInOutSection( userProfile: state.userProfile, - onAction: state.userProfile.authenticator == - AuthenticatorPB.Local - ? widget.didLogin - : widget.didLogout, - signIn: state.userProfile.authenticator == - AuthenticatorPB.Local, + onAction: + state.userProfile.authType == AuthenticatorPB.Local + ? widget.didLogin + : widget.didLogout, + signIn: + state.userProfile.authType == AuthenticatorPB.Local, ), ], ), @@ -120,8 +120,7 @@ class _SettingsAccountViewState extends State { ), // user deletion - if (widget.userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + if (widget.userProfile.authType == AuthenticatorPB.AppFlowyCloud) const AccountDeletionButton(), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart index 1cfc833398..a602849527 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -88,7 +88,7 @@ class SettingsWorkspaceView extends StatelessWidget { autoSeparate: false, children: [ // We don't allow changing workspace name/icon for local/offline - if (userProfile.authenticator != AuthenticatorPB.Local) ...[ + if (userProfile.authType != AuthenticatorPB.Local) ...[ SettingsCategory( title: LocaleKeys.settings_workspacePage_workspaceName_title .tr(), @@ -180,7 +180,7 @@ class SettingsWorkspaceView extends StatelessWidget { ), const SettingsCategorySpacer(), - if (userProfile.authenticator != AuthenticatorPB.Local) ...[ + if (userProfile.authType != AuthenticatorPB.Local) ...[ SingleSettingAction( label: LocaleKeys.settings_workspacePage_manageWorkspace_title .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 512d673407..b17a9beb7e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -140,7 +140,7 @@ class SettingsDialog extends StatelessWidget { case SettingsPage.shortcuts: return const SettingsShortcutsView(); case SettingsPage.ai: - if (user.authenticator == AuthenticatorPB.AppFlowyCloud) { + if (user.authType == AuthenticatorPB.AppFlowyCloud) { return SettingsAIView( key: ValueKey(workspaceId), userProfile: user, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart index cf51d7a3e9..8a85377efe 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart @@ -2,7 +2,6 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; @@ -64,12 +63,8 @@ class SettingThirdPartyLogin extends StatelessWidget { ) async { result.fold( (user) async { - if (user.encryptionType == EncryptionTypePB.Symmetric) { - getIt().pushEncryptionScreen(context, user); - } else { - didLogin(); - await runAppFlowy(); - } + didLogin(); + await runAppFlowy(); }, (error) => showSnapBar(context, error.msg), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index c9069b8be3..1a7144993f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -63,8 +63,7 @@ class SettingsMenu extends StatelessWidget { changeSelectedPage: changeSelectedPage, ), if (FeatureFlag.membersSettings.isOn && - userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + userProfile.authType == AuthenticatorPB.AppFlowyCloud) SettingsMenuElement( page: SettingsPage.member, selectedPage: currentPage, @@ -110,8 +109,7 @@ class SettingsMenu extends StatelessWidget { ), changeSelectedPage: changeSelectedPage, ), - if (userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + if (userProfile.authType == AuthenticatorPB.AppFlowyCloud) SettingsMenuElement( page: SettingsPage.sites, selectedPage: currentPage, 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 90e47e7c19..3b81d2a041 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 @@ -96,7 +96,7 @@ class _MoreViewActionsState extends State { return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty && - userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { + userProfile.authType == AuthenticatorPB.AppFlowyCloud) { return const SizedBox.shrink(); } diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart index d6d0351414..41865b7dd7 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart @@ -182,14 +182,14 @@ void main() { await blocResponseFuture(); assert(viewBloc.state.lastCreatedView!.name == gird); - var workspaceSetting = + var workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( (result) => result.fold( (l) => l, (r) => throw Exception(), ), ); - workspaceSetting.latestView.id == viewBloc.state.lastCreatedView!.id; + workspaceLatest.latestView.id == viewBloc.state.lastCreatedView!.id; // ignore: unused_local_variable final documentBloc = DocumentBloc(documentId: document.id) @@ -198,14 +198,13 @@ void main() { ); await blocResponseFuture(); - workspaceSetting = - await FolderEventGetCurrentWorkspaceSetting().send().then( - (result) => result.fold( - (l) => l, - (r) => throw Exception(), - ), - ); - workspaceSetting.latestView.id == document.id; + workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( + (result) => result.fold( + (l) => l, + (r) => throw Exception(), + ), + ); + workspaceLatest.latestView.id == document.id; }); test('create views', () async { diff --git a/frontend/rust-lib/event-integration-test/src/lib.rs b/frontend/rust-lib/event-integration-test/src/lib.rs index 02efc0f75a..1b54087ee1 100644 --- a/frontend/rust-lib/event-integration-test/src/lib.rs +++ b/frontend/rust-lib/event-integration-test/src/lib.rs @@ -8,7 +8,6 @@ use collab_entity::CollabType; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::AppFlowyCore; use flowy_notification::register_notification_sender; -use flowy_server::AppFlowyServer; use flowy_user::entities::AuthenticatorPB; use flowy_user::errors::FlowyError; use lib_dispatch::runtime::AFPluginRuntime; @@ -113,16 +112,25 @@ impl EventIntegrationTest { self.appflowy_core.config.application_path.clone() } - pub fn get_server(&self) -> Arc { - self.appflowy_core.server_provider.get_server().unwrap() - } - pub async fn wait_ws_connected(&self) { - if self.get_server().get_ws_state().is_connected() { + if self + .appflowy_core + .server_provider + .get_server() + .unwrap() + .get_ws_state() + .is_connected() + { return; } - let mut ws_state = self.get_server().subscribe_ws_state().unwrap(); + let mut ws_state = self + .appflowy_core + .server_provider + .get_server() + .unwrap() + .subscribe_ws_state() + .unwrap(); loop { select! { _ = sleep(Duration::from_secs(20)) => { @@ -144,9 +152,10 @@ impl EventIntegrationTest { oid: &str, collab_type: CollabType, ) -> Result, FlowyError> { - let server = self.server_provider.get_server().unwrap(); + let server = self.server_provider.get_server()?; + let workspace_id = self.get_current_workspace().await.id; - let oid = Uuid::from_str(oid).unwrap(); + let oid = Uuid::from_str(oid)?; let uid = self.get_user_profile().await?.id; let doc_state = server .folder_service() diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index 1b82d9b83c..2ec74bafa6 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -24,6 +24,7 @@ use flowy_user::entities::{ }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; +use flowy_user_pub::entities::AuthType; use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes}; use crate::event_builder::EventBuilder; @@ -189,9 +190,10 @@ impl EventIntegrationTest { } } - pub async fn create_workspace(&self, name: &str) -> UserWorkspacePB { + pub async fn create_workspace(&self, name: &str, auth_type: AuthType) -> UserWorkspacePB { let payload = CreateWorkspacePB { name: name.to_string(), + auth_type: auth_type.into(), }; EventBuilder::new(self.clone()) .event(UserEvent::CreateWorkspace) diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs index a9c928db5b..aacba827c4 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -19,7 +19,12 @@ async fn af_cloud_create_chat_message_test() { let current_workspace = test.get_current_workspace().await; let view = test.create_chat(¤t_workspace.id).await; let chat_id = view.id.clone(); - let chat_service = test.server_provider.get_server().unwrap().chat_service(); + let chat_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .chat_service(); for i in 0..10 { let _ = chat_service .create_question( @@ -74,7 +79,12 @@ async fn af_cloud_load_remote_system_message_test() { let view = test.create_chat(¤t_workspace.id).await; let chat_id = view.id.clone(); - let chat_service = test.server_provider.get_server().unwrap().chat_service(); + let chat_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .chat_service(); for i in 0..10 { let _ = chat_service .create_question( diff --git a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs index 04798f044a..7d8ecc9680 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs @@ -66,6 +66,7 @@ async fn af_cloud_upload_big_file_test() { // download the file and then compare the data. let file_service = test + .appflowy_core .server_provider .get_server() .unwrap() diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs index 718bc1d9af..f74b202860 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs @@ -72,7 +72,7 @@ async fn migrate_anon_user_data_to_af_cloud_test() { let user = test.af_cloud_sign_up().await; let workspace = test.get_current_workspace().await; println!("user workspace: {:?}", workspace.id); - assert_eq!(user.authenticator, AuthenticatorPB::AppFlowyCloud); + assert_eq!(user.auth_type, AuthenticatorPB::AppFlowyCloud); let user_first_level_views = test.get_all_workspace_views().await; assert_eq!(user_first_level_views.len(), 3); diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs index 7b31babd0e..eaec8f7540 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs @@ -1,6 +1,5 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; -use flowy_user::entities::UpdateUserProfilePayloadPB; use crate::util::generate_test_email; @@ -13,29 +12,3 @@ async fn af_cloud_sign_up_test() { let user = test.af_cloud_sign_in_with_email(&email).await.unwrap(); assert_eq!(user.email, email); } - -#[tokio::test] -async fn af_cloud_update_user_metadata() { - use_localhost_af_cloud().await; - let test = EventIntegrationTest::new().await; - let user = test.af_cloud_sign_up().await; - - let old_profile = test.get_user_profile().await.unwrap(); - assert_eq!(old_profile.openai_key, "".to_string()); - - test - .update_user_profile(UpdateUserProfilePayloadPB { - id: user.id, - openai_key: Some("new openai key".to_string()), - stability_ai_key: Some("new stability ai key".to_string()), - ..Default::default() - }) - .await; - - let new_profile = test.get_user_profile().await.unwrap(); - assert_eq!(new_profile.openai_key, "new openai key".to_string()); - assert_eq!( - new_profile.stability_ai_key, - "new stability ai key".to_string() - ); -} diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs index 56cf22a4da..366e43c157 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs @@ -1,15 +1,15 @@ +use crate::user::af_cloud_test::util::get_synced_workspaces; use collab::core::collab::DataSource::DocStateV1; use collab::core::origin::CollabOrigin; use collab_entity::CollabType; use collab_folder::Folder; use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; +use flowy_user_pub::entities::AuthType; use std::time::Duration; use tokio::task::LocalSet; use tokio::time::sleep; -use crate::user::af_cloud_test::util::get_synced_workspaces; - #[tokio::test] async fn af_cloud_workspace_delete() { use_localhost_af_cloud().await; @@ -18,7 +18,9 @@ async fn af_cloud_workspace_delete() { let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 1); - let created_workspace = test.create_workspace("my second workspace").await; + let created_workspace = test + .create_workspace("my second workspace", AuthType::AppFlowyCloud) + .await; assert_eq!(created_workspace.name, "my second workspace"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 2); @@ -66,7 +68,9 @@ async fn af_cloud_create_workspace_test() { let first_workspace_id = workspaces[0].workspace_id.as_str(); assert_eq!(workspaces.len(), 1); - let created_workspace = test.create_workspace("my second workspace").await; + let created_workspace = test + .create_workspace("my second workspace", AuthType::AppFlowyCloud) + .await; assert_eq!(created_workspace.name, "my second workspace"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; @@ -113,7 +117,9 @@ async fn af_cloud_open_workspace_test() { assert_eq!(views[2].name, "A"); assert_eq!(views[3].name, "B"); - let user_workspace = test.create_workspace("second workspace").await; + let user_workspace = test + .create_workspace("second workspace", AuthType::AppFlowyCloud) + .await; test.open_workspace(&user_workspace.workspace_id).await; let second_workspace = test.get_current_workspace().await; test.create_document("C").await; diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs index 00df14e8e1..46f8eca9c6 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs @@ -24,9 +24,7 @@ async fn anon_user_profile_get() { .await .parse::(); assert_eq!(user_profile.id, user.id); - assert_eq!(user_profile.openai_key, user.openai_key); - assert_eq!(user_profile.stability_ai_key, user.stability_ai_key); - assert_eq!(user_profile.authenticator, AuthenticatorPB::Local); + assert_eq!(user_profile.auth_type, AuthenticatorPB::Local); } #[tokio::test] @@ -50,31 +48,6 @@ async fn user_update_with_name() { assert_eq!(user_profile.name, new_name,); } -#[tokio::test] -async fn user_update_with_ai_key() { - let sdk = EventIntegrationTest::new().await; - let user = sdk.init_anon_user().await; - let openai_key = "openai_key".to_owned(); - let stability_ai_key = "stability_ai_key".to_owned(); - let request = UpdateUserProfilePayloadPB::new(user.id) - .openai_key(&openai_key) - .stability_ai_key(&stability_ai_key); - let _ = EventBuilder::new(sdk.clone()) - .event(UpdateUserProfile) - .payload(request) - .async_send() - .await; - - let user_profile = EventBuilder::new(sdk.clone()) - .event(GetUserProfile) - .async_send() - .await - .parse::(); - - assert_eq!(user_profile.openai_key, openai_key,); - assert_eq!(user_profile.stability_ai_key, stability_ai_key,); -} - #[tokio::test] async fn anon_user_update_with_email() { let sdk = EventIntegrationTest::new().await; diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index 85f2dc8306..f85858b1c2 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -330,7 +330,7 @@ pub(crate) async fn update_chat_settings_handler( Ok(()) } -#[tracing::instrument(level = "debug", skip_all, err)] +#[tracing::instrument(level = "debug", skip_all)] pub(crate) async fn get_local_ai_setting_handler( ai_manager: AFPluginState>, ) -> DataResult { diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index d217f52785..7e6d477407 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -8,7 +8,7 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::manager::FolderManager; use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_search::services::manager::SearchManager; -use flowy_server::af_cloud::define::LoginUserService; +use flowy_server::af_cloud::define::LoggedUser; use std::path::PathBuf; use std::sync::{Arc, Weak}; use std::time::Duration; @@ -34,7 +34,7 @@ use crate::config::AppFlowyCoreConfig; use crate::deps_resolve::file_storage_deps::FileStorageResolver; use crate::deps_resolve::*; use crate::log_filter::init_log; -use crate::server_layer::{current_server_type, ServerProvider}; +use crate::server_layer::ServerProvider; use deps_resolve::reminder_deps::CollabInteractImpl; use flowy_sqlite::DBConnection; use lib_infra::async_trait::async_trait; @@ -131,12 +131,10 @@ impl AppFlowyCore { store_preference.clone(), )); - let auth_type = current_server_type(); - debug!("🔥runtime:{}, server:{}", runtime, auth_type); + debug!("🔥runtime:{}", runtime); let server_provider = Arc::new(ServerProvider::new( config.clone(), - auth_type, Arc::downgrade(&store_preference), ServerUserImpl(Arc::downgrade(&authenticate_user)), )); @@ -327,7 +325,7 @@ impl ServerUserImpl { } #[async_trait] -impl LoginUserService for ServerUserImpl { +impl LoggedUser for ServerUserImpl { fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index ebb86c4417..4b1834c41c 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -1,11 +1,12 @@ use crate::AppFlowyCoreConfig; use af_plugin::manager::PluginManager; use arc_swap::{ArcSwap, ArcSwapOption}; +use dashmap::mapref::one::Ref; use dashmap::DashMap; use flowy_ai::local_ai::controller::LocalAIController; use flowy_error::{FlowyError, FlowyResult}; use flowy_server::af_cloud::{ - define::{AIUserServiceImpl, LoginUserService}, + define::{AIUserServiceImpl, LoggedUser}, AppFlowyCloudServer, }; use flowy_server::local_server::LocalServer; @@ -13,31 +14,53 @@ use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::entities::*; +use std::ops::Deref; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; +use tracing::info; pub struct ServerProvider { config: AppFlowyCoreConfig, providers: DashMap>, auth_type: ArcSwap, - user: Arc, + logged_user: Arc, pub local_ai: Arc, pub uid: Arc>, pub user_enable_sync: Arc, pub encryption: Arc, } +// Our little guard wrapper: +pub struct ServerHandle<'a>(Ref<'a, AuthType, Arc>); + +impl<'a> Deref for ServerHandle<'a> { + type Target = dyn AppFlowyServer; + fn deref(&self) -> &Self::Target { + // `self.0.value()` is an `&Arc` + // so `&**` gives us a `&dyn AppFlowyServer` + &**self.0.value() + } +} + +/// Determine current server type from ENV +pub fn current_server_type() -> AuthType { + match AuthenticatorType::from_env() { + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, + } +} + impl ServerProvider { pub fn new( config: AppFlowyCoreConfig, - initial_auth: AuthType, store_preferences: Weak, - user_service: impl LoginUserService + 'static, + user_service: impl LoggedUser + 'static, ) -> Self { - let user = Arc::new(user_service); + let initial_auth = current_server_type(); + let logged_user = Arc::new(user_service) as Arc; let auth_type = ArcSwap::from(Arc::new(initial_auth)); let encryption = Arc::new(EncryptionImpl::new(None)) as Arc; - let ai_user = Arc::new(AIUserServiceImpl(user.clone())); + let ai_user = Arc::new(AIUserServiceImpl(Arc::downgrade(&logged_user))); let plugins = Arc::new(PluginManager::new()); let local_ai = Arc::new(LocalAIController::new( plugins, @@ -51,7 +74,7 @@ impl ServerProvider { encryption, user_enable_sync: Arc::new(AtomicBool::new(true)), auth_type, - user, + logged_user, uid: Default::default(), local_ai, } @@ -59,9 +82,16 @@ impl ServerProvider { pub fn set_auth_type(&self, new_auth_type: AuthType) { let old_type = self.get_auth_type(); + info!( + "ServerProvider: set auth type from {:?} to {:?}", + old_type, new_auth_type + ); + if old_type != new_auth_type { self.auth_type.store(Arc::new(new_auth_type)); - self.providers.remove(&old_type); + if let Some((auth_type, _)) = self.providers.remove(&old_type) { + info!("ServerProvider: remove old auth type: {:?}", auth_type); + } } } @@ -70,14 +100,17 @@ impl ServerProvider { } /// Lazily create or fetch an AppFlowyServer instance - pub fn get_server(&self) -> FlowyResult> { + pub fn get_server(&self) -> FlowyResult { let auth_type = self.get_auth_type(); - if let Some(entry) = self.providers.get(&auth_type) { - return Ok(entry.clone()); + if let Some(r) = self.providers.get(&auth_type) { + return Ok(ServerHandle(r)); } let server: Arc = match auth_type { - AuthType::Local => Arc::new(LocalServer::new(self.user.clone(), self.local_ai.clone())), + AuthType::Local => Arc::new(LocalServer::new( + self.logged_user.clone(), + self.local_ai.clone(), + )), AuthType::AppFlowyCloud => { let cfg = self .config @@ -89,20 +122,13 @@ impl ServerProvider { self.user_enable_sync.load(Ordering::Acquire), self.config.device_id.clone(), self.config.app_version.clone(), - self.user.clone(), + Arc::downgrade(&self.logged_user), )) }, }; - self.providers.insert(auth_type, server.clone()); - Ok(server) - } -} - -/// Determine current server type from ENV -pub fn current_server_type() -> AuthType { - match AuthenticatorType::from_env() { - AuthenticatorType::Local => AuthType::Local, - AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, + self.providers.insert(auth_type, server); + let guard = self.providers.get(&auth_type).unwrap(); + Ok(ServerHandle(guard)) } } diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index 3288252ad2..4112883e61 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -380,6 +380,9 @@ pub enum ErrorCode { #[error("Local AI disabled")] LocalAIDisabled = 130, + + #[error("User not login")] + UserNotLogin = 131, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index 96b9d1c3cf..a9a2b6fa2b 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -161,6 +161,7 @@ impl FlowyError { static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked); static_flowy_error!(local_ai_not_ready, ErrorCode::LocalAINotReady); static_flowy_error!(local_ai_disabled, ErrorCode::LocalAIDisabled); + static_flowy_error!(user_not_login, ErrorCode::UserNotLogin); } impl std::convert::From for FlowyError { diff --git a/frontend/rust-lib/flowy-error/src/impl_from/database.rs b/frontend/rust-lib/flowy-error/src/impl_from/database.rs index 3a72a7cdf3..077ff2b708 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/database.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/database.rs @@ -1,8 +1,12 @@ use crate::FlowyError; +use flowy_sqlite::Error; impl std::convert::From for FlowyError { fn from(error: flowy_sqlite::Error) -> Self { - FlowyError::internal().with_context(error) + match error { + Error::NotFound => FlowyError::record_not_found(), + _ => FlowyError::internal().with_context(error), + } } } diff --git a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs index 21ff046226..72e50562f3 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs @@ -134,7 +134,7 @@ impl TryInto for GetWorkspaceViewPB { } #[derive(Default, ProtoBuf, Debug, Clone)] -pub struct WorkspaceSettingPB { +pub struct WorkspaceLatestPB { #[pb(index = 1)] pub workspace_id: String, diff --git a/frontend/rust-lib/flowy-folder/src/event_handler.rs b/frontend/rust-lib/flowy-folder/src/event_handler.rs index c20eb8a7ad..6889b7ebe6 100644 --- a/frontend/rust-lib/flowy-folder/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/event_handler.rs @@ -84,7 +84,7 @@ pub(crate) async fn read_private_views_handler( #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_current_workspace_setting_handler( folder: AFPluginState>, -) -> DataResult { +) -> DataResult { let folder = upgrade_folder(folder)?; let setting = folder.get_workspace_setting_pb().await?; data_result_ok(setting) diff --git a/frontend/rust-lib/flowy-folder/src/event_map.rs b/frontend/rust-lib/flowy-folder/src/event_map.rs index abd74bd338..19953aad1b 100644 --- a/frontend/rust-lib/flowy-folder/src/event_map.rs +++ b/frontend/rust-lib/flowy-folder/src/event_map.rs @@ -65,7 +65,7 @@ pub enum FolderEvent { CreateFolderWorkspace = 0, /// Read the current opening workspace. Currently, we only support one workspace - #[event(output = "WorkspaceSettingPB")] + #[event(output = "WorkspaceLatestPB")] GetCurrentWorkspaceSetting = 1, /// Return a list of workspaces that the current user can access. diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index ea89def872..e704be043d 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -3,7 +3,7 @@ use crate::entities::{ view_pb_with_child_views, view_pb_without_child_views, view_pb_without_child_views_from_arc, CreateViewParams, CreateWorkspaceParams, DeletedViewPB, DuplicateViewParams, FolderSnapshotPB, MoveNestedViewParams, RepeatedTrashPB, RepeatedViewIdPB, RepeatedViewPB, UpdateViewParams, - ViewLayoutPB, ViewPB, ViewSectionPB, WorkspacePB, WorkspaceSettingPB, + ViewLayoutPB, ViewPB, ViewSectionPB, WorkspaceLatestPB, WorkspacePB, }; use crate::manager_observer::{ notify_child_views_changed, notify_did_update_workspace, notify_parent_view_did_change, @@ -377,10 +377,10 @@ impl FolderManager { Ok(new_workspace) } - pub async fn get_workspace_setting_pb(&self) -> FlowyResult { + pub async fn get_workspace_setting_pb(&self) -> FlowyResult { let workspace_id = self.user.workspace_id()?; let latest_view = self.get_current_view().await; - Ok(WorkspaceSettingPB { + Ok(WorkspaceLatestPB { workspace_id: workspace_id.to_string(), latest_view, }) @@ -1262,7 +1262,7 @@ impl FolderManager { } let workspace_id = self.user.workspace_id()?; - let setting = WorkspaceSettingPB { + let setting = WorkspaceLatestPB { workspace_id: workspace_id.to_string(), latest_view: view, }; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index 0cebe3371f..a93066054d 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -3,7 +3,7 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; use lib_infra::async_trait::async_trait; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; @@ -13,7 +13,7 @@ pub const USER_DEVICE_ID: &str = "device_id"; /// Represents a user that is currently using the server. #[async_trait] -pub trait LoginUserService: Send + Sync { +pub trait LoggedUser: Send + Sync { /// different user might return different workspace id. fn workspace_id(&self) -> FlowyResult; @@ -24,27 +24,36 @@ pub trait LoginUserService: Send + Sync { fn application_root_dir(&self) -> Result; } -pub struct AIUserServiceImpl(pub Arc); +pub struct AIUserServiceImpl(pub Weak); + +impl AIUserServiceImpl { + fn logged_user(&self) -> FlowyResult> { + self + .0 + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("User is not logged in")) + } +} #[async_trait] impl AIUserService for AIUserServiceImpl { fn user_id(&self) -> Result { - self.0.user_id() + self.logged_user()?.user_id() } async fn is_local_model(&self) -> FlowyResult { - self.0.is_local_mode().await + self.logged_user()?.is_local_mode().await } fn workspace_id(&self) -> Result { - self.0.workspace_id() + self.logged_user()?.workspace_id() } fn sqlite_connection(&self, uid: i64) -> Result { - self.0.get_sqlite_db(uid) + self.logged_user()?.get_sqlite_db(uid) } fn application_root_dir(&self) -> Result { - self.0.application_root_dir() + self.logged_user()?.application_root_dir() } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index d6a22a2f73..f29a7f89ad 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -1,5 +1,5 @@ #![allow(unused_variables)] -use crate::af_cloud::define::LoginUserService; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; use client_api::entity::ai_dto::{ @@ -17,13 +17,13 @@ use flowy_database_pub::cloud::{ use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; use serde_json::{Map, Value}; -use std::sync::Arc; +use std::sync::Weak; use tracing::{error, instrument}; use uuid::Uuid; pub(crate) struct AFCloudDatabaseCloudServiceImpl { pub inner: T, - pub logged_user: Arc, + pub logged_user: Weak, } #[async_trait] @@ -40,7 +40,6 @@ where workspace_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*object_id, collab_type), @@ -50,7 +49,7 @@ where Ok(data) => { check_request_workspace_id_is_match( workspace_id, - &cloned_user, + &self.logged_user, format!("get database object: {}:{}", object_id, collab_type), )?; Ok(Some(data.encode_collab)) @@ -95,14 +94,17 @@ where workspace_id: &Uuid, ) -> Result { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.logged_user.clone(); let client = try_get_client?; let params = object_ids .into_iter() .map(|object_id| QueryCollab::new(object_id, object_ty)) .collect(); let results = client.batch_get_collab(workspace_id, params).await?; - check_request_workspace_id_is_match(workspace_id, &cloned_user, "batch get database object")?; + check_request_workspace_id_is_match( + workspace_id, + &self.logged_user, + "batch get database object", + )?; Ok( results .0 diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index ae67fc6d26..1e000d5971 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -9,17 +9,17 @@ use collab_entity::CollabType; use flowy_document_pub::cloud::*; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; -use std::sync::Arc; +use std::sync::Weak; use tracing::instrument; use uuid::Uuid; -use crate::af_cloud::define::LoginUserService; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudDocumentCloudServiceImpl { pub inner: T, - pub logged_user: Arc, + pub logged_user: Weak, } #[async_trait] diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index 116651a734..e6408bc24c 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -10,7 +10,7 @@ use collab_entity::CollabType; use collab_folder::RepeatedViewIdentifier; use serde_json::to_vec; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::Weak; use tracing::{instrument, trace}; use uuid::Uuid; @@ -22,13 +22,13 @@ use flowy_folder_pub::cloud::{ use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; -use crate::af_cloud::define::LoginUserService; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudFolderCloudServiceImpl { pub inner: T, - pub logged_user: Arc, + pub logged_user: Weak, } #[async_trait] @@ -91,7 +91,6 @@ where ) -> Result, FlowyError> { let uid = *uid; let try_get_client = self.inner.try_get_client(); - let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*workspace_id, CollabType::Folder), @@ -103,7 +102,7 @@ where .encode_collab .doc_state .to_vec(); - check_request_workspace_id_is_match(workspace_id, &cloned_user, "get folder data")?; + check_request_workspace_id_is_match(workspace_id, &self.logged_user, "get folder data")?; let folder = Folder::from_collab_doc_state( uid, CollabOrigin::Empty, @@ -131,7 +130,6 @@ where object_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*object_id, collab_type), @@ -143,7 +141,7 @@ where .encode_collab .doc_state .to_vec(); - check_request_workspace_id_is_match(workspace_id, &cloned_user, "get folder doc state")?; + check_request_workspace_id_is_match(workspace_id, &self.logged_user, "get folder doc state")?; Ok(doc_state) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 0ac4555d6e..8309e4c65f 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use anyhow::anyhow; use arc_swap::ArcSwapOption; @@ -31,7 +31,7 @@ use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; use uuid::Uuid; -use crate::af_cloud::define::{LoginUserService, USER_SIGN_IN_URL}; +use crate::af_cloud::define::{LoggedUser, USER_SIGN_IN_URL}; use crate::af_cloud::impls::user::dto::{ af_update_from_update_params, from_af_workspace_member, to_af_role, user_profile_from_af_profile, }; @@ -44,19 +44,19 @@ use super::dto::{from_af_workspace_invitation_status, to_workspace_invitation_st pub(crate) struct AFCloudUserAuthServiceImpl { server: T, user_change_recv: ArcSwapOption>, - user: Arc, + logged_user: Weak, } impl AFCloudUserAuthServiceImpl { pub(crate) fn new( server: T, user_change_recv: tokio::sync::mpsc::Receiver, - user: Arc, + logged_user: Weak, ) -> Self { Self { server, user_change_recv: ArcSwapOption::new(Some(Arc::new(user_change_recv))), - user, + logged_user, } } } @@ -168,11 +168,7 @@ where Ok(url) } - async fn update_user( - &self, - _credential: UserCredentials, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError> { + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); let client = try_get_client?; client @@ -187,8 +183,11 @@ where _credential: UserCredentials, ) -> Result { let try_get_client = self.server.try_get_client(); - let cloned_user = self.user.clone(); - let expected_workspace_id = cloned_user.workspace_id()?; + let expected_workspace_id = self + .logged_user + .upgrade() + .ok_or_else(FlowyError::user_not_login)? + .workspace_id()?; let client = try_get_client?; let profile = client.get_profile().await?; let token = client.get_token()?; @@ -196,7 +195,11 @@ where // Discard the response if the user has switched to a new workspace. This avoids updating the // user profile with potentially outdated information when the workspace ID no longer matches. - check_request_workspace_id_is_match(&expected_workspace_id, &cloned_user, "get user profile")?; + check_request_workspace_id_is_match( + &expected_workspace_id, + &self.logged_user, + "get user profile", + )?; Ok(profile) } @@ -233,19 +236,17 @@ where async fn patch_workspace( &self, workspace_id: &Uuid, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + new_workspace_name: Option, + new_workspace_icon: Option, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); let workspace_id = workspace_id.to_owned(); - let owned_workspace_name = new_workspace_name.map(|s| s.to_owned()); - let owned_workspace_icon = new_workspace_icon.map(|s| s.to_owned()); let client = try_get_client?; client .patch_workspace(PatchWorkspaceParam { workspace_id, - workspace_name: owned_workspace_name, - workspace_icon: owned_workspace_icon, + workspace_name: new_workspace_name, + workspace_icon: new_workspace_icon, }) .await?; Ok(()) @@ -363,7 +364,7 @@ where object_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); - let cloned_user = self.user.clone(); + let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { workspace_id: *workspace_id, inner: QueryCollab::new(*object_id, CollabType::UserAwareness), @@ -435,9 +436,9 @@ where async fn subscribe_workspace( &self, - workspace_id: String, + workspace_id: Uuid, recurring_interval: RecurringInterval, - subscription_plan: SubscriptionPlan, + workspace_subscription_plan: SubscriptionPlan, success_url: String, ) -> Result { let try_get_client = self.server.try_get_client(); @@ -447,7 +448,7 @@ where .create_subscription( &workspace_id, recurring_interval, - subscription_plan, + workspace_subscription_plan, &success_url, ) .await?; @@ -490,11 +491,13 @@ where async fn get_workspace_subscription_one( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let client = try_get_client?; - let workspace_subscriptions = client.get_workspace_subscriptions(&workspace_id).await?; + let workspace_subscriptions = client + .get_workspace_subscriptions(&workspace_id.to_string()) + .await?; Ok(workspace_subscriptions) } @@ -531,11 +534,13 @@ where async fn get_workspace_usage( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result { let try_get_client = self.server.try_get_client(); let client = try_get_client?; - let usage = client.get_workspace_usage_and_limit(&workspace_id).await?; + let usage = client + .get_workspace_usage_and_limit(&workspace_id.to_string()) + .await?; Ok(usage) } @@ -548,7 +553,7 @@ where async fn update_workspace_subscription_payment_period( &self, - workspace_id: String, + workspace_id: &Uuid, plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> Result<(), FlowyError> { @@ -556,7 +561,7 @@ where let client = try_get_client?; client .set_subscription_recurring_interval(&SetSubscriptionRecurringInterval { - workspace_id, + workspace_id: workspace_id.to_string(), plan, recurring_interval, }) @@ -573,7 +578,7 @@ where async fn get_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { let workspace_id = workspace_id.to_string(); let try_get_client = self.server.try_get_client(); @@ -584,7 +589,7 @@ where async fn update_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, workspace_settings: AFWorkspaceSettingsChange, ) -> Result { trace!("Sync workspace settings: {:?}", workspace_settings); diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index eb2bf26698..ba13a7fbca 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -4,20 +4,11 @@ use client_api::entity::{AFRole, AFUserProfile, AFWorkspaceInvitationStatus, AFW use flowy_user_pub::entities::{ AuthType, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, WorkspaceMember, - USER_METADATA_ICON_URL, USER_METADATA_OPEN_AI_KEY, USER_METADATA_STABILITY_AI_KEY, + USER_METADATA_ICON_URL, }; -use crate::af_cloud::impls::user::util::encryption_type_from_profile; - pub fn af_update_from_update_params(update: UpdateUserProfileParams) -> UpdateUserParams { let mut user_metadata = UserMetaData::new(); - if let Some(openai_key) = update.openai_key { - user_metadata.insert(USER_METADATA_OPEN_AI_KEY, openai_key); - } - - if let Some(stability_ai_key) = update.stability_ai_key { - user_metadata.insert(USER_METADATA_STABILITY_AI_KEY, stability_ai_key); - } if let Some(icon_url) = update.icon_url { user_metadata.insert(USER_METADATA_ICON_URL, icon_url); @@ -35,19 +26,12 @@ pub fn user_profile_from_af_profile( token: String, profile: AFUserProfile, ) -> Result { - let encryption_type = encryption_type_from_profile(&profile); - let (icon_url, openai_key, stability_ai_key) = { + let icon_url = { profile .metadata .map(|m| { - ( - m.get(USER_METADATA_ICON_URL) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - m.get(USER_METADATA_OPEN_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - m.get(USER_METADATA_STABILITY_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - ) + m.get(USER_METADATA_ICON_URL) + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) }) .unwrap_or_default() }; @@ -57,13 +41,9 @@ pub fn user_profile_from_af_profile( name: profile.name.unwrap_or("".to_string()), token, icon_url: icon_url.unwrap_or_default(), - openai_key: openai_key.unwrap_or_default(), - stability_ai_key: stability_ai_key.unwrap_or_default(), - authenticator: AuthType::AppFlowyCloud, - encryption_type, + auth_type: AuthType::AppFlowyCloud, uid: profile.uid, updated_at: profile.updated_at, - ai_model: "".to_string(), }) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs index bedcc90ca0..300738c833 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs @@ -1,6 +1,6 @@ -use crate::af_cloud::define::LoginUserService; +use crate::af_cloud::define::LoggedUser; use flowy_error::{FlowyError, FlowyResult}; -use std::sync::Arc; +use std::sync::Weak; use tracing::warn; use uuid::Uuid; @@ -9,9 +9,10 @@ use uuid::Uuid; /// This ensures that the operation is being performed in the correct workspace context, enhancing security. pub fn check_request_workspace_id_is_match( expected_workspace_id: &Uuid, - user: &Arc, + user: &Weak, action: impl AsRef, ) -> FlowyResult<()> { + let user = user.upgrade().ok_or_else(FlowyError::user_not_login)?; let actual_workspace_id = user.workspace_id()?; if expected_workspace_id != &actual_workspace_id { warn!( diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs index bb2d11cdbd..66abb32031 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -1,8 +1,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; -use crate::af_cloud::define::{AIUserServiceImpl, LoginUserService}; +use crate::af_cloud::define::{AIUserServiceImpl, LoggedUser}; use anyhow::Error; use arc_swap::ArcSwap; use client_api::collab_sync::ServerCollabMessage; @@ -53,7 +53,7 @@ pub struct AppFlowyCloudServer { network_reachable: Arc, pub device_id: String, ws_client: Arc, - logged_user: Arc, + logged_user: Weak, } impl AppFlowyCloudServer { @@ -62,7 +62,7 @@ impl AppFlowyCloudServer { enable_sync: bool, mut device_id: String, client_version: Version, - auth_user_service: Arc, + logged_user: Weak, ) -> Self { // The device id can't be empty, so we generate a new one if it is. if device_id.is_empty() { @@ -91,8 +91,8 @@ impl AppFlowyCloudServer { ); let ws_client = Arc::new(ws_client); let api_client = Arc::new(api_client); - spawn_ws_conn(token_state_rx, &ws_client, &api_client, &enable_sync); + Self { config, client: api_client, @@ -100,16 +100,17 @@ impl AppFlowyCloudServer { network_reachable, device_id, ws_client, - logged_user: auth_user_service, + logged_user, } } - fn get_client(&self) -> Option> { - if self.enable_sync.load(Ordering::SeqCst) { + fn get_server_impl(&self) -> AFServerImpl { + let client = if self.enable_sync.load(Ordering::SeqCst) { Some(self.client.clone()) } else { None - } + }; + AFServerImpl { client } } } @@ -165,9 +166,6 @@ impl AppFlowyServer for AppFlowyCloudServer { } fn user_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; let mut user_change = self.ws_client.subscribe_user_changed(); let (tx, rx) = tokio::sync::mpsc::channel(1); tokio::spawn(async move { @@ -185,59 +183,45 @@ impl AppFlowyServer for AppFlowyCloudServer { }); Arc::new(AFCloudUserAuthServiceImpl::new( - server, + self.get_server_impl(), rx, self.logged_user.clone(), )) } fn folder_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudFolderCloudServiceImpl { - inner: server, + inner: self.get_server_impl(), logged_user: self.logged_user.clone(), }) } fn database_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudDatabaseCloudServiceImpl { - inner: server, + inner: self.get_server_impl(), logged_user: self.logged_user.clone(), }) } fn database_ai_service(&self) -> Option> { - let server = AFServerImpl { - client: self.get_client(), - }; Some(Arc::new(AFCloudDatabaseCloudServiceImpl { - inner: server, + inner: self.get_server_impl(), logged_user: self.logged_user.clone(), })) } fn document_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudDocumentCloudServiceImpl { - inner: server, + inner: self.get_server_impl(), logged_user: self.logged_user.clone(), }) } fn chat_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; - Arc::new(AutoSyncChatService::new( - Arc::new(CloudChatServiceImpl { inner: server }), + Arc::new(CloudChatServiceImpl { + inner: self.get_server_impl(), + }), Arc::new(AIUserServiceImpl(self.logged_user.clone())), )) } @@ -269,21 +253,16 @@ impl AppFlowyServer for AppFlowyCloudServer { } fn file_storage(&self) -> Option> { - let client = AFServerImpl { - client: self.get_client(), - }; Some(Arc::new(AFCloudFileStorageServiceImpl::new( - client, + self.get_server_impl(), self.config.maximum_upload_file_size_in_bytes, ))) } fn search_service(&self) -> Option> { - let server = AFServerImpl { - client: self.get_client(), - }; - - Some(Arc::new(AFCloudSearchCloudServiceImpl { inner: server })) + Some(Arc::new(AFCloudSearchCloudServiceImpl { + inner: self.get_server_impl(), + })) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 8e731edb84..f56a8d6e8b 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -1,4 +1,4 @@ -use crate::af_cloud::define::LoginUserService; +use crate::af_cloud::define::LoggedUser; use chrono::{TimeZone, Utc}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; use client_api::entity::CompletionStream; @@ -28,7 +28,7 @@ use tracing::trace; use uuid::Uuid; pub struct LocalChatServiceImpl { - pub user: Arc, + pub user: Arc, pub local_ai: Arc, } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 706e7f0597..49db8e03a2 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -113,11 +113,7 @@ impl UserCloudService for LocalServerUserServiceImpl { Err(FlowyError::internal().with_context("Can't oauth url when using offline mode")) } - async fn update_user( - &self, - _credential: UserCredentials, - _params: UpdateUserProfileParams, - ) -> Result<(), FlowyError> { + async fn update_user(&self, _params: UpdateUserProfileParams) -> Result<(), FlowyError> { Ok(()) } @@ -146,8 +142,8 @@ impl UserCloudService for LocalServerUserServiceImpl { async fn patch_workspace( &self, workspace_id: &Uuid, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + new_workspace_name: Option, + new_workspace_icon: Option, ) -> Result<(), FlowyError> { Err( FlowyError::local_version_not_support() diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 719dd59c95..c81d526acb 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -1,7 +1,7 @@ use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; -use crate::af_cloud::define::LoginUserService; +use crate::af_cloud::define::LoggedUser; use crate::local_server::impls::{ LocalChatServiceImpl, LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, LocalServerFolderCloudServiceImpl, LocalServerUserServiceImpl, @@ -17,13 +17,13 @@ use flowy_user_pub::cloud::UserCloudService; use tokio::sync::mpsc; pub struct LocalServer { - user: Arc, + user: Arc, local_ai: Arc, stop_tx: Option>, } impl LocalServer { - pub fn new(user: Arc, local_ai: Arc) -> Self { + pub fn new(user: Arc, local_ai: Arc) -> Self { Self { user, local_ai, diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index a8dcdb507f..249ff9136d 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -8,7 +8,7 @@ use flowy_error::{FlowyError, FlowyResult}; use uuid::Uuid; use crate::setup_log; -use flowy_server::af_cloud::define::LoginUserService; +use flowy_server::af_cloud::define::LoggedUser; use flowy_server::af_cloud::AppFlowyCloudServer; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_sqlite::DBConnection; @@ -30,19 +30,21 @@ pub fn get_af_cloud_config() -> Option { pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc { let fake_device_id = uuid::Uuid::new_v4().to_string(); + let logged_user = Arc::new(FakeServerUserImpl) as Arc; Arc::new(AppFlowyCloudServer::new( config, true, fake_device_id, Version::new(0, 5, 8), - Arc::new(FakeServerUserImpl), + // do nothing, just for test + Arc::downgrade(&logged_user), )) } struct FakeServerUserImpl; #[async_trait] -impl LoginUserService for FakeServerUserImpl { +impl LoggedUser for FakeServerUserImpl { fn workspace_id(&self) -> FlowyResult { todo!() } diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 91c9aa8162..27ccdc8f18 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -89,16 +89,11 @@ diesel::table! { user_table (id) { id -> Text, name -> Text, - workspace -> Text, icon_url -> Text, - openai_key -> Text, token -> Text, email -> Text, auth_type -> Integer, - encryption_type -> Text, - stability_ai_key -> Text, updated_at -> BigInt, - ai_model -> Text, } } @@ -112,6 +107,7 @@ diesel::table! { icon -> Text, member_count -> BigInt, role -> Nullable, + auth_type -> Integer, } } @@ -127,16 +123,25 @@ diesel::table! { } } +diesel::table! { + workspace_setting_table (id) { + id -> Text, + disable_search_indexing -> Bool, + ai_model -> Text, + } +} + diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, + workspace_setting_table, ); diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index dd69e4aa37..dee44fa5f3 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -164,11 +164,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn generate_oauth_url_with_provider(&self, provider: &str) -> Result; /// Using the user's token to update the user information - async fn update_user( - &self, - credential: UserCredentials, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError>; + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError>; /// Get the user information using the user's token or uid /// return None if the user is not found @@ -187,8 +183,8 @@ pub trait UserCloudService: Send + Sync + 'static { async fn patch_workspace( &self, workspace_id: &Uuid, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + new_workspace_name: Option, + new_workspace_icon: Option, ) -> Result<(), FlowyError>; /// Deletes a workspace owned by the user. @@ -277,7 +273,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn subscribe_workspace( &self, - workspace_id: String, + workspace_id: Uuid, recurring_interval: RecurringInterval, workspace_subscription_plan: SubscriptionPlan, success_url: String, @@ -303,7 +299,7 @@ pub trait UserCloudService: Send + Sync + 'static { /// Get the workspace subscriptions for a workspace async fn get_workspace_subscription_one( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result, FlowyError> { Err(FlowyError::not_support()) } @@ -326,7 +322,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_usage( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result { Err(FlowyError::not_support()) } @@ -337,7 +333,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn update_workspace_subscription_payment_period( &self, - workspace_id: String, + workspace_id: &Uuid, plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> Result<(), FlowyError> { @@ -350,14 +346,14 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { Err(FlowyError::not_support()) } async fn update_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, workspace_settings: AFWorkspaceSettingsChange, ) -> Result { Err(FlowyError::not_support()) diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index d59f9cab47..7c8d775fa6 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -10,8 +10,6 @@ use serde_json::Value; use serde_repr::*; use uuid::Uuid; -pub const USER_METADATA_OPEN_AI_KEY: &str = "openai_key"; -pub const USER_METADATA_STABILITY_AI_KEY: &str = "stability_ai_key"; pub const USER_METADATA_ICON_URL: &str = "icon_url"; pub const USER_METADATA_UPDATE_AT: &str = "updated_at"; @@ -171,21 +169,15 @@ impl UserWorkspace { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct UserProfile { - #[serde(rename = "id")] pub uid: i64, pub email: String, pub name: String, pub token: String, pub icon_url: String, - pub openai_key: String, - pub stability_ai_key: String, - pub authenticator: AuthType, - // If the encryption_sign is not empty, which means the user has enabled the encryption. - pub encryption_type: EncryptionType, + pub auth_type: AuthType, pub updated_at: i64, - pub ai_model: String, } #[derive(Serialize, Deserialize, Debug, Clone, Default, Eq, PartialEq)] @@ -233,37 +225,23 @@ where { fn from(params: (&T, &AuthType)) -> Self { let (value, auth_type) = params; - let (icon_url, openai_key, stability_ai_key) = { - value - .metadata() - .as_ref() - .map(|m| { - ( - m.get(USER_METADATA_ICON_URL) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - m.get(USER_METADATA_OPEN_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - m.get(USER_METADATA_STABILITY_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - ) - }) - .unwrap_or_default() - }; + let icon_url = value + .metadata() + .as_ref() + .map(|m| { + m.get(USER_METADATA_ICON_URL) + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) + .unwrap_or_default() + }) + .unwrap_or_default(); Self { uid: value.user_id(), email: value.user_email().unwrap_or_default(), name: value.user_name().to_owned(), token: value.user_token().unwrap_or_default(), icon_url, - openai_key, - authenticator: *auth_type, - encryption_type: value.encryption_type(), - stability_ai_key, + auth_type: *auth_type, updated_at: value.updated_at(), - ai_model: "".to_string(), } } } @@ -275,11 +253,7 @@ pub struct UpdateUserProfileParams { pub email: Option, pub password: Option, pub icon_url: Option, - pub openai_key: Option, - pub stability_ai_key: Option, - pub encryption_sign: Option, pub token: Option, - pub ai_model: Option, } impl UpdateUserProfileParams { @@ -314,40 +288,6 @@ impl UpdateUserProfileParams { self.icon_url = Some(icon_url.to_string()); self } - - pub fn with_openai_key(mut self, openai_key: &str) -> Self { - self.openai_key = Some(openai_key.to_owned()); - self - } - - pub fn with_stability_ai_key(mut self, stability_ai_key: &str) -> Self { - self.stability_ai_key = Some(stability_ai_key.to_owned()); - self - } - - pub fn with_encryption_type(mut self, encryption_type: EncryptionType) -> Self { - let sign = match encryption_type { - EncryptionType::NoEncryption => "".to_string(), - EncryptionType::SelfEncryption(sign) => sign, - }; - self.encryption_sign = Some(sign); - self - } - - pub fn with_ai_model(mut self, ai_model: &str) -> Self { - self.ai_model = Some(ai_model.to_owned()); - self - } - - pub fn is_empty(&self) -> bool { - self.name.is_none() - && self.email.is_none() - && self.password.is_none() - && self.icon_url.is_none() - && self.openai_key.is_none() - && self.encryption_sign.is_none() - && self.stability_ai_key.is_none() - } } #[derive(Debug, Clone, Copy, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 7e62958c68..54cb41db1e 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -4,11 +4,10 @@ use lib_infra::validator_fn::required_not_empty_str; use std::convert::TryInto; use validator::Validate; -use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey}; +use crate::entities::parser::{UserEmail, UserIcon, UserName}; use crate::entities::AuthenticatorPB; use crate::errors::ErrorCode; -use super::parser::UserStabilityAIKey; use super::AFRolePB; #[derive(Default, ProtoBuf)] @@ -41,22 +40,7 @@ pub struct UserProfilePB { pub icon_url: String, #[pb(index = 6)] - pub openai_key: String, - - #[pb(index = 7)] - pub authenticator: AuthenticatorPB, - - #[pb(index = 8)] - pub encryption_sign: String, - - #[pb(index = 9)] - pub encryption_type: EncryptionTypePB, - - #[pb(index = 10)] - pub stability_ai_key: String, - - #[pb(index = 11)] - pub ai_model: String, + pub auth_type: AuthenticatorPB, } #[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] @@ -73,23 +57,13 @@ impl Default for EncryptionTypePB { impl From for UserProfilePB { fn from(user_profile: UserProfile) -> Self { - let (encryption_sign, encryption_ty) = match user_profile.encryption_type { - EncryptionType::NoEncryption => ("".to_string(), EncryptionTypePB::NoEncryption), - EncryptionType::SelfEncryption(sign) => (sign, EncryptionTypePB::Symmetric), - }; - let ai_model = user_profile.ai_model; Self { id: user_profile.uid, email: user_profile.email, name: user_profile.name, token: user_profile.token, icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, - authenticator: user_profile.authenticator.into(), - encryption_sign, - encryption_type: encryption_ty, - stability_ai_key: user_profile.stability_ai_key, - ai_model, + auth_type: user_profile.auth_type.into(), } } } @@ -110,12 +84,6 @@ pub struct UpdateUserProfilePayloadPB { #[pb(index = 5, one_of)] pub icon_url: Option, - - #[pb(index = 6, one_of)] - pub openai_key: Option, - - #[pb(index = 7, one_of)] - pub stability_ai_key: Option, } impl UpdateUserProfilePayloadPB { @@ -145,16 +113,6 @@ impl UpdateUserProfilePayloadPB { self.icon_url = Some(icon_url.to_owned()); self } - - pub fn openai_key(mut self, openai_key: &str) -> Self { - self.openai_key = Some(openai_key.to_owned()); - self - } - - pub fn stability_ai_key(mut self, stability_ai_key: &str) -> Self { - self.stability_ai_key = Some(stability_ai_key.to_owned()); - self - } } impl TryInto for UpdateUserProfilePayloadPB { @@ -178,27 +136,13 @@ impl TryInto for UpdateUserProfilePayloadPB { Some(icon_url) => Some(UserIcon::parse(icon_url)?.0), }; - let openai_key = match self.openai_key { - None => None, - Some(openai_key) => Some(UserOpenaiKey::parse(openai_key)?.0), - }; - - let stability_ai_key = match self.stability_ai_key { - None => None, - Some(stability_ai_key) => Some(UserStabilityAIKey::parse(stability_ai_key)?.0), - }; - Ok(UpdateUserProfileParams { uid: self.id, name, email, password, icon_url, - openai_key, - encryption_sign: None, token: None, - stability_ai_key, - ai_model: None, }) } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index 885ad6f3cf..26f848f3f5 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -5,9 +5,10 @@ use client_api::entity::billing_dto::{ use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::services::sqlite_sql::workspace_setting_sql::WorkspaceSettingsTable; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; -use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::entities::{AuthType, Role, WorkspaceInvitation, WorkspaceMember}; use lib_infra::validator_fn::required_not_empty_str; #[derive(ProtoBuf, Default, Clone)] @@ -215,6 +216,34 @@ pub struct CreateWorkspacePB { #[pb(index = 1)] #[validate(custom(function = "required_not_empty_str"))] pub name: String, + + #[pb(index = 2)] + pub auth_type: AuthTypePB, +} + +#[derive(ProtoBuf_Enum, Default, Clone)] +pub enum AuthTypePB { + LocalAuthType = 0, + #[default] + CloudAuthType = 1, +} + +impl From for AuthTypePB { + fn from(value: AuthType) -> Self { + match value { + AuthType::Local => AuthTypePB::LocalAuthType, + AuthType::AppFlowyCloud => AuthTypePB::CloudAuthType, + } + } +} + +impl From for AuthType { + fn from(value: AuthTypePB) -> Self { + match value { + AuthTypePB::LocalAuthType => AuthType::Local, + AuthTypePB::CloudAuthType => AuthType::AppFlowyCloud, + } + } } #[derive(ProtoBuf, Default, Clone, Validate)] @@ -375,8 +404,8 @@ pub struct BillingPortalPB { pub url: String, } -#[derive(ProtoBuf, Default, Clone, Validate)] -pub struct UseAISettingPB { +#[derive(ProtoBuf, Default, Clone, Validate, Eq, PartialEq)] +pub struct WorkspaceSettingsPB { #[pb(index = 1)] pub disable_search_indexing: bool, @@ -384,8 +413,17 @@ pub struct UseAISettingPB { pub ai_model: String, } -impl From for UseAISettingPB { - fn from(value: AFWorkspaceSettings) -> Self { +impl From<&AFWorkspaceSettings> for WorkspaceSettingsPB { + fn from(value: &AFWorkspaceSettings) -> Self { + Self { + disable_search_indexing: value.disable_search_indexing, + ai_model: value.ai_model.clone(), + } + } +} + +impl From for WorkspaceSettingsPB { + fn from(value: WorkspaceSettingsTable) -> Self { Self { disable_search_indexing: value.disable_search_indexing, ai_model: value.ai_model, diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 7a64c20e06..bb2a0b2c30 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -1,6 +1,5 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; -use flowy_user_pub::cloud::UserCloudConfig; use flowy_user_pub::entities::*; use lib_dispatch::prelude::*; use lib_infra::box_any::BoxAny; @@ -17,6 +16,7 @@ use crate::services::cloud_config::{ get_cloud_config, get_or_create_cloud_config, save_cloud_config, }; use crate::services::data_import::prepare_import; +use crate::services::sqlite_sql::workspace_sql::UserWorkspaceChangeset; use crate::user_manager::UserManager; fn upgrade_manager(manager: AFPluginState>) -> FlowyResult> { @@ -45,7 +45,7 @@ pub async fn sign_in_with_email_password_handler( let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let old_authenticator = manager.cloud_services.get_server_auth_type(); + let old_authenticator = manager.cloud_service.get_server_auth_type(); match manager .sign_in_with_password(¶ms.email, ¶ms.password) .await @@ -53,7 +53,7 @@ pub async fn sign_in_with_email_password_handler( Ok(token) => data_result_ok(token.into()), Err(err) => { manager - .cloud_services + .cloud_service .set_server_auth_type(&old_authenticator); return Err(err); }, @@ -78,11 +78,11 @@ pub async fn sign_up( let params: SignUpParams = data.into_inner().try_into()?; let auth_type = params.auth_type; - let prev_auth_type = manager.cloud_services.get_server_auth_type(); + let prev_auth_type = manager.cloud_service.get_server_auth_type(); match manager.sign_up(auth_type, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), Err(err) => { - manager.cloud_services.set_server_auth_type(&prev_auth_type); + manager.cloud_service.set_server_auth_type(&prev_auth_type); Err(err) }, } @@ -117,7 +117,7 @@ pub async fn get_user_profile_handler( // When the user is logged in with a local account, the email field is a placeholder and should // not be exposed to the client. So we set the email field to an empty string. - if user_profile.authenticator == AuthType::Local { + if user_profile.auth_type == AuthType::Local { user_profile.email = "".to_string(); } @@ -374,66 +374,6 @@ pub async fn sign_in_with_provider_handler( }) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn set_encrypt_secret_handler( - manager: AFPluginState>, - data: AFPluginData, - store_preferences: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let store_preferences = upgrade_store_preferences(store_preferences)?; - let data = data.into_inner(); - match data.encryption_type { - EncryptionTypePB::NoEncryption => { - tracing::error!("Encryption type is NoEncryption, but set encrypt secret"); - }, - EncryptionTypePB::Symmetric => { - manager.check_encryption_sign_with_secret( - data.user_id, - &data.encryption_sign, - &data.encryption_secret, - )?; - - let config = UserCloudConfig::new(data.encryption_secret).with_enable_encrypt(true); - manager - .set_encrypt_secret( - data.user_id, - config.encrypt_secret.clone(), - EncryptionType::SelfEncryption(data.encryption_sign), - ) - .await?; - save_cloud_config(data.user_id, &store_preferences, &config)?; - }, - } - - manager.resume_sign_up().await?; - Ok(()) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn check_encrypt_secret_handler( - manager: AFPluginState>, -) -> DataResult { - let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let profile = manager.get_user_profile_from_disk(uid).await?; - - let is_need_secret = match profile.encryption_type { - EncryptionType::NoEncryption => false, - EncryptionType::SelfEncryption(sign) => { - if sign.is_empty() { - false - } else { - manager.check_encryption_sign(uid, &sign).is_err() - } - }, - }; - - data_result_ok(UserEncryptionConfigurationPB { - require_secret: is_need_secret, - }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn set_cloud_config_handler( manager: AFPluginState>, @@ -449,40 +389,18 @@ pub async fn set_cloud_config_handler( if let Some(enable_sync) = update.enable_sync { manager - .cloud_services + .cloud_service .set_enable_sync(session.user_id, enable_sync); config.enable_sync = enable_sync; } - if let Some(enable_encrypt) = update.enable_encrypt { - debug_assert!(enable_encrypt, "Disable encryption is not supported"); - - if enable_encrypt { - tracing::info!("Enable encryption for user: {}", session.user_id); - config = config.with_enable_encrypt(enable_encrypt); - let encrypt_secret = config.encrypt_secret.clone(); - - // The encryption secret is generated when the user first enables encryption and will be - // used to validate the encryption secret is correct when the user logs in. - let encryption_sign = manager.generate_encryption_sign(session.user_id, &encrypt_secret)?; - let encryption_type = EncryptionType::SelfEncryption(encryption_sign); - manager - .set_encrypt_secret(session.user_id, encrypt_secret, encryption_type.clone()) - .await?; - - let params = - UpdateUserProfileParams::new(session.user_id).with_encryption_type(encryption_type); - manager.update_user_profile(params).await?; - } - } - save_cloud_config(session.user_id, &store_preferences, &config)?; let payload = CloudSettingPB { enable_sync: config.enable_sync, enable_encrypt: config.enable_encrypt, encrypt_secret: config.encrypt_secret, - server_url: manager.cloud_services.service_url(), + server_url: manager.cloud_service.service_url(), }; send_notification( @@ -509,7 +427,7 @@ pub async fn get_cloud_config_handler( enable_sync: config.enable_sync, enable_encrypt: config.enable_encrypt, encrypt_secret: config.encrypt_secret, - server_url: manager.cloud_services.service_url(), + server_url: manager.cloud_service.service_url(), }) } @@ -518,8 +436,10 @@ pub async fn get_all_workspace_handler( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let user_workspaces = manager.get_all_user_workspaces(uid).await?; + let profile = manager.get_user_profile().await?; + let user_workspaces = manager + .get_all_user_workspaces(profile.uid, profile.auth_type) + .await?; data_result_ok(user_workspaces.into()) } @@ -542,7 +462,7 @@ pub async fn update_network_state_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let reachable = data.into_inner().ty.is_reachable(); - manager.cloud_services.set_network_reachable(reachable); + manager.cloud_service.set_network_reachable(reachable); manager .user_status_callback .read() @@ -687,8 +607,9 @@ pub async fn create_workspace_handler( manager: AFPluginState>, ) -> DataResult { let data = data.try_into_inner()?; + let auth_type = AuthType::from(data.auth_type); let manager = upgrade_manager(manager)?; - let new_workspace = manager.add_workspace(&data.name).await?; + let new_workspace = manager.create_workspace(&data.name, auth_type).await?; data_result_ok(new_workspace.into()) } @@ -712,9 +633,12 @@ pub async fn rename_workspace_handler( let params = rename_workspace_param.try_into_inner()?; let manager = upgrade_manager(manager)?; let workspace_id = Uuid::from_str(¶ms.workspace_id)?; - manager - .patch_workspace(&workspace_id, Some(¶ms.new_name), None) - .await?; + let changeset = UserWorkspaceChangeset { + id: params.workspace_id, + name: Some(params.new_name), + icon: None, + }; + manager.patch_workspace(&workspace_id, changeset).await?; Ok(()) } @@ -726,9 +650,12 @@ pub async fn change_workspace_icon_handler( let params = change_workspace_icon_param.try_into_inner()?; let manager = upgrade_manager(manager)?; let workspace_id = Uuid::from_str(¶ms.workspace_id)?; - manager - .patch_workspace(&workspace_id, None, Some(¶ms.new_icon)) - .await?; + let changeset = UserWorkspaceChangeset { + id: workspace_id.to_string(), + name: None, + icon: Some(params.new_icon), + }; + manager.patch_workspace(&workspace_id, changeset).await?; Ok(()) } @@ -825,9 +752,9 @@ pub async fn get_workspace_usage_handler( param: AFPluginData, manager: AFPluginState>, ) -> DataResult { - let workspace_id = param.into_inner().workspace_id; + let workspace_id = Uuid::from_str(¶m.into_inner().workspace_id)?; let manager = upgrade_manager(manager)?; - let workspace_usage = manager.get_workspace_usage(workspace_id).await?; + let workspace_usage = manager.get_workspace_usage(&workspace_id).await?; data_result_ok(WorkspaceUsagePB::from(workspace_usage)) } @@ -845,11 +772,12 @@ pub async fn update_workspace_subscription_payment_period_handler( params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; let params = params.try_into_inner()?; let manager = upgrade_manager(manager)?; manager .update_workspace_subscription_payment_period( - params.workspace_id, + &workspace_id, params.plan.into(), params.recurring_interval.into(), ) @@ -884,7 +812,7 @@ pub async fn get_workspace_member_info( } #[tracing::instrument(level = "info", skip_all, err)] -pub async fn update_workspace_setting( +pub async fn update_workspace_setting_handler( params: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { @@ -895,13 +823,14 @@ pub async fn update_workspace_setting( } #[tracing::instrument(level = "info", skip_all, err)] -pub async fn get_workspace_setting( +pub async fn get_workspace_setting_handler( params: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let params = params.try_into_inner()?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; let manager = upgrade_manager(manager)?; - let pb = manager.get_workspace_settings(¶ms.workspace_id).await?; + let pb = manager.get_workspace_settings(&workspace_id).await?; data_result_ok(pb) } diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 953011bc1c..03660ad0ff 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -35,8 +35,6 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GetUserSetting, get_user_setting) .event(UserEvent::SetCloudConfig, set_cloud_config_handler) .event(UserEvent::GetCloudConfig, get_cloud_config_handler) - .event(UserEvent::SetEncryptionSecret, set_encrypt_secret_handler) - .event(UserEvent::CheckEncryptionSign, check_encrypt_secret_handler) .event(UserEvent::OauthSignIn, oauth_sign_in_handler) .event(UserEvent::GenerateSignInURL, gen_sign_in_url_handler) .event(UserEvent::GetOauthURLWithProvider, sign_in_with_provider_handler) @@ -77,8 +75,8 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::UpdateWorkspaceSubscriptionPaymentPeriod, update_workspace_subscription_payment_period_handler) .event(UserEvent::GetSubscriptionPlanDetails, get_subscription_plan_details_handler) // Workspace Setting - .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting) - .event(UserEvent::GetWorkspaceSetting, get_workspace_setting) + .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting_handler) + .event(UserEvent::GetWorkspaceSetting, get_workspace_setting_handler) .event(UserEvent::NotifyDidSwitchPlan, notify_did_switch_plan_handler) .event(UserEvent::PasscodeSignIn, sign_in_with_passcode_handler) } @@ -142,12 +140,6 @@ pub enum UserEvent { #[event(output = "CloudSettingPB")] GetCloudConfig = 14, - #[event(input = "UserSecretPB")] - SetEncryptionSecret = 15, - - #[event(output = "UserEncryptionConfigurationPB")] - CheckEncryptionSign = 16, - /// Return the all the workspaces of the user #[event(output = "RepeatedUserWorkspacePB")] GetAllWorkspace = 17, @@ -257,7 +249,7 @@ pub enum UserEvent { #[event(input = "UpdateUserWorkspaceSettingPB")] UpdateWorkspaceSetting = 57, - #[event(input = "UserWorkspaceIdPB", output = "UseAISettingPB")] + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSettingsPB")] GetWorkspaceSetting = 58, #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSubscriptionInfoPB")] diff --git a/frontend/rust-lib/flowy-user/src/notification.rs b/frontend/rust-lib/flowy-user/src/notification.rs index a8bd91b55b..dd93593468 100644 --- a/frontend/rust-lib/flowy-user/src/notification.rs +++ b/frontend/rust-lib/flowy-user/src/notification.rs @@ -14,7 +14,7 @@ pub(crate) enum UserNotification { DidUpdateUserWorkspaces = 3, DidUpdateCloudConfig = 4, DidUpdateUserWorkspace = 5, - DidUpdateAISetting = 6, + DidUpdateWorkspaceSetting = 6, } impl std::convert::From for i32 { diff --git a/frontend/rust-lib/flowy-user/src/services/db.rs b/frontend/rust-lib/flowy-user/src/services/db.rs index e324c2820f..f05c0bda95 100644 --- a/frontend/rust-lib/flowy-user/src/services/db.rs +++ b/frontend/rust-lib/flowy-user/src/services/db.rs @@ -7,20 +7,17 @@ use collab_plugins::local_storage::kv::KVTransactionDB; use dashmap::mapref::entry::Entry; use dashmap::DashMap; use flowy_error::FlowyError; -use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::ConnectionPool; use flowy_sqlite::{ query_dsl::*, schema::{user_table, user_table::dsl}, DBConnection, Database, ExpressionMethods, }; -use flowy_user_pub::entities::{UserProfile, UserWorkspace}; - +use flowy_user_pub::entities::UserProfile; use lib_infra::file_util::{unzip_and_replace, zip_folder}; use tracing::{error, event, info, instrument}; use crate::services::sqlite_sql::user_sql::UserTable; -use crate::services::sqlite_sql::workspace_sql::UserWorkspaceTable; pub trait UserDBPath: Send + Sync + 'static { fn sqlite_db_path(&self, uid: i64) -> PathBuf; @@ -143,18 +140,6 @@ impl UserDB { Ok(user.into()) } - pub fn get_user_workspace( - &self, - pool: &Arc, - uid: i64, - ) -> Result, FlowyError> { - let mut conn = pool.get()?; - let row = user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(uid)) - .first::(&mut *conn)?; - Ok(Some(UserWorkspace::from(row))) - } - /// Open a collab db for the user. If the db is already opened, return the opened db. /// fn open_collab_db( diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs index 93e642f72e..635c79ba39 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod member_sql; pub(crate) mod user_sql; +pub(crate) mod workspace_setting_sql; pub(crate) mod workspace_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs index 1138efd092..3fbd4c5854 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs @@ -1,6 +1,5 @@ use diesel::RunQueryDsl; use flowy_error::FlowyError; -use std::str::FromStr; use flowy_user_pub::cloud::UserUpdate; use flowy_user_pub::entities::*; @@ -15,40 +14,26 @@ use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; pub struct UserTable { pub(crate) id: String, pub(crate) name: String, - #[deprecated( - note = "The workspace_id is deprecated, please use the [Session::UserWorkspace] instead" - )] - pub(crate) workspace: String, pub(crate) icon_url: String, - pub(crate) openai_key: String, pub(crate) token: String, pub(crate) email: String, pub(crate) auth_type: i32, - pub(crate) encryption_type: String, - pub(crate) stability_ai_key: String, pub(crate) updated_at: i64, - pub(crate) ai_model: String, } #[allow(deprecated)] impl From<(UserProfile, AuthType)> for UserTable { fn from(value: (UserProfile, AuthType)) -> Self { let (user_profile, auth_type) = value; - let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); UserTable { id: user_profile.uid.to_string(), name: user_profile.name, #[allow(deprecated)] - workspace: "".to_string(), icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, token: user_profile.token, email: user_profile.email, auth_type: auth_type as i32, - encryption_type, - stability_ai_key: user_profile.stability_ai_key, updated_at: user_profile.updated_at, - ai_model: user_profile.ai_model, } } } @@ -61,12 +46,8 @@ impl From for UserProfile { name: table.name, token: table.token, icon_url: table.icon_url, - openai_key: table.openai_key, - authenticator: AuthType::from(table.auth_type), - encryption_type: EncryptionType::from_str(&table.encryption_type).unwrap_or_default(), - stability_ai_key: table.stability_ai_key, + auth_type: AuthType::from(table.auth_type), updated_at: table.updated_at, - ai_model: table.ai_model, } } } @@ -75,50 +56,30 @@ impl From for UserProfile { #[diesel(table_name = user_table)] pub struct UserTableChangeset { pub id: String, - pub workspace: Option, // deprecated pub name: Option, pub email: Option, pub icon_url: Option, - pub openai_key: Option, - pub encryption_type: Option, pub token: Option, - pub stability_ai_key: Option, - pub ai_model: Option, } impl UserTableChangeset { pub fn new(params: UpdateUserProfileParams) -> Self { - let encryption_type = params.encryption_sign.map(|sign| { - let ty = EncryptionType::from_sign(&sign); - serde_json::to_string(&ty).unwrap_or_default() - }); UserTableChangeset { id: params.uid.to_string(), - workspace: None, name: params.name, email: params.email, icon_url: params.icon_url, - openai_key: params.openai_key, - encryption_type, token: params.token, - stability_ai_key: params.stability_ai_key, - ai_model: params.ai_model, } } pub fn from_user_profile(user_profile: UserProfile) -> Self { - let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); UserTableChangeset { id: user_profile.uid.to_string(), - workspace: None, name: Some(user_profile.name), email: Some(user_profile.email), icon_url: Some(user_profile.icon_url), - openai_key: Some(user_profile.openai_key), - encryption_type: Some(encryption_type), token: Some(user_profile.token), - stability_ai_key: Some(user_profile.stability_ai_key), - ai_model: Some(user_profile.ai_model), } } } diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs index 8d5c1e8dc7..f197ca9738 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs @@ -4,8 +4,7 @@ use flowy_error::FlowyError; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::DBConnection; use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use flowy_user_pub::entities::UserWorkspace; -use std::convert::TryFrom; +use flowy_user_pub::entities::{AuthType, UserWorkspace}; #[derive(Clone, Default, Queryable, Identifiable, Insertable)] #[diesel(table_name = user_workspace_table)] @@ -18,9 +17,45 @@ pub struct UserWorkspaceTable { pub icon: String, pub member_count: i64, pub role: Option, + pub auth_type: i32, } -pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Option { +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceChangeset { + pub id: String, + pub name: Option, + pub icon: Option, +} + +impl UserWorkspaceTable { + pub fn from_workspace( + uid: i64, + workspace: &UserWorkspace, + auth_type: AuthType, + ) -> Result { + if workspace.id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The id is empty")); + } + if workspace.workspace_database_id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); + } + + Ok(Self { + id: workspace.id.clone(), + name: workspace.name.clone(), + uid, + created_at: workspace.created_at.timestamp(), + database_storage_id: workspace.workspace_database_id.clone(), + icon: workspace.icon.clone(), + member_count: workspace.member_count, + role: workspace.role.clone().map(|v| v as i32), + auth_type: auth_type as i32, + }) + } +} + +pub fn select_user_workspace(workspace_id: &str, mut conn: DBConnection) -> Option { user_workspace_table::dsl::user_workspace_table .filter(user_workspace_table::id.eq(workspace_id)) .first::(&mut *conn) @@ -28,7 +63,7 @@ pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Opti .map(UserWorkspace::from) } -pub fn get_all_user_workspace_op( +pub fn select_all_user_workspace( user_id: i64, mut conn: DBConnection, ) -> Result, FlowyError> { @@ -38,81 +73,45 @@ pub fn get_all_user_workspace_op( Ok(rows.into_iter().map(UserWorkspace::from).collect()) } -/// Remove all existing workspaces for given user and insert the new ones. -/// -#[allow(dead_code)] -pub fn save_user_workspaces_op( - uid: i64, +pub fn update_user_workspace( mut conn: DBConnection, - user_workspaces: &[UserWorkspace], + changeset: UserWorkspaceChangeset, ) -> Result<(), FlowyError> { - conn.immediate_transaction(|conn| { - delete_existing_workspaces(uid, conn)?; - insert_or_update_workspaces_op(uid, user_workspaces, conn)?; - Ok(()) - }) -} + diesel::update(user_workspace_table::dsl::user_workspace_table) + .filter(user_workspace_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(&mut conn)?; -#[allow(dead_code)] -fn delete_existing_workspaces(uid: i64, conn: &mut SqliteConnection) -> Result<(), FlowyError> { - diesel::delete( - user_workspace_table::dsl::user_workspace_table.filter(user_workspace_table::uid.eq(uid)), - ) - .execute(conn)?; Ok(()) } -pub fn insert_or_update_workspaces_op( +pub fn upsert_user_workspace( uid: i64, - user_workspaces: &[UserWorkspace], + auth_type: AuthType, + user_workspace: UserWorkspace, conn: &mut SqliteConnection, ) -> Result<(), FlowyError> { - for user_workspace in user_workspaces { - let new_record = UserWorkspaceTable::try_from((uid, user_workspace))?; + let new_record = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; - diesel::insert_into(user_workspace_table::table) - .values(new_record.clone()) - .on_conflict(user_workspace_table::id) - .do_update() - .set(( - user_workspace_table::name.eq(new_record.name), - user_workspace_table::uid.eq(new_record.uid), - user_workspace_table::created_at.eq(new_record.created_at), - user_workspace_table::database_storage_id.eq(new_record.database_storage_id), - user_workspace_table::icon.eq(new_record.icon), - user_workspace_table::member_count.eq(new_record.member_count), - user_workspace_table::role.eq(new_record.role), - )) - .execute(conn)?; - } + diesel::insert_into(user_workspace_table::table) + .values(new_record.clone()) + .on_conflict(user_workspace_table::id) + .do_update() + .set(( + user_workspace_table::name.eq(new_record.name), + user_workspace_table::uid.eq(new_record.uid), + user_workspace_table::created_at.eq(new_record.created_at), + user_workspace_table::database_storage_id.eq(new_record.database_storage_id), + user_workspace_table::icon.eq(new_record.icon), + user_workspace_table::member_count.eq(new_record.member_count), + user_workspace_table::role.eq(new_record.role), + user_workspace_table::auth_type.eq(new_record.auth_type), + )) + .execute(conn)?; Ok(()) } -impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable { - type Error = FlowyError; - - fn try_from(value: (i64, &UserWorkspace)) -> Result { - if value.1.id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The id is empty")); - } - if value.1.workspace_database_id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); - } - - Ok(Self { - id: value.1.id.clone(), - name: value.1.name.clone(), - uid: value.0, - created_at: value.1.created_at.timestamp(), - database_storage_id: value.1.workspace_database_id.clone(), - icon: value.1.icon.clone(), - member_count: value.1.member_count, - role: value.1.role.clone().map(|v| v as i32), - }) - } -} - impl From for UserWorkspace { fn from(value: UserWorkspaceTable) -> Self { Self { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 8fb4009991..d7d7c8b68d 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -39,17 +39,16 @@ use crate::services::authenticate_user::AuthenticateUser; use crate::services::cloud_config::get_cloud_config; use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; -use super::manager_user_workspace::save_user_workspace; use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; use crate::services::sqlite_sql::user_sql::{select_user_profile, UserTable, UserTableChangeset}; -use crate::user_manager::manager_user_encryption::validate_encryption_sign; +use crate::services::sqlite_sql::workspace_sql::upsert_user_workspace; use crate::user_manager::manager_user_workspace::save_all_user_workspaces; use crate::user_manager::user_login_state::UserAuthProcess; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; pub struct UserManager { - pub(crate) cloud_services: Arc, + pub(crate) cloud_service: Arc, pub(crate) store_preferences: Arc, pub(crate) user_awareness: Arc>>, pub(crate) user_status_callback: RwLock>, @@ -75,7 +74,7 @@ impl UserManager { let refresh_user_profile_since = AtomicI64::new(0); let user_manager = Arc::new(Self { - cloud_services, + cloud_service: cloud_services, store_preferences, user_awareness: Default::default(), user_status_callback, @@ -89,7 +88,7 @@ impl UserManager { }); let weak_user_manager = Arc::downgrade(&user_manager); - if let Ok(user_service) = user_manager.cloud_services.get_user_service() { + if let Ok(user_service) = user_manager.cloud_service.get_user_service() { if let Some(mut rx) = user_service.subscribe_user_update() { tokio::spawn(async move { while let Some(update) = rx.recv().await { @@ -141,11 +140,11 @@ impl UserManager { // If the current authenticator is different from the authenticator in the session and it's // not a local authenticator, we need to sign out the user. - if user.authenticator != AuthType::Local && user.authenticator != current_authenticator { + if user.auth_type != AuthType::Local && user.auth_type != current_authenticator { event!( tracing::Level::INFO, "Authenticator changed from {:?} to {:?}", - user.authenticator, + user.auth_type, current_authenticator ); self.sign_out().await?; @@ -157,7 +156,7 @@ impl UserManager { "init user session: {}:{}, authenticator: {:?}", user.uid, user.email, - user.authenticator, + user.auth_type, ); self.prepare_user(&session).await; @@ -166,21 +165,17 @@ impl UserManager { // Set the token if the current cloud service using token to authenticate // Currently, only the AppFlowy cloud using token to init the client api. // TODO(nathan): using trait to separate the init process for different cloud service - if user.authenticator.is_appflowy_cloud() { - if let Err(err) = self.cloud_services.set_token(&user.token) { + if user.auth_type.is_appflowy_cloud() { + if let Err(err) = self.cloud_service.set_token(&user.token) { error!("Set token failed: {}", err); } - if let Err(err) = self.cloud_services.set_ai_model(&user.ai_model) { - error!("Set ai model failed: {}", err); - } - // Subscribe the token state - let weak_cloud_services = Arc::downgrade(&self.cloud_services); + let weak_cloud_services = Arc::downgrade(&self.cloud_service); let weak_authenticate_user = Arc::downgrade(&self.authenticate_user); let weak_pool = Arc::downgrade(&self.db_pool(user.uid)?); let cloned_session = session.clone(); - if let Some(mut token_state_rx) = self.cloud_services.subscribe_token_state() { + if let Some(mut token_state_rx) = self.cloud_service.subscribe_token_state() { event!(tracing::Level::DEBUG, "Listen token state change"); let user_uid = user.uid; let local_token = user.token.clone(); @@ -273,18 +268,16 @@ impl UserManager { self.set_first_time_installed_version(); let cloud_config = get_cloud_config(session.user_id, &self.store_preferences); // Init the user awareness. here we ignore the error - let _ = self - .initial_user_awareness(&session, &user.authenticator) - .await; + let _ = self.initial_user_awareness(&session, &user.auth_type).await; user_status_callback .did_init( user.uid, - &user.authenticator, + &user.auth_type, &cloud_config, &session.user_workspace, &self.authenticate_user.user_config.device_id, - &user.authenticator, + &user.auth_type, ) .await?; } else { @@ -349,12 +342,12 @@ impl UserManager { pub async fn sign_in( &self, params: SignInParams, - authenticator: AuthType, + auth_type: AuthType, ) -> Result { - self.cloud_services.set_server_auth_type(&authenticator); + self.cloud_service.set_server_auth_type(&auth_type); let response: AuthResponse = self - .cloud_services + .cloud_service .get_user_service()? .sign_in(BoxAny::new(params)) .await?; @@ -362,13 +355,11 @@ impl UserManager { self.prepare_user(&session).await; let latest_workspace = response.latest_workspace.clone(); - let user_profile = UserProfile::from((&response, &authenticator)); - self - .save_auth_data(&response, &authenticator, &session) - .await?; + let user_profile = UserProfile::from((&response, &auth_type)); + self.save_auth_data(&response, auth_type, &session).await?; let _ = self - .initial_user_awareness(&session, &user_profile.authenticator) + .initial_user_awareness(&session, &user_profile.auth_type) .await; self .user_status_callback @@ -378,7 +369,7 @@ impl UserManager { user_profile.uid, &latest_workspace, &self.authenticate_user.user_config.device_id, - &authenticator, + &auth_type, ) .await?; send_auth_state_notification(AuthStateChangedPB { @@ -403,22 +394,13 @@ impl UserManager { ) -> Result { // sign out the current user if there is one let migration_user = self.get_migration_user(&auth_type).await; - self.cloud_services.set_server_auth_type(&auth_type); - let auth_service = self.cloud_services.get_user_service()?; + self.cloud_service.set_server_auth_type(&auth_type); + let auth_service = self.cloud_service.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; let new_user_profile = UserProfile::from((&response, &auth_type)); - if new_user_profile.encryption_type.require_encrypt_secret() { - self.auth_process.lock().await.replace(UserAuthProcess { - user_profile: new_user_profile.clone(), - migration_user, - response, - authenticator: auth_type, - }); - } else { - self - .continue_sign_up(&new_user_profile, migration_user, response, &auth_type) - .await?; - } + self + .continue_sign_up(&new_user_profile, migration_user, response, &auth_type) + .await?; Ok(new_user_profile) } @@ -455,10 +437,10 @@ impl UserManager { let new_session = Session::from(&response); self.prepare_user(&new_session).await; self - .save_auth_data(&response, auth_type, &new_session) + .save_auth_data(&response, *auth_type, &new_session) .await?; let _ = self - .initial_user_awareness(&new_session, &new_user_profile.authenticator) + .initial_user_awareness(&new_session, &new_user_profile.auth_type) .await; self .user_status_callback @@ -514,7 +496,7 @@ impl UserManager { pub async fn sign_out(&self) -> Result<(), FlowyError> { if let Ok(session) = self.get_session() { sign_out( - &self.cloud_services, + &self.cloud_service, &session, &self.authenticate_user, self.db_connection(session.user_id)?, @@ -527,7 +509,7 @@ impl UserManager { #[tracing::instrument(level = "info", skip(self))] pub async fn delete_account(&self) -> Result<(), FlowyError> { self - .cloud_services + .cloud_service .get_user_service()? .delete_account() .await?; @@ -553,10 +535,7 @@ impl UserManager { changeset, )?; - let profile = self.get_user_profile_from_disk(session.user_id).await?; - self - .update_user(session.user_id, profile.token, params) - .await?; + self.update_user(params).await?; Ok(()) } @@ -580,6 +559,12 @@ impl UserManager { .backup(session.user_id, &session.user_workspace.id); } + pub async fn get_user_profile(&self) -> FlowyResult { + let uid = self.get_session()?.user_id; + let profile = self.get_user_profile_from_disk(uid).await?; + Ok(profile) + } + /// Fetches the user profile for the given user ID. pub async fn get_user_profile_from_disk(&self, uid: i64) -> Result { select_user_profile(uid, self.db_connection(uid)?) @@ -588,7 +573,7 @@ impl UserManager { #[tracing::instrument(level = "info", skip_all, err)] pub async fn refresh_user_profile(&self, old_user_profile: &UserProfile) -> FlowyResult<()> { // If the user is a local user, no need to refresh the user profile - if old_user_profile.authenticator.is_local() { + if old_user_profile.auth_type.is_local() { return Ok(()); } @@ -601,7 +586,7 @@ impl UserManager { let uid = old_user_profile.uid; let result: Result = self - .cloud_services + .cloud_service .get_user_service()? .get_user_profile(UserCredentials::from_uid(uid)) .await; @@ -610,7 +595,6 @@ impl UserManager { Ok(new_user_profile) => { // If the user profile is updated, save the new user profile if new_user_profile.updated_at > old_user_profile.updated_at { - validate_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign()); // Save the new user profile let changeset = UserTableChangeset::from_user_profile(new_user_profile); let _ = upsert_user_profile_change( @@ -669,19 +653,11 @@ impl UserManager { Ok(None) } - async fn update_user( - &self, - uid: i64, - token: String, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError> { - let server = self.cloud_services.get_user_service()?; - tokio::spawn(async move { - let credentials = UserCredentials::new(Some(token), Some(uid), None); - server.update_user(credentials, params).await - }) - .await - .map_err(internal_error)??; + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { + let server = self.cloud_service.get_user_service()?; + tokio::spawn(async move { server.update_user(params).await }) + .await + .map_err(internal_error)??; Ok(()) } @@ -702,7 +678,7 @@ impl UserManager { } pub async fn receive_realtime_event(&self, json: Value) { - if let Ok(user_service) = self.cloud_services.get_user_service() { + if let Ok(user_service) = self.cloud_service.get_user_service() { user_service.receive_realtime_event(json) } } @@ -712,9 +688,9 @@ impl UserManager { authenticator: &AuthType, email: &str, ) -> Result { - self.cloud_services.set_server_auth_type(authenticator); + self.cloud_service.set_server_auth_type(authenticator); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; let url = auth_service.generate_sign_in_url_with_email(email).await?; Ok(url) } @@ -725,9 +701,9 @@ impl UserManager { password: &str, ) -> Result { self - .cloud_services + .cloud_service .set_server_auth_type(&AuthType::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; let response = auth_service.sign_in_with_password(email, password).await?; Ok(response) } @@ -738,9 +714,9 @@ impl UserManager { redirect_to: &str, ) -> Result<(), FlowyError> { self - .cloud_services + .cloud_service .set_server_auth_type(&AuthType::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; auth_service .sign_in_with_magic_link(email, redirect_to) .await?; @@ -753,9 +729,9 @@ impl UserManager { passcode: &str, ) -> Result { self - .cloud_services + .cloud_service .set_server_auth_type(&AuthType::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; let response = auth_service.sign_in_with_passcode(email, passcode).await?; Ok(response) } @@ -765,9 +741,9 @@ impl UserManager { oauth_provider: &str, ) -> Result { self - .cloud_services + .cloud_service .set_server_auth_type(&AuthType::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) .await?; @@ -778,27 +754,32 @@ impl UserManager { async fn save_auth_data( &self, response: &impl UserAuthResponse, - authenticator: &AuthType, + auth_type: AuthType, session: &Session, ) -> Result<(), FlowyError> { - let user_profile = UserProfile::from((response, authenticator)); + let user_profile = UserProfile::from((response, &auth_type)); let uid = user_profile.uid; - if authenticator.is_local() { + if auth_type.is_local() { event!(tracing::Level::DEBUG, "Save new anon user: {:?}", uid); self.set_anon_user(session); } - save_all_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; + save_all_user_workspaces( + uid, + self.db_connection(uid)?, + auth_type, + response.user_workspaces(), + )?; info!( "Save new user profile to disk, authenticator: {:?}", - authenticator + auth_type ); self .authenticate_user .set_session(Some(session.clone().into()))?; self - .save_user(uid, (user_profile, *authenticator).into()) + .save_user(uid, (user_profile, auth_type).into()) .await?; Ok(()) } @@ -807,11 +788,6 @@ impl UserManager { let session = self.get_session()?; if session.user_id == user_update.uid { debug!("Receive user update: {:?}", user_update); - let user_profile = self.get_user_profile_from_disk(user_update.uid).await?; - if !validate_encryption_sign(&user_profile, &user_update.encryption_sign) { - return Ok(()); - } - // Save the user profile change upsert_user_profile_change( user_update.uid, @@ -827,27 +803,29 @@ impl UserManager { &self, old_user: &AnonUser, _new_user_session: &Session, - authenticator: &AuthType, + auth_type: &AuthType, ) -> Result<(), FlowyError> { let old_collab_db = self .authenticate_user .database .get_collab_db(old_user.session.user_id)?; - if authenticator == &AuthType::AppFlowyCloud { + if auth_type == &AuthType::AppFlowyCloud { self .migration_anon_user_on_appflowy_cloud_sign_up(old_user, &old_collab_db) .await?; } // Save the old user workspace setting. - save_user_workspace( + let mut conn = self + .authenticate_user + .database + .get_connection(old_user.session.user_id)?; + upsert_user_workspace( old_user.session.user_id, - self - .authenticate_user - .database - .get_connection(old_user.session.user_id)?, - &old_user.session.user_workspace.clone(), + *auth_type, + old_user.session.user_workspace.clone(), + &mut conn, )?; Ok(()) } @@ -927,7 +905,7 @@ pub(crate) fn run_collab_data_migration( let migrations = collab_migration_list(); match UserLocalDataMigration::new(session.clone(), collab_db, sqlite_pool, kv).run( migrations, - &user.authenticator, + &user.auth_type, app_version, ) { Ok(applied_migrations) => { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs index ddb73667c8..7c5330149e 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs @@ -24,7 +24,7 @@ impl UserManager { .await .ok()?; - if user_profile.authenticator.is_local() { + if user_profile.auth_type.is_local() { Some(AnonUser { session }) } else { None diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index 7de09995a0..afdfd218ef 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -215,7 +215,7 @@ impl UserManager { let collab_db = self.get_collab_db(session.user_id)?; let weak_builder = self.collab_builder.clone(); let user_awareness = Arc::downgrade(&self.user_awareness); - let cloud_services = self.cloud_services.clone(); + let cloud_services = self.cloud_service.clone(); let authenticate_user = self.authenticate_user.clone(); let is_loading_awareness = self.is_loading_awareness.clone(); @@ -376,7 +376,7 @@ impl UserManager { if !is_loading { let user_profile = self.get_user_profile_from_disk(session.user_id).await?; self - .initial_user_awareness(&session, &user_profile.authenticator) + .initial_user_awareness(&session, &user_profile.auth_type) .await?; } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs index 2bfba3422e..1462d1f019 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs @@ -1,31 +1,9 @@ -use crate::entities::{AuthStateChangedPB, AuthStatePB}; -use crate::notification::send_auth_state_notification; use crate::services::cloud_config::get_encrypt_secret; use crate::user_manager::UserManager; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::{ - EncryptionType, UpdateUserProfileParams, UserCredentials, UserProfile, -}; use lib_infra::encryption::{decrypt_text, encrypt_text}; impl UserManager { - pub async fn set_encrypt_secret( - &self, - uid: i64, - secret: String, - encryption_type: EncryptionType, - ) -> FlowyResult<()> { - let params = UpdateUserProfileParams::new(uid).with_encryption_type(encryption_type); - self - .cloud_services - .get_user_service()? - .update_user(UserCredentials::from_uid(uid), params.clone()) - .await?; - self.cloud_services.set_encrypt_secret(secret); - - Ok(()) - } - pub fn generate_encryption_sign(&self, uid: i64, encrypt_secret: &str) -> FlowyResult { let encrypt_sign = encrypt_text(uid.to_string(), encrypt_secret)?; Ok(encrypt_sign) @@ -63,16 +41,3 @@ impl UserManager { } } } - -pub(crate) fn validate_encryption_sign(user_profile: &UserProfile, encryption_sign: &str) -> bool { - // If the local user profile's encryption sign is not equal to the user update's encryption sign, - // which means the user enable encryption in another device, we should logout the current user. - let is_valid = user_profile.encryption_type.sign() == encryption_sign; - if !is_valid { - send_auth_state_notification(AuthStateChangedPB { - state: AuthStatePB::InvalidAuth, - message: "Encryption configuration was changed".to_string(), - }); - } - is_valid -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index b5ae54309b..3144b27213 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -2,7 +2,6 @@ use chrono::{Duration, NaiveDateTime, Utc}; use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlanDetail}; use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; -use std::convert::TryFrom; use std::str::FromStr; use std::sync::Arc; @@ -12,15 +11,14 @@ use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderD use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::entities::{ - Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, - WorkspaceMember, + AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; use tracing::{error, info, instrument, trace, warn}; use uuid::Uuid; use crate::entities::{ RepeatedUserWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, - UpdateUserWorkspaceSettingPB, UseAISettingPB, UserWorkspacePB, WorkspaceSubscriptionInfoPB, + UpdateUserWorkspaceSettingPB, UserWorkspacePB, WorkspaceSettingsPB, WorkspaceSubscriptionInfoPB, }; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; @@ -31,12 +29,15 @@ use crate::services::data_import::{ use crate::services::sqlite_sql::member_sql::{ select_workspace_member, upsert_workspace_member, WorkspaceMemberTable, }; -use crate::services::sqlite_sql::user_sql::UserTableChangeset; -use crate::services::sqlite_sql::workspace_sql::{ - get_all_user_workspace_op, get_user_workspace_op, insert_or_update_workspaces_op, - UserWorkspaceTable, +use crate::services::sqlite_sql::workspace_setting_sql::{ + select_workspace_setting, update_workspace_setting, upsert_workspace_setting, + WorkspaceSettingsChangeset, WorkspaceSettingsTable, }; -use crate::user_manager::{upsert_user_profile_change, UserManager}; +use crate::services::sqlite_sql::workspace_sql::{ + select_all_user_workspace, select_user_workspace, update_user_workspace, upsert_user_workspace, + UserWorkspaceChangeset, UserWorkspaceTable, +}; +use crate::user_manager::UserManager; use flowy_user_pub::session::Session; impl UserManager { @@ -119,12 +120,12 @@ impl UserManager { let user_id = current_session.user_id; let weak_user_collab_db = Arc::downgrade(&user_collab_db); - let weak_user_cloud_service = self.cloud_services.get_user_service()?; + let weak_user_cloud_service = self.cloud_service.get_user_service()?; match upload_collab_objects_data( user_id, weak_user_collab_db, ¤t_session.user_workspace.workspace_id()?, - &user.authenticator, + &user.auth_type, collab_data, weak_user_cloud_service, ) @@ -164,7 +165,7 @@ impl UserManager { pub async fn open_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("open workspace: {}", workspace_id); let user_workspace = self - .cloud_services + .cloud_service .get_user_service()? .open_workspace(workspace_id) .await?; @@ -177,7 +178,7 @@ impl UserManager { let user_profile = self.get_user_profile_from_disk(uid).await?; if let Err(err) = self - .initial_user_awareness(self.get_session()?.as_ref(), &user_profile.authenticator) + .initial_user_awareness(self.get_session()?.as_ref(), &user_profile.auth_type) .await { error!( @@ -190,7 +191,7 @@ impl UserManager { .user_status_callback .read() .await - .open_workspace(uid, &user_workspace, &user_profile.authenticator) + .open_workspace(uid, &user_workspace, &user_profile.auth_type) .await { error!("Open workspace failed: {:?}", err); @@ -200,9 +201,13 @@ impl UserManager { } #[instrument(level = "info", skip(self), err)] - pub async fn add_workspace(&self, workspace_name: &str) -> FlowyResult { + pub async fn create_workspace( + &self, + workspace_name: &str, + auth_type: AuthType, + ) -> FlowyResult { let new_workspace = self - .cloud_services + .cloud_service .get_user_service()? .create_workspace(workspace_name) .await?; @@ -215,44 +220,29 @@ impl UserManager { // save the workspace to sqlite db let uid = self.user_id()?; let mut conn = self.db_connection(uid)?; - insert_or_update_workspaces_op(uid, &[new_workspace.clone()], &mut conn)?; + upsert_user_workspace(uid, auth_type, new_workspace.clone(), &mut conn)?; Ok(new_workspace) } pub async fn patch_workspace( &self, workspace_id: &Uuid, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + changeset: UserWorkspaceChangeset, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? - .patch_workspace(workspace_id, new_workspace_name, new_workspace_icon) + .patch_workspace(workspace_id, changeset.name.clone(), changeset.icon.clone()) .await?; // save the icon and name to sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - let mut user_workspace = match self.get_user_workspace(uid, workspace_id) { - Some(user_workspace) => user_workspace, - None => { - return Err(FlowyError::record_not_found().with_context(format!( - "Expected to find user workspace with id: {}, but not found", - workspace_id - ))); - }, - }; - - if let Some(new_workspace_name) = new_workspace_name { - user_workspace.name = new_workspace_name.to_string(); - } - if let Some(new_workspace_icon) = new_workspace_icon { - user_workspace.icon = new_workspace_icon.to_string(); - } - - let _ = save_user_workspace(uid, conn, &user_workspace); + update_user_workspace(conn, changeset)?; + let user_workspace = self + .get_user_workspace(uid, workspace_id) + .ok_or_else(FlowyError::record_not_found)?; let payload: UserWorkspacePB = user_workspace.clone().into(); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspace) .payload(payload) @@ -265,7 +255,7 @@ impl UserManager { pub async fn leave_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("leave workspace: {}", workspace_id); self - .cloud_services + .cloud_service .get_user_service()? .leave_workspace(workspace_id) .await?; @@ -285,7 +275,7 @@ impl UserManager { pub async fn delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("delete workspace: {}", workspace_id); self - .cloud_services + .cloud_service .get_user_service()? .delete_workspace(workspace_id) .await?; @@ -308,7 +298,7 @@ impl UserManager { role: Role, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .invite_workspace_member(invitee_email, workspace_id, role) .await?; @@ -318,7 +308,7 @@ impl UserManager { pub async fn list_pending_workspace_invitations(&self) -> FlowyResult> { let status = Some(WorkspaceInvitationStatus::Pending); let invitations = self - .cloud_services + .cloud_service .get_user_service()? .list_workspace_invitations(status) .await?; @@ -327,7 +317,7 @@ impl UserManager { pub async fn accept_workspace_invitation(&self, invite_id: String) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .accept_workspace_invitations(invite_id) .await?; @@ -340,7 +330,7 @@ impl UserManager { workspace_id: Uuid, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .remove_workspace_member(user_email, workspace_id) .await?; @@ -352,7 +342,7 @@ impl UserManager { workspace_id: Uuid, ) -> FlowyResult> { let members = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_members(workspace_id) .await?; @@ -365,7 +355,7 @@ impl UserManager { uid: i64, ) -> FlowyResult { let member = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_member(workspace_id, uid) .await?; @@ -379,7 +369,7 @@ impl UserManager { role: Role, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .update_workspace_member(user_email, workspace_id, role) .await?; @@ -388,19 +378,23 @@ impl UserManager { pub fn get_user_workspace(&self, uid: i64, workspace_id: &Uuid) -> Option { let conn = self.db_connection(uid).ok()?; - get_user_workspace_op(workspace_id.to_string().as_str(), conn) + select_user_workspace(workspace_id.to_string().as_str(), conn) } - pub async fn get_all_user_workspaces(&self, uid: i64) -> FlowyResult> { + pub async fn get_all_user_workspaces( + &self, + uid: i64, + auth_type: AuthType, + ) -> FlowyResult> { let conn = self.db_connection(uid)?; - let workspaces = get_all_user_workspace_op(uid, conn)?; + let workspaces = select_all_user_workspace(uid, conn)?; - if let Ok(service) = self.cloud_services.get_user_service() { + if let Ok(service) = self.cloud_service.get_user_service() { if let Ok(pool) = self.db_pool(uid) { tokio::spawn(async move { if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { if let Ok(conn) = pool.get() { - let _ = save_all_user_workspaces(uid, conn, &new_user_workspaces); + let _ = save_all_user_workspaces(uid, conn, auth_type, &new_user_workspaces); let repeated_workspace_pbs = RepeatedUserWorkspacePB::from(new_user_workspaces); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) .payload(repeated_workspace_pbs) @@ -418,11 +412,12 @@ impl UserManager { &self, workspace_subscription: SubscribeWorkspacePB, ) -> FlowyResult { + let workspace_id = Uuid::from_str(&workspace_subscription.workspace_id)?; let payment_link = self - .cloud_services + .cloud_service .get_user_service()? .subscribe_workspace( - workspace_subscription.workspace_id, + workspace_id, workspace_subscription.recurring_interval.into(), workspace_subscription.workspace_subscription_plan.into(), workspace_subscription.success_url, @@ -437,10 +432,11 @@ impl UserManager { &self, workspace_id: String, ) -> FlowyResult { + let workspace_id = Uuid::from_str(&workspace_id)?; let subscriptions = self - .cloud_services + .cloud_service .get_user_service()? - .get_workspace_subscription_one(workspace_id.clone()) + .get_workspace_subscription_one(&workspace_id) .await?; Ok(WorkspaceSubscriptionInfoPB::from(subscriptions)) @@ -454,7 +450,7 @@ impl UserManager { reason: Option, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .cancel_workspace_subscription(workspace_id, plan, reason) .await?; @@ -464,12 +460,12 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn update_workspace_subscription_payment_period( &self, - workspace_id: String, + workspace_id: &Uuid, plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .update_workspace_subscription_payment_period(workspace_id, plan, recurring_interval) .await?; @@ -479,7 +475,7 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn get_subscription_plan_details(&self) -> FlowyResult> { let plan_details = self - .cloud_services + .cloud_service .get_user_service()? .get_subscription_plan_details() .await?; @@ -489,10 +485,10 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn get_workspace_usage( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> FlowyResult { let workspace_usage = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_usage(workspace_id) .await?; @@ -518,7 +514,7 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn get_billing_portal_url(&self) -> FlowyResult { let url = self - .cloud_services + .cloud_service .get_user_service()? .get_billing_portal_url() .await?; @@ -529,39 +525,92 @@ impl UserManager { &self, updated_settings: UpdateUserWorkspaceSettingPB, ) -> FlowyResult<()> { - let ai_model = updated_settings.ai_model.clone(); - let workspace_id = updated_settings.workspace_id.clone(); - let cloud_service = self.cloud_services.get_user_service()?; + let workspace_id = Uuid::from_str(&updated_settings.workspace_id)?; + let cloud_service = self.cloud_service.get_user_service()?; let settings = cloud_service - .update_workspace_setting(&workspace_id, updated_settings.into()) + .update_workspace_setting(&workspace_id, updated_settings.clone().into()) .await?; - let pb = UseAISettingPB::from(settings); + let changeset = WorkspaceSettingsChangeset { + id: workspace_id.to_string(), + disable_search_indexing: updated_settings.disable_search_indexing, + ai_model: updated_settings.ai_model.clone(), + }; + let uid = self.user_id()?; - send_notification(&uid.to_string(), UserNotification::DidUpdateAISetting) - .payload(pb) - .send(); + let mut conn = self.db_connection(uid)?; + update_workspace_setting(&mut conn, changeset)?; - if let Some(ai_model) = &ai_model { - if let Err(err) = self.cloud_services.set_ai_model(ai_model) { - error!("Set ai model failed: {}", err); - } - - let conn = self.db_connection(uid)?; - let params = UpdateUserProfileParams::new(uid).with_ai_model(ai_model); - upsert_user_profile_change(uid, conn, UserTableChangeset::new(params))?; - } + let pb = WorkspaceSettingsPB::from(&settings); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(pb) + .send(); Ok(()) } - pub async fn get_workspace_settings(&self, workspace_id: &str) -> FlowyResult { - let cloud_service = self.cloud_services.get_user_service()?; - let settings = cloud_service.get_workspace_setting(workspace_id).await?; + pub async fn get_workspace_settings( + &self, + workspace_id: &Uuid, + ) -> FlowyResult { let uid = self.user_id()?; - let conn = self.db_connection(uid)?; - let params = UpdateUserProfileParams::new(uid).with_ai_model(&settings.ai_model); - upsert_user_profile_change(uid, conn, UserTableChangeset::new(params))?; - Ok(UseAISettingPB::from(settings)) + let mut conn = self.db_connection(uid)?; + match select_workspace_setting(&mut conn, &workspace_id.to_string()) { + Ok(workspace_settings) => { + trace!("workspace settings found in local db"); + let pb = WorkspaceSettingsPB::from(workspace_settings); + + let old_pb = pb.clone(); + let workspace_id = *workspace_id; + let pool = self.db_pool(uid)?; + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + let cloud_service = cloud_service.get_user_service()?; + let settings = cloud_service.get_workspace_setting(&workspace_id).await?; + let new_pb = WorkspaceSettingsPB::from(&settings); + if new_pb != old_pb { + trace!("workspace settings updated"); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(new_pb) + .send(); + + if let Ok(mut conn) = pool.get() { + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), + )?; + } + } + + Ok::<_, FlowyError>(()) + }); + Ok(pb) + }, + Err(err) => { + if err.is_record_not_found() { + trace!("No workspace settings found, fetch from remote"); + let settings = self + .cloud_service + .get_user_service()? + .get_workspace_setting(&workspace_id) + .await?; + let pb = WorkspaceSettingsPB::from(&settings); + let mut conn = self.db_connection(uid)?; + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), + )?; + Ok::<_, FlowyError>(pb.clone()) + } else { + Err(err) + } + }, + } } pub async fn get_workspace_member_info( @@ -600,7 +649,7 @@ impl UserManager { ) -> FlowyResult { trace!("get workspace member info from remote: {}", workspace_id); let member = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_member_info(workspace_id, uid) .await?; @@ -629,7 +678,7 @@ impl UserManager { let plans = PeriodicallyCheckBillingState::new( workspace_id, success.plan.map(SubscriptionPlan::from), - Arc::downgrade(&self.cloud_services), + Arc::downgrade(&self.cloud_service), Arc::downgrade(&self.authenticate_user), ) .start() @@ -645,56 +694,21 @@ impl UserManager { } } -/// This method is used to save one user workspace to the SQLite database -/// -/// If the workspace is already persisted in the database, it will be overridden. -/// -/// Consider using [save_all_user_workspaces] if you need to override all workspaces of the user. -/// -pub fn save_user_workspace( - uid: i64, - mut conn: DBConnection, - user_workspace: &UserWorkspace, -) -> FlowyResult<()> { - conn.immediate_transaction(|conn| { - let user_workspace = UserWorkspaceTable::try_from((uid, user_workspace))?; - let affected_rows = diesel::update( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(&user_workspace.id)), - ) - .set(( - user_workspace_table::name.eq(&user_workspace.name), - user_workspace_table::created_at.eq(&user_workspace.created_at), - user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), - user_workspace_table::icon.eq(&user_workspace.icon), - user_workspace_table::member_count.eq(&user_workspace.member_count), - )) - .execute(conn)?; - - if affected_rows == 0 { - diesel::insert_into(user_workspace_table::table) - .values(user_workspace) - .execute(conn)?; - } - - Ok::<(), FlowyError>(()) - }) -} - /// This method is used to save the user workspaces (plural) to the SQLite database /// /// The workspaces provided in [user_workspaces] will override the existing workspaces in the database. /// -/// Consider using [save_user_workspace] if you only need to save a single workspace. +/// Consider using [upsert_user_workspace] if you only need to save a single workspace. /// pub fn save_all_user_workspaces( uid: i64, mut conn: DBConnection, + auth_type: AuthType, user_workspaces: &[UserWorkspace], ) -> FlowyResult<()> { let user_workspaces = user_workspaces .iter() - .map(|user_workspace| UserWorkspaceTable::try_from((uid, user_workspace))) + .map(|user_workspace| UserWorkspaceTable::from_workspace(uid, user_workspace, auth_type)) .collect::, _>>()?; conn.immediate_transaction(|conn| { From 3a05a4851fa83c8517e9f3ab29408a3c947975d6 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 14:07:42 +0800 Subject: [PATCH 188/207] chore: clippy --- .../down.sql | 3 + .../up.sql | 24 ++++++ .../sqlite_sql/workspace_setting_sql.rs | 72 ++++++++++++++++ .../user_manager/manager_user_workspace.rs | 84 ++++++++++--------- 4 files changed, 144 insertions(+), 39 deletions(-) create mode 100644 frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql create mode 100644 frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql create mode 100644 frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql new file mode 100644 index 0000000000..50602eb129 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE user_workspace_table +DROP COLUMN auth_type; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql new file mode 100644 index 0000000000..b77372ad63 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql @@ -0,0 +1,24 @@ +-- Your SQL goes here +ALTER TABLE user_workspace_table + ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 1; + +-- 2. Back‑fill from user_table.auth_type +UPDATE user_workspace_table +SET auth_type = (SELECT ut.auth_type + FROM user_table ut + WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)) +WHERE EXISTS (SELECT 1 + FROM user_table ut + WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)); + +ALTER TABLE user_table DROP COLUMN stability_ai_key; +ALTER TABLE user_table DROP COLUMN openai_key; +ALTER TABLE user_table DROP COLUMN workspace; +ALTER TABLE user_table DROP COLUMN encryption_type; +ALTER TABLE user_table DROP COLUMN ai_model; + +CREATE TABLE workspace_setting_table ( + id TEXT PRIMARY KEY NOT NULL , + disable_search_indexing BOOLEAN DEFAULT FALSE NOT NULL , + ai_model TEXT DEFAULT "" NOT NULL +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs new file mode 100644 index 0000000000..dcd87a36ee --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs @@ -0,0 +1,72 @@ +use client_api::entity::AFWorkspaceSettings; +use flowy_error::FlowyError; +use flowy_sqlite::schema::workspace_setting_table; +use flowy_sqlite::schema::workspace_setting_table::dsl; +use flowy_sqlite::DBConnection; +use flowy_sqlite::{query_dsl::*, ExpressionMethods}; +use uuid::Uuid; + +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = workspace_setting_table)] +pub struct WorkspaceSettingsTable { + pub id: String, + pub disable_search_indexing: bool, + pub ai_model: String, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = workspace_setting_table)] +pub struct WorkspaceSettingsChangeset { + pub id: String, + pub disable_search_indexing: Option, + pub ai_model: Option, +} + +impl WorkspaceSettingsTable { + pub fn from_workspace_settings(workspace_id: &Uuid, settings: &AFWorkspaceSettings) -> Self { + Self { + id: workspace_id.to_string(), + disable_search_indexing: settings.disable_search_indexing, + ai_model: settings.ai_model.clone(), + } + } +} + +pub fn update_workspace_setting( + conn: &mut DBConnection, + changeset: WorkspaceSettingsChangeset, +) -> Result<(), FlowyError> { + diesel::update(dsl::workspace_setting_table) + .filter(workspace_setting_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(conn)?; + Ok(()) +} + +/// Upserts a workspace setting into the database. +pub fn upsert_workspace_setting( + conn: &mut DBConnection, + settings: WorkspaceSettingsTable, +) -> Result<(), FlowyError> { + diesel::insert_into(dsl::workspace_setting_table) + .values(settings.clone()) + .on_conflict(workspace_setting_table::id) + .do_update() + .set(( + workspace_setting_table::disable_search_indexing.eq(settings.disable_search_indexing), + workspace_setting_table::ai_model.eq(settings.ai_model), + )) + .execute(conn)?; + Ok(()) +} + +/// Selects a workspace setting by id from the database. +pub fn select_workspace_setting( + conn: &mut DBConnection, + id: &str, +) -> Result { + let setting = dsl::workspace_setting_table + .filter(workspace_setting_table::id.eq(id)) + .first::(conn)?; + Ok(setting) +} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 3144b27213..ea99a01f3a 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -5,17 +5,6 @@ use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; use std::str::FromStr; use std::sync::Arc; -use collab_integrate::CollabKVDB; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; -use flowy_user_pub::entities::{ - AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, -}; -use tracing::{error, info, instrument, trace, warn}; -use uuid::Uuid; - use crate::entities::{ RepeatedUserWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, UpdateUserWorkspaceSettingPB, UserWorkspacePB, WorkspaceSettingsPB, WorkspaceSubscriptionInfoPB, @@ -38,7 +27,18 @@ use crate::services::sqlite_sql::workspace_sql::{ UserWorkspaceChangeset, UserWorkspaceTable, }; use crate::user_manager::UserManager; +use collab_integrate::CollabKVDB; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; +use flowy_sqlite::schema::user_workspace_table; +use flowy_sqlite::{query_dsl::*, ConnectionPool, DBConnection, ExpressionMethods}; +use flowy_user_pub::cloud::UserCloudServiceProvider; +use flowy_user_pub::entities::{ + AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, +}; use flowy_user_pub::session::Session; +use tracing::{error, info, instrument, trace, warn}; +use uuid::Uuid; impl UserManager { /// Import appflowy data from the given path. @@ -561,51 +561,29 @@ impl UserManager { Ok(workspace_settings) => { trace!("workspace settings found in local db"); let pb = WorkspaceSettingsPB::from(workspace_settings); - let old_pb = pb.clone(); let workspace_id = *workspace_id; + + // Spawn a task to sync remote settings using the helper let pool = self.db_pool(uid)?; let cloud_service = self.cloud_service.clone(); tokio::spawn(async move { - let cloud_service = cloud_service.get_user_service()?; - let settings = cloud_service.get_workspace_setting(&workspace_id).await?; - let new_pb = WorkspaceSettingsPB::from(&settings); - if new_pb != old_pb { - trace!("workspace settings updated"); - send_notification( - &uid.to_string(), - UserNotification::DidUpdateWorkspaceSetting, - ) - .payload(new_pb) - .send(); - - if let Ok(mut conn) = pool.get() { - upsert_workspace_setting( - &mut conn, - WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), - )?; - } - } - - Ok::<_, FlowyError>(()) + let _ = sync_workspace_settings(cloud_service, workspace_id, old_pb, uid, pool).await; }); Ok(pb) }, Err(err) => { if err.is_record_not_found() { trace!("No workspace settings found, fetch from remote"); - let settings = self - .cloud_service - .get_user_service()? - .get_workspace_setting(&workspace_id) - .await?; + let service = self.cloud_service.get_user_service()?; + let settings = service.get_workspace_setting(&workspace_id).await?; let pb = WorkspaceSettingsPB::from(&settings); let mut conn = self.db_connection(uid)?; upsert_workspace_setting( &mut conn, WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), )?; - Ok::<_, FlowyError>(pb.clone()) + Ok(pb) } else { Err(err) } @@ -777,3 +755,31 @@ fn is_older_than_n_minutes(updated_at: NaiveDateTime, minutes: i64) -> bool { None => false, } } + +async fn sync_workspace_settings( + cloud_service: Arc, + workspace_id: Uuid, + old_pb: WorkspaceSettingsPB, + uid: i64, + pool: Arc, +) -> FlowyResult<()> { + let service = cloud_service.get_user_service()?; + let settings = service.get_workspace_setting(&workspace_id).await?; + let new_pb = WorkspaceSettingsPB::from(&settings); + if new_pb != old_pb { + trace!("workspace settings updated"); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(new_pb) + .send(); + if let Ok(mut conn) = pool.get() { + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), + )?; + } + } + Ok(()) +} From e851fba71b28c38b599ef1ef0a4d96339f97c349 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 14:21:22 +0800 Subject: [PATCH 189/207] chore: clippy --- .../flowy-user/src/user_manager/manager_user_workspace.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index ea99a01f3a..ac15324c00 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -576,12 +576,12 @@ impl UserManager { if err.is_record_not_found() { trace!("No workspace settings found, fetch from remote"); let service = self.cloud_service.get_user_service()?; - let settings = service.get_workspace_setting(&workspace_id).await?; + let settings = service.get_workspace_setting(workspace_id).await?; let pb = WorkspaceSettingsPB::from(&settings); let mut conn = self.db_connection(uid)?; upsert_workspace_setting( &mut conn, - WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), + WorkspaceSettingsTable::from_workspace_settings(workspace_id, &settings), )?; Ok(pb) } else { From 84952b905694863bd4f300f47792a87f05371dee Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Sat, 19 Apr 2025 14:24:32 +0800 Subject: [PATCH 190/207] chore: adjust generate theme script to import subtle colors (#7784) --- .../data/appflowy_default/primitive.dart | 362 +++++++++++++++++- .../theme/data/appflowy_default/semantic.dart | 2 +- .../appflowy_ui/script/generate_theme.dart | 211 ++++++---- 3 files changed, 509 insertions(+), 66 deletions(-) diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart index 790db31660..2bd6d619d8 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart @@ -3,7 +3,7 @@ // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script -// Generation time: 2025-04-16T22:13:33.297893 +// Generation time: 2025-04-19T13:45:56.076897 // // To modify these colors, edit the source JSON files and run the script: // @@ -295,4 +295,364 @@ class AppFlowyPrimitiveTokens { /// #7f6200 static Color get yellow1000 => Color(0xFF7F6200); + + /// #fcf2f2 + static Color get subtleColorRose100 => Color(0xFFFCF2F2); + + /// #fae3e3 + static Color get subtleColorRose200 => Color(0xFFFAE3E3); + + /// #fad9d9 + static Color get subtleColorRose300 => Color(0xFFFAD9D9); + + /// #edadad + static Color get subtleColorRose400 => Color(0xFFEDADAD); + + /// #cc4e4e + static Color get subtleColorRose500 => Color(0xFFCC4E4E); + + /// #702828 + static Color get subtleColorRose600 => Color(0xFF702828); + + /// #fcf4f0 + static Color get subtleColorPapaya100 => Color(0xFFFCF4F0); + + /// #fae8de + static Color get subtleColorPapaya200 => Color(0xFFFAE8DE); + + /// #fadfd2 + static Color get subtleColorPapaya300 => Color(0xFFFADFD2); + + /// #f0bda3 + static Color get subtleColorPapaya400 => Color(0xFFF0BDA3); + + /// #d67240 + static Color get subtleColorPapaya500 => Color(0xFFD67240); + + /// #6b3215 + static Color get subtleColorPapaya600 => Color(0xFF6B3215); + + /// #fff7ed + static Color get subtleColorTangerine100 => Color(0xFFFFF7ED); + + /// #fcedd9 + static Color get subtleColorTangerine200 => Color(0xFFFCEDD9); + + /// #fae5ca + static Color get subtleColorTangerine300 => Color(0xFFFAE5CA); + + /// #f2cb99 + static Color get subtleColorTangerine400 => Color(0xFFF2CB99); + + /// #db8f2c + static Color get subtleColorTangerine500 => Color(0xFFDB8F2C); + + /// #613b0a + static Color get subtleColorTangerine600 => Color(0xFF613B0A); + + /// #fff9ec + static Color get subtleColorMango100 => Color(0xFFFFF9EC); + + /// #fcf1d7 + static Color get subtleColorMango200 => Color(0xFFFCF1D7); + + /// #fae9c3 + static Color get subtleColorMango300 => Color(0xFFFAE9C3); + + /// #f5d68e + static Color get subtleColorMango400 => Color(0xFFF5D68E); + + /// #e0a416 + static Color get subtleColorMango500 => Color(0xFFE0A416); + + /// #5c4102 + static Color get subtleColorMango600 => Color(0xFF5C4102); + + /// #fffbe8 + static Color get subtleColorLemon100 => Color(0xFFFFFBE8); + + /// #fcf5cf + static Color get subtleColorLemon200 => Color(0xFFFCF5CF); + + /// #faefb9 + static Color get subtleColorLemon300 => Color(0xFFFAEFB9); + + /// #f5e282 + static Color get subtleColorLemon400 => Color(0xFFF5E282); + + /// #e0bb00 + static Color get subtleColorLemon500 => Color(0xFFE0BB00); + + /// #574800 + static Color get subtleColorLemon600 => Color(0xFF574800); + + /// #f9fae6 + static Color get subtleColorOlive100 => Color(0xFFF9FAE6); + + /// #f6f7d0 + static Color get subtleColorOlive200 => Color(0xFFF6F7D0); + + /// #f0f2b3 + static Color get subtleColorOlive300 => Color(0xFFF0F2B3); + + /// #dbde83 + static Color get subtleColorOlive400 => Color(0xFFDBDE83); + + /// #adb204 + static Color get subtleColorOlive500 => Color(0xFFADB204); + + /// #4a4c03 + static Color get subtleColorOlive600 => Color(0xFF4A4C03); + + /// #f6f9e6 + static Color get subtleColorLime100 => Color(0xFFF6F9E6); + + /// #eef5ce + static Color get subtleColorLime200 => Color(0xFFEEF5CE); + + /// #e7f0bb + static Color get subtleColorLime300 => Color(0xFFE7F0BB); + + /// #cfdb91 + static Color get subtleColorLime400 => Color(0xFFCFDB91); + + /// #92a822 + static Color get subtleColorLime500 => Color(0xFF92A822); + + /// #414d05 + static Color get subtleColorLime600 => Color(0xFF414D05); + + /// #f4faeb + static Color get subtleColorGrass100 => Color(0xFFF4FAEB); + + /// #e9f5d7 + static Color get subtleColorGrass200 => Color(0xFFE9F5D7); + + /// #def0c5 + static Color get subtleColorGrass300 => Color(0xFFDEF0C5); + + /// #bfd998 + static Color get subtleColorGrass400 => Color(0xFFBFD998); + + /// #75a828 + static Color get subtleColorGrass500 => Color(0xFF75A828); + + /// #334d0c + static Color get subtleColorGrass600 => Color(0xFF334D0C); + + /// #f1faf0 + static Color get subtleColorForest100 => Color(0xFFF1FAF0); + + /// #e2f5df + static Color get subtleColorForest200 => Color(0xFFE2F5DF); + + /// #d7f0d3 + static Color get subtleColorForest300 => Color(0xFFD7F0D3); + + /// #a8d6a1 + static Color get subtleColorForest400 => Color(0xFFA8D6A1); + + /// #49a33b + static Color get subtleColorForest500 => Color(0xFF49A33B); + + /// #1e4f16 + static Color get subtleColorForest600 => Color(0xFF1E4F16); + + /// #f0faf6 + static Color get subtleColorJade100 => Color(0xFFF0FAF6); + + /// #dff5eb + static Color get subtleColorJade200 => Color(0xFFDFF5EB); + + /// #cef0e1 + static Color get subtleColorJade300 => Color(0xFFCEF0E1); + + /// #90d1b5 + static Color get subtleColorJade400 => Color(0xFF90D1B5); + + /// #1c9963 + static Color get subtleColorJade500 => Color(0xFF1C9963); + + /// #075231 + static Color get subtleColorJade600 => Color(0xFF075231); + + /// #f0f9fa + static Color get subtleColorAqua100 => Color(0xFFF0F9FA); + + /// #dff3f5 + static Color get subtleColorAqua200 => Color(0xFFDFF3F5); + + /// #ccecf0 + static Color get subtleColorAqua300 => Color(0xFFCCECF0); + + /// #83ccd4 + static Color get subtleColorAqua400 => Color(0xFF83CCD4); + + /// #008e9e + static Color get subtleColorAqua500 => Color(0xFF008E9E); + + /// #004e57 + static Color get subtleColorAqua600 => Color(0xFF004E57); + + /// #f0f6fa + static Color get subtleColorAzure100 => Color(0xFFF0F6FA); + + /// #e1eef7 + static Color get subtleColorAzure200 => Color(0xFFE1EEF7); + + /// #d3e6f5 + static Color get subtleColorAzure300 => Color(0xFFD3E6F5); + + /// #88c0eb + static Color get subtleColorAzure400 => Color(0xFF88C0EB); + + /// #0877cc + static Color get subtleColorAzure500 => Color(0xFF0877CC); + + /// #154469 + static Color get subtleColorAzure600 => Color(0xFF154469); + + /// #f0f3fa + static Color get subtleColorDenim100 => Color(0xFFF0F3FA); + + /// #e3ebfa + static Color get subtleColorDenim200 => Color(0xFFE3EBFA); + + /// #d7e2f7 + static Color get subtleColorDenim300 => Color(0xFFD7E2F7); + + /// #9ab6ed + static Color get subtleColorDenim400 => Color(0xFF9AB6ED); + + /// #3267d1 + static Color get subtleColorDenim500 => Color(0xFF3267D1); + + /// #223c70 + static Color get subtleColorDenim600 => Color(0xFF223C70); + + /// #f2f2fc + static Color get subtleColorMauve100 => Color(0xFFF2F2FC); + + /// #e6e6fa + static Color get subtleColorMauve200 => Color(0xFFE6E6FA); + + /// #dcdcf7 + static Color get subtleColorMauve300 => Color(0xFFDCDCF7); + + /// #aeaef5 + static Color get subtleColorMauve400 => Color(0xFFAEAEF5); + + /// #5555e0 + static Color get subtleColorMauve500 => Color(0xFF5555E0); + + /// #36366b + static Color get subtleColorMauve600 => Color(0xFF36366B); + + /// #f6f3fc + static Color get subtleColorLavender100 => Color(0xFFF6F3FC); + + /// #ebe3fa + static Color get subtleColorLavender200 => Color(0xFFEBE3FA); + + /// #e4daf7 + static Color get subtleColorLavender300 => Color(0xFFE4DAF7); + + /// #c1aaf0 + static Color get subtleColorLavender400 => Color(0xFFC1AAF0); + + /// #8153db + static Color get subtleColorLavender500 => Color(0xFF8153DB); + + /// #462f75 + static Color get subtleColorLavender600 => Color(0xFF462F75); + + /// #f7f0fa + static Color get subtleColorLilac100 => Color(0xFFF7F0FA); + + /// #f0e1f7 + static Color get subtleColorLilac200 => Color(0xFFF0E1F7); + + /// #edd7f7 + static Color get subtleColorLilac300 => Color(0xFFEDD7F7); + + /// #d3a9e8 + static Color get subtleColorLilac400 => Color(0xFFD3A9E8); + + /// #9e4cc7 + static Color get subtleColorLilac500 => Color(0xFF9E4CC7); + + /// #562d6b + static Color get subtleColorLilac600 => Color(0xFF562D6B); + + /// #faf0fa + static Color get subtleColorMallow100 => Color(0xFFFAF0FA); + + /// #f5e1f4 + static Color get subtleColorMallow200 => Color(0xFFF5E1F4); + + /// #f5d7f4 + static Color get subtleColorMallow300 => Color(0xFFF5D7F4); + + /// #dea4dc + static Color get subtleColorMallow400 => Color(0xFFDEA4DC); + + /// #b240af + static Color get subtleColorMallow500 => Color(0xFFB240AF); + + /// #632861 + static Color get subtleColorMallow600 => Color(0xFF632861); + + /// #f9eff3 + static Color get subtleColorCamellia100 => Color(0xFFF9EFF3); + + /// #f7e1eb + static Color get subtleColorCamellia200 => Color(0xFFF7E1EB); + + /// #f7d7e5 + static Color get subtleColorCamellia300 => Color(0xFFF7D7E5); + + /// #e5a3c0 + static Color get subtleColorCamellia400 => Color(0xFFE5A3C0); + + /// #c24279 + static Color get subtleColorCamellia500 => Color(0xFFC24279); + + /// #6e2343 + static Color get subtleColorCamellia600 => Color(0xFF6E2343); + + /// #f5f5f5 + static Color get subtleColorSmoke100 => Color(0xFFF5F5F5); + + /// #e8e8e8 + static Color get subtleColorSmoke200 => Color(0xFFE8E8E8); + + /// #dedede + static Color get subtleColorSmoke300 => Color(0xFFDEDEDE); + + /// #b8b8b8 + static Color get subtleColorSmoke400 => Color(0xFFB8B8B8); + + /// #6e6e6e + static Color get subtleColorSmoke500 => Color(0xFF6E6E6E); + + /// #404040 + static Color get subtleColorSmoke600 => Color(0xFF404040); + + /// #f2f4f7 + static Color get subtleColorIron100 => Color(0xFFF2F4F7); + + /// #e6e9f0 + static Color get subtleColorIron200 => Color(0xFFE6E9F0); + + /// #dadee5 + static Color get subtleColorIron300 => Color(0xFFDADEE5); + + /// #b0b5bf + static Color get subtleColorIron400 => Color(0xFFB0B5BF); + + /// #666f80 + static Color get subtleColorIron500 => Color(0xFF666F80); + + /// #394152 + static Color get subtleColorIron600 => Color(0xFF394152); } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart index 03e80da962..fe774d3561 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart @@ -3,7 +3,7 @@ // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script -// Generation time: 2025-04-16T22:13:33.307397 +// Generation time: 2025-04-19T13:45:56.089922 // // To modify these colors, edit the source JSON files and run the script: // diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart index 9d429f537e..bddcdb4eae 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart @@ -38,22 +38,13 @@ class AppFlowyPrimitiveTokens { // 3. Process each color category. jsonData.forEach((categoryName, categoryData) { - if (categoryData is Map) { - categoryData.forEach((tokenName, tokenData) { - if (tokenData is Map && - tokenData['\$type'] == 'color') { - final colorValue = tokenData['\$value'] as String; - final dartColorValue = convertColor(colorValue); - final dartTokenName = - '${categoryName}_$tokenName'.replaceAll('-', '_').toCamelCase(); - - buffer.writeln(''' - - /// $colorValue - static Color get $dartTokenName => Color(0x$dartColorValue);'''); - } - }); - } + categoryData.forEach((tokenName, tokenData) { + processPrimitiveTokenData( + buffer, + tokenData, + '${categoryName}_$tokenName', + ); + }); }); buffer.writeln('}'); @@ -65,6 +56,32 @@ class AppFlowyPrimitiveTokens { print('Successfully generated ${outputFile.path}'); } +void processPrimitiveTokenData( + StringBuffer buffer, + Map tokenData, + final String currentTokenName, +) { + if (tokenData + case { + r'$type': 'color', + r'$value': final String colorValue, + }) { + final dartColorValue = convertColor(colorValue); + final dartTokenName = currentTokenName.replaceAll('-', '_').toCamelCase(); + + buffer.writeln(''' + + /// $colorValue + static Color get $dartTokenName => Color(0x$dartColorValue);'''); + } else { + tokenData.forEach((key, value) { + if (value is Map) { + processPrimitiveTokenData(buffer, value, '${currentTokenName}_$key'); + } + }); + } +} + void generateSemantic() { // 1. Load the JSON file. final lightJsonString = @@ -98,61 +115,39 @@ import 'primitive.dart'; class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); // 3. Process light mode semantic tokens - void writeThemeData(String brightness, Map jsonData) { - buffer.writeln(''' + buffer.writeln(''' @override - AppFlowyThemeData $brightness() { + AppFlowyThemeData light() { final textStyle = AppFlowyBaseTextStyle(); final borderRadius = AppFlowySharedTokens.buildBorderRadius(); final spacing = AppFlowySharedTokens.buildSpacing(); - final shadow = AppFlowySharedTokens.buildShadow(Brightness.$brightness);'''); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.light);'''); - jsonData.forEach((categoryName, categoryData) { - if (categoryData is Map) { - final hasNonColorType = categoryData.values.any( - (element) => - element is Map && element['\$type'] != 'color', - ); - if (hasNonColorType) { - return; - } + lightJsonData.forEach((categoryName, categoryData) { + if ([ + 'Spacing', + 'Border_Radius', + 'Shadow', + 'Badge_Color', + ].contains(categoryName)) { + return; + } - final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); - final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; - buffer - ..writeln() - ..writeln(' final $fullCategoryName = $className('); + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); - categoryData.forEach((tokenName, tokenData) { - if (tokenData is Map && - tokenData['\$type'] == 'color') { - final semanticTokenName = - tokenName.replaceAll('-', '_').toCamelCase(); - - final value = tokenData['\$value'] as String; - final String colorOrPrimitiveToken; - if (value.isColor) { - colorOrPrimitiveToken = 'Color(0x${convertColor(value)})'; - } else { - final primitiveToken = value - .replaceAll('{', '') - .replaceAll('}', '') - .replaceAll('.', '_') - .replaceAll('-', '_') - .toCamelCase(); - colorOrPrimitiveToken = 'AppFlowyPrimitiveTokens.$primitiveToken'; - } - - buffer.writeln(' $semanticTokenName: $colorOrPrimitiveToken,'); - } - }); - buffer.writeln(' );'); - } + categoryData.forEach((tokenName, tokenData) { + processSemanticTokenData(buffer, tokenData, tokenName); }); + buffer.writeln(' );'); + }); - buffer.writeln(); - buffer.writeln(''' + buffer.writeln(); + buffer.writeln(''' return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, @@ -168,11 +163,61 @@ class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); shadow: shadow, ); }'''); - } - writeThemeData('light', lightJsonData); buffer.writeln(); - writeThemeData('dark', darkJsonData); + + buffer.writeln(''' + @override + AppFlowyThemeData dark() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark);'''); + + darkJsonData.forEach((categoryName, categoryData) { + if ([ + 'Spacing', + 'Border_Radius', + 'Shadow', + 'Badge_Color', + ].contains(categoryName)) { + return; + } + + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); + + categoryData.forEach((tokenName, tokenData) { + if (tokenData is Map) { + processSemanticTokenData(buffer, tokenData, tokenName); + } + }); + buffer.writeln(' );'); + }); + + buffer.writeln(); + + buffer.writeln(''' + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + }'''); + buffer.writeln('}'); // 4. Write the output to a Dart file. @@ -182,6 +227,44 @@ class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); print('Successfully generated ${outputFile.path}'); } +void processSemanticTokenData( + StringBuffer buffer, + Map json, + final String currentTokenName, +) { + if (json + case { + r'$type': 'color', + r'$value': final String value, + }) { + final semanticTokenName = + currentTokenName.replaceAll('-', '_').toCamelCase(); + + final String colorValueOrPrimitiveToken; + if (value.isColor) { + colorValueOrPrimitiveToken = 'Color(0x${convertColor(value)})'; + } else { + final primitiveToken = value + .replaceAll(RegExp(r'\{|\}'), '') + .replaceAll(RegExp(r'\.|-'), '_') + .toCamelCase(); + colorValueOrPrimitiveToken = 'AppFlowyPrimitiveTokens.$primitiveToken'; + } + + buffer.writeln(' $semanticTokenName: $colorValueOrPrimitiveToken,'); + } else { + json.forEach((key, value) { + if (value is Map) { + processSemanticTokenData( + buffer, + value, + '${currentTokenName}_$key', + ); + } + }); + } +} + String convertColor(String hexColor) { String color = hexColor.toUpperCase().replaceAll('#', ''); if (color.length == 6) { From 6dac45172e2e1e53ceacbcb82b4dea174f3022f3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 15:34:06 +0800 Subject: [PATCH 191/207] chore: auth type --- .../home/mobile_home_page_header.dart | 1 + .../lib/user/application/user_service.dart | 9 +- .../application/user/user_workspace_bloc.dart | 53 +++++++++--- .../workspace/_sidebar_workspace_menu.dart | 9 +- frontend/appflowy_flutter/macos/Podfile.lock | 46 +++++----- .../src/folder_event.rs | 16 +++- .../event-integration-test/src/user_event.rs | 13 +-- .../user/af_cloud_test/workspace_test.rs | 42 ++++++++-- .../flowy-core/src/user_state_callback.rs | 15 ++-- .../flowy-user/src/entities/user_profile.rs | 45 +++++++--- .../flowy-user/src/entities/workspace.rs | 32 ++++++- .../rust-lib/flowy-user/src/event_handler.rs | 29 ++++++- frontend/rust-lib/flowy-user/src/event_map.rs | 13 +-- .../src/services/sqlite_sql/workspace_sql.rs | 10 ++- .../flowy-user/src/user_manager/manager.rs | 1 - .../user_manager/manager_user_workspace.rs | 84 ++++++++++++++----- 16 files changed, 308 insertions(+), 110 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 97cc243c9e..4a1df63740 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -194,6 +194,7 @@ class _MobileWorkspace extends StatelessWidget { context.read().add( UserWorkspaceEvent.openWorkspace( workspace.workspaceId, + workspace.authType, ), ); }, diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 18f89ebc14..5e163b4e62 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -121,8 +121,13 @@ class UserBackendService implements IUserBackendService { }); } - Future> openWorkspace(String workspaceId) { - final payload = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + Future> openWorkspace( + String workspaceId, + AuthTypePB authType, + ) { + final payload = OpenUserWorkspacePB() + ..workspaceId = workspaceId + ..authType = authType; return UserEventOpenWorkspace(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index e6a8c4d921..7e341f4f1f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -52,7 +52,10 @@ class UserWorkspaceBloc extends Bloc { ); if (currentWorkspace != null && result.$3 == true) { Log.info('init open workspace: ${currentWorkspace.workspaceId}'); - await _userService.openWorkspace(currentWorkspace.workspaceId); + await _userService.openWorkspace( + currentWorkspace.workspaceId, + currentWorkspace.authType, + ); } emit( @@ -86,7 +89,12 @@ class UserWorkspaceBloc extends Bloc { Log.info( 'fetch workspaces: try to open workspace: ${currentWorkspace.workspaceId}', ); - add(OpenWorkspace(currentWorkspace.workspaceId)); + add( + OpenWorkspace( + currentWorkspace.workspaceId, + currentWorkspace.authType, + ), + ); } }, createWorkspace: (name) async { @@ -118,7 +126,12 @@ class UserWorkspaceBloc extends Bloc { result ..onSuccess((s) { Log.info('create workspace success: $s'); - add(OpenWorkspace(s.workspaceId)); + add( + OpenWorkspace( + s.workspaceId, + s.authType, + ), + ); }) ..onFailure((f) { Log.error('create workspace error: $f'); @@ -171,7 +184,12 @@ class UserWorkspaceBloc extends Bloc { Log.info('delete workspace success: $workspaceId'); // if the current workspace is deleted, open the first workspace if (state.currentWorkspace?.workspaceId == workspaceId) { - add(OpenWorkspace(workspaces.first.workspaceId)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.authType, + ), + ); } }) ..onFailure((f) { @@ -179,7 +197,12 @@ class UserWorkspaceBloc extends Bloc { // if the workspace is deleted but return an error, we need to // open the first workspace if (!containsDeletedWorkspace) { - add(OpenWorkspace(workspaces.first.workspaceId)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.authType, + ), + ); } }); emit( @@ -193,7 +216,7 @@ class UserWorkspaceBloc extends Bloc { ), ); }, - openWorkspace: (workspaceId) async { + openWorkspace: (workspaceId, authType) async { emit( state.copyWith( actionResult: const UserWorkspaceActionResult( @@ -203,7 +226,10 @@ class UserWorkspaceBloc extends Bloc { ), ), ); - final result = await _userService.openWorkspace(workspaceId); + final result = await _userService.openWorkspace( + workspaceId, + authType, + ); final currentWorkspace = result.fold( (s) => state.workspaces.firstWhereOrNull( (e) => e.workspaceId == workspaceId, @@ -337,7 +363,12 @@ class UserWorkspaceBloc extends Bloc { Log.info('leave workspace success: $workspaceId'); // if leaving the current workspace, open the first workspace if (state.currentWorkspace?.workspaceId == workspaceId) { - add(OpenWorkspace(workspaces.first.workspaceId)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.authType, + ), + ); } }) ..onFailure((f) { @@ -445,8 +476,10 @@ class UserWorkspaceEvent with _$UserWorkspaceEvent { CreateWorkspace; const factory UserWorkspaceEvent.deleteWorkspace(String workspaceId) = DeleteWorkspace; - const factory UserWorkspaceEvent.openWorkspace(String workspaceId) = - OpenWorkspace; + const factory UserWorkspaceEvent.openWorkspace( + String workspaceId, + AuthTypePB authType, + ) = OpenWorkspace; const factory UserWorkspaceEvent.renameWorkspace( String workspaceId, String name, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index ff393a8305..daf332cc15 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -306,9 +306,12 @@ class _WorkspaceInfo extends StatelessWidget { // Persist and close other tabs when switching workspace, restore tabs for new workspace getIt().add(TabsEvent.switchWorkspace(workspace.workspaceId)); - context - .read() - .add(UserWorkspaceEvent.openWorkspace(workspace.workspaceId)); + context.read().add( + UserWorkspaceEvent.openWorkspace( + workspace.workspaceId, + workspace.authType, + ), + ); PopoverContainer.of(context).closeAll(); } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 30ee626f09..b4a1a3d20d 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 - appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 - auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 - connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 - file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 - irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c - window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/rust-lib/event-integration-test/src/folder_event.rs b/frontend/rust-lib/event-integration-test/src/folder_event.rs index 2e1b1cc417..345c1e58e0 100644 --- a/frontend/rust-lib/event-integration-test/src/folder_event.rs +++ b/frontend/rust-lib/event-integration-test/src/folder_event.rs @@ -11,8 +11,8 @@ use flowy_folder_pub::entities::PublishPayload; use flowy_search::services::manager::{SearchHandler, SearchType}; use flowy_user::entities::{ AcceptWorkspaceInvitationPB, QueryWorkspacePB, RemoveWorkspaceMemberPB, - RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, WorkspaceMemberInvitationPB, - WorkspaceMemberPB, + RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, UserWorkspaceIdPB, UserWorkspacePB, + WorkspaceMemberInvitationPB, WorkspaceMemberPB, }; use flowy_user::errors::FlowyError; use flowy_user::event_map::UserEvent; @@ -112,6 +112,18 @@ impl EventIntegrationTest { .parse::() } + pub async fn get_user_workspace(&self, workspace_id: &str) -> UserWorkspacePB { + let payload = UserWorkspaceIdPB { + workspace_id: workspace_id.to_string(), + }; + EventBuilder::new(self.clone()) + .event(UserEvent::GetUserWorkspace) + .payload(payload) + .async_send() + .await + .parse::() + } + pub fn get_folder_search_handler(&self) -> &Arc { self .appflowy_core diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index 2ec74bafa6..5c9be65660 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -17,10 +17,10 @@ use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_UR use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, - OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, - SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, - UserWorkspaceIdPB, UserWorkspacePB, + AuthTypePB, AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, + ImportAppFlowyDataPB, OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, + RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, + UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; @@ -280,9 +280,10 @@ impl EventIntegrationTest { .await; } - pub async fn open_workspace(&self, workspace_id: &str) { - let payload = UserWorkspaceIdPB { + pub async fn open_workspace(&self, workspace_id: &str, auth_type: AuthTypePB) { + let payload = OpenUserWorkspacePB { workspace_id: workspace_id.to_string(), + auth_type, }; EventBuilder::new(self.clone()) .event(UserEvent::OpenWorkspace) diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs index 366e43c157..ea04d922fc 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs @@ -89,7 +89,9 @@ async fn af_cloud_create_workspace_test() { } { // after opening new workspace - test.open_workspace(&created_workspace.workspace_id).await; + test + .open_workspace(&created_workspace.workspace_id, created_workspace.auth_type) + .await; let folder_ws = test.folder_read_current_workspace().await; assert_eq!(folder_ws.id, created_workspace.workspace_id); let views = test.folder_read_current_workspace_views().await; @@ -110,6 +112,7 @@ async fn af_cloud_open_workspace_test() { test.create_document("A").await; test.create_document("B").await; let first_workspace = test.get_current_workspace().await; + let first_workspace = test.get_user_workspace(&first_workspace.id).await; let views = test.get_all_workspace_views().await; assert_eq!(views.len(), 4); assert_eq!(views[0].name, default_document_name); @@ -120,8 +123,11 @@ async fn af_cloud_open_workspace_test() { let user_workspace = test .create_workspace("second workspace", AuthType::AppFlowyCloud) .await; - test.open_workspace(&user_workspace.workspace_id).await; + test + .open_workspace(&user_workspace.workspace_id, user_workspace.auth_type) + .await; let second_workspace = test.get_current_workspace().await; + let second_workspace = test.get_user_workspace(&second_workspace.id).await; test.create_document("C").await; test.create_document("D").await; @@ -135,13 +141,23 @@ async fn af_cloud_open_workspace_test() { // simulate open workspace and check if the views are correct for i in 0..10 { if i % 2 == 0 { - test.open_workspace(&first_workspace.id).await; + test + .open_workspace( + &first_workspace.workspace_id, + first_workspace.auth_type.clone(), + ) + .await; sleep(Duration::from_millis(300)).await; test .create_document(&uuid::Uuid::new_v4().to_string()) .await; } else { - test.open_workspace(&second_workspace.id).await; + test + .open_workspace( + &second_workspace.workspace_id, + second_workspace.auth_type.clone(), + ) + .await; sleep(Duration::from_millis(200)).await; test .create_document(&uuid::Uuid::new_v4().to_string()) @@ -149,14 +165,24 @@ async fn af_cloud_open_workspace_test() { } } - test.open_workspace(&first_workspace.id).await; + test + .open_workspace( + &first_workspace.workspace_id, + first_workspace.auth_type.clone(), + ) + .await; let views_1 = test.get_all_workspace_views().await; assert_eq!(views_1[0].name, default_document_name); assert_eq!(views_1[1].name, "Shared"); assert_eq!(views_1[2].name, "A"); assert_eq!(views_1[3].name, "B"); - test.open_workspace(&second_workspace.id).await; + test + .open_workspace( + &second_workspace.workspace_id, + second_workspace.auth_type.clone(), + ) + .await; let views_2 = test.get_all_workspace_views().await; assert_eq!(views_2[0].name, default_document_name); assert_eq!(views_2[1].name, "Shared"); @@ -212,7 +238,9 @@ async fn af_cloud_different_open_same_workspace_test() { for i in 0..30 { let index = i % 2; let iter_workspace_id = &all_workspaces[index].workspace_id; - client.open_workspace(iter_workspace_id).await; + client + .open_workspace(iter_workspace_id, all_workspaces[index].auth_type.clone()) + .await; if iter_workspace_id == &cloned_shared_workspace_id { let views = client.get_all_workspace_views().await; assert_eq!(views.len(), 2); diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index ff1c6b6699..6002746603 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -49,11 +49,10 @@ impl UserStatusCallback for UserStatusCallbackImpl { async fn did_init( &self, user_id: i64, - auth_type: &AuthType, cloud_config: &Option, user_workspace: &UserWorkspace, _device_id: &str, - authenticator: &AuthType, + auth_type: &AuthType, ) -> FlowyResult<()> { let workspace_id = user_workspace.workspace_id()?; self.server_provider.set_auth_type(*auth_type); @@ -81,7 +80,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { .await?; self .database_manager - .initialize(user_id, authenticator == &AuthType::Local) + .initialize(user_id, auth_type == &AuthType::Local) .await?; self.document_manager.initialize(user_id).await?; @@ -95,7 +94,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_id: i64, user_workspace: &UserWorkspace, device_id: &str, - authenticator: &AuthType, + auth_type: &AuthType, ) -> FlowyResult<()> { event!( tracing::Level::TRACE, @@ -103,6 +102,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace, device_id ); + self.server_provider.set_auth_type(*auth_type); self .folder_manager @@ -110,7 +110,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { .await?; self .database_manager - .initialize(user_id, authenticator.is_local()) + .initialize(user_id, auth_type.is_local()) .await?; self.document_manager.initialize(user_id).await?; @@ -207,15 +207,16 @@ impl UserStatusCallback for UserStatusCallbackImpl { &self, user_id: i64, user_workspace: &UserWorkspace, - authenticator: &AuthType, + auth_type: &AuthType, ) -> FlowyResult<()> { + self.server_provider.set_auth_type(*auth_type); self .folder_manager .initialize_with_workspace_id(user_id) .await?; self .database_manager - .initialize(user_id, authenticator.is_local()) + .initialize(user_id, auth_type.is_local()) .await?; self.document_manager.initialize(user_id).await?; self.ai_manager.initialize(&user_workspace.id).await?; diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 54cb41db1e..f7a78a8f7d 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -1,15 +1,14 @@ +use super::AFRolePB; +use crate::entities::parser::{UserEmail, UserIcon, UserName}; +use crate::entities::{AuthTypePB, AuthenticatorPB}; +use crate::errors::ErrorCode; +use crate::services::sqlite_sql::workspace_sql::UserWorkspaceTable; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; use lib_infra::validator_fn::required_not_empty_str; use std::convert::TryInto; use validator::Validate; -use crate::entities::parser::{UserEmail, UserIcon, UserName}; -use crate::entities::AuthenticatorPB; -use crate::errors::ErrorCode; - -use super::AFRolePB; - #[derive(Default, ProtoBuf)] pub struct UserTokenPB { #[pb(index = 1)] @@ -153,10 +152,14 @@ pub struct RepeatedUserWorkspacePB { pub items: Vec, } -impl From> for RepeatedUserWorkspacePB { - fn from(workspaces: Vec) -> Self { +impl From<(AuthType, Vec)> for RepeatedUserWorkspacePB { + fn from(value: (AuthType, Vec)) -> Self { + let (auth_type, workspaces) = value; Self { - items: workspaces.into_iter().map(UserWorkspacePB::from).collect(), + items: workspaces + .into_iter() + .map(|w| UserWorkspacePB::from((auth_type, w))) + .collect(), } } } @@ -181,17 +184,35 @@ pub struct UserWorkspacePB { #[pb(index = 6, one_of)] pub role: Option, + + #[pb(index = 7)] + pub auth_type: AuthTypePB, } -impl From for UserWorkspacePB { - fn from(value: UserWorkspace) -> Self { +impl From<(AuthType, UserWorkspace)> for UserWorkspacePB { + fn from(value: (AuthType, UserWorkspace)) -> Self { + Self { + workspace_id: value.1.id, + name: value.1.name, + created_at_timestamp: value.1.created_at.timestamp(), + icon: value.1.icon, + member_count: value.1.member_count, + role: value.1.role.map(AFRolePB::from), + auth_type: AuthTypePB::from(value.0), + } + } +} + +impl From for UserWorkspacePB { + fn from(value: UserWorkspaceTable) -> Self { Self { workspace_id: value.id, name: value.name, - created_at_timestamp: value.created_at.timestamp(), + created_at_timestamp: value.created_at, icon: value.icon, member_count: value.member_count, role: value.role.map(AFRolePB::from), + auth_type: AuthTypePB::from(value.auth_type), } } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index 26f848f3f5..d277fb4614 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -155,6 +155,17 @@ pub enum AFRolePB { Guest = 2, } +impl From for AFRolePB { + fn from(value: i32) -> Self { + match value { + 0 => AFRolePB::Owner, + 1 => AFRolePB::Member, + 2 => AFRolePB::Guest, + _ => AFRolePB::Guest, + } + } +} + impl From for Role { fn from(value: AFRolePB) -> Self { match value { @@ -182,6 +193,16 @@ pub struct UserWorkspaceIdPB { pub workspace_id: String, } +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct OpenUserWorkspacePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2)] + pub auth_type: AuthTypePB, +} + #[derive(ProtoBuf, Default, Clone, Validate)] pub struct CancelWorkspaceSubscriptionPB { #[pb(index = 1)] @@ -221,12 +242,21 @@ pub struct CreateWorkspacePB { pub auth_type: AuthTypePB, } -#[derive(ProtoBuf_Enum, Default, Clone)] +#[derive(ProtoBuf_Enum, Default, Debug, Clone)] pub enum AuthTypePB { LocalAuthType = 0, #[default] CloudAuthType = 1, } +impl From for AuthTypePB { + fn from(value: i32) -> Self { + match value { + 0 => AuthTypePB::LocalAuthType, + 1 => AuthTypePB::CloudAuthType, + _ => AuthTypePB::CloudAuthType, + } + } +} impl From for AuthTypePB { fn from(value: AuthType) -> Self { diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index bb2a0b2c30..4cd49649ce 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -440,21 +440,42 @@ pub async fn get_all_workspace_handler( let user_workspaces = manager .get_all_user_workspaces(profile.uid, profile.auth_type) .await?; - data_result_ok(user_workspaces.into()) + + data_result_ok(RepeatedUserWorkspacePB::from(( + profile.auth_type, + user_workspaces, + ))) } #[tracing::instrument(level = "info", skip(data, manager), err)] pub async fn open_workspace_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; let workspace_id = Uuid::from_str(¶ms.workspace_id)?; - manager.open_workspace(&workspace_id).await?; + manager + .open_workspace(&workspace_id, AuthType::from(params.auth_type)) + .await?; Ok(()) } +#[tracing::instrument(level = "info", skip(data, manager), err)] +pub async fn get_user_workspace_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params = data.try_into_inner()?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let uid = manager.user_id()?; + let user_workspace = manager + .get_user_workspace_from_db(uid, &workspace_id) + .ok_or_else(FlowyError::record_not_found)?; + data_result_ok(UserWorkspacePB::from(user_workspace)) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn update_network_state_handler( data: AFPluginData, @@ -610,7 +631,7 @@ pub async fn create_workspace_handler( let auth_type = AuthType::from(data.auth_type); let manager = upgrade_manager(manager)?; let new_workspace = manager.create_workspace(&data.name, auth_type).await?; - data_result_ok(new_workspace.into()) + data_result_ok(UserWorkspacePB::from((auth_type, new_workspace))) } #[tracing::instrument(level = "debug", skip_all, err)] diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 03660ad0ff..1ae414afa7 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -39,6 +39,7 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GenerateSignInURL, gen_sign_in_url_handler) .event(UserEvent::GetOauthURLWithProvider, sign_in_with_provider_handler) .event(UserEvent::OpenWorkspace, open_workspace_handler) + .event(UserEvent::GetUserWorkspace, get_user_workspace_handler) .event(UserEvent::UpdateNetworkState, update_network_state_handler) .event(UserEvent::OpenAnonUser, open_anon_user_handler) .event(UserEvent::GetAnonUser, get_anon_user_handler) @@ -144,9 +145,12 @@ pub enum UserEvent { #[event(output = "RepeatedUserWorkspacePB")] GetAllWorkspace = 17, - #[event(input = "UserWorkspaceIdPB")] + #[event(input = "OpenUserWorkspacePB")] OpenWorkspace = 21, + #[event(input = "UserWorkspaceIdPB", output = "UserWorkspacePB")] + GetUserWorkspace = 22, + #[event(input = "NetworkStatePB")] UpdateNetworkState = 24, @@ -281,11 +285,10 @@ pub trait UserStatusCallback: Send + Sync + 'static { async fn did_init( &self, _user_id: i64, - _user_authenticator: &AuthType, _cloud_config: &Option, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &AuthType, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } @@ -295,7 +298,7 @@ pub trait UserStatusCallback: Send + Sync + 'static { _user_id: i64, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &AuthType, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } @@ -318,7 +321,7 @@ pub trait UserStatusCallback: Send + Sync + 'static { &self, _user_id: i64, _user_workspace: &UserWorkspace, - _authenticator: &AuthType, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs index f197ca9738..7fc3b00157 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs @@ -1,5 +1,5 @@ use chrono::{TimeZone, Utc}; -use diesel::{RunQueryDsl, SqliteConnection}; +use diesel::RunQueryDsl; use flowy_error::FlowyError; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::DBConnection; @@ -55,12 +55,14 @@ impl UserWorkspaceTable { } } -pub fn select_user_workspace(workspace_id: &str, mut conn: DBConnection) -> Option { +pub fn select_user_workspace( + workspace_id: &str, + mut conn: DBConnection, +) -> Option { user_workspace_table::dsl::user_workspace_table .filter(user_workspace_table::id.eq(workspace_id)) .first::(&mut *conn) .ok() - .map(UserWorkspace::from) } pub fn select_all_user_workspace( @@ -89,7 +91,7 @@ pub fn upsert_user_workspace( uid: i64, auth_type: AuthType, user_workspace: UserWorkspace, - conn: &mut SqliteConnection, + conn: &mut DBConnection, ) -> Result<(), FlowyError> { let new_record = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index d7d7c8b68d..14aaebe86c 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -273,7 +273,6 @@ impl UserManager { user_status_callback .did_init( user.uid, - &user.auth_type, &cloud_config, &session.user_workspace, &self.authenticate_user.user_config.device_id, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index ac15324c00..b24764cc6b 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -32,7 +32,7 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, ConnectionPool, DBConnection, ExpressionMethods}; -use flowy_user_pub::cloud::UserCloudServiceProvider; +use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; use flowy_user_pub::entities::{ AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; @@ -162,13 +162,32 @@ impl UserManager { } #[instrument(skip(self), err)] - pub async fn open_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { - info!("open workspace: {}", workspace_id); - let user_workspace = self - .cloud_service - .get_user_service()? - .open_workspace(workspace_id) - .await?; + pub async fn open_workspace(&self, workspace_id: &Uuid, auth_type: AuthType) -> FlowyResult<()> { + info!("open workspace: {}, auth_type:{}", workspace_id, auth_type); + let uid = self.user_id()?; + let conn = self.db_connection(self.user_id()?)?; + let user_workspace = match select_user_workspace(&workspace_id.to_string(), conn) { + None => { + sync_workspace( + workspace_id, + self.cloud_service.get_user_service()?, + uid, + auth_type, + self.db_pool(uid)?, + ) + .await? + }, + Some(row) => { + let user_workspace = UserWorkspace::from(row); + let workspace_id = *workspace_id; + let user_service = self.cloud_service.get_user_service()?; + let pool = self.db_pool(uid)?; + tokio::spawn(async move { + let _ = sync_workspace(&workspace_id, user_service, uid, auth_type, pool).await; + }); + user_workspace + }, + }; self .authenticate_user @@ -176,6 +195,15 @@ impl UserManager { let uid = self.user_id()?; let user_profile = self.get_user_profile_from_disk(uid).await?; + if let Err(err) = self + .user_status_callback + .read() + .await + .open_workspace(uid, &user_workspace, &user_profile.auth_type) + .await + { + error!("Open workspace failed: {:?}", err); + } if let Err(err) = self .initial_user_awareness(self.get_session()?.as_ref(), &user_profile.auth_type) @@ -187,16 +215,6 @@ impl UserManager { ); } - if let Err(err) = self - .user_status_callback - .read() - .await - .open_workspace(uid, &user_workspace, &user_profile.auth_type) - .await - { - error!("Open workspace failed: {:?}", err); - } - Ok(()) } @@ -240,10 +258,11 @@ impl UserManager { let conn = self.db_connection(uid)?; update_user_workspace(conn, changeset)?; - let user_workspace = self - .get_user_workspace(uid, workspace_id) + let row = self + .get_user_workspace_from_db(uid, workspace_id) .ok_or_else(FlowyError::record_not_found)?; - let payload: UserWorkspacePB = user_workspace.clone().into(); + + let payload = UserWorkspacePB::from(row); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspace) .payload(payload) .send(); @@ -376,7 +395,11 @@ impl UserManager { Ok(()) } - pub fn get_user_workspace(&self, uid: i64, workspace_id: &Uuid) -> Option { + pub fn get_user_workspace_from_db( + &self, + uid: i64, + workspace_id: &Uuid, + ) -> Option { let conn = self.db_connection(uid).ok()?; select_user_workspace(workspace_id.to_string().as_str(), conn) } @@ -395,7 +418,8 @@ impl UserManager { if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { if let Ok(conn) = pool.get() { let _ = save_all_user_workspaces(uid, conn, auth_type, &new_user_workspaces); - let repeated_workspace_pbs = RepeatedUserWorkspacePB::from(new_user_workspaces); + let repeated_workspace_pbs = + RepeatedUserWorkspacePB::from((auth_type, new_user_workspaces)); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) .payload(repeated_workspace_pbs) .send(); @@ -783,3 +807,17 @@ async fn sync_workspace_settings( } Ok(()) } + +async fn sync_workspace( + workspace_id: &Uuid, + user_service: Arc, + uid: i64, + auth_type: AuthType, + pool: Arc, +) -> FlowyResult { + let user_workspace = user_service.open_workspace(workspace_id).await?; + if let Ok(mut conn) = pool.get() { + upsert_user_workspace(uid, auth_type, user_workspace.clone(), &mut conn)?; + } + Ok(user_workspace) +} From 102087537a4a057170d3d040ac2dc98016fafee5 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 15:50:42 +0800 Subject: [PATCH 192/207] chore: fmt --- frontend/rust-lib/flowy-sqlite/src/schema.rs | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 27ccdc8f18..28a83ed449 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -132,16 +132,16 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, - workspace_setting_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, + workspace_setting_table, ); From 81f63bebe60fcf5a581ff6c49db71230d71e941f Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 21:03:50 +0800 Subject: [PATCH 193/207] chore: sql --- frontend/rust-lib/Cargo.lock | 5 +- .../src/local_server/impls/user.rs | 35 ++-- .../flowy-server/src/local_server/server.rs | 4 +- frontend/rust-lib/flowy-user-pub/Cargo.toml | 4 +- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 8 +- .../rust-lib/flowy-user-pub/src/entities.rs | 2 +- frontend/rust-lib/flowy-user-pub/src/lib.rs | 1 + .../rust-lib/flowy-user-pub/src/sql/mod.rs | 1 + .../flowy-user-pub/src/sql/workspace_sql.rs | 188 ++++++++++++++++++ frontend/rust-lib/flowy-user/Cargo.toml | 1 - .../src/services/sqlite_sql/workspace_sql.rs | 64 +++++- .../flowy-user/src/user_manager/manager.rs | 8 +- .../user_manager/manager_user_workspace.rs | 93 +-------- 13 files changed, 294 insertions(+), 120 deletions(-) create mode 100644 frontend/rust-lib/flowy-user-pub/src/sql/mod.rs create mode 100644 frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index a703fb1e05..5beac0b3a6 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -3086,7 +3086,6 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", - "nanoid", "protobuf", "quickcheck", "quickcheck_macros", @@ -3110,16 +3109,16 @@ dependencies = [ name = "flowy-user-pub" version = "0.1.0" dependencies = [ - "anyhow", - "arc-swap", "base64 0.21.5", "chrono", "client-api", "collab", "collab-entity", "collab-folder", + "diesel", "flowy-error", "flowy-folder-pub", + "flowy-sqlite", "lib-infra", "serde", "serde_json", diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 49db8e03a2..8189ec140c 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -1,13 +1,17 @@ #![allow(unused_variables)] + use client_api::entity::GotrueTokenResponse; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_entity::CollabObject; use collab_user::core::UserAwareness; use lazy_static::lazy_static; +use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; +use crate::af_cloud::define::LoggedUser; +use crate::local_server::uid::UserIDGenerator; use flowy_error::FlowyError; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; use flowy_user_pub::entities::*; @@ -16,14 +20,14 @@ use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; use lib_infra::util::timestamp; -use crate::local_server::uid::UserIDGenerator; - lazy_static! { - //FIXME: seriously, userID generation should work using lock-free algorithm static ref ID_GEN: Mutex = Mutex::new(UserIDGenerator::new(1)); } -pub(crate) struct LocalServerUserServiceImpl; +pub(crate) struct LocalServerUserServiceImpl { + pub user: Arc, +} + #[async_trait] impl UserCloudService for LocalServerUserServiceImpl { async fn sign_up(&self, params: BoxAny) -> Result { @@ -128,7 +132,13 @@ impl UserCloudService for LocalServerUserServiceImpl { ) } - async fn get_all_workspace(&self, _uid: i64) -> Result, FlowyError> { + async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError> { + let conn = self.user.get_sqlite_db(uid)?; + + select_all_user_workspaces(&conn).await.map_err(|e| { + FlowyError::internal().with_context(format!("Failed to get all workspaces: {}", e)) + })?; + Ok(vec![]) } @@ -145,17 +155,11 @@ impl UserCloudService for LocalServerUserServiceImpl { new_workspace_name: Option, new_workspace_icon: Option, ) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) + Ok(()) } async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) + Ok(()) } async fn get_user_awareness_doc_state( @@ -188,10 +192,7 @@ impl UserCloudService for LocalServerUserServiceImpl { workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support batch create collab object"), - ) + Ok(()) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index c81d526acb..8ce0d86221 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -41,7 +41,9 @@ impl LocalServer { impl AppFlowyServer for LocalServer { fn user_service(&self) -> Arc { - Arc::new(LocalServerUserServiceImpl) + Arc::new(LocalServerUserServiceImpl { + user: self.user.clone(), + }) } fn folder_service(&self) -> Arc { diff --git a/frontend/rust-lib/flowy-user-pub/Cargo.toml b/frontend/rust-lib/flowy-user-pub/Cargo.toml index 80d087e88e..cb6b9b0738 100644 --- a/frontend/rust-lib/flowy-user-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-user-pub/Cargo.toml @@ -15,7 +15,6 @@ collab-entity = { workspace = true } serde_json.workspace = true serde_repr.workspace = true chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } -anyhow.workspace = true tokio = { workspace = true, features = ["sync"] } tokio-stream = "0.1.14" flowy-folder-pub.workspace = true @@ -23,4 +22,5 @@ collab-folder = { workspace = true } tracing.workspace = true base64 = "0.21" client-api = { workspace = true } -arc-swap = "1.7.1" +flowy-sqlite.workspace = true +diesel.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index dee44fa5f3..00eaef8917 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -293,7 +293,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_subscriptions( &self, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } /// Get the workspace subscriptions for a workspace @@ -301,7 +301,7 @@ pub trait UserCloudService: Send + Sync + 'static { &self, workspace_id: &Uuid, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } async fn cancel_workspace_subscription( @@ -310,14 +310,14 @@ pub trait UserCloudService: Send + Sync + 'static { plan: SubscriptionPlan, reason: Option, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Ok(()) } async fn get_workspace_plan( &self, workspace_id: Uuid, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } async fn get_workspace_usage( diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 7c8d775fa6..6be5a9f64d 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -164,7 +164,7 @@ impl UserWorkspace { workspace_database_id: Uuid::new_v4().to_string(), icon: "".to_string(), member_count: 1, - role: None, + role: Some(Role::Owner), } } } diff --git a/frontend/rust-lib/flowy-user-pub/src/lib.rs b/frontend/rust-lib/flowy-user-pub/src/lib.rs index 2e51ecc626..d820e8cd34 100644 --- a/frontend/rust-lib/flowy-user-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-user-pub/src/lib.rs @@ -1,6 +1,7 @@ pub mod cloud; pub mod entities; pub mod session; +mod sql; pub mod workspace_service; pub const DEFAULT_USER_NAME: fn() -> String = || "Me".to_string(); diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs new file mode 100644 index 0000000000..d5a7aaf317 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs @@ -0,0 +1 @@ +pub mod workspace_sql; diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs new file mode 100644 index 0000000000..ecd242c240 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -0,0 +1,188 @@ +use crate::entities::{AuthType, UserWorkspace}; +use chrono::{TimeZone, Utc}; +use diesel::{RunQueryDsl, SqliteConnection}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::schema::user_workspace_table; +use flowy_sqlite::DBConnection; +use flowy_sqlite::{query_dsl::*, ExpressionMethods}; +use tracing::{info, trace, warn}; + +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceTable { + pub id: String, + pub name: String, + pub uid: i64, + pub created_at: i64, + pub database_storage_id: String, + pub icon: String, + pub member_count: i64, + pub role: Option, + pub auth_type: i32, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceChangeset { + pub id: String, + pub name: Option, + pub icon: Option, +} + +impl UserWorkspaceTable { + pub fn from_workspace( + uid: i64, + workspace: &UserWorkspace, + auth_type: AuthType, + ) -> Result { + if workspace.id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The id is empty")); + } + if workspace.workspace_database_id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); + } + + Ok(Self { + id: workspace.id.clone(), + name: workspace.name.clone(), + uid, + created_at: workspace.created_at.timestamp(), + database_storage_id: workspace.workspace_database_id.clone(), + icon: workspace.icon.clone(), + member_count: workspace.member_count, + role: workspace.role.clone().map(|v| v as i32), + auth_type: auth_type as i32, + }) + } +} + +pub fn select_user_workspace( + workspace_id: &str, + mut conn: DBConnection, +) -> Option { + user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::id.eq(workspace_id)) + .first::(&mut *conn) + .ok() +} + +pub fn select_all_user_workspace( + user_id: i64, + mut conn: DBConnection, +) -> Result, FlowyError> { + let rows = user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(user_id)) + .load::(&mut *conn)?; + Ok(rows.into_iter().map(UserWorkspace::from).collect()) +} + +pub fn update_user_workspace( + mut conn: DBConnection, + changeset: UserWorkspaceChangeset, +) -> Result<(), FlowyError> { + diesel::update(user_workspace_table::dsl::user_workspace_table) + .filter(user_workspace_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(&mut conn)?; + + Ok(()) +} + +pub fn upsert_user_workspace( + uid: i64, + auth_type: AuthType, + user_workspace: UserWorkspace, + conn: &mut SqliteConnection, +) -> Result<(), FlowyError> { + let new_record = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; + diesel::insert_into(user_workspace_table::table) + .values(new_record.clone()) + .on_conflict(user_workspace_table::id) + .do_update() + .set(( + user_workspace_table::name.eq(new_record.name), + user_workspace_table::uid.eq(new_record.uid), + user_workspace_table::created_at.eq(new_record.created_at), + user_workspace_table::database_storage_id.eq(new_record.database_storage_id), + user_workspace_table::icon.eq(new_record.icon), + user_workspace_table::member_count.eq(new_record.member_count), + user_workspace_table::role.eq(new_record.role), + user_workspace_table::auth_type.eq(new_record.auth_type), + )) + .execute(conn)?; + + Ok(()) +} + +pub fn delete_user_workspace(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { + let n = conn.immediate_transaction(|conn| { + let rows_affected: usize = + diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) + .execute(conn)?; + Ok::(rows_affected) + })?; + + if n != 1 { + warn!("expected to delete 1 row, but deleted {} rows", n); + } + Ok(()) +} + +impl From for UserWorkspace { + fn from(value: UserWorkspaceTable) -> Self { + Self { + id: value.id, + name: value.name, + created_at: Utc + .timestamp_opt(value.created_at, 0) + .single() + .unwrap_or_default(), + workspace_database_id: value.database_storage_id, + icon: value.icon, + member_count: value.member_count, + role: value.role.map(|v| v.into()), + } + } +} + +/// Delete all user workspaces for the given user and auth type. +pub fn delete_user_all_workspace( + uid: i64, + auth_type: AuthType, + conn: &mut SqliteConnection, +) -> FlowyResult<()> { + let n = diesel::delete( + user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(uid)) + .filter(user_workspace_table::auth_type.eq(auth_type as i32)), + ) + .execute(conn)?; + info!( + "Delete {} workspaces for user {} and auth type {:?}", + n, uid, auth_type + ); + Ok(()) +} + +/// Delete all user workspaces for the given user and auth type, then insert the provided user workspaces. +pub fn delete_all_then_insert_user_workspaces( + uid: i64, + mut conn: DBConnection, + auth_type: AuthType, + user_workspaces: &[UserWorkspace], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + delete_user_all_workspace(uid, auth_type, conn)?; + + info!( + "Insert {} workspaces for user {} and auth type {:?}", + user_workspaces.len(), + uid, + auth_type + ); + for user_workspace in user_workspaces { + upsert_user_workspace(uid, auth_type, user_workspace.clone(), conn)?; + } + Ok::<(), FlowyError>(()) + }) +} diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 4d021161ac..65be4cc3f9 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -48,7 +48,6 @@ validator = { workspace = true, features = ["derive"] } rayon = "1.10.0" [dev-dependencies] -nanoid = "0.4.0" fake = "2.0.0" rand = "0.8.4" quickcheck = "1.0.3" diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs index 7fc3b00157..46d5d74e35 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs @@ -1,10 +1,11 @@ use chrono::{TimeZone, Utc}; -use diesel::RunQueryDsl; -use flowy_error::FlowyError; +use diesel::{RunQueryDsl, SqliteConnection}; +use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::DBConnection; use flowy_sqlite::{query_dsl::*, ExpressionMethods}; use flowy_user_pub::entities::{AuthType, UserWorkspace}; +use tracing::{info, trace, warn}; #[derive(Clone, Default, Queryable, Identifiable, Insertable)] #[diesel(table_name = user_workspace_table)] @@ -91,10 +92,9 @@ pub fn upsert_user_workspace( uid: i64, auth_type: AuthType, user_workspace: UserWorkspace, - conn: &mut DBConnection, + conn: &mut SqliteConnection, ) -> Result<(), FlowyError> { let new_record = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; - diesel::insert_into(user_workspace_table::table) .values(new_record.clone()) .on_conflict(user_workspace_table::id) @@ -114,6 +114,20 @@ pub fn upsert_user_workspace( Ok(()) } +pub fn delete_user_workspace(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { + let n = conn.immediate_transaction(|conn| { + let rows_affected: usize = + diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) + .execute(conn)?; + Ok::(rows_affected) + })?; + + if n != 1 { + warn!("expected to delete 1 row, but deleted {} rows", n); + } + Ok(()) +} + impl From for UserWorkspace { fn from(value: UserWorkspaceTable) -> Self { Self { @@ -130,3 +144,45 @@ impl From for UserWorkspace { } } } + +/// Delete all user workspaces for the given user and auth type. +pub fn delete_user_all_workspace( + uid: i64, + auth_type: AuthType, + conn: &mut SqliteConnection, +) -> FlowyResult<()> { + let n = diesel::delete( + user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(uid)) + .filter(user_workspace_table::auth_type.eq(auth_type as i32)), + ) + .execute(conn)?; + info!( + "Delete {} workspaces for user {} and auth type {:?}", + n, uid, auth_type + ); + Ok(()) +} + +/// Delete all user workspaces for the given user and auth type, then insert the provided user workspaces. +pub fn delete_all_then_insert_user_workspaces( + uid: i64, + mut conn: DBConnection, + auth_type: AuthType, + user_workspaces: &[UserWorkspace], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + delete_user_all_workspace(uid, auth_type, conn)?; + + info!( + "Insert {} workspaces for user {} and auth type {:?}", + user_workspaces.len(), + uid, + auth_type + ); + for user_workspace in user_workspaces { + upsert_user_workspace(uid, auth_type, user_workspace.clone(), conn)?; + } + Ok::<(), FlowyError>(()) + }) +} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 14aaebe86c..8d75a2c150 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -41,8 +41,9 @@ use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; use crate::services::sqlite_sql::user_sql::{select_user_profile, UserTable, UserTableChangeset}; -use crate::services::sqlite_sql::workspace_sql::upsert_user_workspace; -use crate::user_manager::manager_user_workspace::save_all_user_workspaces; +use crate::services::sqlite_sql::workspace_sql::{ + delete_all_then_insert_user_workspaces, upsert_user_workspace, +}; use crate::user_manager::user_login_state::UserAuthProcess; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; @@ -758,12 +759,13 @@ impl UserManager { ) -> Result<(), FlowyError> { let user_profile = UserProfile::from((response, &auth_type)); let uid = user_profile.uid; + if auth_type.is_local() { event!(tracing::Level::DEBUG, "Save new anon user: {:?}", uid); self.set_anon_user(session); } - save_all_user_workspaces( + delete_all_then_insert_user_workspaces( uid, self.db_connection(uid)?, auth_type, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index b24764cc6b..1eaab813e4 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -23,21 +23,21 @@ use crate::services::sqlite_sql::workspace_setting_sql::{ WorkspaceSettingsChangeset, WorkspaceSettingsTable, }; use crate::services::sqlite_sql::workspace_sql::{ - select_all_user_workspace, select_user_workspace, update_user_workspace, upsert_user_workspace, - UserWorkspaceChangeset, UserWorkspaceTable, + delete_all_then_insert_user_workspaces, delete_user_workspace, select_all_user_workspace, + select_user_workspace, update_user_workspace, upsert_user_workspace, UserWorkspaceChangeset, + UserWorkspaceTable, }; use crate::user_manager::UserManager; use collab_integrate::CollabKVDB; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::{query_dsl::*, ConnectionPool, DBConnection, ExpressionMethods}; +use flowy_sqlite::{ConnectionPool, DBConnection, ExpressionMethods}; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; use flowy_user_pub::entities::{ AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; use flowy_user_pub::session::Session; -use tracing::{error, info, instrument, trace, warn}; +use tracing::{error, info, instrument, trace}; use uuid::Uuid; impl UserManager { @@ -282,7 +282,7 @@ impl UserManager { // delete workspace from local sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id.to_string().as_str())?; + delete_user_workspace(conn, workspace_id.to_string().as_str())?; self .user_workspace_service @@ -300,7 +300,7 @@ impl UserManager { .await?; let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id.to_string().as_str())?; + delete_user_workspace(conn, workspace_id.to_string().as_str())?; self .user_workspace_service @@ -417,7 +417,8 @@ impl UserManager { tokio::spawn(async move { if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { if let Ok(conn) = pool.get() { - let _ = save_all_user_workspaces(uid, conn, auth_type, &new_user_workspaces); + let _ = + delete_all_then_insert_user_workspaces(uid, conn, auth_type, &new_user_workspaces); let repeated_workspace_pbs = RepeatedUserWorkspacePB::from((auth_type, new_user_workspaces)); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) @@ -696,82 +697,6 @@ impl UserManager { } } -/// This method is used to save the user workspaces (plural) to the SQLite database -/// -/// The workspaces provided in [user_workspaces] will override the existing workspaces in the database. -/// -/// Consider using [upsert_user_workspace] if you only need to save a single workspace. -/// -pub fn save_all_user_workspaces( - uid: i64, - mut conn: DBConnection, - auth_type: AuthType, - user_workspaces: &[UserWorkspace], -) -> FlowyResult<()> { - let user_workspaces = user_workspaces - .iter() - .map(|user_workspace| UserWorkspaceTable::from_workspace(uid, user_workspace, auth_type)) - .collect::, _>>()?; - - conn.immediate_transaction(|conn| { - let existing_ids = user_workspace_table::dsl::user_workspace_table - .select(user_workspace_table::id) - .load::(conn)?; - let new_ids: Vec = user_workspaces.iter().map(|w| w.id.clone()).collect(); - let ids_to_delete: Vec = existing_ids - .into_iter() - .filter(|id| !new_ids.contains(id)) - .collect(); - - // insert or update the user workspaces - for user_workspace in &user_workspaces { - let affected_rows = diesel::update( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(&user_workspace.id)), - ) - .set(( - user_workspace_table::name.eq(&user_workspace.name), - user_workspace_table::created_at.eq(&user_workspace.created_at), - user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), - user_workspace_table::icon.eq(&user_workspace.icon), - user_workspace_table::member_count.eq(&user_workspace.member_count), - user_workspace_table::role.eq(&user_workspace.role), - )) - .execute(conn)?; - - if affected_rows == 0 { - diesel::insert_into(user_workspace_table::table) - .values(user_workspace) - .execute(conn)?; - } - } - - // delete the user workspaces that are not in the new list - if !ids_to_delete.is_empty() { - diesel::delete( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq_any(ids_to_delete)), - ) - .execute(conn)?; - } - - Ok::<(), FlowyError>(()) - }) -} - -pub fn delete_user_workspaces(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { - let n = conn.immediate_transaction(|conn| { - let rows_affected: usize = - diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) - .execute(conn)?; - Ok::(rows_affected) - })?; - if n != 1 { - warn!("expected to delete 1 row, but deleted {} rows", n); - } - Ok(()) -} - fn is_older_than_n_minutes(updated_at: NaiveDateTime, minutes: i64) -> bool { let current_time: NaiveDateTime = Utc::now().naive_utc(); match current_time.checked_sub_signed(Duration::minutes(minutes)) { From 72fc0cce075fdd9183d1bc0376d77849ef8b1239 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 22:18:15 +0800 Subject: [PATCH 194/207] chore: move sql --- frontend/rust-lib/Cargo.lock | 1 - frontend/rust-lib/Cargo.toml | 1 + frontend/rust-lib/flowy-ai/src/ai_manager.rs | 18 ++ .../flowy-core/src/user_state_callback.rs | 34 +++- .../rust-lib/flowy-database2/src/manager.rs | 22 +- .../rust-lib/flowy-document/src/manager.rs | 15 +- frontend/rust-lib/flowy-folder/src/manager.rs | 8 +- .../src/local_server/impls/user.rs | 9 +- frontend/rust-lib/flowy-sqlite/Cargo.toml | 2 +- .../rust-lib/flowy-storage/src/manager.rs | 8 + frontend/rust-lib/flowy-user-pub/Cargo.toml | 3 +- frontend/rust-lib/flowy-user-pub/src/lib.rs | 2 +- .../src/sql}/member_sql.rs | 4 +- .../rust-lib/flowy-user-pub/src/sql/mod.rs | 10 +- .../src/sql}/user_sql.rs | 25 ++- .../src/sql}/workspace_setting_sql.rs | 2 +- .../flowy-user-pub/src/sql/workspace_sql.rs | 5 +- .../flowy-user/src/entities/user_profile.rs | 2 +- .../flowy-user/src/entities/workspace.rs | 2 +- .../rust-lib/flowy-user/src/event_handler.rs | 17 +- .../data_import/appflowy_data_import.rs | 2 +- .../rust-lib/flowy-user/src/services/db.rs | 19 +- .../rust-lib/flowy-user/src/services/mod.rs | 1 - .../flowy-user/src/services/sqlite_sql/mod.rs | 4 - .../src/services/sqlite_sql/workspace_sql.rs | 188 ------------------ .../flowy-user/src/user_manager/manager.rs | 19 +- .../user_manager/manager_user_workspace.rs | 16 +- 27 files changed, 148 insertions(+), 291 deletions(-) rename frontend/rust-lib/{flowy-user/src/services/sqlite_sql => flowy-user-pub/src/sql}/member_sql.rs (95%) rename frontend/rust-lib/{flowy-user/src/services/sqlite_sql => flowy-user-pub/src/sql}/user_sql.rs (81%) rename frontend/rust-lib/{flowy-user/src/services/sqlite_sql => flowy-user-pub/src/sql}/workspace_setting_sql.rs (97%) delete mode 100644 frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs delete mode 100644 frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 5beac0b3a6..e285ae83c8 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -3115,7 +3115,6 @@ dependencies = [ "collab", "collab-entity", "collab-folder", - "diesel", "flowy-error", "flowy-folder-pub", "flowy-sqlite", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index ee99fcacf1..0112c862a8 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -77,6 +77,7 @@ diesel = { version = "2.1.0", features = [ "r2d2", "serde_json", ] } +diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" futures = "0.3.31" diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 7602ff8040..399a8d2d5d 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -128,6 +128,24 @@ impl AIManager { Ok(()) } + #[instrument(skip_all, err)] + pub async fn initialize_after_open_workspace( + &self, + _workspace_id: &str, + ) -> Result<(), FlowyError> { + let local_ai = self.local_ai.clone(); + tokio::spawn(async move { + if let Err(err) = local_ai.destroy_plugin().await { + error!("Failed to destroy plugin: {}", err); + } + + if let Err(err) = local_ai.reload().await { + error!("[AI Manager] failed to reload local AI: {:?}", err); + } + }); + Ok(()) + } + pub async fn open_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { self.chats.entry(*chat_id).or_insert_with(|| { Arc::new(Chat::new( diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index 6002746603..e85b773ec4 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -106,13 +106,16 @@ impl UserStatusCallback for UserStatusCallbackImpl { self .folder_manager - .initialize_with_workspace_id(user_id) + .initialize_after_sign_in(user_id) .await?; self .database_manager - .initialize(user_id, auth_type.is_local()) + .initialize_after_sign_in(user_id, auth_type.is_local()) + .await?; + self + .document_manager + .initialize_after_sign_in(user_id) .await?; - self.document_manager.initialize(user_id).await?; let workspace_id = user_workspace.id.clone(); self.init_ai_component(workspace_id); @@ -171,7 +174,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { self .folder_manager - .initialize_with_new_user( + .initialize_after_sign_up( user_profile.uid, &user_profile.token, is_new_user, @@ -183,13 +186,13 @@ impl UserStatusCallback for UserStatusCallbackImpl { self .database_manager - .initialize_with_new_user(user_profile.uid, auth_type.is_local()) + .initialize_after_sign_up(user_profile.uid, auth_type.is_local()) .await .context("DatabaseManager error")?; self .document_manager - .initialize_with_new_user(user_profile.uid) + .initialize_after_sign_up(user_profile.uid) .await .context("DocumentManager error")?; @@ -212,15 +215,24 @@ impl UserStatusCallback for UserStatusCallbackImpl { self.server_provider.set_auth_type(*auth_type); self .folder_manager - .initialize_with_workspace_id(user_id) + .initialize_after_open_workspace(user_id) .await?; self .database_manager - .initialize(user_id, auth_type.is_local()) + .initialize_after_open_workspace(user_id, auth_type.is_local()) .await?; - self.document_manager.initialize(user_id).await?; - self.ai_manager.initialize(&user_workspace.id).await?; - self.storage_manager.initialize(&user_workspace.id).await; + self + .document_manager + .initialize_after_open_workspace(user_id) + .await?; + self + .ai_manager + .initialize_after_open_workspace(&user_workspace.id) + .await?; + self + .storage_manager + .initialize_after_open_workspace(&user_workspace.id) + .await; Ok(()) } diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 49a946d108..6ae1e5cf15 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -134,12 +134,30 @@ impl DatabaseManager { } #[instrument( - name = "database_initialize_with_new_user", + name = "database_initialize_after_sign_up", level = "debug", skip_all, err )] - pub async fn initialize_with_new_user( + pub async fn initialize_after_sign_up( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; + Ok(()) + } + + pub async fn initialize_after_open_workspace( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; + Ok(()) + } + + pub async fn initialize_after_sign_in( &self, user_id: i64, is_local_user: bool, diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 704dcd0865..9c6a383bae 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -106,12 +106,23 @@ impl DocumentManager { } #[instrument( - name = "document_initialize_with_new_user", + name = "document_initialize_after_sign_up", level = "debug", skip_all, err )] - pub async fn initialize_with_new_user(&self, uid: i64) -> FlowyResult<()> { + pub async fn initialize_after_sign_up(&self, uid: i64) -> FlowyResult<()> { + self.initialize(uid).await?; + Ok(()) + } + + pub async fn initialize_after_open_workspace(&self, uid: i64) -> FlowyResult<()> { + self.initialize(uid).await?; + Ok(()) + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn initialize_after_sign_in(&self, uid: i64) -> FlowyResult<()> { self.initialize(uid).await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index e704be043d..8662c1e061 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -263,7 +263,7 @@ impl FolderManager { /// Initialize the folder with the given workspace id. /// Fetch the folder updates from the cloud service and initialize the folder. #[tracing::instrument(skip(self, user_id), err)] - pub async fn initialize_with_workspace_id(&self, user_id: i64) -> FlowyResult<()> { + pub async fn initialize_after_sign_in(&self, user_id: i64) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; let object_id = &workspace_id; @@ -312,10 +312,14 @@ impl FolderManager { Ok(()) } + pub async fn initialize_after_open_workspace(&self, uid: i64) -> FlowyResult<()> { + self.initialize_after_sign_in(uid).await + } + /// Initialize the folder for the new user. /// Using the [DefaultFolderBuilder] to create the default workspace for the new user. #[instrument(level = "info", skip_all, err)] - pub async fn initialize_with_new_user( + pub async fn initialize_after_sign_up( &self, user_id: i64, _token: &str, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 8189ec140c..d378765ebb 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -15,6 +15,7 @@ use crate::local_server::uid::UserIDGenerator; use flowy_error::FlowyError; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; use flowy_user_pub::entities::*; +use flowy_user_pub::sql::select_all_user_workspace; use flowy_user_pub::DEFAULT_USER_NAME; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; @@ -134,12 +135,8 @@ impl UserCloudService for LocalServerUserServiceImpl { async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError> { let conn = self.user.get_sqlite_db(uid)?; - - select_all_user_workspaces(&conn).await.map_err(|e| { - FlowyError::internal().with_context(format!("Failed to get all workspaces: {}", e)) - })?; - - Ok(vec![]) + let workspaces = select_all_user_workspace(uid, conn)?; + Ok(workspaces) } async fn create_workspace(&self, _workspace_name: &str) -> Result { diff --git a/frontend/rust-lib/flowy-sqlite/Cargo.toml b/frontend/rust-lib/flowy-sqlite/Cargo.toml index 0e85aebee5..345b05f903 100644 --- a/frontend/rust-lib/flowy-sqlite/Cargo.toml +++ b/frontend/rust-lib/flowy-sqlite/Cargo.toml @@ -7,7 +7,7 @@ edition = "2018" [dependencies] diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } +diesel_derives = { workspace = true, features = ["sqlite", "r2d2"] } diesel_migrations = { version = "2.1.0", features = ["sqlite"] } tracing.workspace = true serde.workspace = true diff --git a/frontend/rust-lib/flowy-storage/src/manager.rs b/frontend/rust-lib/flowy-storage/src/manager.rs index dc1bf053ea..619dc47f90 100644 --- a/frontend/rust-lib/flowy-storage/src/manager.rs +++ b/frontend/rust-lib/flowy-storage/src/manager.rs @@ -181,6 +181,14 @@ impl StorageManager { } } + pub async fn initialize_after_open_workspace(&self, workspace_id: &str) { + self.enable_storage_write_access(); + + if let Err(err) = prepare_upload_task(self.uploader.clone(), self.user_service.clone()).await { + error!("prepare {} upload task failed: {}", workspace_id, err); + } + } + pub fn update_network_reachable(&self, reachable: bool) { if reachable { self.uploader.resume(); diff --git a/frontend/rust-lib/flowy-user-pub/Cargo.toml b/frontend/rust-lib/flowy-user-pub/Cargo.toml index cb6b9b0738..f8a673e918 100644 --- a/frontend/rust-lib/flowy-user-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-user-pub/Cargo.toml @@ -22,5 +22,4 @@ collab-folder = { workspace = true } tracing.workspace = true base64 = "0.21" client-api = { workspace = true } -flowy-sqlite.workspace = true -diesel.workspace = true \ No newline at end of file +flowy-sqlite.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-user-pub/src/lib.rs b/frontend/rust-lib/flowy-user-pub/src/lib.rs index d820e8cd34..773ae96a9a 100644 --- a/frontend/rust-lib/flowy-user-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-user-pub/src/lib.rs @@ -1,7 +1,7 @@ pub mod cloud; pub mod entities; pub mod session; -mod sql; +pub mod sql; pub mod workspace_service; pub const DEFAULT_USER_NAME: fn() -> String = || "Me".to_string(); diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/member_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs similarity index 95% rename from frontend/rust-lib/flowy-user/src/services/sqlite_sql/member_sql.rs rename to frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs index 70351ab105..bc73a8f34c 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/member_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs @@ -1,10 +1,8 @@ use diesel::{insert_into, RunQueryDsl}; use flowy_error::FlowyResult; - use flowy_sqlite::schema::workspace_members_table; - use flowy_sqlite::schema::workspace_members_table::dsl; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; +use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods}; #[derive(Queryable, Insertable, AsChangeset, Debug)] #[diesel(table_name = workspace_members_table)] diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs index d5a7aaf317..2a5f7bf891 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs @@ -1 +1,9 @@ -pub mod workspace_sql; +mod member_sql; +mod user_sql; +mod workspace_setting_sql; +mod workspace_sql; + +pub use member_sql::*; +pub use user_sql::*; +pub use workspace_setting_sql::*; +pub use workspace_sql::*; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs similarity index 81% rename from frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs rename to frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs index 3fbd4c5854..5a910888a8 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs @@ -1,12 +1,9 @@ -use diesel::RunQueryDsl; -use flowy_error::FlowyError; - -use flowy_user_pub::cloud::UserUpdate; -use flowy_user_pub::entities::*; - +use crate::cloud::UserUpdate; +use crate::entities::{AuthType, UpdateUserProfileParams, UserProfile}; +use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::schema::user_table; +use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods, RunQueryDsl}; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; /// The order of the fields in the struct must be the same as the order of the fields in the table. /// Check out the [schema.rs] for table schema. #[derive(Clone, Default, Queryable, Identifiable, Insertable)] @@ -109,3 +106,17 @@ pub fn select_user_profile(uid: i64, mut conn: DBConnection) -> Result FlowyResult<()> { + conn.immediate_transaction(|conn| { + // delete old user if exists + diesel::delete(user_table::dsl::user_table.filter(user_table::dsl::id.eq(&user.id))) + .execute(conn)?; + + let _ = diesel::insert_into(user_table::table) + .values(user) + .execute(conn)?; + Ok::<(), FlowyError>(()) + })?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs similarity index 97% rename from frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs rename to frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs index dcd87a36ee..667d1f0ca0 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_setting_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs @@ -3,7 +3,7 @@ use flowy_error::FlowyError; use flowy_sqlite::schema::workspace_setting_table; use flowy_sqlite::schema::workspace_setting_table::dsl; use flowy_sqlite::DBConnection; -use flowy_sqlite::{query_dsl::*, ExpressionMethods}; +use flowy_sqlite::{prelude::*, ExpressionMethods}; use uuid::Uuid; #[derive(Clone, Default, Queryable, Identifiable, Insertable)] diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs index ecd242c240..bcac009527 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -1,11 +1,10 @@ use crate::entities::{AuthType, UserWorkspace}; use chrono::{TimeZone, Utc}; -use diesel::{RunQueryDsl, SqliteConnection}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::DBConnection; -use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use tracing::{info, trace, warn}; +use flowy_sqlite::{prelude::*, ExpressionMethods, RunQueryDsl, SqliteConnection}; +use tracing::{info, warn}; #[derive(Clone, Default, Queryable, Identifiable, Insertable)] #[diesel(table_name = user_workspace_table)] diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index f7a78a8f7d..17b951bae0 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -2,9 +2,9 @@ use super::AFRolePB; use crate::entities::parser::{UserEmail, UserIcon, UserName}; use crate::entities::{AuthTypePB, AuthenticatorPB}; use crate::errors::ErrorCode; -use crate::services::sqlite_sql::workspace_sql::UserWorkspaceTable; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; +use flowy_user_pub::sql::UserWorkspaceTable; use lib_infra::validator_fn::required_not_empty_str; use std::convert::TryInto; use validator::Validate; diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index d277fb4614..e178b724db 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -5,10 +5,10 @@ use client_api::entity::billing_dto::{ use serde::{Deserialize, Serialize}; use validator::Validate; -use crate::services::sqlite_sql::workspace_setting_sql::WorkspaceSettingsTable; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use flowy_user_pub::entities::{AuthType, Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::sql::WorkspaceSettingsTable; use lib_infra::validator_fn::required_not_empty_str; #[derive(ProtoBuf, Default, Clone)] diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 4cd49649ce..12dd1f3e93 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -1,6 +1,14 @@ +use crate::entities::*; +use crate::notification::{send_notification, UserNotification}; +use crate::services::cloud_config::{ + get_cloud_config, get_or_create_cloud_config, save_cloud_config, +}; +use crate::services::data_import::prepare_import; +use crate::user_manager::UserManager; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::entities::*; +use flowy_user_pub::sql::UserWorkspaceChangeset; use lib_dispatch::prelude::*; use lib_infra::box_any::BoxAny; use serde_json::Value; @@ -10,15 +18,6 @@ use std::{convert::TryInto, sync::Arc}; use tracing::{event, trace}; use uuid::Uuid; -use crate::entities::*; -use crate::notification::{send_notification, UserNotification}; -use crate::services::cloud_config::{ - get_cloud_config, get_or_create_cloud_config, save_cloud_config, -}; -use crate::services::data_import::prepare_import; -use crate::services::sqlite_sql::workspace_sql::UserWorkspaceChangeset; -use crate::user_manager::UserManager; - fn upgrade_manager(manager: AFPluginState>) -> FlowyResult> { let manager = manager .upgrade() diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index 106e911c1e..2db5f418de 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -3,7 +3,6 @@ use crate::migrations::session_migration::migrate_session_with_user_uuid; use crate::services::data_import::importer::load_collab_by_object_ids; use crate::services::db::UserDBPath; use crate::services::entities::UserPaths; -use crate::services::sqlite_sql::user_sql::select_user_profile; use crate::user_manager::run_collab_data_migration; use anyhow::anyhow; use collab::core::collab::DataSource; @@ -37,6 +36,7 @@ use std::collections::{HashMap, HashSet}; use collab_document::blocks::TextDelta; use collab_document::document::Document; +use flowy_user_pub::sql::select_user_profile; use semver::Version; use serde_json::json; use std::ops::{Deref, DerefMut}; diff --git a/frontend/rust-lib/flowy-user/src/services/db.rs b/frontend/rust-lib/flowy-user/src/services/db.rs index f05c0bda95..138ad95819 100644 --- a/frontend/rust-lib/flowy-user/src/services/db.rs +++ b/frontend/rust-lib/flowy-user/src/services/db.rs @@ -8,17 +8,12 @@ use dashmap::mapref::entry::Entry; use dashmap::DashMap; use flowy_error::FlowyError; use flowy_sqlite::ConnectionPool; -use flowy_sqlite::{ - query_dsl::*, - schema::{user_table, user_table::dsl}, - DBConnection, Database, ExpressionMethods, -}; +use flowy_sqlite::{DBConnection, Database}; use flowy_user_pub::entities::UserProfile; +use flowy_user_pub::sql::select_user_profile; use lib_infra::file_util::{unzip_and_replace, zip_folder}; use tracing::{error, event, info, instrument}; -use crate::services::sqlite_sql::user_sql::UserTable; - pub trait UserDBPath: Send + Sync + 'static { fn sqlite_db_path(&self, uid: i64) -> PathBuf; fn collab_db_path(&self, uid: i64) -> PathBuf; @@ -131,13 +126,9 @@ impl UserDB { pool: &Arc, uid: i64, ) -> Result { - let uid = uid.to_string(); - let mut conn = pool.get()?; - let user = dsl::user_table - .filter(user_table::id.eq(&uid)) - .first::(&mut *conn)?; - - Ok(user.into()) + let conn = pool.get()?; + let profile = select_user_profile(uid, conn)?; + Ok(profile) } /// Open a collab db for the user. If the db is already opened, return the opened db. diff --git a/frontend/rust-lib/flowy-user/src/services/mod.rs b/frontend/rust-lib/flowy-user/src/services/mod.rs index 66316fa01a..ab4b3bea37 100644 --- a/frontend/rust-lib/flowy-user/src/services/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/mod.rs @@ -5,4 +5,3 @@ pub mod collab_interact; pub mod data_import; pub mod db; pub mod entities; -pub mod sqlite_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs deleted file mode 100644 index 635c79ba39..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod member_sql; -pub(crate) mod user_sql; -pub(crate) mod workspace_setting_sql; -pub(crate) mod workspace_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs deleted file mode 100644 index 46d5d74e35..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ /dev/null @@ -1,188 +0,0 @@ -use chrono::{TimeZone, Utc}; -use diesel::{RunQueryDsl, SqliteConnection}; -use flowy_error::{FlowyError, FlowyResult}; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::DBConnection; -use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use flowy_user_pub::entities::{AuthType, UserWorkspace}; -use tracing::{info, trace, warn}; - -#[derive(Clone, Default, Queryable, Identifiable, Insertable)] -#[diesel(table_name = user_workspace_table)] -pub struct UserWorkspaceTable { - pub id: String, - pub name: String, - pub uid: i64, - pub created_at: i64, - pub database_storage_id: String, - pub icon: String, - pub member_count: i64, - pub role: Option, - pub auth_type: i32, -} - -#[derive(AsChangeset, Identifiable, Default, Debug)] -#[diesel(table_name = user_workspace_table)] -pub struct UserWorkspaceChangeset { - pub id: String, - pub name: Option, - pub icon: Option, -} - -impl UserWorkspaceTable { - pub fn from_workspace( - uid: i64, - workspace: &UserWorkspace, - auth_type: AuthType, - ) -> Result { - if workspace.id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The id is empty")); - } - if workspace.workspace_database_id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); - } - - Ok(Self { - id: workspace.id.clone(), - name: workspace.name.clone(), - uid, - created_at: workspace.created_at.timestamp(), - database_storage_id: workspace.workspace_database_id.clone(), - icon: workspace.icon.clone(), - member_count: workspace.member_count, - role: workspace.role.clone().map(|v| v as i32), - auth_type: auth_type as i32, - }) - } -} - -pub fn select_user_workspace( - workspace_id: &str, - mut conn: DBConnection, -) -> Option { - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(workspace_id)) - .first::(&mut *conn) - .ok() -} - -pub fn select_all_user_workspace( - user_id: i64, - mut conn: DBConnection, -) -> Result, FlowyError> { - let rows = user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(user_id)) - .load::(&mut *conn)?; - Ok(rows.into_iter().map(UserWorkspace::from).collect()) -} - -pub fn update_user_workspace( - mut conn: DBConnection, - changeset: UserWorkspaceChangeset, -) -> Result<(), FlowyError> { - diesel::update(user_workspace_table::dsl::user_workspace_table) - .filter(user_workspace_table::id.eq(changeset.id.clone())) - .set(changeset) - .execute(&mut conn)?; - - Ok(()) -} - -pub fn upsert_user_workspace( - uid: i64, - auth_type: AuthType, - user_workspace: UserWorkspace, - conn: &mut SqliteConnection, -) -> Result<(), FlowyError> { - let new_record = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; - diesel::insert_into(user_workspace_table::table) - .values(new_record.clone()) - .on_conflict(user_workspace_table::id) - .do_update() - .set(( - user_workspace_table::name.eq(new_record.name), - user_workspace_table::uid.eq(new_record.uid), - user_workspace_table::created_at.eq(new_record.created_at), - user_workspace_table::database_storage_id.eq(new_record.database_storage_id), - user_workspace_table::icon.eq(new_record.icon), - user_workspace_table::member_count.eq(new_record.member_count), - user_workspace_table::role.eq(new_record.role), - user_workspace_table::auth_type.eq(new_record.auth_type), - )) - .execute(conn)?; - - Ok(()) -} - -pub fn delete_user_workspace(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { - let n = conn.immediate_transaction(|conn| { - let rows_affected: usize = - diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) - .execute(conn)?; - Ok::(rows_affected) - })?; - - if n != 1 { - warn!("expected to delete 1 row, but deleted {} rows", n); - } - Ok(()) -} - -impl From for UserWorkspace { - fn from(value: UserWorkspaceTable) -> Self { - Self { - id: value.id, - name: value.name, - created_at: Utc - .timestamp_opt(value.created_at, 0) - .single() - .unwrap_or_default(), - workspace_database_id: value.database_storage_id, - icon: value.icon, - member_count: value.member_count, - role: value.role.map(|v| v.into()), - } - } -} - -/// Delete all user workspaces for the given user and auth type. -pub fn delete_user_all_workspace( - uid: i64, - auth_type: AuthType, - conn: &mut SqliteConnection, -) -> FlowyResult<()> { - let n = diesel::delete( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(uid)) - .filter(user_workspace_table::auth_type.eq(auth_type as i32)), - ) - .execute(conn)?; - info!( - "Delete {} workspaces for user {} and auth type {:?}", - n, uid, auth_type - ); - Ok(()) -} - -/// Delete all user workspaces for the given user and auth type, then insert the provided user workspaces. -pub fn delete_all_then_insert_user_workspaces( - uid: i64, - mut conn: DBConnection, - auth_type: AuthType, - user_workspaces: &[UserWorkspace], -) -> FlowyResult<()> { - conn.immediate_transaction(|conn| { - delete_user_all_workspace(uid, auth_type, conn)?; - - info!( - "Insert {} workspaces for user {} and auth type {:?}", - user_workspaces.len(), - uid, - auth_type - ); - for user_workspace in user_workspaces { - upsert_user_workspace(uid, auth_type, user_workspace.clone(), conn)?; - } - Ok::<(), FlowyError>(()) - }) -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 8d75a2c150..86a3dfabce 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -40,13 +40,10 @@ use crate::services::cloud_config::get_cloud_config; use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; -use crate::services::sqlite_sql::user_sql::{select_user_profile, UserTable, UserTableChangeset}; -use crate::services::sqlite_sql::workspace_sql::{ - delete_all_then_insert_user_workspaces, upsert_user_workspace, -}; use crate::user_manager::user_login_state::UserAuthProcess; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; +use flowy_user_pub::sql::*; pub struct UserManager { pub(crate) cloud_service: Arc, @@ -662,18 +659,8 @@ impl UserManager { } async fn save_user(&self, uid: i64, user: UserTable) -> Result<(), FlowyError> { - let mut conn = self.db_connection(uid)?; - conn.immediate_transaction(|conn| { - // delete old user if exists - diesel::delete(user_table::dsl::user_table.filter(user_table::dsl::id.eq(&user.id))) - .execute(conn)?; - - let _ = diesel::insert_into(user_table::table) - .values(user) - .execute(conn)?; - Ok::<(), FlowyError>(()) - })?; - + let conn = self.db_connection(uid)?; + upsert_user(user, conn)?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 1eaab813e4..cc884dc1d9 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -15,28 +15,18 @@ use crate::services::billing_check::PeriodicallyCheckBillingState; use crate::services::data_import::{ generate_import_data, upload_collab_objects_data, ImportedFolder, ImportedSource, }; -use crate::services::sqlite_sql::member_sql::{ - select_workspace_member, upsert_workspace_member, WorkspaceMemberTable, -}; -use crate::services::sqlite_sql::workspace_setting_sql::{ - select_workspace_setting, update_workspace_setting, upsert_workspace_setting, - WorkspaceSettingsChangeset, WorkspaceSettingsTable, -}; -use crate::services::sqlite_sql::workspace_sql::{ - delete_all_then_insert_user_workspaces, delete_user_workspace, select_all_user_workspace, - select_user_workspace, update_user_workspace, upsert_user_workspace, UserWorkspaceChangeset, - UserWorkspaceTable, -}; + use crate::user_manager::UserManager; use collab_integrate::CollabKVDB; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; -use flowy_sqlite::{ConnectionPool, DBConnection, ExpressionMethods}; +use flowy_sqlite::ConnectionPool; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; use flowy_user_pub::entities::{ AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; use flowy_user_pub::session::Session; +use flowy_user_pub::sql::*; use tracing::{error, info, instrument, trace}; use uuid::Uuid; From d478ecfd416fb8d0c5414eb92188beab71c4c3b2 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 19 Apr 2025 23:33:34 +0800 Subject: [PATCH 195/207] chore: remove unused code --- .../workspace_menu_bottom_sheet.dart | 1 + .../lib/startup/tasks/generate_router.dart | 12 - .../lib/user/application/user_service.dart | 20 +- .../helpers/handle_user_profile_result.dart | 21 -- .../user/presentation/helpers/helpers.dart | 1 - .../lib/user/presentation/router.dart | 4 - .../user/presentation/screens/screens.dart | 1 - .../presentation/screens/sign_up_screen.dart | 220 ------------------ .../application/user/user_workspace_bloc.dart | 13 +- .../application/workspace/workspace_bloc.dart | 4 +- .../workspace/_sidebar_workspace_menu.dart | 7 +- .../af_cloud/impls/user/cloud_service_impl.rs | 9 +- .../src/local_server/impls/user.rs | 34 +-- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 6 +- .../rust-lib/flowy-user-pub/src/entities.rs | 34 --- .../flowy-user-pub/src/sql/workspace_sql.rs | 8 +- .../rust-lib/flowy-user/src/entities/auth.rs | 44 ---- .../rust-lib/flowy-user/src/event_handler.rs | 4 +- .../flowy-user/src/user_manager/manager.rs | 2 +- .../user_manager/manager_user_workspace.rs | 33 +-- 20 files changed, 69 insertions(+), 409 deletions(-) delete mode 100644 frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart delete mode 100644 frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart index ef7f4492a5..eff2b4f420 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart @@ -123,6 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget { context.read().add( UserWorkspaceEvent.createWorkspace( name, + AuthTypePB.CloudAuthType, ), ); }, diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 7f9a2df329..e64e0f98de 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -119,18 +119,6 @@ GoRouter generateRouter(Widget child) { ); }, ), - GoRoute( - path: SignUpScreen.routeName, - pageBuilder: (context, state) { - return CustomTransitionPage( - child: SignUpScreen( - router: getIt(), - ), - transitionsBuilder: _buildFadeTransition, - transitionDuration: _slowDuration, - ); - }, - ), ], ); } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 5e163b4e62..3ec181e009 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -140,25 +140,13 @@ class UserBackendService implements IUserBackendService { }); } - Future> createWorkspace( - String name, - String desc, - ) { - final request = CreateWorkspacePayloadPB.create() - ..name = name - ..desc = desc; - return FolderEventCreateFolderWorkspace(request).send().then((result) { - return result.fold( - (workspace) => FlowyResult.success(workspace), - (error) => FlowyResult.failure(error), - ); - }); - } - Future> createUserWorkspace( String name, + AuthTypePB authType, ) { - final request = CreateWorkspacePB.create()..name = name; + final request = CreateWorkspacePB.create() + ..name = name + ..authType = authType; return UserEventCreateWorkspace(request).send(); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart deleted file mode 100644 index 83007786f1..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:appflowy/user/presentation/helpers/helpers.dart'; -import 'package:appflowy/user/presentation/presentation.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter/material.dart'; - -void handleUserProfileResult( - FlowyResult userProfileResult, - BuildContext context, - AuthRouter authRouter, -) { - userProfileResult.fold( - (userProfile) { - authRouter.goHomeScreen(context, userProfile); - }, - (error) { - handleOpenWorkspaceError(context, error); - }, - ); -} diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart index 084a360666..11f321232e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart @@ -1,2 +1 @@ export 'handle_open_workspace_error.dart'; -export 'handle_user_profile_result.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/router.dart b/frontend/appflowy_flutter/lib/user/presentation/router.dart index f6f6ec3e3a..339c2f29f7 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/router.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/router.dart @@ -21,10 +21,6 @@ class AuthRouter { getIt().pushWorkspaceStartScreen(context, userProfile); } - void pushSignUpScreen(BuildContext context) { - context.push(SignUpScreen.routeName); - } - /// Navigates to the home screen based on the current workspace and platform. /// /// This function takes in a [BuildContext] and a [UserProfilePB] object to diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart index 540da8c2b4..2aeba87995 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart @@ -1,6 +1,5 @@ export 'sign_in_screen/sign_in_screen.dart'; export 'skip_log_in_screen.dart'; export 'splash_screen.dart'; -export 'sign_up_screen.dart'; export 'workspace_error_screen.dart'; export 'workspace_start_screen/workspace_start_screen.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart deleted file mode 100644 index 8aea8dde55..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/sign_up_bloc.dart'; -import 'package:appflowy/user/presentation/router.dart'; -import 'package:appflowy/user/presentation/widgets/widgets.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' - show UserProfilePB; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SignUpScreen extends StatelessWidget { - const SignUpScreen({ - super.key, - required this.router, - }); - - static const routeName = '/SignUpScreen'; - final AuthRouter router; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(), - child: BlocListener( - listener: (context, state) { - final successOrFail = state.successOrFail; - if (successOrFail != null) { - _handleSuccessOrFail(context, successOrFail); - } - }, - child: const Scaffold(body: SignUpForm()), - ), - ); - } - - void _handleSuccessOrFail( - BuildContext context, - FlowyResult result, - ) { - result.fold( - (user) => router.pushWorkspaceStartScreen(context, user), - (error) => showSnapBar(context, error.msg), - ); - } -} - -class SignUpForm extends StatelessWidget { - const SignUpForm({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Align( - child: AuthFormContainer( - children: [ - FlowyLogoTitle( - title: LocaleKeys.signUp_title.tr(), - logoSize: const Size(60, 60), - ), - const VSpace(30), - const EmailTextField(), - const VSpace(5), - const PasswordTextField(), - const VSpace(5), - const RepeatPasswordTextField(), - const VSpace(30), - const SignUpButton(), - const VSpace(10), - const SignUpPrompt(), - if (context.read().state.isSubmitting) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(), - ], - ], - ), - ); - } -} - -class SignUpPrompt extends StatelessWidget { - const SignUpPrompt({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowyText.medium( - LocaleKeys.signUp_alreadyHaveAnAccount.tr(), - color: Theme.of(context).hintColor, - ), - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context).textTheme.bodyMedium, - ), - onPressed: () => Navigator.pop(context), - child: FlowyText.medium( - LocaleKeys.signIn_buttonText.tr(), - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ); - } -} - -class SignUpButton extends StatelessWidget { - const SignUpButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return RoundedTextButton( - title: LocaleKeys.signUp_getStartedText.tr(), - height: 48, - onPressed: () { - context - .read() - .add(const SignUpEvent.signUpWithUserEmailAndPassword()); - }, - ); - } -} - -class PasswordTextField extends StatelessWidget { - const PasswordTextField({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - previous.passwordError != current.passwordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - obscureIcon: const FlowySvg(FlowySvgs.hide_m), - obscureHideIcon: const FlowySvg(FlowySvgs.show_m), - hintText: LocaleKeys.signUp_passwordHint.tr(), - normalBorderColor: Theme.of(context).colorScheme.outline, - errorBorderColor: Theme.of(context).colorScheme.error, - cursorColor: Theme.of(context).colorScheme.primary, - errorText: context.read().state.passwordError ?? '', - onChanged: (value) => context - .read() - .add(SignUpEvent.passwordChanged(value)), - ); - }, - ); - } -} - -class RepeatPasswordTextField extends StatelessWidget { - const RepeatPasswordTextField({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - previous.repeatPasswordError != current.repeatPasswordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - obscureIcon: const FlowySvg(FlowySvgs.hide_m), - obscureHideIcon: const FlowySvg(FlowySvgs.show_m), - hintText: LocaleKeys.signUp_repeatPasswordHint.tr(), - normalBorderColor: Theme.of(context).colorScheme.outline, - errorBorderColor: Theme.of(context).colorScheme.error, - cursorColor: Theme.of(context).colorScheme.primary, - errorText: context.read().state.repeatPasswordError ?? '', - onChanged: (value) => context - .read() - .add(SignUpEvent.repeatPasswordChanged(value)), - ); - }, - ); - } -} - -class EmailTextField extends StatelessWidget { - const EmailTextField({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - previous.emailError != current.emailError, - builder: (context, state) { - return RoundedInputField( - hintText: LocaleKeys.signUp_emailHint.tr(), - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - normalBorderColor: Theme.of(context).colorScheme.outline, - errorBorderColor: Theme.of(context).colorScheme.error, - cursorColor: Theme.of(context).colorScheme.primary, - errorText: context.read().state.emailError ?? '', - onChanged: (value) => - context.read().add(SignUpEvent.emailChanged(value)), - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 7e341f4f1f..44e9fa3f92 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -97,7 +97,7 @@ class UserWorkspaceBloc extends Bloc { ); } }, - createWorkspace: (name) async { + createWorkspace: (name, authType) async { emit( state.copyWith( actionResult: const UserWorkspaceActionResult( @@ -107,7 +107,10 @@ class UserWorkspaceBloc extends Bloc { ), ), ); - final result = await _userService.createUserWorkspace(name); + final result = await _userService.createUserWorkspace( + name, + authType, + ); final workspaces = result.fold( (s) => [...state.workspaces, s], (e) => state.workspaces, @@ -472,8 +475,10 @@ class UserWorkspaceBloc extends Bloc { class UserWorkspaceEvent with _$UserWorkspaceEvent { const factory UserWorkspaceEvent.initial() = Initial; const factory UserWorkspaceEvent.fetchWorkspaces() = FetchWorkspaces; - const factory UserWorkspaceEvent.createWorkspace(String name) = - CreateWorkspace; + const factory UserWorkspaceEvent.createWorkspace( + String name, + AuthTypePB authType, + ) = CreateWorkspace; const factory UserWorkspaceEvent.deleteWorkspace(String workspaceId) = DeleteWorkspace; const factory UserWorkspaceEvent.openWorkspace( diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart index d8d5db45b4..0db1ac9443 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -2,6 +2,7 @@ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -64,7 +65,8 @@ class WorkspaceBloc extends Bloc { String desc, Emitter emit, ) async { - final result = await userService.createWorkspace(name, desc); + final result = + await userService.createUserWorkspace(name, AuthTypePB.CloudAuthType); emit( result.fold( (workspace) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index daf332cc15..24a122fee5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -386,7 +386,12 @@ class _CreateWorkspaceButton extends StatelessWidget { final workspaceBloc = context.read(); await CreateWorkspaceDialog( onConfirm: (name) { - workspaceBloc.add(UserWorkspaceEvent.createWorkspace(name)); + workspaceBloc.add( + UserWorkspaceEvent.createWorkspace( + name, + AuthTypePB.CloudAuthType, + ), + ); }, ).show(context); } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 8309e4c65f..e0f81a62e4 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -24,8 +24,8 @@ use tracing::{instrument, trace}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, UserUpdateReceiver}; use flowy_user_pub::entities::{ - AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserProfile, UserWorkspace, + WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; @@ -178,10 +178,7 @@ where } #[instrument(level = "debug", skip_all)] - async fn get_user_profile( - &self, - _credential: UserCredentials, - ) -> Result { + async fn get_user_profile(&self, _uid: i64) -> Result { let try_get_client = self.server.try_get_client(); let expected_workspace_id = self .logged_user diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index d378765ebb..eec50b1480 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -15,7 +15,7 @@ use crate::local_server::uid::UserIDGenerator; use flowy_error::FlowyError; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; use flowy_user_pub::entities::*; -use flowy_user_pub::sql::select_all_user_workspace; +use flowy_user_pub::sql::{select_all_user_workspace, select_user_profile, select_user_workspace}; use flowy_user_pub::DEFAULT_USER_NAME; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; @@ -59,7 +59,7 @@ impl UserCloudService for LocalServerUserServiceImpl { async fn sign_in(&self, params: BoxAny) -> Result { let params: SignInParams = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); - let user_workspace = make_user_workspace(); + let user_workspace = make_user_workspace("My Workspace"); Ok(AuthResponse { user_id: uid, user_uuid: Uuid::new_v4(), @@ -122,15 +122,18 @@ impl UserCloudService for LocalServerUserServiceImpl { Ok(()) } - async fn get_user_profile(&self, credential: UserCredentials) -> Result { - Err(FlowyError::local_version_not_support().with_context("Not support")) + async fn get_user_profile(&self, uid: i64) -> Result { + let conn = self.user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, conn)?; + Ok(profile) } async fn open_workspace(&self, workspace_id: &Uuid) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support open workspace"), - ) + let uid = self.user.user_id()?; + let conn = self.user.get_sqlite_db(uid)?; + + let workspace = select_user_workspace(&workspace_id.to_string(), conn)?; + Ok(UserWorkspace::from(workspace)) } async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError> { @@ -139,11 +142,8 @@ impl UserCloudService for LocalServerUserServiceImpl { Ok(workspaces) } - async fn create_workspace(&self, _workspace_name: &str) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) + async fn create_workspace(&self, workspace_name: &str) -> Result { + Ok(make_user_workspace(workspace_name)) } async fn patch_workspace( @@ -193,12 +193,12 @@ impl UserCloudService for LocalServerUserServiceImpl { } } -fn make_user_workspace() -> UserWorkspace { +fn make_user_workspace(name: &str) -> UserWorkspace { UserWorkspace { - id: uuid::Uuid::new_v4().to_string(), - name: "My Workspace".to_string(), + id: Uuid::new_v4().to_string(), + name: name.to_string(), created_at: Default::default(), - workspace_database_id: uuid::Uuid::new_v4().to_string(), + workspace_database_id: Uuid::new_v4().to_string(), icon: "".to_string(), member_count: 1, role: None, diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index 00eaef8917..0964d80472 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -20,8 +20,8 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::entities::{ - AuthResponse, AuthType, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + AuthResponse, AuthType, Role, UpdateUserProfileParams, UserProfile, UserTokenState, + UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -168,7 +168,7 @@ pub trait UserCloudService: Send + Sync + 'static { /// Get the user information using the user's token or uid /// return None if the user is not found - async fn get_user_profile(&self, credential: UserCredentials) -> Result; + async fn get_user_profile(&self, uid: i64) -> Result; async fn open_workspace(&self, workspace_id: &Uuid) -> Result; diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 6be5a9f64d..0d91f84b0e 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -100,40 +100,6 @@ impl UserAuthResponse for AuthResponse { } } -#[derive(Clone, Debug)] -pub struct UserCredentials { - /// Currently, the token is only used when the [AuthType] is AppFlowyCloud - pub token: Option, - - /// The user id - pub uid: Option, - - /// The user id - pub uuid: Option, -} - -impl UserCredentials { - pub fn from_uid(uid: i64) -> Self { - Self { - token: None, - uid: Some(uid), - uuid: None, - } - } - - pub fn from_uuid(uuid: String) -> Self { - Self { - token: None, - uid: None, - uuid: Some(uuid), - } - } - - pub fn new(token: Option, uid: Option, uuid: Option) -> Self { - Self { token, uid, uuid } - } -} - #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserWorkspace { pub id: String, diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs index bcac009527..a1165c9621 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -58,11 +58,11 @@ impl UserWorkspaceTable { pub fn select_user_workspace( workspace_id: &str, mut conn: DBConnection, -) -> Option { - user_workspace_table::dsl::user_workspace_table +) -> FlowyResult { + let row = user_workspace_table::dsl::user_workspace_table .filter(user_workspace_table::id.eq(workspace_id)) - .first::(&mut *conn) - .ok() + .first::(&mut *conn)?; + Ok(row) } pub fn select_all_user_workspace( diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index 6a889359c1..ddc0dc29f9 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -259,50 +259,6 @@ impl Default for AuthenticatorPB { } } -#[derive(Debug, ProtoBuf, Default)] -pub struct UserCredentialsPB { - #[pb(index = 1, one_of)] - pub uid: Option, - - #[pb(index = 2, one_of)] - pub uuid: Option, - - #[pb(index = 3, one_of)] - pub token: Option, -} - -impl UserCredentialsPB { - pub fn from_uid(uid: i64) -> Self { - Self { - uid: Some(uid), - uuid: None, - token: None, - } - } - - pub fn from_token(token: &str) -> Self { - Self { - uid: None, - uuid: None, - token: Some(token.to_owned()), - } - } - - pub fn from_uuid(uuid: &str) -> Self { - Self { - uid: None, - uuid: Some(uuid.to_owned()), - token: None, - } - } -} - -impl From for UserCredentials { - fn from(value: UserCredentialsPB) -> Self { - Self::new(value.token, value.uid, value.uuid) - } -} - #[derive(Default, ProtoBuf)] pub struct UserStatePB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 12dd1f3e93..bb38a9e0a0 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -469,9 +469,7 @@ pub async fn get_user_workspace_handler( let params = data.try_into_inner()?; let workspace_id = Uuid::from_str(¶ms.workspace_id)?; let uid = manager.user_id()?; - let user_workspace = manager - .get_user_workspace_from_db(uid, &workspace_id) - .ok_or_else(FlowyError::record_not_found)?; + let user_workspace = manager.get_user_workspace_from_db(uid, &workspace_id)?; data_result_ok(UserWorkspacePB::from(user_workspace)) } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 86a3dfabce..6cfa8bf16f 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -585,7 +585,7 @@ impl UserManager { let result: Result = self .cloud_service .get_user_service()? - .get_user_profile(UserCredentials::from_uid(uid)) + .get_user_profile(uid) .await; match result { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index cc884dc1d9..17565e83df 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -157,17 +157,21 @@ impl UserManager { let uid = self.user_id()?; let conn = self.db_connection(self.user_id()?)?; let user_workspace = match select_user_workspace(&workspace_id.to_string(), conn) { - None => { - sync_workspace( - workspace_id, - self.cloud_service.get_user_service()?, - uid, - auth_type, - self.db_pool(uid)?, - ) - .await? + Err(err) => { + if err.is_record_not_found() { + sync_workspace( + workspace_id, + self.cloud_service.get_user_service()?, + uid, + auth_type, + self.db_pool(uid)?, + ) + .await? + } else { + return Err(err); + } }, - Some(row) => { + Ok(row) => { let user_workspace = UserWorkspace::from(row); let workspace_id = *workspace_id; let user_service = self.cloud_service.get_user_service()?; @@ -248,10 +252,7 @@ impl UserManager { let conn = self.db_connection(uid)?; update_user_workspace(conn, changeset)?; - let row = self - .get_user_workspace_from_db(uid, workspace_id) - .ok_or_else(FlowyError::record_not_found)?; - + let row = self.get_user_workspace_from_db(uid, workspace_id)?; let payload = UserWorkspacePB::from(row); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspace) .payload(payload) @@ -389,8 +390,8 @@ impl UserManager { &self, uid: i64, workspace_id: &Uuid, - ) -> Option { - let conn = self.db_connection(uid).ok()?; + ) -> FlowyResult { + let conn = self.db_connection(uid)?; select_user_workspace(workspace_id.to_string().as_str(), conn) } From 2f5b494885904aac89cd173d0eb0978c7a0bd9b7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 00:28:15 +0800 Subject: [PATCH 196/207] chore: remove authticator pb --- .../bottom_sheet/bottom_sheet_view_page.dart | 3 +- .../home/setting/settings_popup_menu.dart | 2 +- .../home/tab/mobile_space_tab.dart | 2 +- .../workspace_menu_bottom_sheet.dart | 2 +- .../setting/user_session_setting_group.dart | 2 +- .../lib/plugins/ai_chat/chat_page.dart | 2 +- .../application/sync/database_sync_bloc.dart | 2 +- .../database/widgets/row/row_banner.dart | 5 +- .../document/application/document_bloc.dart | 4 +- .../document_collaborators_bloc.dart | 3 +- .../application/document_sync_bloc.dart | 3 +- .../editor_plugins/file/file_util.dart | 6 +-- .../page_style/_page_style_cover_image.dart | 3 +- .../lib/plugins/shared/share/share_bloc.dart | 2 +- .../icon_emoji_picker/icon_uploader.dart | 6 +-- .../lib/startup/deps_resolver.dart | 2 +- .../startup/tasks/appflowy_cloud_task.dart | 2 +- .../auth/af_cloud_auth_service.dart | 2 +- .../auth/af_cloud_mock_auth_service.dart | 6 +-- .../auth/backend_auth_service.dart | 7 +-- .../application/password/password_bloc.dart | 4 +- .../lib/user/presentation/anon_user.dart | 2 +- .../settings/settings_dialog_bloc.dart | 4 +- .../application/user/user_workspace_bloc.dart | 2 +- .../application/workspace/workspace_bloc.dart | 2 +- .../workspace/_sidebar_workspace_menu.dart | 2 +- .../menu/view/view_more_action_button.dart | 2 +- .../settings/pages/settings_account_view.dart | 28 +++++------ .../pages/settings_workspace_view.dart | 4 +- .../settings/settings_dialog.dart | 2 +- .../settings/widgets/settings_menu.dart | 4 +- .../more_view_actions/more_view_actions.dart | 2 +- .../event-integration-test/src/lib.rs | 4 +- .../event-integration-test/src/user_event.rs | 10 ++-- .../user/af_cloud_test/anon_user_test.rs | 4 +- .../tests/user/local_test/auth_test.rs | 8 ++-- .../user/local_test/user_profile_test.rs | 4 +- .../src/local_server/impls/user.rs | 24 ++++------ .../rust-lib/flowy-user-pub/src/entities.rs | 6 +-- .../flowy-user-pub/src/sql/workspace_sql.rs | 20 ++++---- .../rust-lib/flowy-user/src/entities/auth.rs | 47 ++++--------------- .../flowy-user/src/entities/user_profile.rs | 4 +- .../flowy-user/src/entities/workspace.rs | 25 ++++++---- .../user_manager/manager_user_workspace.rs | 22 ++++++--- 44 files changed, 134 insertions(+), 168 deletions(-) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 85a5e3cbfa..9706777df0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -202,8 +202,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { List _buildPublishActions(BuildContext context) { final userProfile = context.read().state.userProfilePB; // the publish feature is only available for AppFlowy Cloud - if (userProfile == null || - userProfile.authType != AuthenticatorPB.AppFlowyCloud) { + if (userProfile == null || userProfile.authType != AuthTypePB.Server) { return []; } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart index 11a82d2c7a..bd41730934 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -48,7 +48,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget { text: LocaleKeys.settings_popupMenuItem_settings.tr(), ), // only show the member items in cloud mode - if (userProfile.authType == AuthenticatorPB.AppFlowyCloud) ...[ + if (userProfile.authType == AuthTypePB.Server) ...[ const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.members, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart index cc3f4c8c56..c89367f379 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart @@ -167,7 +167,7 @@ class _MobileSpaceTabState extends State children: [ MobileHomeSpace(userProfile: widget.userProfile), // only show ai chat button for cloud user - if (widget.userProfile.authType == AuthenticatorPB.AppFlowyCloud) + if (widget.userProfile.authType == AuthTypePB.Server) Positioned( bottom: MediaQuery.of(context).padding.bottom + 16, left: 20, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart index eff2b4f420..d306f48964 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart @@ -123,7 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget { context.read().add( UserWorkspaceEvent.createWorkspace( name, - AuthTypePB.CloudAuthType, + AuthTypePB.Server, ), ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index 09f38223f4..405fef0d1a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -40,7 +40,7 @@ class UserSessionSettingGroup extends StatelessWidget { // delete account button // only show the delete account button in cloud mode - if (userProfile.authType == AuthenticatorPB.AppFlowyCloud) ...[ + if (userProfile.authType == AuthTypePB.Server) ...[ const VSpace(16.0), MobileLogoutButton( text: LocaleKeys.button_deleteAccount.tr(), 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 c154b0e3a0..90085354db 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -48,7 +48,7 @@ class AIChatPage extends StatelessWidget { @override Widget build(BuildContext context) { - // if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + // if (userProfile.authenticator != AuthTypePB.Server) { // return Center( // child: FlowyText( // LocaleKeys.chat_unsupportedCloudPrompt.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart index 53dc2bd6d5..5116785c1f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart @@ -31,7 +31,7 @@ class DatabaseSyncBloc extends Bloc { emit( state.copyWith( shouldShowIndicator: - userProfile?.authType == AuthenticatorPB.AppFlowyCloud && + userProfile?.authType == AuthTypePB.Server && databaseId != null, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart index 5706167f1a..e2f470e0d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -21,8 +21,8 @@ import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -69,8 +69,7 @@ class RowBanner extends StatefulWidget { class _RowBannerState extends State { final _isHovering = ValueNotifier(false); late final isLocalMode = - (widget.userProfile?.authType ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + (widget.userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; @override void dispose() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index 252742aa5e..ac03fe5308 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -101,8 +101,8 @@ class DocumentBloc extends Bloc { bool get isLocalMode { final userProfilePB = state.userProfilePB; - final type = userProfilePB?.authType ?? AuthenticatorPB.Local; - return type == AuthenticatorPB.Local; + final type = userProfilePB?.authType ?? AuthTypePB.Local; + return type == AuthTypePB.Local; } @override diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart index b593ccc6cc..682f600f0a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart @@ -31,8 +31,7 @@ class DocumentCollaboratorsBloc final userProfile = result.fold((s) => s, (f) => null); emit( state.copyWith( - shouldShowIndicator: - userProfile?.authType == AuthenticatorPB.AppFlowyCloud, + shouldShowIndicator: userProfile?.authType == AuthTypePB.Server, ), ); final deviceId = ApplicationInfo.deviceId; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart index 1001aaef5e..2ba50fc6c4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart @@ -30,8 +30,7 @@ class DocumentSyncBloc extends Bloc { ); emit( state.copyWith( - shouldShowIndicator: - userProfile?.authType == AuthenticatorPB.AppFlowyCloud, + shouldShowIndicator: userProfile?.authType == AuthTypePB.Server, ), ); _syncStateListener.start( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index d3bbbd27d5..69791f78b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -14,8 +14,8 @@ import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; @@ -185,7 +185,7 @@ Future insertLocalFile( // Check upload type final isLocalMode = - (userProfile?.authType ?? AuthenticatorPB.Local) == AuthenticatorPB.Local; + (userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; String? path; String? errorMsg; @@ -230,7 +230,7 @@ Future insertLocalFiles( // Check upload type final isLocalMode = - (userProfile?.authType ?? AuthenticatorPB.Local) == AuthenticatorPB.Local; + (userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; for (final file in files) { final fileType = file.fileType.toMediaFileTypePB(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart index 601ba8fa20..6136392884 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart @@ -225,8 +225,7 @@ class PageStyleCoverImage extends StatelessWidget { (s) => s, (f) => null, ); - final isAppFlowyCloud = - userProfile?.authType == AuthenticatorPB.AppFlowyCloud; + final isAppFlowyCloud = userProfile?.authType == AuthTypePB.Server; final PageStyleCoverImageType type; if (!isAppFlowyCloud) { result = await saveImageToLocalStorage(path); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart index c3f04a8837..2356399b4a 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -193,7 +193,7 @@ class ShareBloc extends Bloc { Future _updatePublishStatus(Emitter emit) async { final publishInfo = await ViewBackendService.getPublishInfo(view); final enablePublish = await UserBackendService.getCurrentUserProfile().fold( - (v) => v.authType == AuthenticatorPB.AppFlowyCloud, + (v) => v.authType == AuthTypePB.Server, (p) => false, ); diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart index f39fdb01dc..ff8e7b88ec 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart @@ -13,7 +13,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -294,8 +294,8 @@ class _IconUploaderState extends State { (userProfile) => userProfile, (l) => null, ); - final isLocalMode = (userProfile?.authType ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + final isLocalMode = + (userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; if (isLocalMode) { result = await pickedImages.first.saveToLocal(); } else { diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 621ba988cf..5a8c0fa651 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -102,7 +102,7 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { case AuthenticatorType.local: getIt.registerFactory( () => BackendAuthService( - AuthenticatorPB.Local, + AuthTypePB.Local, ), ); break; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 184cc9dd09..362b27a85a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -112,7 +112,7 @@ class AppFlowyCloudDeepLink { (_) async { final deviceId = await getDeviceId(); final payload = OauthSignInPB( - authenticator: AuthenticatorPB.AppFlowyCloud, + authenticator: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: uri.toString(), AuthServiceMapKeys.deviceId: deviceId, diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index 6d02f188c8..4f4cece9bb 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -18,7 +18,7 @@ class AppFlowyCloudAuthService implements AuthService { AppFlowyCloudAuthService(); final BackendAuthService _backendAuthService = BackendAuthService( - AuthenticatorPB.AppFlowyCloud, + AuthTypePB.Server, ); @override diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart index d8cee89b59..8be71dc648 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart @@ -20,7 +20,7 @@ class AppFlowyCloudMockAuthService implements AuthService { final String userEmail; final BackendAuthService _appFlowyAuthService = - BackendAuthService(AuthenticatorPB.AppFlowyCloud); + BackendAuthService(AuthTypePB.Server); @override Future> signUp({ @@ -48,7 +48,7 @@ class AppFlowyCloudMockAuthService implements AuthService { Map params = const {}, }) async { final payload = SignInUrlPayloadPB.create() - ..authenticator = AuthenticatorPB.AppFlowyCloud + ..authenticator = AuthTypePB.Server // don't use nanoid here, the gotrue server will transform the email ..email = userEmail; @@ -58,7 +58,7 @@ class AppFlowyCloudMockAuthService implements AuthService { return getSignInURLResult.fold( (urlPB) async { final payload = OauthSignInPB( - authenticator: AuthenticatorPB.AppFlowyCloud, + authenticator: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: urlPB.signInUrl, AuthServiceMapKeys.deviceId: deviceId, diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart index f47fd5a4a6..cab8cd170c 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -6,6 +6,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -15,7 +16,7 @@ import 'device_id.dart'; class BackendAuthService implements AuthService { BackendAuthService(this.authType); - final AuthenticatorPB authType; + final AuthTypePB authType; @override Future> @@ -71,7 +72,7 @@ class BackendAuthService implements AuthService { ..email = userEmail ..password = password // When sign up as guest, the auth type is always local. - ..authType = AuthenticatorPB.Local + ..authType = AuthTypePB.Local ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( (value) => value, @@ -82,7 +83,7 @@ class BackendAuthService implements AuthService { @override Future> signUpWithOAuth({ required String platform, - AuthenticatorPB authType = AuthenticatorPB.Local, + AuthTypePB authType = AuthTypePB.Local, Map params = const {}, }) async { return FlowyResult.failure( diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart index d421440d08..b85efe38ae 100644 --- a/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart @@ -4,8 +4,8 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/user/application/password/password_http_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -46,7 +46,7 @@ class PasswordBloc extends Bloc { bool _isInitialized = false; Future _init() async { - if (userProfile.authType == AuthenticatorPB.Local) { + if (userProfile.authType == AuthTypePB.Local) { Log.debug('PasswordBloc: skip init because user is local authenticator'); return; } diff --git a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart index 014c7caaf8..c8744fb304 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart @@ -74,7 +74,7 @@ class AnonUserItem extends StatelessWidget { @override Widget build(BuildContext context) { final icon = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; - final isDisabled = isSelected || user.authType != AuthenticatorPB.Local; + final isDisabled = isSelected || user.authType != AuthTypePB.Local; final desc = "${user.name}\t ${user.authType}\t"; final child = SizedBox( height: 30, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 66277cf30b..ce659d5262 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -2,8 +2,6 @@ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; @@ -91,7 +89,7 @@ class SettingsDialogBloc AFRolePB? currentWorkspaceMemberRole, ]) async { if ([ - AuthenticatorPB.Local, + AuthTypePB.Local, ].contains(userProfile.authType)) { return false; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 44e9fa3f92..5ed56b890e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -44,7 +44,7 @@ class UserWorkspaceBloc extends Bloc { final currentWorkspace = result.$1; final workspaces = result.$2; final isCollabWorkspaceOn = - userProfile.authType == AuthenticatorPB.AppFlowyCloud && + userProfile.authType == AuthTypePB.Server && FeatureFlag.collaborativeWorkspace.isOn; Log.info( 'init workspace, current workspace: ${currentWorkspace?.workspaceId}, ' diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart index 0db1ac9443..ed06f16c8f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -66,7 +66,7 @@ class WorkspaceBloc extends Bloc { Emitter emit, ) async { final result = - await userService.createUserWorkspace(name, AuthTypePB.CloudAuthType); + await userService.createUserWorkspace(name, AuthTypePB.Server); emit( result.fold( (workspace) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 24a122fee5..c9f464d43a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -389,7 +389,7 @@ class _CreateWorkspaceButton extends StatelessWidget { workspaceBloc.add( UserWorkspaceEvent.createWorkspace( name, - AuthTypePB.CloudAuthType, + AuthTypePB.Local, ), ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart index 576ff07cb4..7ccd03b4f4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -211,7 +211,7 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { ) { final userProfile = context.read().userProfile; // move to feature doesn't support in local mode - if (userProfile.authType != AuthenticatorPB.AppFlowyCloud) { + if (userProfile.authType != AuthTypePB.Server) { return const SizedBox.shrink(); } return BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index f1ecfda8ad..e242da473b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -7,8 +7,8 @@ import 'package:appflowy/workspace/presentation/settings/pages/account/account.d import 'package:appflowy/workspace/presentation/settings/pages/account/email/email_section.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -70,7 +70,7 @@ class _SettingsAccountViewState extends State { // user email // Only show email if the user is authenticated and not using local auth if (isAuthEnabled && - state.userProfile.authType != AuthenticatorPB.Local) ...[ + state.userProfile.authType != AuthTypePB.Local) ...[ SettingsCategory( title: LocaleKeys.newSettings_myAccount_myAccount.tr(), children: [ @@ -82,30 +82,26 @@ class _SettingsAccountViewState extends State { ), AccountSignInOutSection( userProfile: state.userProfile, - onAction: - state.userProfile.authType == AuthenticatorPB.Local - ? widget.didLogin - : widget.didLogout, - signIn: - state.userProfile.authType == AuthenticatorPB.Local, + onAction: state.userProfile.authType == AuthTypePB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.authType == AuthTypePB.Local, ), ], ), ], if (isAuthEnabled && - state.userProfile.authType == AuthenticatorPB.Local) ...[ + state.userProfile.authType == AuthTypePB.Local) ...[ SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ AccountSignInOutSection( userProfile: state.userProfile, - onAction: - state.userProfile.authType == AuthenticatorPB.Local - ? widget.didLogin - : widget.didLogout, - signIn: - state.userProfile.authType == AuthenticatorPB.Local, + onAction: state.userProfile.authType == AuthTypePB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.authType == AuthTypePB.Local, ), ], ), @@ -120,7 +116,7 @@ class _SettingsAccountViewState extends State { ), // user deletion - if (widget.userProfile.authType == AuthenticatorPB.AppFlowyCloud) + if (widget.userProfile.authType == AuthTypePB.Server) const AccountDeletionButton(), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart index a602849527..9a17016e5f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -88,7 +88,7 @@ class SettingsWorkspaceView extends StatelessWidget { autoSeparate: false, children: [ // We don't allow changing workspace name/icon for local/offline - if (userProfile.authType != AuthenticatorPB.Local) ...[ + if (userProfile.authType != AuthTypePB.Local) ...[ SettingsCategory( title: LocaleKeys.settings_workspacePage_workspaceName_title .tr(), @@ -180,7 +180,7 @@ class SettingsWorkspaceView extends StatelessWidget { ), const SettingsCategorySpacer(), - if (userProfile.authType != AuthenticatorPB.Local) ...[ + if (userProfile.authType != AuthTypePB.Local) ...[ SingleSettingAction( label: LocaleKeys.settings_workspacePage_manageWorkspace_title .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index b17a9beb7e..e262a27cb6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -140,7 +140,7 @@ class SettingsDialog extends StatelessWidget { case SettingsPage.shortcuts: return const SettingsShortcutsView(); case SettingsPage.ai: - if (user.authType == AuthenticatorPB.AppFlowyCloud) { + if (user.authType == AuthTypePB.Server) { return SettingsAIView( key: ValueKey(workspaceId), userProfile: user, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index 1a7144993f..04a93656ca 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -63,7 +63,7 @@ class SettingsMenu extends StatelessWidget { changeSelectedPage: changeSelectedPage, ), if (FeatureFlag.membersSettings.isOn && - userProfile.authType == AuthenticatorPB.AppFlowyCloud) + userProfile.authType == AuthTypePB.Server) SettingsMenuElement( page: SettingsPage.member, selectedPage: currentPage, @@ -109,7 +109,7 @@ class SettingsMenu extends StatelessWidget { ), changeSelectedPage: changeSelectedPage, ), - if (userProfile.authType == AuthenticatorPB.AppFlowyCloud) + if (userProfile.authType == AuthTypePB.Server) SettingsMenuElement( page: SettingsPage.sites, selectedPage: currentPage, 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 3b81d2a041..fe202e7590 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 @@ -96,7 +96,7 @@ class _MoreViewActionsState extends State { return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty && - userProfile.authType == AuthenticatorPB.AppFlowyCloud) { + userProfile.authType == AuthTypePB.Server) { return const SizedBox.shrink(); } diff --git a/frontend/rust-lib/event-integration-test/src/lib.rs b/frontend/rust-lib/event-integration-test/src/lib.rs index 1b54087ee1..ff0a3847df 100644 --- a/frontend/rust-lib/event-integration-test/src/lib.rs +++ b/frontend/rust-lib/event-integration-test/src/lib.rs @@ -8,7 +8,7 @@ use collab_entity::CollabType; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::AppFlowyCore; use flowy_notification::register_notification_sender; -use flowy_user::entities::AuthenticatorPB; +use flowy_user::entities::AuthTypePB; use flowy_user::errors::FlowyError; use lib_dispatch::runtime::AFPluginRuntime; use nanoid::nanoid; @@ -59,7 +59,7 @@ impl EventIntegrationTest { let clean_path = config.storage_path.clone(); let inner = init_core(config).await; let notification_sender = TestNotificationSender::new(); - let authenticator = Arc::new(AtomicU8::new(AuthenticatorPB::Local as u8)); + let authenticator = Arc::new(AtomicU8::new(AuthTypePB::Local as u8)); register_notification_sender(notification_sender.clone()); // In case of dropping the runtime that runs the core, we need to forget the dispatcher diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index 5c9be65660..c4e34f27f7 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -17,7 +17,7 @@ use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_UR use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthTypePB, AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, + AuthTypePB, AuthTypePB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, @@ -65,7 +65,7 @@ impl EventIntegrationTest { email, name: "appflowy".to_string(), password: password.clone(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: uuid::Uuid::new_v4().to_string(), } .into_bytes() @@ -113,7 +113,7 @@ impl EventIntegrationTest { .await; } - pub fn set_auth_type(&self, auth_type: AuthenticatorPB) { + pub fn set_auth_type(&self, auth_type: AuthTypePB) { self.authenticator.store(auth_type as u8, Ordering::Release); } @@ -140,7 +140,7 @@ impl EventIntegrationTest { pub async fn af_cloud_sign_in_with_email(&self, email: &str) -> FlowyResult { let payload = SignInUrlPayloadPB { email: email.to_string(), - authenticator: AuthenticatorPB::AppFlowyCloud, + authenticator: AuthTypePB::AppFlowyCloud, }; let sign_in_url = EventBuilder::new(self.clone()) .event(UserEvent::GenerateSignInURL) @@ -155,7 +155,7 @@ impl EventIntegrationTest { map.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); let payload = OauthSignInPB { map, - authenticator: AuthenticatorPB::AppFlowyCloud, + authenticator: AuthTypePB::AppFlowyCloud, }; let user_profile = EventBuilder::new(self.clone()) diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs index f74b202860..1abdde509f 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs @@ -1,7 +1,7 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_user::entities::AuthenticatorPB; +use flowy_user::entities::AuthTypePB; use crate::util::unzip; @@ -72,7 +72,7 @@ async fn migrate_anon_user_data_to_af_cloud_test() { let user = test.af_cloud_sign_up().await; let workspace = test.get_current_workspace().await; println!("user workspace: {:?}", workspace.id); - assert_eq!(user.auth_type, AuthenticatorPB::AppFlowyCloud); + assert_eq!(user.auth_type, AuthTypePB::AppFlowyCloud); let user_first_level_views = test.get_all_workspace_views().await; assert_eq!(user_first_level_views.len(), 3); diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs index 3c73fd01eb..138f6f0258 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs @@ -1,6 +1,6 @@ use event_integration_test::user_event::{login_password, unique_email}; use event_integration_test::{event_builder::EventBuilder, EventIntegrationTest}; -use flowy_user::entities::{AuthenticatorPB, SignInPayloadPB, SignUpPayloadPB}; +use flowy_user::entities::{AuthTypePB, SignInPayloadPB, SignUpPayloadPB}; use flowy_user::errors::ErrorCode; use flowy_user::event_map::UserEvent::*; @@ -14,7 +14,7 @@ async fn sign_up_with_invalid_email() { email: email.to_string(), name: valid_name(), password: login_password(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; @@ -40,7 +40,7 @@ async fn sign_in_with_invalid_email() { email: email.to_string(), password: login_password(), name: "".to_string(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; @@ -67,7 +67,7 @@ async fn sign_in_with_invalid_password() { email: unique_email(), password, name: "".to_string(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs index 46f8eca9c6..47c2d53a6b 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs @@ -1,6 +1,6 @@ use crate::user::local_test::helper::*; use event_integration_test::{event_builder::EventBuilder, EventIntegrationTest}; -use flowy_user::entities::{AuthenticatorPB, UpdateUserProfilePayloadPB, UserProfilePB}; +use flowy_user::entities::{AuthTypePB, UpdateUserProfilePayloadPB, UserProfilePB}; use flowy_user::{errors::ErrorCode, event_map::UserEvent::*}; use nanoid::nanoid; #[tokio::test] @@ -24,7 +24,7 @@ async fn anon_user_profile_get() { .await .parse::(); assert_eq!(user_profile.id, user.id); - assert_eq!(user_profile.auth_type, AuthenticatorPB::Local); + assert_eq!(user_profile.auth_type, AuthTypePB::Local); } #[tokio::test] diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index eec50b1480..14d39690f9 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -35,7 +35,7 @@ impl UserCloudService for LocalServerUserServiceImpl { let params = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); let workspace_id = Uuid::new_v4().to_string(); - let user_workspace = UserWorkspace::new_local(&workspace_id, uid); + let user_workspace = UserWorkspace::new_local(workspace_id, ""); let user_name = if params.name.is_empty() { DEFAULT_USER_NAME() } else { @@ -59,7 +59,9 @@ impl UserCloudService for LocalServerUserServiceImpl { async fn sign_in(&self, params: BoxAny) -> Result { let params: SignInParams = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); - let user_workspace = make_user_workspace("My Workspace"); + + let workspace_id = Uuid::new_v4(); + let user_workspace = UserWorkspace::new_local(workspace_id.to_string(), "My Workspace"); Ok(AuthResponse { user_id: uid, user_uuid: Uuid::new_v4(), @@ -143,7 +145,11 @@ impl UserCloudService for LocalServerUserServiceImpl { } async fn create_workspace(&self, workspace_name: &str) -> Result { - Ok(make_user_workspace(workspace_name)) + let workspace_id = Uuid::new_v4(); + Ok(UserWorkspace::new_local( + workspace_id.to_string(), + workspace_name, + )) } async fn patch_workspace( @@ -192,15 +198,3 @@ impl UserCloudService for LocalServerUserServiceImpl { Ok(()) } } - -fn make_user_workspace(name: &str) -> UserWorkspace { - UserWorkspace { - id: Uuid::new_v4().to_string(), - name: name.to_string(), - created_at: Default::default(), - workspace_database_id: Uuid::new_v4().to_string(), - icon: "".to_string(), - member_count: 1, - role: None, - } -} diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 0d91f84b0e..efceb8b5f6 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -122,10 +122,10 @@ impl UserWorkspace { Ok(id) } - pub fn new_local(workspace_id: &str, _uid: i64) -> Self { + pub fn new_local(workspace_id: String, name: &str) -> Self { Self { - id: workspace_id.to_string(), - name: "".to_string(), + id: workspace_id, + name: name.to_string(), created_at: Utc::now(), workspace_database_id: Uuid::new_v4().to_string(), icon: "".to_string(), diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs index a1165c9621..0570020716 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -93,20 +93,20 @@ pub fn upsert_user_workspace( user_workspace: UserWorkspace, conn: &mut SqliteConnection, ) -> Result<(), FlowyError> { - let new_record = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; + let row = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; diesel::insert_into(user_workspace_table::table) - .values(new_record.clone()) + .values(row.clone()) .on_conflict(user_workspace_table::id) .do_update() .set(( - user_workspace_table::name.eq(new_record.name), - user_workspace_table::uid.eq(new_record.uid), - user_workspace_table::created_at.eq(new_record.created_at), - user_workspace_table::database_storage_id.eq(new_record.database_storage_id), - user_workspace_table::icon.eq(new_record.icon), - user_workspace_table::member_count.eq(new_record.member_count), - user_workspace_table::role.eq(new_record.role), - user_workspace_table::auth_type.eq(new_record.auth_type), + user_workspace_table::name.eq(row.name), + user_workspace_table::uid.eq(row.uid), + user_workspace_table::created_at.eq(row.created_at), + user_workspace_table::database_storage_id.eq(row.database_storage_id), + user_workspace_table::icon.eq(row.icon), + user_workspace_table::member_count.eq(row.member_count), + user_workspace_table::role.eq(row.role), + user_workspace_table::auth_type.eq(row.auth_type), )) .execute(conn)?; diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index ddc0dc29f9..a61ba5cc96 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; use std::convert::TryInto; +use crate::entities::parser::*; +use crate::entities::AuthTypePB; +use crate::errors::ErrorCode; use client_api::entity::GotrueTokenResponse; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; -use crate::entities::parser::*; -use crate::errors::ErrorCode; - #[derive(ProtoBuf, Default)] pub struct SignInPayloadPB { #[pb(index = 1)] @@ -20,7 +20,7 @@ pub struct SignInPayloadPB { pub name: String, #[pb(index = 4)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, #[pb(index = 5)] pub device_id: String, @@ -53,7 +53,7 @@ pub struct SignUpPayloadPB { pub password: String, #[pb(index = 4)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, #[pb(index = 5)] pub device_id: String, @@ -144,7 +144,7 @@ pub struct OauthSignInPB { pub map: HashMap, #[pb(index = 2)] - pub authenticator: AuthenticatorPB, + pub authenticator: AuthTypePB, } #[derive(ProtoBuf, Default)] @@ -153,7 +153,7 @@ pub struct SignInUrlPayloadPB { pub email: String, #[pb(index = 2)] - pub authenticator: AuthenticatorPB, + pub authenticator: AuthTypePB, } #[derive(ProtoBuf, Default)] @@ -228,41 +228,10 @@ pub struct OauthProviderDataPB { pub oauth_url: String, } -#[repr(u8)] -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum AuthenticatorPB { - Local = 0, - AppFlowyCloud = 2, -} - -impl From for AuthenticatorPB { - fn from(auth_type: AuthType) -> Self { - match auth_type { - AuthType::Local => AuthenticatorPB::Local, - AuthType::AppFlowyCloud => AuthenticatorPB::AppFlowyCloud, - } - } -} - -impl From for AuthType { - fn from(pb: AuthenticatorPB) -> Self { - match pb { - AuthenticatorPB::Local => AuthType::Local, - AuthenticatorPB::AppFlowyCloud => AuthType::AppFlowyCloud, - } - } -} - -impl Default for AuthenticatorPB { - fn default() -> Self { - Self::Local - } -} - #[derive(Default, ProtoBuf)] pub struct UserStatePB { #[pb(index = 1)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, } #[derive(ProtoBuf, Debug, Default, Clone)] diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 17b951bae0..5afc93850a 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -1,6 +1,6 @@ use super::AFRolePB; use crate::entities::parser::{UserEmail, UserIcon, UserName}; -use crate::entities::{AuthTypePB, AuthenticatorPB}; +use crate::entities::AuthTypePB; use crate::errors::ErrorCode; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; @@ -39,7 +39,7 @@ pub struct UserProfilePB { pub icon_url: String, #[pb(index = 6)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, } #[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index e178b724db..ed8eb8aa76 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -242,18 +242,23 @@ pub struct CreateWorkspacePB { pub auth_type: AuthTypePB, } -#[derive(ProtoBuf_Enum, Default, Debug, Clone)] +#[derive(ProtoBuf_Enum, Default, Debug, Clone, Eq, PartialEq)] +#[repr(u8)] pub enum AuthTypePB { - LocalAuthType = 0, #[default] - CloudAuthType = 1, + Local = 0, + Server = 1, } + impl From for AuthTypePB { fn from(value: i32) -> Self { match value { - 0 => AuthTypePB::LocalAuthType, - 1 => AuthTypePB::CloudAuthType, - _ => AuthTypePB::CloudAuthType, + 0 => AuthTypePB::Local, + 1 => AuthTypePB::Server, + // For historical reasons, 2 also maps to Server. + // Check the AuthenticatorType in flowy-server-pub + 2 => AuthTypePB::Server, + _ => AuthTypePB::Server, } } } @@ -261,8 +266,8 @@ impl From for AuthTypePB { impl From for AuthTypePB { fn from(value: AuthType) -> Self { match value { - AuthType::Local => AuthTypePB::LocalAuthType, - AuthType::AppFlowyCloud => AuthTypePB::CloudAuthType, + AuthType::Local => AuthTypePB::Local, + AuthType::AppFlowyCloud => AuthTypePB::Server, } } } @@ -270,8 +275,8 @@ impl From for AuthTypePB { impl From for AuthType { fn from(value: AuthTypePB) -> Self { match value { - AuthTypePB::LocalAuthType => AuthType::Local, - AuthTypePB::CloudAuthType => AuthType::AppFlowyCloud, + AuthTypePB::Local => AuthType::Local, + AuthTypePB::Server => AuthType::AppFlowyCloud, } } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 17565e83df..6c786d9435 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -218,15 +218,23 @@ impl UserManager { workspace_name: &str, auth_type: AuthType, ) -> FlowyResult { - let new_workspace = self - .cloud_service - .get_user_service()? - .create_workspace(workspace_name) - .await?; + let new_workspace = match auth_type { + AuthType::Local => { + let workspace_id = Uuid::new_v4(); + UserWorkspace::new_local(workspace_id.to_string(), workspace_name) + }, + AuthType::AppFlowyCloud => { + self + .cloud_service + .get_user_service()? + .create_workspace(workspace_name) + .await? + }, + }; info!( - "new workspace: {}, name:{}", - new_workspace.id, new_workspace.name + "create workspace: {}, name:{}, auth_type: {}", + new_workspace.id, new_workspace.name, auth_type ); // save the workspace to sqlite db From 2c5f41b58062771135fa4dd77c6b3e8777c7c2db Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 10:53:21 +0800 Subject: [PATCH 197/207] fix: set auth type --- .../settings/settings_dialog_bloc.dart | 1 + .../workspace/_sidebar_workspace_menu.dart | 2 +- .../user/af_cloud_test/anon_user_test.rs | 2 +- .../rust-lib/flowy-core/src/server_layer.rs | 10 ++--- .../flowy-core/src/user_state_callback.rs | 6 --- .../src/local_server/impls/folder.rs | 2 +- .../src/local_server/impls/user.rs | 3 +- .../flowy-user-pub/src/sql/workspace_sql.rs | 1 - .../flowy-user/src/entities/workspace.rs | 3 -- .../rust-lib/flowy-user/src/event_handler.rs | 14 +----- .../flowy-user/src/user_manager/manager.rs | 45 ++++++------------- .../user_manager/manager_user_workspace.rs | 2 + 12 files changed, 29 insertions(+), 62 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index ce659d5262..726e95bb9e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -2,6 +2,7 @@ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index c9f464d43a..097da2c2ae 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -389,7 +389,7 @@ class _CreateWorkspaceButton extends StatelessWidget { workspaceBloc.add( UserWorkspaceEvent.createWorkspace( name, - AuthTypePB.Local, + AuthTypePB.Server, ), ); }, diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs index 1abdde509f..7f743b931c 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs @@ -72,7 +72,7 @@ async fn migrate_anon_user_data_to_af_cloud_test() { let user = test.af_cloud_sign_up().await; let workspace = test.get_current_workspace().await; println!("user workspace: {:?}", workspace.id); - assert_eq!(user.auth_type, AuthTypePB::AppFlowyCloud); + assert_eq!(user.auth_type, AuthTypePB::Server); let user_first_level_views = test.get_all_workspace_views().await; assert_eq!(user_first_level_views.len(), 3); diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 4b1834c41c..b666ab4749 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -82,12 +82,12 @@ impl ServerProvider { pub fn set_auth_type(&self, new_auth_type: AuthType) { let old_type = self.get_auth_type(); - info!( - "ServerProvider: set auth type from {:?} to {:?}", - old_type, new_auth_type - ); - if old_type != new_auth_type { + info!( + "ServerProvider: auth type from {:?} to {:?}", + old_type, new_auth_type + ); + self.auth_type.store(Arc::new(new_auth_type)); if let Some((auth_type, _)) = self.providers.remove(&old_type) { info!("ServerProvider: remove old auth type: {:?}", auth_type); diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index e85b773ec4..f3eaa00543 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -55,7 +55,6 @@ impl UserStatusCallback for UserStatusCallbackImpl { auth_type: &AuthType, ) -> FlowyResult<()> { let workspace_id = user_workspace.workspace_id()?; - self.server_provider.set_auth_type(*auth_type); if let Some(cloud_config) = cloud_config { self @@ -102,8 +101,6 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace, device_id ); - self.server_provider.set_auth_type(*auth_type); - self .folder_manager .initialize_after_sign_in(user_id) @@ -130,8 +127,6 @@ impl UserStatusCallback for UserStatusCallbackImpl { device_id: &str, auth_type: &AuthType, ) -> FlowyResult<()> { - self.server_provider.set_auth_type(*auth_type); - event!( tracing::Level::TRACE, "Notify did sign up: is new: {} user_workspace: {:?}, device_id: {}", @@ -212,7 +207,6 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace: &UserWorkspace, auth_type: &AuthType, ) -> FlowyResult<()> { - self.server_provider.set_auth_type(*auth_type); self .folder_manager .initialize_after_open_workspace(user_id) diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 31a65e7fa9..72bab514ec 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -84,7 +84,7 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { workspace_id: &Uuid, view_ids: Vec, ) -> Result<(), FlowyError> { - Err(FlowyError::local_version_not_support()) + Ok(()) } async fn get_publish_info(&self, view_id: &Uuid) -> Result { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 14d39690f9..e73943bbbe 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -48,7 +48,8 @@ impl UserCloudService for LocalServerUserServiceImpl { latest_workspace: user_workspace.clone(), user_workspaces: vec![user_workspace], is_new_user: true, - email: Some(params.email), + // Anon user doesn't have email + email: None, token: None, encryption_type: EncryptionType::NoEncryption, updated_at: timestamp(), diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs index 0570020716..cb6d299039 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -172,7 +172,6 @@ pub fn delete_all_then_insert_user_workspaces( ) -> FlowyResult<()> { conn.immediate_transaction(|conn| { delete_user_all_workspace(uid, auth_type, conn)?; - info!( "Insert {} workspaces for user {} and auth type {:?}", user_workspaces.len(), diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index ed8eb8aa76..99544eede4 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -255,9 +255,6 @@ impl From for AuthTypePB { match value { 0 => AuthTypePB::Local, 1 => AuthTypePB::Server, - // For historical reasons, 2 also maps to Server. - // Check the AuthenticatorType in flowy-server-pub - 2 => AuthTypePB::Server, _ => AuthTypePB::Server, } } diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index bb38a9e0a0..905eb595f1 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -44,18 +44,12 @@ pub async fn sign_in_with_email_password_handler( let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let old_authenticator = manager.cloud_service.get_server_auth_type(); match manager .sign_in_with_password(¶ms.email, ¶ms.password) .await { Ok(token) => data_result_ok(token.into()), - Err(err) => { - manager - .cloud_service - .set_server_auth_type(&old_authenticator); - return Err(err); - }, + Err(err) => Err(err), } } @@ -77,13 +71,9 @@ pub async fn sign_up( let params: SignUpParams = data.into_inner().try_into()?; let auth_type = params.auth_type; - let prev_auth_type = manager.cloud_service.get_server_auth_type(); match manager.sign_up(auth_type, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), - Err(err) => { - manager.cloud_service.set_server_auth_type(&prev_auth_type); - Err(err) - }, + Err(err) => Err(err), } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 6cfa8bf16f..9520489771 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -132,18 +132,19 @@ impl UserManager { if let Ok(session) = self.get_session() { let user = self.get_user_profile_from_disk(session.user_id).await?; + self.cloud_service.set_server_auth_type(&user.auth_type); // Get the current authenticator from the environment variable - let current_authenticator = current_authenticator(); + let env_auth_type = current_authenticator(); // If the current authenticator is different from the authenticator in the session and it's // not a local authenticator, we need to sign out the user. - if user.auth_type != AuthType::Local && user.auth_type != current_authenticator { + if user.auth_type != AuthType::Local && user.auth_type != env_auth_type { event!( tracing::Level::INFO, - "Authenticator changed from {:?} to {:?}", + "Auth type changed from {:?} to {:?}", user.auth_type, - current_authenticator + env_auth_type ); self.sign_out().await?; return Ok(()); @@ -151,7 +152,7 @@ impl UserManager { event!( tracing::Level::INFO, - "init user session: {}:{}, authenticator: {:?}", + "init user session: {}:{}, auth type: {:?}", user.uid, user.email, user.auth_type, @@ -389,9 +390,10 @@ impl UserManager { auth_type: AuthType, params: BoxAny, ) -> Result { + self.cloud_service.set_server_auth_type(&auth_type); + // sign out the current user if there is one let migration_user = self.get_migration_user(&auth_type).await; - self.cloud_service.set_server_auth_type(&auth_type); let auth_service = self.cloud_service.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; let new_user_profile = UserProfile::from((&response, &auth_type)); @@ -401,28 +403,6 @@ impl UserManager { Ok(new_user_profile) } - #[tracing::instrument(level = "info", skip(self))] - pub async fn resume_sign_up(&self) -> Result<(), FlowyError> { - let UserAuthProcess { - user_profile, - migration_user, - response, - authenticator, - } = self - .auth_process - .lock() - .await - .clone() - .ok_or(FlowyError::new( - ErrorCode::Internal, - "No resumable sign up data", - ))?; - self - .continue_sign_up(&user_profile, migration_user, response, &authenticator) - .await?; - Ok(()) - } - #[tracing::instrument(level = "info", skip_all, err)] async fn continue_sign_up( &self, @@ -436,9 +416,7 @@ impl UserManager { self .save_auth_data(&response, *auth_type, &new_session) .await?; - let _ = self - .initial_user_awareness(&new_session, &new_user_profile.auth_type) - .await; + let _ = self.initial_user_awareness(&new_session, auth_type).await; self .user_status_callback .read() @@ -670,6 +648,7 @@ impl UserManager { } } + #[instrument(level = "info", skip_all)] pub(crate) async fn generate_sign_in_url_with_email( &self, authenticator: &AuthType, @@ -682,6 +661,7 @@ impl UserManager { Ok(url) } + #[instrument(level = "info", skip_all)] pub(crate) async fn sign_in_with_password( &self, email: &str, @@ -695,6 +675,7 @@ impl UserManager { Ok(response) } + #[instrument(level = "info", skip_all)] pub(crate) async fn sign_in_with_magic_link( &self, email: &str, @@ -710,6 +691,7 @@ impl UserManager { Ok(()) } + #[instrument(level = "info", skip_all)] pub(crate) async fn sign_in_with_passcode( &self, email: &str, @@ -723,6 +705,7 @@ impl UserManager { Ok(response) } + #[instrument(level = "info", skip_all)] pub(crate) async fn generate_oauth_url( &self, oauth_provider: &str, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 6c786d9435..04e7e6b0d9 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -154,6 +154,8 @@ impl UserManager { #[instrument(skip(self), err)] pub async fn open_workspace(&self, workspace_id: &Uuid, auth_type: AuthType) -> FlowyResult<()> { info!("open workspace: {}, auth_type:{}", workspace_id, auth_type); + self.cloud_service.set_server_auth_type(&auth_type); + let uid = self.user_id()?; let conn = self.db_connection(self.user_id()?)?; let user_workspace = match select_user_workspace(&workspace_id.to_string(), conn) { From ccd1f5f8e9742a86c023664edb2d17e848a1c6ef Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 12:47:55 +0800 Subject: [PATCH 198/207] chore: revert pod lock --- frontend/appflowy_flutter/ios/Podfile.lock | 46 +++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 92e52a1a79..4b7ed5d639 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -181,37 +181,37 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0 - appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88 - connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 + appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a + connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc + file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 + flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c - image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 - keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 - open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 + keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87 + saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca From bb5d36402aa1396d4b76429a7ddd4ec265cc5257 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 13:35:06 +0800 Subject: [PATCH 199/207] chore: fix test --- .../event-integration-test/src/user_event.rs | 12 ++++---- .../flowy-core/src/user_state_callback.rs | 16 +++++----- .../rust-lib/flowy-user/src/event_handler.rs | 2 +- frontend/rust-lib/flowy-user/src/event_map.rs | 29 ++++++++++--------- .../flowy-user/src/user_manager/manager.rs | 12 +++----- .../user_manager/manager_user_workspace.rs | 6 ++-- .../flowy-user/src/user_manager/mod.rs | 1 - .../src/user_manager/user_login_state.rs | 11 ------- 8 files changed, 38 insertions(+), 51 deletions(-) delete mode 100644 frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index c4e34f27f7..ab10bb7083 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -17,10 +17,10 @@ use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_UR use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthTypePB, AuthTypePB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, - ImportAppFlowyDataPB, OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, - RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, - UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, + AuthTypePB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, + OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, + SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, + UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; @@ -140,7 +140,7 @@ impl EventIntegrationTest { pub async fn af_cloud_sign_in_with_email(&self, email: &str) -> FlowyResult { let payload = SignInUrlPayloadPB { email: email.to_string(), - authenticator: AuthTypePB::AppFlowyCloud, + authenticator: AuthTypePB::Server, }; let sign_in_url = EventBuilder::new(self.clone()) .event(UserEvent::GenerateSignInURL) @@ -155,7 +155,7 @@ impl EventIntegrationTest { map.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); let payload = OauthSignInPB { map, - authenticator: AuthTypePB::AppFlowyCloud, + authenticator: AuthTypePB::Server, }; let user_profile = EventBuilder::new(self.clone()) diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index f3eaa00543..e43002d709 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -46,7 +46,7 @@ impl UserStatusCallbackImpl { #[async_trait] impl UserStatusCallback for UserStatusCallbackImpl { - async fn did_init( + async fn on_launch_if_authenticated( &self, user_id: i64, cloud_config: &Option, @@ -88,7 +88,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { Ok(()) } - async fn did_sign_in( + async fn on_sign_in( &self, user_id: i64, user_workspace: &UserWorkspace, @@ -119,7 +119,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { Ok(()) } - async fn did_sign_up( + async fn on_sign_up( &self, is_new_user: bool, user_profile: &UserProfile, @@ -196,12 +196,12 @@ impl UserStatusCallback for UserStatusCallbackImpl { Ok(()) } - async fn did_expired(&self, _token: &str, user_id: i64) -> FlowyResult<()> { + async fn on_token_expired(&self, _token: &str, user_id: i64) -> FlowyResult<()> { self.folder_manager.clear(user_id).await; Ok(()) } - async fn open_workspace( + async fn on_workspace_opened( &self, user_id: i64, user_workspace: &UserWorkspace, @@ -230,13 +230,13 @@ impl UserStatusCallback for UserStatusCallbackImpl { Ok(()) } - fn did_update_network(&self, reachable: bool) { + fn on_network_status_changed(&self, reachable: bool) { info!("Notify did update network: reachable: {}", reachable); self.collab_builder.update_network(reachable); self.storage_manager.update_network_reachable(reachable); } - fn did_update_plans(&self, plans: Vec) { + fn on_subscription_plans_updated(&self, plans: Vec) { let mut storage_plan_changed = false; for plan in &plans { match plan { @@ -249,7 +249,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { } } - fn did_update_storage_limitation(&self, can_write: bool) { + fn on_storage_permission_updated(&self, can_write: bool) { if can_write { self.storage_manager.enable_storage_write_access(); } else { diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 905eb595f1..b1ce8c5ec6 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -475,7 +475,7 @@ pub async fn update_network_state_handler( .user_status_callback .read() .await - .did_update_network(reachable); + .on_network_status_changed(reachable); Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 1ae414afa7..0b489e6f28 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -279,10 +279,9 @@ pub enum UserEvent { pub trait UserStatusCallback: Send + Sync + 'static { /// When the [AuthType] changed, this method will be called. Currently, the auth type /// will be changed when the user sign in or sign up. - fn authenticator_did_changed(&self, _authenticator: AuthType) {} - /// This will be called after the application launches if the user is already signed in. - /// If the user is not signed in, this method will not be called - async fn did_init( + fn on_auth_type_changed(&self, _authenticator: AuthType) {} + /// Fires on app launch, but only if the user is already signed in. + async fn on_launch_if_authenticated( &self, _user_id: i64, _cloud_config: &Option, @@ -292,8 +291,8 @@ pub trait UserStatusCallback: Send + Sync + 'static { ) -> FlowyResult<()> { Ok(()) } - /// Will be called after the user signed in. - async fn did_sign_in( + /// Fires right after the user successfully signs in. + async fn on_sign_in( &self, _user_id: i64, _user_workspace: &UserWorkspace, @@ -302,8 +301,9 @@ pub trait UserStatusCallback: Send + Sync + 'static { ) -> FlowyResult<()> { Ok(()) } - /// Will be called after the user signed up. - async fn did_sign_up( + + /// Fires right after the user successfully signs up. + async fn on_sign_up( &self, _is_new_user: bool, _user_profile: &UserProfile, @@ -314,10 +314,13 @@ pub trait UserStatusCallback: Send + Sync + 'static { Ok(()) } - async fn did_expired(&self, _token: &str, _user_id: i64) -> FlowyResult<()> { + /// Fires when an authentication token has expired. + async fn on_token_expired(&self, _token: &str, _user_id: i64) -> FlowyResult<()> { Ok(()) } - async fn open_workspace( + + /// Fires when a workspace is opened by the user. + async fn on_workspace_opened( &self, _user_id: i64, _user_workspace: &UserWorkspace, @@ -325,9 +328,9 @@ pub trait UserStatusCallback: Send + Sync + 'static { ) -> FlowyResult<()> { Ok(()) } - fn did_update_network(&self, _reachable: bool) {} - fn did_update_plans(&self, _plans: Vec) {} - fn did_update_storage_limitation(&self, _can_write: bool) {} + fn on_network_status_changed(&self, _reachable: bool) {} + fn on_subscription_plans_updated(&self, _plans: Vec) {} + fn on_storage_permission_updated(&self, _can_write: bool) {} } /// Acts as a placeholder [UserStatusCallback] for the user session, but does not perform any function diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 9520489771..f4c09d0af8 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -1,7 +1,7 @@ use client_api::entity::GotrueTokenResponse; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; -use flowy_error::{internal_error, ErrorCode, FlowyResult}; +use flowy_error::{internal_error, FlowyResult}; use arc_swap::ArcSwapOption; use collab::lock::RwLock; @@ -21,7 +21,6 @@ use serde_json::Value; use std::string::ToString; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Weak}; -use tokio::sync::Mutex; use tokio_stream::StreamExt; use tracing::{debug, error, event, info, instrument, warn}; use uuid::Uuid; @@ -40,7 +39,6 @@ use crate::services::cloud_config::get_cloud_config; use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; -use crate::user_manager::user_login_state::UserAuthProcess; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; use flowy_user_pub::sql::*; @@ -53,7 +51,6 @@ pub struct UserManager { pub(crate) collab_builder: Weak, pub(crate) collab_interact: RwLock>, pub(crate) user_workspace_service: Arc, - auth_process: Mutex>, pub(crate) authenticate_user: Arc, refresh_user_profile_since: AtomicI64, pub(crate) is_loading_awareness: Arc>, @@ -78,7 +75,6 @@ impl UserManager { user_status_callback, collab_builder, collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)), - auth_process: Default::default(), authenticate_user, refresh_user_profile_since, user_workspace_service, @@ -270,7 +266,7 @@ impl UserManager { let _ = self.initial_user_awareness(&session, &user.auth_type).await; user_status_callback - .did_init( + .on_launch_if_authenticated( user.uid, &cloud_config, &session.user_workspace, @@ -363,7 +359,7 @@ impl UserManager { .user_status_callback .read() .await - .did_sign_in( + .on_sign_in( user_profile.uid, &latest_workspace, &self.authenticate_user.user_config.device_id, @@ -421,7 +417,7 @@ impl UserManager { .user_status_callback .read() .await - .did_sign_up( + .on_sign_up( response.is_new_user, new_user_profile, &new_session.user_workspace, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 04e7e6b0d9..20acb9866f 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -195,7 +195,7 @@ impl UserManager { .user_status_callback .read() .await - .open_workspace(uid, &user_workspace, &user_profile.auth_type) + .on_workspace_opened(uid, &user_workspace, &user_profile.auth_type) .await { error!("Open workspace failed: {:?}", err); @@ -532,7 +532,7 @@ impl UserManager { .user_status_callback .read() .await - .did_update_storage_limitation(can_write); + .on_storage_permission_updated(can_write); Ok(workspace_usage) } @@ -693,7 +693,7 @@ impl UserManager { .user_status_callback .read() .await - .did_update_plans(plans); + .on_subscription_plans_updated(plans); Ok(()) } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/mod.rs b/frontend/rust-lib/flowy-user/src/user_manager/mod.rs index 3ce66227c5..23c050c1f2 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/mod.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/mod.rs @@ -3,6 +3,5 @@ pub(crate) mod manager_history_user; pub(crate) mod manager_user_awareness; pub(crate) mod manager_user_encryption; pub(crate) mod manager_user_workspace; -mod user_login_state; pub use manager::*; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs b/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs deleted file mode 100644 index ed7fdacf8d..0000000000 --- a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::migrations::AnonUser; -use flowy_user_pub::entities::{AuthResponse, AuthType, UserProfile}; - -/// recording the intermediate state of the sign-in/sign-up process -#[derive(Clone)] -pub struct UserAuthProcess { - pub user_profile: UserProfile, - pub response: AuthResponse, - pub authenticator: AuthType, - pub migration_user: Option, -} From 747a63d452507c201d2d0007a8fd2868f8cce9fc Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 14:33:05 +0800 Subject: [PATCH 200/207] chore: create user workspace for anon user --- .../home/mobile_home_page_header.dart | 2 +- .../application/user/user_workspace_bloc.dart | 12 ++-- .../workspace/_sidebar_workspace_menu.dart | 2 +- .../user/af_cloud_test/workspace_test.rs | 23 +++++--- .../src/local_server/impls/user.rs | 4 +- .../up.sql | 4 +- frontend/rust-lib/flowy-sqlite/src/schema.rs | 26 ++++----- .../flowy-user-pub/src/sql/workspace_sql.rs | 12 ++-- .../flowy-user/src/entities/user_profile.rs | 6 +- .../src/migrations/anon_user_workspace.rs | 58 +++++++++++++++++++ .../src/migrations/doc_key_with_workspace.rs | 2 + .../src/migrations/document_empty_content.rs | 2 + .../flowy-user/src/migrations/migration.rs | 5 +- .../rust-lib/flowy-user/src/migrations/mod.rs | 1 + .../migrations/workspace_and_favorite_v1.rs | 2 + .../src/migrations/workspace_trash_v1.rs | 2 + .../flowy-user/src/user_manager/manager.rs | 2 + .../user_manager/manager_user_workspace.rs | 8 +-- 18 files changed, 126 insertions(+), 47 deletions(-) create mode 100644 frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 4a1df63740..113f12e543 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -194,7 +194,7 @@ class _MobileWorkspace extends StatelessWidget { context.read().add( UserWorkspaceEvent.openWorkspace( workspace.workspaceId, - workspace.authType, + workspace.workspaceAuthType, ), ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 5ed56b890e..0e0b912a08 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -54,7 +54,7 @@ class UserWorkspaceBloc extends Bloc { Log.info('init open workspace: ${currentWorkspace.workspaceId}'); await _userService.openWorkspace( currentWorkspace.workspaceId, - currentWorkspace.authType, + currentWorkspace.workspaceAuthType, ); } @@ -92,7 +92,7 @@ class UserWorkspaceBloc extends Bloc { add( OpenWorkspace( currentWorkspace.workspaceId, - currentWorkspace.authType, + currentWorkspace.workspaceAuthType, ), ); } @@ -132,7 +132,7 @@ class UserWorkspaceBloc extends Bloc { add( OpenWorkspace( s.workspaceId, - s.authType, + s.workspaceAuthType, ), ); }) @@ -190,7 +190,7 @@ class UserWorkspaceBloc extends Bloc { add( OpenWorkspace( workspaces.first.workspaceId, - workspaces.first.authType, + workspaces.first.workspaceAuthType, ), ); } @@ -203,7 +203,7 @@ class UserWorkspaceBloc extends Bloc { add( OpenWorkspace( workspaces.first.workspaceId, - workspaces.first.authType, + workspaces.first.workspaceAuthType, ), ); } @@ -369,7 +369,7 @@ class UserWorkspaceBloc extends Bloc { add( OpenWorkspace( workspaces.first.workspaceId, - workspaces.first.authType, + workspaces.first.workspaceAuthType, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 097da2c2ae..4ff5ccbf67 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -309,7 +309,7 @@ class _WorkspaceInfo extends StatelessWidget { context.read().add( UserWorkspaceEvent.openWorkspace( workspace.workspaceId, - workspace.authType, + workspace.workspaceAuthType, ), ); diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs index ea04d922fc..3bb71ea0dc 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs @@ -90,7 +90,10 @@ async fn af_cloud_create_workspace_test() { { // after opening new workspace test - .open_workspace(&created_workspace.workspace_id, created_workspace.auth_type) + .open_workspace( + &created_workspace.workspace_id, + created_workspace.workspace_auth_type, + ) .await; let folder_ws = test.folder_read_current_workspace().await; assert_eq!(folder_ws.id, created_workspace.workspace_id); @@ -124,7 +127,10 @@ async fn af_cloud_open_workspace_test() { .create_workspace("second workspace", AuthType::AppFlowyCloud) .await; test - .open_workspace(&user_workspace.workspace_id, user_workspace.auth_type) + .open_workspace( + &user_workspace.workspace_id, + user_workspace.workspace_auth_type, + ) .await; let second_workspace = test.get_current_workspace().await; let second_workspace = test.get_user_workspace(&second_workspace.id).await; @@ -144,7 +150,7 @@ async fn af_cloud_open_workspace_test() { test .open_workspace( &first_workspace.workspace_id, - first_workspace.auth_type.clone(), + first_workspace.workspace_auth_type.clone(), ) .await; sleep(Duration::from_millis(300)).await; @@ -155,7 +161,7 @@ async fn af_cloud_open_workspace_test() { test .open_workspace( &second_workspace.workspace_id, - second_workspace.auth_type.clone(), + second_workspace.workspace_auth_type.clone(), ) .await; sleep(Duration::from_millis(200)).await; @@ -168,7 +174,7 @@ async fn af_cloud_open_workspace_test() { test .open_workspace( &first_workspace.workspace_id, - first_workspace.auth_type.clone(), + first_workspace.workspace_auth_type.clone(), ) .await; let views_1 = test.get_all_workspace_views().await; @@ -180,7 +186,7 @@ async fn af_cloud_open_workspace_test() { test .open_workspace( &second_workspace.workspace_id, - second_workspace.auth_type.clone(), + second_workspace.workspace_auth_type.clone(), ) .await; let views_2 = test.get_all_workspace_views().await; @@ -239,7 +245,10 @@ async fn af_cloud_different_open_same_workspace_test() { let index = i % 2; let iter_workspace_id = &all_workspaces[index].workspace_id; client - .open_workspace(iter_workspace_id, all_workspaces[index].auth_type.clone()) + .open_workspace( + iter_workspace_id, + all_workspaces[index].workspace_auth_type.clone(), + ) .await; if iter_workspace_id == &cloned_shared_workspace_id { let views = client.get_all_workspace_views().await; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index e73943bbbe..512ad90a22 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -133,9 +133,9 @@ impl UserCloudService for LocalServerUserServiceImpl { async fn open_workspace(&self, workspace_id: &Uuid) -> Result { let uid = self.user.user_id()?; - let conn = self.user.get_sqlite_db(uid)?; + let mut conn = self.user.get_sqlite_db(uid)?; - let workspace = select_user_workspace(&workspace_id.to_string(), conn)?; + let workspace = select_user_workspace(&workspace_id.to_string(), &mut conn)?; Ok(UserWorkspace::from(workspace)) } diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql index b77372ad63..7d986e3e57 100644 --- a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql @@ -1,10 +1,10 @@ -- Your SQL goes here ALTER TABLE user_workspace_table - ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 1; + ADD COLUMN workspace_type INTEGER NOT NULL DEFAULT 1; -- 2. Back‑fill from user_table.auth_type UPDATE user_workspace_table -SET auth_type = (SELECT ut.auth_type +SET workspace_type = (SELECT ut.auth_type FROM user_table ut WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)) WHERE EXISTS (SELECT 1 diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 28a83ed449..eb6b248ea6 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -107,7 +107,7 @@ diesel::table! { icon -> Text, member_count -> BigInt, role -> Nullable, - auth_type -> Integer, + workspace_type -> Integer, } } @@ -132,16 +132,16 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, - workspace_setting_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, + workspace_setting_table, ); diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs index cb6d299039..709e218514 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -17,7 +17,7 @@ pub struct UserWorkspaceTable { pub icon: String, pub member_count: i64, pub role: Option, - pub auth_type: i32, + pub workspace_type: i32, } #[derive(AsChangeset, Identifiable, Default, Debug)] @@ -50,18 +50,18 @@ impl UserWorkspaceTable { icon: workspace.icon.clone(), member_count: workspace.member_count, role: workspace.role.clone().map(|v| v as i32), - auth_type: auth_type as i32, + workspace_type: auth_type as i32, }) } } pub fn select_user_workspace( workspace_id: &str, - mut conn: DBConnection, + conn: &mut SqliteConnection, ) -> FlowyResult { let row = user_workspace_table::dsl::user_workspace_table .filter(user_workspace_table::id.eq(workspace_id)) - .first::(&mut *conn)?; + .first::(conn)?; Ok(row) } @@ -106,7 +106,7 @@ pub fn upsert_user_workspace( user_workspace_table::icon.eq(row.icon), user_workspace_table::member_count.eq(row.member_count), user_workspace_table::role.eq(row.role), - user_workspace_table::auth_type.eq(row.auth_type), + user_workspace_table::workspace_type.eq(row.workspace_type), )) .execute(conn)?; @@ -153,7 +153,7 @@ pub fn delete_user_all_workspace( let n = diesel::delete( user_workspace_table::dsl::user_workspace_table .filter(user_workspace_table::uid.eq(uid)) - .filter(user_workspace_table::auth_type.eq(auth_type as i32)), + .filter(user_workspace_table::workspace_type.eq(auth_type as i32)), ) .execute(conn)?; info!( diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 5afc93850a..6a95a89041 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -186,7 +186,7 @@ pub struct UserWorkspacePB { pub role: Option, #[pb(index = 7)] - pub auth_type: AuthTypePB, + pub workspace_auth_type: AuthTypePB, } impl From<(AuthType, UserWorkspace)> for UserWorkspacePB { @@ -198,7 +198,7 @@ impl From<(AuthType, UserWorkspace)> for UserWorkspacePB { icon: value.1.icon, member_count: value.1.member_count, role: value.1.role.map(AFRolePB::from), - auth_type: AuthTypePB::from(value.0), + workspace_auth_type: AuthTypePB::from(value.0), } } } @@ -212,7 +212,7 @@ impl From for UserWorkspacePB { icon: value.icon, member_count: value.member_count, role: value.role.map(AFRolePB::from), - auth_type: AuthTypePB::from(value.auth_type), + workspace_auth_type: AuthTypePB::from(value.workspace_type), } } } diff --git a/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs new file mode 100644 index 0000000000..7c806d3aaf --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs @@ -0,0 +1,58 @@ +use diesel::SqliteConnection; +use semver::Version; +use std::sync::Arc; +use tracing::{info, instrument}; + +use collab_integrate::CollabKVDB; +use flowy_error::FlowyResult; +use flowy_user_pub::entities::AuthType; + +use crate::migrations::migration::UserDataMigration; +use flowy_user_pub::session::Session; +use flowy_user_pub::sql::{select_user_workspace, upsert_user_workspace}; + +pub struct AnonUserWorkspaceTableMigration; + +impl UserDataMigration for AnonUserWorkspaceTableMigration { + fn name(&self) -> &str { + "anon_user_workspace_table_migration" + } + + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => true, + Some(version) => version <= &Version::new(0, 8, 10), + } + } + + #[instrument(name = "AnonUserWorkspaceTableMigration", skip_all, err)] + fn run( + &self, + session: &Session, + _collab_db: &Arc, + auth_type: &AuthType, + db: &mut SqliteConnection, + ) -> FlowyResult<()> { + // For historical reason, anon user doesn't have a workspace in user_workspace_table. + // So we need to create a new entry for the anon user in the user_workspace_table. + if matches!(auth_type, AuthType::Local) { + let user_workspace = &session.user_workspace; + let result = select_user_workspace(&user_workspace.id, db); + if let Err(e) = result { + if e.is_record_not_found() { + info!( + "Anon user workspace not found in the database, creating a new entry for user_id: {}", + session.user_id + ); + upsert_user_workspace(session.user_id, *auth_type, user_workspace.clone(), db)?; + } + } + } + + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs index 5a8bb4d516..84acc0b56a 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use collab_plugins::local_storage::kv::doc::migrate_old_keys; use collab_plugins::local_storage::kv::KVTransactionDB; +use diesel::SqliteConnection; use semver::Version; use tracing::{instrument, trace}; @@ -40,6 +41,7 @@ impl UserDataMigration for CollabDocKeyWithWorkspaceIdMigration { session: &Session, collab_db: &Arc, _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { trace!( "migrate key with workspace id:{}", diff --git a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs index ab828e18d8..2e4581f7ec 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs @@ -6,6 +6,7 @@ use collab_document::document::Document; use collab_document::document_data::default_document_data; use collab_folder::{Folder, View}; use collab_plugins::local_storage::kv::KVTransactionDB; +use diesel::SqliteConnection; use semver::Version; use tracing::{event, instrument}; @@ -42,6 +43,7 @@ impl UserDataMigration for HistoricalEmptyDocumentMigration { session: &Session, collab_db: &Arc, authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { // - The `empty document` struct has already undergone refactoring prior to the launch of the AppFlowy cloud version. // - Consequently, if a user is utilizing the AppFlowy cloud version, there is no need to perform any migration for the `empty document` struct. diff --git a/frontend/rust-lib/flowy-user/src/migrations/migration.rs b/frontend/rust-lib/flowy-user/src/migrations/migration.rs index 1330d1995b..0f5c2c2624 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/migration.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/migration.rs @@ -54,7 +54,7 @@ impl UserLocalDataMigration { pub fn run( self, migrations: Vec>, - authenticator: &AuthType, + auth_type: &AuthType, app_version: &Version, ) -> FlowyResult> { let mut applied_migrations = vec![]; @@ -75,7 +75,7 @@ impl UserLocalDataMigration { let migration_name = migration.name().to_string(); if !duplicated_names.contains(&migration_name) { - migration.run(&self.session, &self.collab_db, authenticator)?; + migration.run(&self.session, &self.collab_db, auth_type, &mut conn)?; applied_migrations.push(migration.name().to_string()); save_migration_record(&mut conn, &migration_name); duplicated_names.push(migration_name); @@ -99,6 +99,7 @@ pub trait UserDataMigration { user: &Session, collab_db: &Arc, authenticator: &AuthType, + db: &mut SqliteConnection, ) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/src/migrations/mod.rs b/frontend/rust-lib/flowy-user/src/migrations/mod.rs index c8d04edf66..3d87dc595f 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/mod.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/mod.rs @@ -1,6 +1,7 @@ use flowy_user_pub::session::Session; use std::sync::Arc; +pub mod anon_user_workspace; pub mod doc_key_with_workspace; pub mod document_empty_content; pub mod migration; diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs index 51894f9a04..5f14051e26 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use collab_folder::Folder; use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; +use diesel::SqliteConnection; use semver::Version; use tracing::instrument; @@ -40,6 +41,7 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { session: &Session, collab_db: &Arc, _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { if let Ok(collab) = load_collab( diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs index 70123edb2c..b5eeead8c6 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use collab_folder::Folder; use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; +use diesel::SqliteConnection; use semver::Version; use tracing::instrument; @@ -38,6 +39,7 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { session: &Session, collab_db: &Arc, _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { if let Ok(collab) = load_collab( diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index f4c09d0af8..4299f0823b 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -38,6 +38,7 @@ use crate::services::authenticate_user::AuthenticateUser; use crate::services::cloud_config::get_cloud_config; use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; +use crate::migrations::anon_user_workspace::AnonUserWorkspaceTableMigration; use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; @@ -849,6 +850,7 @@ fn collab_migration_list() -> Vec> { Box::new(FavoriteV1AndWorkspaceArrayMigration), Box::new(WorkspaceTrashMapToSectionMigration), Box::new(CollabDocKeyWithWorkspaceIdMigration), + Box::new(AnonUserWorkspaceTableMigration), ] } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 20acb9866f..b68643c83b 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -157,8 +157,8 @@ impl UserManager { self.cloud_service.set_server_auth_type(&auth_type); let uid = self.user_id()?; - let conn = self.db_connection(self.user_id()?)?; - let user_workspace = match select_user_workspace(&workspace_id.to_string(), conn) { + let mut conn = self.db_connection(self.user_id()?)?; + let user_workspace = match select_user_workspace(&workspace_id.to_string(), &mut conn) { Err(err) => { if err.is_record_not_found() { sync_workspace( @@ -401,8 +401,8 @@ impl UserManager { uid: i64, workspace_id: &Uuid, ) -> FlowyResult { - let conn = self.db_connection(uid)?; - select_user_workspace(workspace_id.to_string().as_str(), conn) + let mut conn = self.db_connection(uid)?; + select_user_workspace(workspace_id.to_string().as_str(), &mut conn) } pub async fn get_all_user_workspaces( From fd581b44535894540c5e9e2a2f6240cb47cf0642 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 14:37:21 +0800 Subject: [PATCH 201/207] chore: clippy --- frontend/rust-lib/flowy-sqlite/src/schema.rs | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index eb6b248ea6..f91d187b75 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -132,16 +132,16 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - af_collab_metadata, - chat_local_setting_table, - chat_message_table, - chat_table, - collab_snapshot, - upload_file_part, - upload_file_table, - user_data_migration_records, - user_table, - user_workspace_table, - workspace_members_table, - workspace_setting_table, + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, + collab_snapshot, + upload_file_part, + upload_file_table, + user_data_migration_records, + user_table, + user_workspace_table, + workspace_members_table, + workspace_setting_table, ); From fa798f3ecd95a5f98d1e0891bb68abac00f7b6a2 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 15:54:37 +0800 Subject: [PATCH 202/207] chore: workspace usage --- .../workspace/_sidebar_workspace_menu.dart | 2 +- frontend/rust-lib/flowy-core/src/lib.rs | 5 + .../flowy-server/src/af_cloud/define.rs | 4 + .../src/local_server/impls/chat.rs | 34 ++-- .../src/local_server/impls/folder.rs | 7 +- .../src/local_server/impls/user.rs | 153 ++++++++++++++++-- .../flowy-server/src/local_server/server.rs | 14 +- .../flowy-server/tests/af_cloud_test/util.rs | 10 +- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 21 +-- .../flowy-user-pub/src/sql/member_sql.rs | 18 ++- .../flowy-user-pub/src/sql/user_sql.rs | 18 ++- .../src/sql/workspace_setting_sql.rs | 8 +- .../data_import/appflowy_data_import.rs | 2 +- .../rust-lib/flowy-user/src/services/db.rs | 4 +- .../flowy-user/src/user_manager/manager.rs | 21 ++- .../user_manager/manager_user_workspace.rs | 4 +- 16 files changed, 241 insertions(+), 84 deletions(-) diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 4ff5ccbf67..04eb701a66 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -389,7 +389,7 @@ class _CreateWorkspaceButton extends StatelessWidget { workspaceBloc.add( UserWorkspaceEvent.createWorkspace( name, - AuthTypePB.Server, + AuthTypePB.Local, ), ); }, diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 7e6d477407..893a010a18 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -1,6 +1,7 @@ #![allow(unused_doc_comments)] use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_plugins::CollabKVDB; use flowy_ai::ai_manager::AIManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; @@ -342,6 +343,10 @@ impl LoggedUser for ServerUserImpl { self.upgrade_user()?.get_sqlite_connection(uid) } + fn get_collab_db(&self, uid: i64) -> Result, FlowyError> { + self.upgrade_user()?.get_collab_db(uid) + } + fn application_root_dir(&self) -> Result { Ok(PathBuf::from( self.upgrade_user()?.get_application_root_dir(), diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index a93066054d..65808e5b6b 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,3 +1,4 @@ +use collab_plugins::CollabKVDB; use flowy_ai::ai_manager::AIUserService; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; @@ -21,6 +22,9 @@ pub trait LoggedUser: Send + Sync { async fn is_local_mode(&self) -> FlowyResult; fn get_sqlite_db(&self, uid: i64) -> Result; + + fn get_collab_db(&self, uid: i64) -> Result, FlowyError>; + fn application_root_dir(&self) -> Result; } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index f56a8d6e8b..3af87eb18e 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -28,14 +28,14 @@ use tracing::trace; use uuid::Uuid; pub struct LocalChatServiceImpl { - pub user: Arc, + pub logged_user: Arc, pub local_ai: Arc, } impl LocalChatServiceImpl { fn get_message_content(&self, message_id: i64) -> FlowyResult { - let uid = self.user.user_id()?; - let db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; let content = select_message_content(db, message_id)?.ok_or_else(|| { FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) })?; @@ -43,8 +43,8 @@ impl LocalChatServiceImpl { } async fn upsert_message(&self, chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { - let uid = self.user.user_id()?; - let conn = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let conn = self.logged_user.get_sqlite_db(uid)?; let row = ChatMessageTable::from_message(chat_id.to_string(), message, true); upsert_chat_messages(conn, &[row])?; Ok(()) @@ -62,8 +62,8 @@ impl ChatCloudService for LocalChatServiceImpl { _name: &str, metadata: Value, ) -> Result<(), FlowyError> { - let uid = self.user.user_id()?; - let db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; let row = ChatTable::new(chat_id.to_string(), metadata, rag_ids, true); upsert_chat(db, &row)?; Ok(()) @@ -139,8 +139,8 @@ impl ChatCloudService for LocalChatServiceImpl { chat_id: &Uuid, question_id: i64, ) -> Result { - let uid = self.user.user_id()?; - let db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; match select_answer_where_match_reply_message_id(db, &chat_id.to_string(), question_id)? { None => Err(FlowyError::record_not_found()), @@ -156,8 +156,8 @@ impl ChatCloudService for LocalChatServiceImpl { limit: u64, ) -> Result { let chat_id = chat_id.to_string(); - let uid = self.user.user_id()?; - let db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; let result = select_chat_messages(db, &chat_id, limit, offset)?; let messages = result @@ -180,8 +180,8 @@ impl ChatCloudService for LocalChatServiceImpl { answer_message_id: i64, ) -> Result { let chat_id = chat_id.to_string(); - let uid = self.user.user_id()?; - let db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; let row = select_answer_where_match_reply_message_id(db, &chat_id, answer_message_id)? .map(chat_message_from_row) .ok_or_else(FlowyError::record_not_found)?; @@ -278,8 +278,8 @@ impl ChatCloudService for LocalChatServiceImpl { chat_id: &Uuid, ) -> Result { let chat_id = chat_id.to_string(); - let uid = self.user.user_id()?; - let db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; let row = read_chat(db, &chat_id)?; let rag_ids = deserialize_rag_ids(&row.rag_ids); let metadata = deserialize_chat_metadata::(&row.metadata); @@ -298,8 +298,8 @@ impl ChatCloudService for LocalChatServiceImpl { id: &Uuid, s: UpdateChatParams, ) -> Result<(), FlowyError> { - let uid = self.user.user_id()?; - let mut db = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let mut db = self.logged_user.get_sqlite_db(uid)?; let changeset = ChatTableChangeset { chat_id: id.to_string(), name: s.name, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 72bab514ec..1acb0846c9 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,5 +1,6 @@ #![allow(unused_variables)] +use crate::af_cloud::define::LoggedUser; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; use collab_entity::CollabType; @@ -10,9 +11,13 @@ use flowy_folder_pub::cloud::{ }; use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; +use std::sync::Arc; use uuid::Uuid; -pub(crate) struct LocalServerFolderCloudServiceImpl; +pub(crate) struct LocalServerFolderCloudServiceImpl { + #[allow(dead_code)] + pub logged_user: Arc, +} #[async_trait] impl FolderCloudService for LocalServerFolderCloudServiceImpl { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index 512ad90a22..bd6e930584 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -1,32 +1,38 @@ #![allow(unused_variables)] +use crate::af_cloud::define::LoggedUser; +use crate::local_server::uid::UserIDGenerator; use client_api::entity::GotrueTokenResponse; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_entity::CollabObject; use collab_user::core::UserAwareness; -use lazy_static::lazy_static; -use std::sync::Arc; -use tokio::sync::Mutex; -use uuid::Uuid; - -use crate::af_cloud::define::LoggedUser; -use crate::local_server::uid::UserIDGenerator; +use flowy_ai_pub::cloud::billing_dto::WorkspaceUsageAndLimit; +use flowy_ai_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use flowy_error::FlowyError; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; use flowy_user_pub::entities::*; -use flowy_user_pub::sql::{select_all_user_workspace, select_user_profile, select_user_workspace}; +use flowy_user_pub::sql::{ + select_all_user_workspace, select_user_profile, select_user_workspace, select_workspace_member, + select_workspace_setting, update_user_profile, update_workspace_setting, upsert_workspace_member, + upsert_workspace_setting, UserTableChangeset, WorkspaceMemberTable, WorkspaceSettingsChangeset, + WorkspaceSettingsTable, +}; use flowy_user_pub::DEFAULT_USER_NAME; +use lazy_static::lazy_static; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; use lib_infra::util::timestamp; +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; lazy_static! { static ref ID_GEN: Mutex = Mutex::new(UserIDGenerator::new(1)); } pub(crate) struct LocalServerUserServiceImpl { - pub user: Arc, + pub logged_user: Arc, } #[async_trait] @@ -121,26 +127,30 @@ impl UserCloudService for LocalServerUserServiceImpl { Err(FlowyError::internal().with_context("Can't oauth url when using offline mode")) } - async fn update_user(&self, _params: UpdateUserProfileParams) -> Result<(), FlowyError> { + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let changeset = UserTableChangeset::new(params); + update_user_profile(&mut conn, changeset)?; Ok(()) } async fn get_user_profile(&self, uid: i64) -> Result { - let conn = self.user.get_sqlite_db(uid)?; - let profile = select_user_profile(uid, conn)?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, &mut conn)?; Ok(profile) } async fn open_workspace(&self, workspace_id: &Uuid) -> Result { - let uid = self.user.user_id()?; - let mut conn = self.user.get_sqlite_db(uid)?; + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; let workspace = select_user_workspace(&workspace_id.to_string(), &mut conn)?; Ok(UserWorkspace::from(workspace)) } async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError> { - let conn = self.user.get_sqlite_db(uid)?; + let conn = self.logged_user.get_sqlite_db(uid)?; let workspaces = select_all_user_workspace(uid, conn)?; Ok(workspaces) } @@ -198,4 +208,117 @@ impl UserCloudService for LocalServerUserServiceImpl { ) -> Result<(), FlowyError> { Ok(()) } + + async fn get_workspace_member_info( + &self, + workspace_id: &Uuid, + uid: i64, + ) -> Result { + // For local server, only current user is the member + let conn = self.logged_user.get_sqlite_db(uid)?; + let result = select_workspace_member(conn, &workspace_id.to_string(), uid); + + match result { + Ok(row) => Ok(WorkspaceMember::from(row)), + Err(err) => { + if err.is_record_not_found() { + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, &mut conn)?; + let row = WorkspaceMemberTable { + email: profile.email.to_string(), + role: 0, + name: profile.name.to_string(), + avatar_url: Some(profile.icon_url), + uid, + workspace_id: workspace_id.to_string(), + updated_at: Default::default(), + }; + + let member = WorkspaceMember::from(row.clone()); + upsert_workspace_member(&mut conn, row)?; + Ok(member) + } else { + Err(err) + } + }, + } + } + + async fn get_workspace_usage( + &self, + workspace_id: &Uuid, + ) -> Result { + Ok(WorkspaceUsageAndLimit { + member_count: 1, + member_count_limit: 1, + storage_bytes: i64::MAX, + storage_bytes_limit: i64::MAX, + storage_bytes_unlimited: true, + single_upload_limit: i64::MAX, + single_upload_unlimited: true, + ai_responses_count: i64::MAX, + ai_responses_count_limit: i64::MAX, + ai_image_responses_count: i64::MAX, + ai_image_responses_count_limit: 0, + local_ai: true, + ai_responses_unlimited: true, + }) + } + + async fn get_workspace_setting( + &self, + workspace_id: &Uuid, + ) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + // By default, workspace setting is existed in local server + let result = select_workspace_setting(&mut conn, &workspace_id.to_string()); + match result { + Ok(row) => Ok(AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model, + }), + Err(err) => { + if err.is_record_not_found() { + let row = WorkspaceSettingsTable { + id: workspace_id.to_string(), + disable_search_indexing: false, + ai_model: "".to_string(), + }; + let setting = AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model.clone(), + }; + upsert_workspace_setting(&mut conn, row)?; + Ok(setting) + } else { + Err(err) + } + }, + } + } + + async fn update_workspace_setting( + &self, + workspace_id: &Uuid, + workspace_settings: AFWorkspaceSettingsChange, + ) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + let changeset = WorkspaceSettingsChangeset { + id: workspace_id.to_string(), + disable_search_indexing: workspace_settings.disable_search_indexing, + ai_model: workspace_settings.ai_model, + }; + + update_workspace_setting(&mut conn, changeset)?; + let row = select_workspace_setting(&mut conn, &workspace_id.to_string())?; + + Ok(AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model, + }) + } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 8ce0d86221..8ddcb2d76c 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -17,15 +17,15 @@ use flowy_user_pub::cloud::UserCloudService; use tokio::sync::mpsc; pub struct LocalServer { - user: Arc, + logged_user: Arc, local_ai: Arc, stop_tx: Option>, } impl LocalServer { - pub fn new(user: Arc, local_ai: Arc) -> Self { + pub fn new(logged_user: Arc, local_ai: Arc) -> Self { Self { - user, + logged_user, local_ai, stop_tx: Default::default(), } @@ -42,12 +42,14 @@ impl LocalServer { impl AppFlowyServer for LocalServer { fn user_service(&self) -> Arc { Arc::new(LocalServerUserServiceImpl { - user: self.user.clone(), + logged_user: self.logged_user.clone(), }) } fn folder_service(&self) -> Arc { - Arc::new(LocalServerFolderCloudServiceImpl) + Arc::new(LocalServerFolderCloudServiceImpl { + logged_user: self.logged_user.clone(), + }) } fn database_service(&self) -> Arc { @@ -64,7 +66,7 @@ impl AppFlowyServer for LocalServer { fn chat_service(&self) -> Arc { Arc::new(LocalChatServiceImpl { - user: self.user.clone(), + logged_user: self.logged_user.clone(), local_ai: self.local_ai.clone(), }) } diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index 249ff9136d..7e38f423cc 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -1,10 +1,10 @@ use client_api::ClientConfiguration; +use collab_plugins::CollabKVDB; +use flowy_error::{FlowyError, FlowyResult}; use semver::Version; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; - -use flowy_error::{FlowyError, FlowyResult}; +use std::sync::{Arc, Weak}; use uuid::Uuid; use crate::setup_log; @@ -61,6 +61,10 @@ impl LoggedUser for FakeServerUserImpl { todo!() } + fn get_collab_db(&self, _uid: i64) -> Result, FlowyError> { + todo!() + } + fn application_root_dir(&self) -> Result { todo!() } diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index 0964d80472..b15299b194 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -285,10 +285,7 @@ pub trait UserCloudService: Send + Sync + 'static { &self, workspace_id: &Uuid, uid: i64, - ) -> Result { - Err(FlowyError::not_support()) - } - + ) -> Result; /// Get all subscriptions for all workspaces for a user (email) async fn get_workspace_subscriptions( &self, @@ -323,9 +320,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_usage( &self, workspace_id: &Uuid, - ) -> Result { - Err(FlowyError::not_support()) - } + ) -> Result; async fn get_billing_portal_url(&self) -> Result { Err(FlowyError::not_support()) @@ -337,27 +332,23 @@ pub trait UserCloudService: Send + Sync + 'static { plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Ok(()) } async fn get_subscription_plan_details(&self) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } async fn get_workspace_setting( &self, workspace_id: &Uuid, - ) -> Result { - Err(FlowyError::not_support()) - } + ) -> Result; async fn update_workspace_setting( &self, workspace_id: &Uuid, workspace_settings: AFWorkspaceSettingsChange, - ) -> Result { - Err(FlowyError::not_support()) - } + ) -> Result; } pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver; diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs index bc73a8f34c..58ca65e732 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs @@ -1,10 +1,11 @@ +use crate::entities::{Role, WorkspaceMember}; use diesel::{insert_into, RunQueryDsl}; use flowy_error::FlowyResult; use flowy_sqlite::schema::workspace_members_table; use flowy_sqlite::schema::workspace_members_table::dsl; use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods}; -#[derive(Queryable, Insertable, AsChangeset, Debug)] +#[derive(Queryable, Insertable, AsChangeset, Debug, Clone)] #[diesel(table_name = workspace_members_table)] #[diesel(primary_key(email, workspace_id))] pub struct WorkspaceMemberTable { @@ -17,8 +18,19 @@ pub struct WorkspaceMemberTable { pub updated_at: chrono::NaiveDateTime, } +impl From for WorkspaceMember { + fn from(value: WorkspaceMemberTable) -> Self { + Self { + email: value.email, + role: Role::from(value.role), + name: value.name, + avatar_url: value.avatar_url, + } + } +} + pub fn upsert_workspace_member>( - mut conn: DBConnection, + conn: &mut SqliteConnection, member: T, ) -> FlowyResult<()> { let member = member.into(); @@ -31,7 +43,7 @@ pub fn upsert_workspace_member>( )) .do_update() .set(&member) - .execute(&mut conn)?; + .execute(conn)?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs index 5a910888a8..4cdf26520e 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs @@ -92,10 +92,24 @@ impl From for UserTableChangeset { } } -pub fn select_user_profile(uid: i64, mut conn: DBConnection) -> Result { +pub fn update_user_profile( + conn: &mut SqliteConnection, + changeset: UserTableChangeset, +) -> Result<(), FlowyError> { + let user_id = changeset.id.clone(); + update(user_table::dsl::user_table.filter(user_table::id.eq(&user_id))) + .set(changeset) + .execute(conn)?; + Ok(()) +} + +pub fn select_user_profile( + uid: i64, + conn: &mut SqliteConnection, +) -> Result { let user: UserProfile = user_table::dsl::user_table .filter(user_table::id.eq(&uid.to_string())) - .first::(&mut *conn) + .first::(conn) .map_err(|err| { FlowyError::record_not_found().with_context(format!( "Can't find the user profile for user id: {}, error: {:?}", diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs index 667d1f0ca0..7eeafaf1e4 100644 --- a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs @@ -45,7 +45,7 @@ pub fn update_workspace_setting( /// Upserts a workspace setting into the database. pub fn upsert_workspace_setting( - conn: &mut DBConnection, + conn: &mut SqliteConnection, settings: WorkspaceSettingsTable, ) -> Result<(), FlowyError> { diesel::insert_into(dsl::workspace_setting_table) @@ -62,11 +62,11 @@ pub fn upsert_workspace_setting( /// Selects a workspace setting by id from the database. pub fn select_workspace_setting( - conn: &mut DBConnection, - id: &str, + conn: &mut SqliteConnection, + workspace_id: &str, ) -> Result { let setting = dsl::workspace_setting_table - .filter(workspace_setting_table::id.eq(id)) + .filter(workspace_setting_table::id.eq(workspace_id)) .first::(conn)?; Ok(setting) } diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index 2db5f418de..a4c7d3bd1d 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -103,7 +103,7 @@ pub(crate) fn prepare_import( ); let imported_user = select_user_profile( imported_session.user_id, - imported_sqlite_db.get_connection()?, + &mut *imported_sqlite_db.get_connection()?, )?; run_collab_data_migration( diff --git a/frontend/rust-lib/flowy-user/src/services/db.rs b/frontend/rust-lib/flowy-user/src/services/db.rs index 138ad95819..3280370c88 100644 --- a/frontend/rust-lib/flowy-user/src/services/db.rs +++ b/frontend/rust-lib/flowy-user/src/services/db.rs @@ -126,8 +126,8 @@ impl UserDB { pool: &Arc, uid: i64, ) -> Result { - let conn = pool.get()?; - let profile = select_user_profile(uid, conn)?; + let mut conn = pool.get()?; + let profile = select_user_profile(uid, &mut conn)?; Ok(profile) } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 4299f0823b..3b568f976e 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -1,7 +1,7 @@ use client_api::entity::GotrueTokenResponse; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; -use flowy_error::{internal_error, FlowyResult}; +use flowy_error::FlowyResult; use arc_swap::ArcSwapOption; use collab::lock::RwLock; @@ -506,8 +506,12 @@ impl UserManager { self.db_connection(session.user_id)?, changeset, )?; + self + .cloud_service + .get_user_service()? + .update_user(params) + .await?; - self.update_user(params).await?; Ok(()) } @@ -539,7 +543,8 @@ impl UserManager { /// Fetches the user profile for the given user ID. pub async fn get_user_profile_from_disk(&self, uid: i64) -> Result { - select_user_profile(uid, self.db_connection(uid)?) + let mut conn = self.db_connection(uid)?; + select_user_profile(uid, &mut conn) } #[tracing::instrument(level = "info", skip_all, err)] @@ -625,14 +630,6 @@ impl UserManager { Ok(None) } - async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { - let server = self.cloud_service.get_user_service()?; - tokio::spawn(async move { server.update_user(params).await }) - .await - .map_err(internal_error)??; - Ok(()) - } - async fn save_user(&self, uid: i64, user: UserTable) -> Result<(), FlowyError> { let conn = self.db_connection(uid)?; upsert_user(user, conn)?; @@ -816,7 +813,7 @@ pub fn upsert_user_profile_change( "Update user profile with changeset: {:?}", changeset ); - diesel_update_table!(user_table, changeset, &mut *conn); + update_user_profile(&mut conn, changeset)?; let user: UserProfile = user_table::dsl::user_table .filter(user_table::id.eq(&uid.to_string())) .first::(&mut *conn)? diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index b68643c83b..425b2351b0 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -668,8 +668,8 @@ impl UserManager { updated_at: Utc::now().naive_utc(), }; - let db = self.authenticate_user.get_sqlite_connection(uid)?; - upsert_workspace_member(db, record)?; + let mut db = self.authenticate_user.get_sqlite_connection(uid)?; + upsert_workspace_member(&mut db, record)?; Ok(member) } From 791a79a2341b3744727e1a8378b97f2701a39902 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 17:29:27 +0800 Subject: [PATCH 203/207] chore: impl local unspport --- .../workspace/_sidebar_workspace_menu.dart | 2 +- frontend/rust-lib/Cargo.lock | 16 +++---- frontend/rust-lib/Cargo.toml | 16 +++---- .../src/local_server/impls/database.rs | 42 +++++++++-------- .../src/local_server/impls/folder.rs | 29 +++++++++++- .../flowy-server/src/local_server/mod.rs | 1 + .../flowy-server/src/local_server/server.rs | 4 +- .../flowy-server/src/local_server/util.rs | 45 +++++++++++++++++++ 8 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 frontend/rust-lib/flowy-server/src/local_server/util.rs diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 04eb701a66..4ff5ccbf67 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -389,7 +389,7 @@ class _CreateWorkspaceButton extends StatelessWidget { workspaceBloc.add( UserWorkspaceEvent.createWorkspace( name, - AuthTypePB.Local, + AuthTypePB.Server, ), ); }, diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index dcac892c6a..4a3fae985a 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1270,7 +1270,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "arc-swap", @@ -1295,7 +1295,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-trait", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "arc-swap", @@ -1356,7 +1356,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "bytes", @@ -1376,7 +1376,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "arc-swap", @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "collab-importer" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-recursion", @@ -1461,7 +1461,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-stream", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1920e21f47e88a238e11356be0b3ef2f3acdc23e#1920e21f47e88a238e11356be0b3ef2f3acdc23e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "collab", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 61259d1e7b..1561c7ea7d 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -144,14 +144,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } -collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1920e21f47e88a238e11356be0b3ef2f3acdc23e" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index 46b0cdd649..ad1184a09a 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -1,16 +1,18 @@ #![allow(unused_variables)] + +use crate::af_cloud::define::LoggedUser; +use crate::local_server::util::default_encode_collab_for_collab_type; use collab::entity::EncodedCollab; -use collab_database::database::default_database_data; -use collab_database::workspace_database::default_workspace_database_data; -use collab_document::document_data::default_document_collab_data; use collab_entity::CollabType; -use collab_user::core::default_user_awareness_data; use flowy_database_pub::cloud::{DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid}; -use flowy_error::FlowyError; +use flowy_error::{ErrorCode, FlowyError}; use lib_infra::async_trait::async_trait; +use std::sync::Arc; use uuid::Uuid; -pub(crate) struct LocalServerDatabaseCloudServiceImpl(); +pub(crate) struct LocalServerDatabaseCloudServiceImpl { + pub logged_user: Arc, +} #[async_trait] impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { @@ -18,24 +20,20 @@ impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { &self, object_id: &Uuid, collab_type: CollabType, - workspace_id: &Uuid, + _workspace_id: &Uuid, // underscore to silence “unused” warning ) -> Result, FlowyError> { + let uid = self.logged_user.user_id()?; let object_id = object_id.to_string(); - match collab_type { - CollabType::Document => { - let encode_collab = default_document_collab_data(&object_id)?; - Ok(Some(encode_collab)) - }, - CollabType::Database => default_database_data(&object_id) - .await - .map(Some) - .map_err(Into::into), - CollabType::WorkspaceDatabase => Ok(Some(default_workspace_database_data(&object_id))), - CollabType::Folder => Ok(None), - CollabType::DatabaseRow => Ok(None), - CollabType::UserAwareness => Ok(Some(default_user_awareness_data(&object_id))), - CollabType::Unknown => Ok(None), - } + default_encode_collab_for_collab_type(uid, &object_id, collab_type) + .await + .map(Some) + .or_else(|err| { + if matches!(err.code, ErrorCode::NotSupportYet) { + Ok(None) + } else { + Err(err) + } + }) } async fn create_database_encode_collab( diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 1acb0846c9..7f4720de99 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,9 +1,14 @@ #![allow(unused_variables)] use crate::af_cloud::define::LoggedUser; +use crate::local_server::util::default_encode_collab_for_collab_type; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; +use collab::core::origin::CollabOrigin; +use collab::preclude::Collab; use collab_entity::CollabType; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ gen_workspace_id, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, @@ -61,7 +66,29 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { collab_type: CollabType, object_id: &Uuid, ) -> Result, FlowyError> { - Err(FlowyError::local_version_not_support()) + let object_id = object_id.to_string(); + let workspace_id = workspace_id.to_string(); + let collab_db = self.logged_user.get_collab_db(uid)?.upgrade().unwrap(); + let read_txn = collab_db.read_txn(); + let is_exist = read_txn.is_exist(uid, &workspace_id.to_string(), &object_id.to_string()); + if is_exist { + // load doc + let collab = Collab::new_with_origin(CollabOrigin::Empty, &object_id, vec![], false); + read_txn.load_doc(uid, &workspace_id, &object_id, collab.doc())?; + let data = collab.encode_collab_v1(|c| { + collab_type + .validate_require_data(c) + .map_err(|err| FlowyError::invalid_data().with_context(err))?; + Ok::<_, FlowyError>(()) + })?; + Ok(data.doc_state.to_vec()) + } else { + let data = default_encode_collab_for_collab_type(uid, &object_id, collab_type).await?; + drop(read_txn); + + // create default folder doc + Err(FlowyError::local_version_not_support()) + } } async fn batch_create_folder_collab_objects( diff --git a/frontend/rust-lib/flowy-server/src/local_server/mod.rs b/frontend/rust-lib/flowy-server/src/local_server/mod.rs index 6e67356fd9..2b9fe07250 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/mod.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/mod.rs @@ -3,3 +3,4 @@ pub use server::*; pub mod impls; mod server; pub(crate) mod uid; +mod util; diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 8ddcb2d76c..9ce19f5df6 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -53,7 +53,9 @@ impl AppFlowyServer for LocalServer { } fn database_service(&self) -> Arc { - Arc::new(LocalServerDatabaseCloudServiceImpl()) + Arc::new(LocalServerDatabaseCloudServiceImpl { + logged_user: self.logged_user.clone(), + }) } fn database_ai_service(&self) -> Option> { diff --git a/frontend/rust-lib/flowy-server/src/local_server/util.rs b/frontend/rust-lib/flowy-server/src/local_server/util.rs new file mode 100644 index 0000000000..bd00212afb --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/local_server/util.rs @@ -0,0 +1,45 @@ +use collab::core::origin::CollabOrigin; +use collab::entity::EncodedCollab; +use collab::preclude::Collab; +use collab_database::database::default_database_data; +use collab_database::workspace_database::default_workspace_database_data; +use collab_document::document_data::default_document_collab_data; +use collab_entity::CollabType; +use collab_user::core::default_user_awareness_data; +use flowy_error::{FlowyError, FlowyResult}; + +pub async fn default_encode_collab_for_collab_type( + _uid: i64, + object_id: &str, + collab_type: CollabType, +) -> FlowyResult { + match collab_type { + CollabType::Document => { + let encode_collab = default_document_collab_data(object_id)?; + Ok(encode_collab) + }, + CollabType::Database => default_database_data(object_id).await.map_err(Into::into), + CollabType::WorkspaceDatabase => Ok(default_workspace_database_data(object_id)), + CollabType::Folder => { + // let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + // let workspace = Workspace::new(object_id.to_string(), "".to_string(), uid); + // let folder_data = FolderData::new(workspace); + // let folder = Folder::create(uid, collab, None, folder_data); + // let data = folder.encode_collab_v1(|c| { + // collab_type + // .validate_require_data(c) + // .map_err(|err| FlowyError::invalid_data().with_context(err))?; + // Ok::<_, FlowyError>(()) + // })?; + // Ok(data) + Err(FlowyError::not_support()) + }, + CollabType::DatabaseRow => Err(FlowyError::not_support()), + CollabType::UserAwareness => Ok(default_user_awareness_data(object_id)), + CollabType::Unknown => { + let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + let data = collab.encode_collab_v1(|_| Ok::<_, FlowyError>(()))?; + Ok(data) + }, + } +} From 92d5690bba8e417ee11aa34c8a72b36886e80134 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 18:07:02 +0800 Subject: [PATCH 204/207] chore: pass folder init data --- frontend/rust-lib/Cargo.lock | 1 + frontend/rust-lib/flowy-ai/src/ai_manager.rs | 2 +- frontend/rust-lib/flowy-core/src/lib.rs | 1 + .../flowy-core/src/user_state_callback.rs | 117 ++++++++++++------ frontend/rust-lib/flowy-folder/Cargo.toml | 1 + frontend/rust-lib/flowy-folder/src/manager.rs | 54 +++----- .../src/local_server/impls/folder.rs | 4 +- .../rust-lib/flowy-storage/src/manager.rs | 2 +- frontend/rust-lib/flowy-user/src/event_map.rs | 7 +- .../user_manager/manager_user_workspace.rs | 2 +- 10 files changed, 106 insertions(+), 85 deletions(-) diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 4a3fae985a..51a3f1a3b2 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -2835,6 +2835,7 @@ dependencies = [ "flowy-notification", "flowy-search-pub", "flowy-sqlite", + "flowy-user-pub", "futures", "lazy_static", "lib-dispatch", diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index 399a8d2d5d..9055341b99 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -131,7 +131,7 @@ impl AIManager { #[instrument(skip_all, err)] pub async fn initialize_after_open_workspace( &self, - _workspace_id: &str, + _workspace_id: &Uuid, ) -> Result<(), FlowyError> { let local_ai = self.local_ai.clone(); tokio::spawn(async move { diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 893a010a18..c2800bd73b 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -253,6 +253,7 @@ impl AppFlowyCore { .await; let user_status_callback = UserStatusCallbackImpl { + user_manager: user_manager.clone(), collab_builder, folder_manager: folder_manager.clone(), database_manager: database_manager.clone(), diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index e43002d709..8db4e3f926 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -4,23 +4,27 @@ use anyhow::Context; use client_api::entity::billing_dto::SubscriptionPlan; use tracing::{error, event, info}; +use crate::server_layer::ServerProvider; use collab_entity::CollabType; use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_ai::ai_manager::AIManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::manager::{FolderInitDataSource, FolderManager}; use flowy_storage::manager::StorageManager; use flowy_user::event_map::UserStatusCallback; +use flowy_user::user_manager::UserManager; use flowy_user_pub::cloud::{UserCloudConfig, UserCloudServiceProvider}; use flowy_user_pub::entities::{AuthType, UserProfile, UserWorkspace}; use lib_dispatch::runtime::AFPluginRuntime; use lib_infra::async_trait::async_trait; - -use crate::server_layer::ServerProvider; +use uuid::Uuid; pub(crate) struct UserStatusCallbackImpl { + pub(crate) user_manager: Arc, pub(crate) collab_builder: Arc, pub(crate) folder_manager: Arc, pub(crate) database_manager: Arc, @@ -42,6 +46,60 @@ impl UserStatusCallbackImpl { } }); } + + async fn folder_init_data_source( + &self, + user_id: i64, + workspace_id: &Uuid, + auth_type: &AuthType, + ) -> FlowyResult { + let is_exist = self.is_object_exist_on_disk(user_id, workspace_id, workspace_id)?; + if is_exist { + Ok(FolderInitDataSource::LocalDisk { + create_if_not_exist: false, + }) + } else { + let data_source = match self + .folder_manager + .cloud_service + .get_folder_doc_state(workspace_id, user_id, CollabType::Folder, workspace_id) + .await + { + Ok(doc_state) => match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }, + AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), + }, + Err(err) => match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }, + AuthType::AppFlowyCloud => { + return Err(err); + }, + }, + }; + Ok(data_source) + } + } + + fn is_object_exist_on_disk( + &self, + user_id: i64, + workspace_id: &Uuid, + object_id: &Uuid, + ) -> FlowyResult { + let db = self + .user_manager + .get_collab_db(user_id)? + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Collab db is not initialized"))?; + let read = db.read_txn(); + let workspace_id = workspace_id.to_string(); + let object_id = object_id.to_string(); + Ok(read.is_exist(user_id, &workspace_id, &object_id)) + } } #[async_trait] @@ -101,9 +159,13 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace, device_id ); + let workspace_id = user_workspace.workspace_id()?; + let data_source = self + .folder_init_data_source(user_id, &workspace_id, auth_type) + .await?; self .folder_manager - .initialize_after_sign_in(user_id) + .initialize_after_sign_in(user_id, data_source) .await?; self .database_manager @@ -135,37 +197,9 @@ impl UserStatusCallback for UserStatusCallbackImpl { device_id ); let workspace_id = user_workspace.workspace_id()?; - - // In the current implementation, when a user signs up for AppFlowy Cloud, a default workspace - // is automatically created for them. However, for users who sign up through Supabase, the creation - // of the default workspace relies on the client-side operation. This means that the process - // for initializing a default workspace differs depending on the sign-up method used. - let data_source = match self - .folder_manager - .cloud_service - .get_folder_doc_state( - &workspace_id, - user_profile.uid, - CollabType::Folder, - &workspace_id, - ) - .await - { - Ok(doc_state) => match auth_type { - AuthType::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), - }, - Err(err) => match auth_type { - AuthType::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - AuthType::AppFlowyCloud => { - return Err(err); - }, - }, - }; + let data_source = self + .folder_init_data_source(user_profile.uid, &workspace_id, auth_type) + .await?; self .folder_manager @@ -204,12 +238,17 @@ impl UserStatusCallback for UserStatusCallbackImpl { async fn on_workspace_opened( &self, user_id: i64, - user_workspace: &UserWorkspace, + workspace_id: &Uuid, + _user_workspace: &UserWorkspace, auth_type: &AuthType, ) -> FlowyResult<()> { + let data_source = self + .folder_init_data_source(user_id, workspace_id, auth_type) + .await?; + self .folder_manager - .initialize_after_open_workspace(user_id) + .initialize_after_open_workspace(user_id, data_source) .await?; self .database_manager @@ -221,11 +260,11 @@ impl UserStatusCallback for UserStatusCallbackImpl { .await?; self .ai_manager - .initialize_after_open_workspace(&user_workspace.id) + .initialize_after_open_workspace(workspace_id) .await?; self .storage_manager - .initialize_after_open_workspace(&user_workspace.id) + .initialize_after_open_workspace(workspace_id) .await; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/Cargo.toml b/frontend/rust-lib/flowy-folder/Cargo.toml index 998fcb84f5..13b19e48b8 100644 --- a/frontend/rust-lib/flowy-folder/Cargo.toml +++ b/frontend/rust-lib/flowy-folder/Cargo.toml @@ -14,6 +14,7 @@ collab-plugins = { workspace = true } collab-integrate = { workspace = true } flowy-folder-pub = { workspace = true } flowy-search-pub = { workspace = true } +flowy-user-pub = { workspace = true } flowy-sqlite = { workspace = true } flowy-derive.workspace = true flowy-notification = { workspace = true } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 8662c1e061..fd12c15fba 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -262,16 +262,17 @@ impl FolderManager { /// Initialize the folder with the given workspace id. /// Fetch the folder updates from the cloud service and initialize the folder. - #[tracing::instrument(skip(self, user_id), err)] - pub async fn initialize_after_sign_in(&self, user_id: i64) -> FlowyResult<()> { + #[tracing::instrument(skip_all, err)] + pub async fn initialize_after_sign_in( + &self, + user_id: i64, + data_source: FolderInitDataSource, + ) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; - let object_id = &workspace_id; - - let is_exist = self - .user - .is_folder_exist_on_disk(user_id, &workspace_id) - .unwrap_or(false); - if is_exist { + if let Err(err) = self.initialize(user_id, &workspace_id, data_source).await { + // If failed to open folder with remote data, open from local disk. After open from the local + // disk. the data will be synced to the remote server. + error!("initialize folder with error {:?}, fallback local", err); self .initialize( user_id, @@ -281,39 +282,17 @@ impl FolderManager { }, ) .await?; - } else { - let folder_doc_state = self - .cloud_service - .get_folder_doc_state(&workspace_id, user_id, CollabType::Folder, object_id) - .await?; - if let Err(err) = self - .initialize( - user_id, - &workspace_id, - FolderInitDataSource::Cloud(folder_doc_state), - ) - .await - { - // If failed to open folder with remote data, open from local disk. After open from the local - // disk. the data will be synced to the remote server. - error!("initialize folder with error {:?}, fallback local", err); - self - .initialize( - user_id, - &workspace_id, - FolderInitDataSource::LocalDisk { - create_if_not_exist: false, - }, - ) - .await?; - } } Ok(()) } - pub async fn initialize_after_open_workspace(&self, uid: i64) -> FlowyResult<()> { - self.initialize_after_sign_in(uid).await + pub async fn initialize_after_open_workspace( + &self, + uid: i64, + data_source: FolderInitDataSource, + ) -> FlowyResult<()> { + self.initialize_after_sign_in(uid, data_source).await } /// Initialize the folder for the new user. @@ -2139,6 +2118,7 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &Uuid, folder: &Folde } #[allow(clippy::large_enum_variant)] +#[derive(Debug)] pub enum FolderInitDataSource { /// It means using the data stored on local disk to initialize the folder LocalDisk { create_if_not_exist: bool }, diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 7f4720de99..483ca3d100 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -85,9 +85,7 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { } else { let data = default_encode_collab_for_collab_type(uid, &object_id, collab_type).await?; drop(read_txn); - - // create default folder doc - Err(FlowyError::local_version_not_support()) + Ok(data.doc_state.to_vec()) } } diff --git a/frontend/rust-lib/flowy-storage/src/manager.rs b/frontend/rust-lib/flowy-storage/src/manager.rs index 619dc47f90..0dd729b087 100644 --- a/frontend/rust-lib/flowy-storage/src/manager.rs +++ b/frontend/rust-lib/flowy-storage/src/manager.rs @@ -181,7 +181,7 @@ impl StorageManager { } } - pub async fn initialize_after_open_workspace(&self, workspace_id: &str) { + pub async fn initialize_after_open_workspace(&self, workspace_id: &Uuid) { self.enable_storage_write_access(); if let Err(err) = prepare_upload_task(self.uploader.clone(), self.user_service.clone()).await { diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 0b489e6f28..fbee0a96d9 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -1,13 +1,13 @@ use client_api::entity::billing_dto::SubscriptionPlan; -use std::sync::Weak; -use strum_macros::Display; - use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; use flowy_error::FlowyResult; use flowy_user_pub::cloud::UserCloudConfig; use flowy_user_pub::entities::*; use lib_dispatch::prelude::*; use lib_infra::async_trait::async_trait; +use std::sync::Weak; +use strum_macros::Display; +use uuid::Uuid; use crate::event_handler::*; use crate::user_manager::UserManager; @@ -323,6 +323,7 @@ pub trait UserStatusCallback: Send + Sync + 'static { async fn on_workspace_opened( &self, _user_id: i64, + _workspace_id: &Uuid, _user_workspace: &UserWorkspace, _auth_type: &AuthType, ) -> FlowyResult<()> { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 425b2351b0..34d0ece483 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -195,7 +195,7 @@ impl UserManager { .user_status_callback .read() .await - .on_workspace_opened(uid, &user_workspace, &user_profile.auth_type) + .on_workspace_opened(uid, workspace_id, &user_workspace, &user_profile.auth_type) .await { error!("Open workspace failed: {:?}", err); From 2ee786f3512a8d631ac084596fd1716dd5a147c0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 19:29:56 +0800 Subject: [PATCH 205/207] chore: update logs --- .../folder_deps/folder_deps_chat_impl.rs | 8 +-- .../folder_deps/folder_deps_database_impl.rs | 4 +- .../flowy-core/src/user_state_callback.rs | 56 ++++++++++--------- .../flowy-server/src/af_cloud/impls/chat.rs | 4 +- .../af_cloud/impls/user/cloud_service_impl.rs | 31 ++-------- .../src/local_server/impls/chat.rs | 6 +- .../src/local_server/impls/user.rs | 2 +- .../flowy-server/src/local_server/util.rs | 6 +- frontend/rust-lib/flowy-user-pub/src/cloud.rs | 12 +--- .../user_manager/manager_user_workspace.rs | 4 +- 10 files changed, 55 insertions(+), 78 deletions(-) diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs index fc7a861cb8..e2791827ee 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs @@ -31,7 +31,7 @@ impl FolderOperationHandler for ChatFolderOperation { } async fn duplicate_view(&self, _view_id: &Uuid) -> Result { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("Duplicate view")) } async fn create_view_with_view_data( @@ -39,7 +39,7 @@ impl FolderOperationHandler for ChatFolderOperation { _user_id: i64, _params: CreateViewParams, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("Can't create view")) } async fn create_default_view( @@ -65,7 +65,7 @@ impl FolderOperationHandler for ChatFolderOperation { _import_type: ImportType, _bytes: Vec, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("import from data")) } async fn import_from_file_path( @@ -74,6 +74,6 @@ impl FolderOperationHandler for ChatFolderOperation { _name: &str, _path: String, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("import file from path")) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs index d98e32f67d..edc40c6d5b 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs @@ -198,7 +198,9 @@ impl FolderOperationHandler for DatabaseFolderOperation { ViewLayoutPB::Calendar => DatabaseLayoutPB::Calendar, ViewLayoutPB::Grid => DatabaseLayoutPB::Grid, ViewLayoutPB::Document | ViewLayoutPB::Chat => { - return Err(FlowyError::not_support()); + return Err( + FlowyError::invalid_data().with_context("Can't handle document layout type"), + ); }, }; let name = params.name.to_string(); diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index 8db4e3f926..3be1bf15ed 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -53,35 +53,17 @@ impl UserStatusCallbackImpl { workspace_id: &Uuid, auth_type: &AuthType, ) -> FlowyResult { - let is_exist = self.is_object_exist_on_disk(user_id, workspace_id, workspace_id)?; - if is_exist { - Ok(FolderInitDataSource::LocalDisk { + if self.is_object_exist_on_disk(user_id, workspace_id, workspace_id)? { + return Ok(FolderInitDataSource::LocalDisk { create_if_not_exist: false, - }) - } else { - let data_source = match self - .folder_manager - .cloud_service - .get_folder_doc_state(workspace_id, user_id, CollabType::Folder, workspace_id) - .await - { - Ok(doc_state) => match auth_type { - AuthType::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), - }, - Err(err) => match auth_type { - AuthType::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - AuthType::AppFlowyCloud => { - return Err(err); - }, - }, - }; - Ok(data_source) + }); } + let doc_state_result = self + .folder_manager + .cloud_service + .get_folder_doc_state(workspace_id, user_id, CollabType::Folder, workspace_id) + .await; + resolve_data_source(auth_type, doc_state_result) } fn is_object_exist_on_disk( @@ -296,3 +278,23 @@ impl UserStatusCallback for UserStatusCallbackImpl { } } } + +fn resolve_data_source( + auth_type: &AuthType, + doc_state_result: Result, FlowyError>, +) -> FlowyResult { + match doc_state_result { + Ok(doc_state) => Ok(match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }, + AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), + }), + Err(err) => match auth_type { + AuthType::Local => Ok(FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }), + AuthType::AppFlowyCloud => Err(err), + }, + } +} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 14a26078f5..6086f7084b 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -217,10 +217,10 @@ where chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError> { - return Err( + Err( FlowyError::not_support() .with_context("indexing file with appflowy cloud is not suppotred yet"), - ); + ) } async fn get_chat_settings( diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index e0f81a62e4..d6260d9e09 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -13,8 +13,8 @@ use client_api::entity::workspace_dto::{ WorkspaceMemberInvitation, }; use client_api::entity::{ - AFRole, AFWorkspace, AFWorkspaceInvitation, AFWorkspaceSettings, AFWorkspaceSettingsChange, - AuthProvider, CollabParams, CreateCollabParams, GotrueTokenResponse, QueryWorkspaceMember, + AFWorkspace, AFWorkspaceInvitation, AFWorkspaceSettings, AFWorkspaceSettingsChange, AuthProvider, + CollabParams, CreateCollabParams, GotrueTokenResponse, QueryWorkspaceMember, }; use client_api::entity::{QueryCollab, QueryCollabParams}; use client_api::{Client, ClientConfiguration}; @@ -341,18 +341,6 @@ where Ok(members) } - async fn get_workspace_member( - &self, - workspace_id: Uuid, - uid: i64, - ) -> Result { - let try_get_client = self.server.try_get_client(); - let client = try_get_client?; - let query = QueryWorkspaceMember { workspace_id, uid }; - let member = client.get_workspace_member(query).await?; - Ok(from_af_workspace_member(member)) - } - #[instrument(level = "debug", skip_all)] async fn get_user_awareness_doc_state( &self, @@ -452,7 +440,7 @@ where Ok(payment_link) } - async fn get_workspace_member_info( + async fn get_workspace_member( &self, workspace_id: &Uuid, uid: i64, @@ -464,17 +452,8 @@ where uid, }; let member = client.get_workspace_member(params).await?; - let role = match member.role { - AFRole::Owner => Role::Owner, - AFRole::Member => Role::Member, - AFRole::Guest => Role::Guest, - }; - Ok(WorkspaceMember { - email: member.email, - role, - name: member.name, - avatar_url: member.avatar_url, - }) + + Ok(from_af_workspace_member(member)) } async fn get_workspace_subscriptions( diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs index 3af87eb18e..845b6dec1c 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -8,7 +8,7 @@ use flowy_ai_pub::cloud::chat_dto::{ChatAuthor, ChatAuthorType}; use flowy_ai_pub::cloud::{ AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, - ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, + ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, DEFAULT_AI_MODEL_NAME, }; use flowy_ai_pub::persistence::{ deserialize_chat_metadata, deserialize_rag_ids, read_chat, @@ -313,11 +313,11 @@ impl ChatCloudService for LocalChatServiceImpl { } async fn get_available_models(&self, _workspace_id: &Uuid) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + Ok(ModelList { models: vec![] }) } async fn get_workspace_default_model(&self, _workspace_id: &Uuid) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + Ok(DEFAULT_AI_MODEL_NAME.to_string()) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index bd6e930584..0023defb41 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -209,7 +209,7 @@ impl UserCloudService for LocalServerUserServiceImpl { Ok(()) } - async fn get_workspace_member_info( + async fn get_workspace_member( &self, workspace_id: &Uuid, uid: i64, diff --git a/frontend/rust-lib/flowy-server/src/local_server/util.rs b/frontend/rust-lib/flowy-server/src/local_server/util.rs index bd00212afb..378ccee6a2 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/util.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/util.rs @@ -32,9 +32,11 @@ pub async fn default_encode_collab_for_collab_type( // Ok::<_, FlowyError>(()) // })?; // Ok(data) - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("Can not create default folder")) + }, + CollabType::DatabaseRow => { + Err(FlowyError::not_support().with_context("Can not create default database row")) }, - CollabType::DatabaseRow => Err(FlowyError::not_support()), CollabType::UserAwareness => Ok(default_user_awareness_data(object_id)), CollabType::Unknown => { let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index b15299b194..e58c626532 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -132,7 +132,7 @@ pub trait UserCloudService: Send + Sync + 'static { /// Delete an account and all the data associated with the account async fn delete_account(&self) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Ok(()) } /// Generate a sign in url for the user with the given email @@ -234,14 +234,6 @@ pub trait UserCloudService: Send + Sync + 'static { Ok(vec![]) } - async fn get_workspace_member( - &self, - workspace_id: Uuid, - uid: i64, - ) -> Result { - Err(FlowyError::not_support()) - } - async fn get_user_awareness_doc_state( &self, uid: i64, @@ -281,7 +273,7 @@ pub trait UserCloudService: Send + Sync + 'static { Err(FlowyError::not_support()) } - async fn get_workspace_member_info( + async fn get_workspace_member( &self, workspace_id: &Uuid, uid: i64, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 34d0ece483..f74cf45ef7 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -377,7 +377,7 @@ impl UserManager { let member = self .cloud_service .get_user_service()? - .get_workspace_member(workspace_id, uid) + .get_workspace_member(&workspace_id, uid) .await?; Ok(member) } @@ -655,7 +655,7 @@ impl UserManager { let member = self .cloud_service .get_user_service()? - .get_workspace_member_info(workspace_id, uid) + .get_workspace_member(workspace_id, uid) .await?; let record = WorkspaceMemberTable { From cf46213e0019b1b1228479598b1e46b5d7d1d71c Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Sun, 20 Apr 2025 19:34:05 +0800 Subject: [PATCH 206/207] chore: Update frontend/rust-lib/flowy-folder/src/manager.rs Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- frontend/rust-lib/flowy-folder/src/manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index fd12c15fba..a12959b803 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -272,7 +272,7 @@ impl FolderManager { if let Err(err) = self.initialize(user_id, &workspace_id, data_source).await { // If failed to open folder with remote data, open from local disk. After open from the local // disk. the data will be synced to the remote server. - error!("initialize folder with error {:?}, fallback local", err); + error!("initialize folder for user {} with workspace {} encountered error: {:?}, fallback local", user_id, workspace_id, err); self .initialize( user_id, From c6010a6734a7b7c61d7ce306be96735f00a91f0b Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 20 Apr 2025 19:51:12 +0800 Subject: [PATCH 207/207] chore: fmt --- frontend/rust-lib/flowy-folder/src/manager.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index a12959b803..8e228191c4 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -272,7 +272,10 @@ impl FolderManager { if let Err(err) = self.initialize(user_id, &workspace_id, data_source).await { // If failed to open folder with remote data, open from local disk. After open from the local // disk. the data will be synced to the remote server. - error!("initialize folder for user {} with workspace {} encountered error: {:?}, fallback local", user_id, workspace_id, err); + error!( + "initialize folder for user {} with workspace {} encountered error: {:?}, fallback local", + user_id, workspace_id, err + ); self .initialize( user_id,