Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c86b13d9 | ||
|
|
13fe4a2395 | ||
|
|
e8b8644716 | ||
|
|
10472475ac | ||
|
|
20e10e6bf2 | ||
|
|
cf14a37dcf | ||
|
|
a359313974 | ||
|
|
636794b9ba | ||
|
|
7e5b243e40 | ||
|
|
5e99eeb167 | ||
|
|
5029776685 | ||
|
|
79009d2851 | ||
|
|
d153b3d9f1 | ||
|
|
f9aa3ff499 | ||
|
|
b4fbfaf2f8 | ||
|
|
a026367f85 | ||
|
|
ef7063b8fd | ||
|
|
808bee5eb2 | ||
|
|
9ecae0a643 | ||
|
|
dfc80e6a5a | ||
|
|
014928cf19 | ||
|
|
30f5692818 | ||
|
|
6e850ab8b2 | ||
|
|
370b5714a1 | ||
|
|
2c8060cb1b | ||
|
|
b4d48a63b9 | ||
|
|
c986e414f4 | ||
|
|
c79ac3290e | ||
|
|
7f7657edc8 | ||
|
|
d3ce59fe08 | ||
|
|
0f89b6f690 | ||
|
|
680015ef66 | ||
|
|
b048407b4e | ||
|
|
95f9b07ef7 | ||
|
|
b1d3553110 | ||
|
|
38839b4255 | ||
|
|
f7b556677f | ||
|
|
19b1bccb03 | ||
|
|
0f5699571c | ||
|
|
53cbd22111 | ||
|
|
926a5b31bb | ||
|
|
bdac73e934 | ||
|
|
20f3116bd9 | ||
|
|
a2b1f8c325 | ||
|
|
612d652320 | ||
|
|
7750f2257f |
No files matched your search
@@ -19,7 +19,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.81.0"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
CARGO_MAKE_VERSION: "0.37.18"
|
||||
CLOUD_VERSION: 0.6.54-amd64
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.81.0"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
CARGO_MAKE_VERSION: "0.37.18"
|
||||
CLOUD_VERSION: 0.9.37-amd64
|
||||
CLOUD_VERSION: 0.9.49-amd64
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
|
||||
@@ -19,35 +19,14 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.81.0"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-self-hosted:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build AppFlowy
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
|
||||
cargo make --profile development-ios-arm64-sim code_generation
|
||||
|
||||
- uses: futureware-tech/simulator-action@v3
|
||||
id: simulator-action
|
||||
with:
|
||||
model: "iPhone 15"
|
||||
shutdown_after_job: false
|
||||
|
||||
integration-tests:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.81.0"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
@@ -232,10 +232,10 @@ jobs:
|
||||
matrix:
|
||||
job:
|
||||
- {
|
||||
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
|
||||
os: macos-latest,
|
||||
extra-build-args: "",
|
||||
}
|
||||
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
|
||||
os: macos-latest,
|
||||
extra-build-args: "",
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
@@ -336,12 +336,12 @@ jobs:
|
||||
matrix:
|
||||
job:
|
||||
- {
|
||||
arch: x86_64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter_profile: production-linux-x86_64,
|
||||
}
|
||||
arch: x86_64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter_profile: production-linux-x86_64,
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -18,8 +18,8 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CLOUD_VERSION: 0.8.3-amd64
|
||||
RUST_TOOLCHAIN: "1.81.0"
|
||||
CLOUD_VERSION: 0.9.49-amd64
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
|
||||
jobs:
|
||||
ubuntu-job:
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
af_cloud_test_ws_url: ws://localhost/ws/v1
|
||||
af_cloud_test_gotrue_url: http://localhost/gotrue
|
||||
run: |
|
||||
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
|
||||
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" -- --skip local_ollama_test
|
||||
|
||||
- name: rustfmt rust-lib
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.81.0"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
# Release Notes
|
||||
## Version 0.9.2 - 14/05/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
- Supported AI Overview in Search to answer user queries based on their entire workspace
|
||||
- Revamped the Search panel in the desktop app
|
||||
- Enabled loading custom prompts from an AppFlowy database page
|
||||
#### Bug Fixes
|
||||
- Improved inserting emojis using the colon (:)
|
||||
- Supported automatically filling the link name with the URL if the name is left empty
|
||||
### Mobile
|
||||
#### Bug Fixes
|
||||
- Supported automatically filling the link name with the URL if the name is left empty
|
||||
|
||||
## Version 0.9.1 - 01/05/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
- Added AppFlowy Prompt Library to AI Chat and Document's Ask AI
|
||||
- Revamped the desktop in-app notification center
|
||||
- Supported login with password, as well as forgot and change password options
|
||||
- Supported copying link to invite members
|
||||
- Improved the Settings' Members tab with new metadata: member avatar and joined time
|
||||
#### Bug Fixes
|
||||
- Fixed data loss when using anonymous local
|
||||
- Fixed crash when trying to delete an emoji
|
||||
- Fixed Windows scaling issue
|
||||
- Correctly displayed mention text by decoding web content
|
||||
### Mobile
|
||||
#### New Features
|
||||
- Supported workspace search
|
||||
- Improved UX for links in documents
|
||||
- Supported changing password in Mobile Settings
|
||||
- Added support for inviting members via links
|
||||
#### Bug Fixes
|
||||
- Correctly displayed mention text by decoding web content
|
||||
|
||||
## Version 0.9.0 - 30/04/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
|
||||
+36
-35
@@ -1,16 +1,16 @@
|
||||
#https://github.com/sagiegurari/cargo-make
|
||||
|
||||
extend = [
|
||||
{ path = "scripts/makefile/desktop.toml" },
|
||||
{ path = "scripts/makefile/mobile.toml" },
|
||||
{ path = "scripts/makefile/protobuf.toml" },
|
||||
{ path = "scripts/makefile/tests.toml" },
|
||||
{ path = "scripts/makefile/docker.toml" },
|
||||
{ path = "scripts/makefile/env.toml" },
|
||||
{ path = "scripts/makefile/flutter.toml" },
|
||||
{ path = "scripts/makefile/tool.toml" },
|
||||
{ path = "scripts/makefile/tauri.toml" },
|
||||
{ path = "scripts/makefile/web.toml" },
|
||||
{ path = "scripts/makefile/desktop.toml" },
|
||||
{ path = "scripts/makefile/mobile.toml" },
|
||||
{ path = "scripts/makefile/protobuf.toml" },
|
||||
{ path = "scripts/makefile/tests.toml" },
|
||||
{ path = "scripts/makefile/docker.toml" },
|
||||
{ path = "scripts/makefile/env.toml" },
|
||||
{ path = "scripts/makefile/flutter.toml" },
|
||||
{ path = "scripts/makefile/tool.toml" },
|
||||
{ path = "scripts/makefile/tauri.toml" },
|
||||
{ path = "scripts/makefile/web.toml" },
|
||||
]
|
||||
|
||||
[config]
|
||||
@@ -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.9.0"
|
||||
APPFLOWY_VERSION = "0.9.2"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
@@ -228,30 +228,31 @@ script = ['''
|
||||
echo APP_ENVIRONMENT: ${APP_ENVIRONMENT}
|
||||
echo BUILD_ARCHS: ${BUILD_ARCHS}
|
||||
echo BUILD_VERSION: ${BUILD_VERSION}
|
||||
echo RUST_VERSION: "$(rustc --version)"
|
||||
''']
|
||||
script_runner = "@shell"
|
||||
|
||||
[tasks.setup-crate-type]
|
||||
private = true
|
||||
script = [
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} "staticlib" ${CRATE_TYPE}
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} "staticlib" ${CRATE_TYPE}
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
]
|
||||
script_runner = "@duckscript"
|
||||
|
||||
[tasks.restore-crate-type]
|
||||
private = true
|
||||
script = [
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} ${CRATE_TYPE} "staticlib"
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} ${CRATE_TYPE} "staticlib"
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
]
|
||||
script_runner = "@duckscript"
|
||||
|
||||
@@ -279,24 +280,24 @@ TEST_COMPILE_TARGET = "x86_64-pc-windows-msvc"
|
||||
[tasks.setup-test-crate-type]
|
||||
private = true
|
||||
script = [
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE}
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE}
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
]
|
||||
script_runner = "@duckscript"
|
||||
|
||||
[tasks.restore-test-crate-type]
|
||||
private = true
|
||||
script = [
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib"
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
"""
|
||||
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
|
||||
val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib"
|
||||
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
|
||||
assert ${result}
|
||||
""",
|
||||
]
|
||||
script_runner = "@duckscript"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"{""data"":""Juan"",""last_modified"":1726063423,""created_at"":1726063423,""field_type"":0}","{""created_at"":1726122510,""reminder_id"":"""",""include_time"":false,""is_range"":true,""last_modified"":1726122515,""data"":""1725604115"",""end_timestamp"":""1725776915"",""field_type"":2}","{""field_type"":1,""created_at"":1726063677,""last_modified"":1726063677,""data"":""93100""}","{""field_type"":1,""data"":""4.86"",""created_at"":1726062597,""last_modified"":1726062597}",,"{""last_modified"":1726062377,""field_type"":5,""data"":""Yes"",""created_at"":1726062377}","{""last_modified"":1726064412,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""tTDq\"",\""name\"":\""complete onboarding\"",\""color\"":\""Purple\""},{\""id\"":\""E8Ds\"",\""name\"":\""contact support\"",\""color\"":\""Purple\""},{\""id\"":\""RoGN\"",\""name\"":\""get started\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""tTDq\"",\""E8Ds\""]}"",""created_at"":1726064396}",,"{""created_at"":1726065278,""field_type"":3,""data"":""qnja"",""last_modified"":1726065278}","{""data"":""R9I7,yORP,1i4f"",""field_type"":4,""created_at"":1726105126,""last_modified"":1726105127}","{""data"":""1726122515"",""field_type"":8}","{""data"":""1726060541"",""field_type"":9}"
|
||||
"{""data"":""Alex"",""created_at"":1726063432,""last_modified"":1726063432,""field_type"":0}","{""reminder_id"":"""",""data"":""1725292800"",""include_time"":true,""last_modified"":1726122448,""created_at"":1726122422,""is_range"":true,""end_timestamp"":""1725551940"",""field_type"":2}","{""field_type"":1,""last_modified"":1726063683,""created_at"":1726063683,""data"":""3560""}","{""created_at"":1726062561,""data"":""1.96"",""last_modified"":1726062561,""field_type"":1}","{""last_modified"":1726063952,""created_at"":1726063931,""data"":""al3x1343@protonmail.com"",""field_type"":6}","{""last_modified"":1726062375,""field_type"":5,""created_at"":1726062375,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""qNyr\"",\""name\"":\""finish reading book\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064616,""last_modified"":1726064616,""field_type"":7}",,"{""data"":""qnja"",""created_at"":1726065272,""last_modified"":1726065272,""field_type"":3}","{""created_at"":1726105180,""last_modified"":1726105180,""field_type"":4,""data"":""R9I7,1i4f""}","{""field_type"":8,""data"":""1726122448""}","{""field_type"":9,""data"":""1726060541""}"
|
||||
"{""last_modified"":1726063478,""created_at"":1726063436,""field_type"":0,""data"":""Alexander""}",,"{""field_type"":1,""last_modified"":1726063691,""created_at"":1726063691,""data"":""2073""}","{""field_type"":1,""data"":""0.5"",""last_modified"":1726062577,""created_at"":1726062577}","{""last_modified"":1726063991,""field_type"":6,""created_at"":1726063991,""data"":""alexandernotthedra@gmail.com""}","{""field_type"":5,""last_modified"":1726062378,""created_at"":1726062377,""data"":""No""}",,,"{""created_at"":1726065291,""data"":""GSf_"",""last_modified"":1726065291,""field_type"":3}","{""last_modified"":1726105142,""created_at"":1726105133,""data"":""SEUo"",""field_type"":4}","{""field_type"":8,""data"":""1726105142""}","{""field_type"":9,""data"":""1726060542""}"
|
||||
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://static.cdninstagram.com/rsrc.php/v3/ym/r/BQdTmxpRI6f.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
|
||||
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://file-examples.com/wp-content/storage/2017/10/file_example_PNG_500kB.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
|
||||
"{""field_type"":0,""last_modified"":1726063467,""data"":""Joanna"",""created_at"":1726063467}","{""include_time"":false,""end_timestamp"":""1727072893"",""is_range"":true,""last_modified"":1726122493,""created_at"":1726122483,""data"":""1726554493"",""field_type"":2,""reminder_id"":""""}","{""last_modified"":1726065463,""data"":""16470"",""field_type"":1,""created_at"":1726065463}","{""created_at"":1726062626,""field_type"":1,""last_modified"":1726062626,""data"":""-5.36""}","{""last_modified"":1726064069,""data"":""joannastrawberry29+hello@gmail.com"",""created_at"":1726064069,""field_type"":6}",,"{""field_type"":7,""created_at"":1726064444,""last_modified"":1726064460,""data"":""{\""options\"":[{\""id\"":\""ZxJz\"",\""name\"":\""post on Twitter\"",\""color\"":\""Purple\""},{\""id\"":\""upwi\"",\""name\"":\""watch Youtube videos\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""upwi\""]}""}",,"{""created_at"":1726065317,""last_modified"":1726065317,""field_type"":3,""data"":""qnja""}","{""field_type"":4,""last_modified"":1726105173,""data"":""uRAO,yORP"",""created_at"":1726105170}","{""data"":""1726122493"",""field_type"":8}","{""data"":""1726060545"",""field_type"":9}"
|
||||
"{""last_modified"":1726063457,""created_at"":1726063457,""data"":""George"",""field_type"":0}","{""include_time"":true,""reminder_id"":"""",""field_type"":2,""is_range"":true,""created_at"":1726122521,""end_timestamp"":""1725829200"",""data"":""1725822900"",""last_modified"":1726122535}","{""last_modified"":1726065493,""field_type"":1,""data"":""9500"",""created_at"":1726065493}","{""last_modified"":1726062680,""created_at"":1726062680,""field_type"":1,""data"":""1.7""}","{""data"":""plgeorgebball@gmail.com"",""field_type"":6,""last_modified"":1726064087,""created_at"":1726064036}",,"{""last_modified"":1726064513,""data"":""{\""options\"":[{\""id\"":\""zy0x\"",\""name\"":\""game vs celtics\"",\""color\"":\""Purple\""},{\""id\"":\""WJsv\"",\""name\"":\""training\"",\""color\"":\""Purple\""},{\""id\"":\""w-f8\"",\""name\"":\""game vs spurs\"",\""color\"":\""Purple\""},{\""id\"":\""p1VQ\"",\""name\"":\""game vs knicks\"",\""color\"":\""Purple\""},{\""id\"":\""VjUA\"",\""name\"":\""recovery\"",\""color\"":\""Purple\""},{\""id\"":\""sQ8X\"",\""name\"":\""don't get injured\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064486,""field_type"":7}",,"{""field_type"":3,""last_modified"":1726065310,""data"":""qnja"",""created_at"":1726065310}","{""created_at"":1726105205,""field_type"":4,""last_modified"":1726105249,""data"":""R9I7,1i4f,yORP,SEUo""}","{""data"":""1726122535"",""field_type"":8}","{""field_type"":9,""data"":""1726060546""}"
|
||||
"{""data"":""Judy"",""created_at"":1726063475,""field_type"":0,""last_modified"":1726063487}","{""end_timestamp"":"""",""reminder_id"":"""",""data"":""1726640950"",""field_type"":2,""include_time"":false,""created_at"":1726122550,""last_modified"":1726122550,""is_range"":false}",,,"{""created_at"":1726063882,""field_type"":6,""last_modified"":1726064000,""data"":""judysmithjr@outlook.com""}","{""last_modified"":1726062712,""field_type"":5,""data"":""Yes"",""created_at"":1726062712}","{""created_at"":1726064549,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""j8cC\"",\""name\"":\""finish training\"",\""color\"":\""Purple\""},{\""id\"":\""SmSk\"",\""name\"":\""brainwash\"",\""color\"":\""Purple\""},{\""id\"":\""mnf5\"",\""name\"":\""welcome to ba sing se\"",\""color\"":\""Purple\""},{\""id\"":\""hcrj\"",\""name\"":\""don't mess up\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""j8cC\"",\""SmSk\"",\""mnf5\"",\""hcrj\""]}"",""last_modified"":1726064591}",,,"{""field_type"":4,""last_modified"":1726105152,""created_at"":1726105152,""data"":""R9I7""}","{""field_type"":8,""data"":""1726122550""}","{""field_type"":9,""data"":""1726060549""}"
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/ai_test_op.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('chat page:', () {
|
||||
testWidgets('send messages with default messages', (tester) async {
|
||||
skipAIChatWelcomePage = true;
|
||||
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a chat page
|
||||
final pageName = 'Untitled';
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: pageName,
|
||||
layout: ViewLayoutPB.Chat,
|
||||
openAfterCreated: false,
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 300));
|
||||
|
||||
final userId = '457037009907617792';
|
||||
final user = User(id: userId, lastName: 'Lucas');
|
||||
final aiUserId = '0';
|
||||
final aiUser = User(id: aiUserId, lastName: 'AI');
|
||||
|
||||
await tester.loadDefaultMessages(
|
||||
[
|
||||
Message.text(
|
||||
id: '1746776401',
|
||||
text: 'How to use Kanban to manage tasks?',
|
||||
author: user,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
),
|
||||
Message.text(
|
||||
id: '1746776401_ans',
|
||||
text:
|
||||
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 2)),
|
||||
),
|
||||
Message.text(
|
||||
id: '1746776402',
|
||||
text: 'How to use Kanban to manage tasks?',
|
||||
author: user,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 3)),
|
||||
),
|
||||
Message.text(
|
||||
id: '1746776402_ans',
|
||||
text:
|
||||
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 4)),
|
||||
),
|
||||
].reversed.toList(),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// start chat
|
||||
final int messageId = 1;
|
||||
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How to use AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text: '''# How to Use AppFlowy
|
||||
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
|
||||
- Create an account or sign in when you first launch the app
|
||||
- The main interface shows your workspace with a sidebar for navigation and a content area''',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
final chatBloc = tester.getCurrentChatBloc();
|
||||
expect(chatBloc.chatController.messages.length, equals(6));
|
||||
});
|
||||
|
||||
testWidgets('send messages without default messages', (tester) async {
|
||||
skipAIChatWelcomePage = true;
|
||||
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a chat page
|
||||
final pageName = 'Untitled';
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: pageName,
|
||||
layout: ViewLayoutPB.Chat,
|
||||
openAfterCreated: false,
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 300));
|
||||
|
||||
final userId = '457037009907617792';
|
||||
final user = User(id: userId, lastName: 'Lucas');
|
||||
final aiUserId = '0';
|
||||
final aiUser = User(id: aiUserId, lastName: 'AI');
|
||||
|
||||
// start chat
|
||||
int messageId = 1;
|
||||
|
||||
// round 1
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How to use AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text: '''# How to Use AppFlowy
|
||||
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
|
||||
- Create an account or sign in when you first launch the app
|
||||
- The main interface shows your workspace with a sidebar for navigation and a content area''',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 2
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How to use AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 3
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'What document formatting options are available?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'# AppFlowy Document Formatting\n- Basic formatting: Bold, italic, underline, strikethrough\n- Headings: 6 levels of headings for structuring content\n- Lists: Bullet points, numbered lists, and checklists\n- Code blocks: Format text as code with syntax highlighting\n- Tables: Create and format data tables\n- Embedded content: Add images, files, and other rich media',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 4
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How do I export my data from AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'# Exporting from AppFlowy\n- Export documents in multiple formats: Markdown, HTML, PDF\n- Export databases as CSV or Excel files\n- Batch export entire workspaces for backup\n- Use the export menu (three dots → Export) on any page\n- Exported files maintain most formatting and structure',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 5
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'Is there a mobile version of AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'# AppFlowy on Mobile\n- Yes, AppFlowy is available for iOS and Android devices\n- Download from the App Store or Google Play Store\n- Mobile app includes core functionality: document editing, databases, and boards\n- Offline mode allows working without internet connection\n- Sync automatically when you reconnect\n- Responsive design adapts to different screen sizes',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
final chatBloc = tester.getCurrentChatBloc();
|
||||
expect(chatBloc.chatController.messages.length, equals(10));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'database/database_test_runner.dart' as database_test_runner;
|
||||
import 'document/document_test_runner.dart' as document_test_runner;
|
||||
import 'set_env.dart' as preset_af_cloud_env_test;
|
||||
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
|
||||
import 'sidebar/sidebar_search_test.dart' as sidebar_search_test;
|
||||
import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
|
||||
import 'sidebar/sidebar_rename_untitled_test.dart'
|
||||
as sidebar_rename_untitled_test;
|
||||
@@ -31,6 +32,7 @@ Future<void> main() async {
|
||||
sidebar_move_page_test.main();
|
||||
sidebar_rename_untitled_test.main();
|
||||
sidebar_icon_test.main();
|
||||
sidebar_search_test.main();
|
||||
}
|
||||
|
||||
// database
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import 'package:appflowy/ai/widgets/prompt_input/desktop_prompt_input.dart';
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
testWidgets('Test for searching', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
/// show searching page
|
||||
final searchingButton = find.text(LocaleKeys.search_label.tr());
|
||||
await tester.tapButton(searchingButton);
|
||||
final askAIButton = find.byType(SearchAskAiEntrance);
|
||||
expect(askAIButton, findsOneWidget);
|
||||
|
||||
/// searching for [gettingStarted]
|
||||
final searchField = find.byType(SearchField);
|
||||
final textFiled =
|
||||
find.descendant(of: searchField, matching: find.byType(TextField));
|
||||
await tester.enterText(textFiled, gettingStarted);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
/// tap ask AI button
|
||||
await tester.tapButton(askAIButton);
|
||||
expect(find.byType(DesktopPromptInput), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+77
-1
@@ -1,7 +1,17 @@
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/page_preview.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
|
||||
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:flutter/material.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
@@ -19,4 +29,70 @@ void main() {
|
||||
expect(find.byType(CommandPaletteModal), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('Search', () {
|
||||
testWidgets('Test for searching', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: 'Switch To New Page');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
/// tap getting started page
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(HomeSideBar),
|
||||
matching: find.textContaining(gettingStarted),
|
||||
),
|
||||
);
|
||||
|
||||
/// show searching page
|
||||
final searchingButton = find.text(LocaleKeys.search_label.tr());
|
||||
await tester.tapButton(searchingButton);
|
||||
final askAIButton = find.byType(SearchAskAiEntrance);
|
||||
expect(askAIButton, findsNothing);
|
||||
final recentList = find.byType(RecentViewsList);
|
||||
expect(recentList, findsOneWidget);
|
||||
|
||||
/// there is [gettingStarted] in recent list
|
||||
final gettingStartedRecentCell = find.descendant(
|
||||
of: recentList,
|
||||
matching: find.textContaining(gettingStarted),
|
||||
);
|
||||
expect(gettingStartedRecentCell, findsAtLeast(1));
|
||||
|
||||
/// hover to show preview
|
||||
await tester.hoverOnWidget(gettingStartedRecentCell.first);
|
||||
final pagePreview = find.byType(PagePreview);
|
||||
expect(pagePreview, findsOneWidget);
|
||||
final gettingStartedPreviewTitle = find.descendant(
|
||||
of: pagePreview,
|
||||
matching: find.textContaining(gettingStarted),
|
||||
);
|
||||
expect(gettingStartedPreviewTitle, findsOneWidget);
|
||||
|
||||
/// searching for [gettingStarted]
|
||||
final searchField = find.byType(SearchField);
|
||||
final textFiled =
|
||||
find.descendant(of: searchField, matching: find.byType(TextField));
|
||||
await tester.enterText(textFiled, gettingStarted);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
/// there is [gettingStarted] in result list
|
||||
final resultList = find.byType(SearchResultList);
|
||||
expect(resultList, findsOneWidget);
|
||||
final resultCells = find.byType(SearchResultCell);
|
||||
expect(resultCells, findsAtLeast(1));
|
||||
|
||||
/// hover to show preview
|
||||
await tester.hoverOnWidget(resultCells.first);
|
||||
expect(find.byType(PagePreview), findsOneWidget);
|
||||
|
||||
/// clear search content
|
||||
final clearButton = find.byFlowySvg(FlowySvgs.search_clear_m);
|
||||
await tester.tapButton(clearButton);
|
||||
expect(find.byType(SearchResultList), findsNothing);
|
||||
expect(find.byType(RecentViewsList), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
+83
@@ -366,5 +366,88 @@ void main() {
|
||||
expect(getLinkFromNode(node), link);
|
||||
expect(getNodeText(node), afterText);
|
||||
});
|
||||
|
||||
testWidgets('insert link and clear link name', (tester) async {
|
||||
const text = 'edit link', link = 'https://test.appflowy.cloud';
|
||||
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, 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));
|
||||
|
||||
/// 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);
|
||||
|
||||
/// clear the link name
|
||||
final titleField = find.descendant(
|
||||
of: linkEditMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(titleField, '');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getNodeText(node), link);
|
||||
});
|
||||
|
||||
testWidgets('insert link and clear link name and remove link', (tester) async {
|
||||
const text = 'edit link', link = 'https://test.appflowy.cloud';
|
||||
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, 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));
|
||||
|
||||
/// 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);
|
||||
|
||||
/// clear the link name
|
||||
final titleField = find.descendant(
|
||||
of: linkEditMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(titleField, '');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getNodeText(node), link);
|
||||
expect(getLinkFromNode(node), null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'desktop/chat/chat_page_test.dart' as chat_page_test;
|
||||
import 'desktop/database/database_icon_test.dart' as database_icon_test;
|
||||
import 'desktop/first_test/first_test.dart' as first_test;
|
||||
import 'desktop/uncategorized/code_block_language_selector_test.dart'
|
||||
@@ -17,4 +18,5 @@ Future<void> runIntegration9OnDesktop() async {
|
||||
tabs_test.main();
|
||||
code_language_selector.main();
|
||||
database_icon_test.main();
|
||||
chat_page_test.main();
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test/util.dart';
|
||||
import 'util.dart';
|
||||
|
||||
extension AppFlowyAITest on WidgetTester {
|
||||
@@ -38,4 +43,27 @@ extension AppFlowyAITest on WidgetTester {
|
||||
testTextInput.enterText(text);
|
||||
await pumpAndSettle(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
ChatBloc getCurrentChatBloc() {
|
||||
return element(find.byType(ChatContentPage)).read<ChatBloc>();
|
||||
}
|
||||
|
||||
Future<void> loadDefaultMessages(List<Message> messages) async {
|
||||
final chatBloc = getCurrentChatBloc();
|
||||
chatBloc.add(ChatEvent.didLoadLatestMessages(messages));
|
||||
await blocResponseFuture();
|
||||
}
|
||||
|
||||
Future<void> sendUserMessage(Message message) async {
|
||||
final chatBloc = getCurrentChatBloc();
|
||||
// using received message to simulate the user message
|
||||
chatBloc.add(ChatEvent.receiveMessage(message));
|
||||
await blocResponseFuture();
|
||||
}
|
||||
|
||||
Future<void> receiveAIMessage(Message message) async {
|
||||
final chatBloc = getCurrentChatBloc();
|
||||
chatBloc.add(ChatEvent.receiveMessage(message));
|
||||
await blocResponseFuture();
|
||||
}
|
||||
}
|
||||
@@ -1051,6 +1051,8 @@ extension ViewLayoutPBTest on ViewLayoutPB {
|
||||
return LocaleKeys.document_menuName.tr();
|
||||
case ViewLayoutPB.Calendar:
|
||||
return LocaleKeys.calendar_menuName.tr();
|
||||
case ViewLayoutPB.Chat:
|
||||
return LocaleKeys.chat_newChat.tr();
|
||||
default:
|
||||
throw UnsupportedError('Unsupported layout: $this');
|
||||
}
|
||||
|
||||
@@ -71,11 +71,6 @@ PODS:
|
||||
- SDWebImage (5.21.0):
|
||||
- SDWebImage/Core (= 5.21.0)
|
||||
- SDWebImage/Core (5.21.0)
|
||||
- Sentry/HybridSDK (8.46.0)
|
||||
- sentry_flutter (8.14.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Sentry/HybridSDK (= 8.46.0)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -112,7 +107,6 @@ DEPENDENCIES:
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
@@ -126,7 +120,6 @@ SPEC REPOS:
|
||||
- DKPhotoGallery
|
||||
- ReachabilitySwift
|
||||
- SDWebImage
|
||||
- Sentry
|
||||
- SwiftyGif
|
||||
- Toast
|
||||
|
||||
@@ -165,8 +158,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
saver_gallery:
|
||||
:path: ".symlinks/plugins/saver_gallery/ios"
|
||||
sentry_flutter:
|
||||
:path: ".symlinks/plugins/sentry_flutter/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
@@ -202,8 +193,6 @@ SPEC CHECKSUMS:
|
||||
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
|
||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
|
||||
@@ -5,7 +5,9 @@ export 'service/appflowy_ai_service.dart';
|
||||
export 'service/error.dart';
|
||||
export 'service/ai_model_state_notifier.dart';
|
||||
export 'service/select_model_bloc.dart';
|
||||
export 'service/view_selector_cubit.dart';
|
||||
export 'widgets/loading_indicator.dart';
|
||||
export 'widgets/view_selector.dart';
|
||||
export 'widgets/prompt_input/action_buttons.dart';
|
||||
export 'widgets/prompt_input/desktop_prompt_input.dart';
|
||||
export 'widgets/prompt_input/file_attachment_list.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
@@ -14,11 +15,12 @@ class AIStreamEventPrefix {
|
||||
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';
|
||||
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:';
|
||||
static const aiFollowUp = 'ai_follow_up:';
|
||||
}
|
||||
|
||||
enum AiType {
|
||||
@@ -192,8 +194,23 @@ class AiPrompt extends Equatable {
|
||||
required this.category,
|
||||
required this.example,
|
||||
required this.isFeatured,
|
||||
required this.isCustom,
|
||||
});
|
||||
|
||||
factory AiPrompt.fromPB(CustomPromptPB pb) {
|
||||
return AiPrompt(
|
||||
id: pb.id,
|
||||
name: pb.name,
|
||||
content: pb.content,
|
||||
category: AiPromptCategory.values.firstWhere(
|
||||
(e) => e.name == pb.category,
|
||||
),
|
||||
example: pb.example,
|
||||
isFeatured: false,
|
||||
isCustom: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory AiPrompt.fromJson(Map<String, dynamic> json) =>
|
||||
_$AiPromptFromJson(json);
|
||||
|
||||
@@ -211,7 +228,10 @@ class AiPrompt extends Equatable {
|
||||
final String example;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isFeatured;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isCustom;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, content, category, example, isFeatured];
|
||||
List<Object?> get props =>
|
||||
[id, name, content, category, example, isFeatured, isCustom];
|
||||
}
|
||||
@@ -70,11 +70,6 @@ class AIModelStateNotifier {
|
||||
stateCallback: (state) async {
|
||||
_localAIState = state;
|
||||
_updateAll();
|
||||
if (state.state == RunningStatePB.Running ||
|
||||
state.state == RunningStatePB.Stopped) {
|
||||
await _loadModelSelection();
|
||||
_updateAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -190,7 +185,7 @@ class AIModelStateNotifier {
|
||||
}
|
||||
|
||||
final enabled = _localAIState!.enabled;
|
||||
final running = _localAIState!.state == RunningStatePB.Running;
|
||||
final running = _localAIState!.isReady;
|
||||
final hintKey = enabled
|
||||
? (running
|
||||
? LocaleKeys.chat_inputLocalAIMessageHint
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -14,7 +18,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
}) : _aiService = aiService ?? AppFlowyAIService(),
|
||||
super(AiPromptSelectorState.loading()) {
|
||||
filterTextController.addListener(_filterTextChanged);
|
||||
_loadPrompts();
|
||||
_init();
|
||||
}
|
||||
|
||||
final AppFlowyAIService _aiService;
|
||||
@@ -27,33 +31,131 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
await super.close();
|
||||
}
|
||||
|
||||
void _loadPrompts() async {
|
||||
void _init() async {
|
||||
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
|
||||
|
||||
final featuredPrompts =
|
||||
availablePrompts.where((prompt) => prompt.isFeatured);
|
||||
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
|
||||
|
||||
emit(
|
||||
AiPromptSelectorState.ready(
|
||||
visiblePrompts: visiblePrompts,
|
||||
showCustomPrompts: false,
|
||||
isFeaturedCategorySelected: true,
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
isCustomPromptSectionSelected: false,
|
||||
isFeaturedSectionSelected: true,
|
||||
selectedPromptId: visiblePrompts.firstOrNull?.id,
|
||||
customPromptDatabaseViewId: null,
|
||||
isLoadingCustomPrompts: true,
|
||||
selectedCategory: null,
|
||||
favoritePrompts: [],
|
||||
),
|
||||
);
|
||||
|
||||
loadCustomPrompts();
|
||||
}
|
||||
|
||||
void selectFeaturedCategory() {
|
||||
void loadCustomPrompts() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) async {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: true),
|
||||
);
|
||||
|
||||
String? databaseViewId = readyState.customPromptDatabaseViewId;
|
||||
if (databaseViewId == null) {
|
||||
final result =
|
||||
await AIEventGetCustomPromptDatabaseViewId().send().toNullable();
|
||||
databaseViewId = result?.id;
|
||||
}
|
||||
|
||||
if (databaseViewId == null) {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
availablePrompts.removeWhere((prompt) => prompt.isCustom);
|
||||
|
||||
final customPrompts =
|
||||
await _aiService.getDatabasePrompts(databaseViewId);
|
||||
|
||||
if (customPrompts == null) {
|
||||
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
customPromptDatabaseViewId: databaseViewId,
|
||||
isLoadingCustomPrompts: false,
|
||||
isFeaturedSectionSelected: true,
|
||||
isCustomPromptSectionSelected: false,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
availablePrompts.addAll(customPrompts);
|
||||
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
customPromptDatabaseViewId: databaseViewId,
|
||||
isLoadingCustomPrompts: false,
|
||||
selectedPromptId: selectedPromptId,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void selectCustomSection() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = availablePrompts.where((prompt) => prompt.isCustom);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: visiblePrompts.firstOrNull?.id,
|
||||
isCustomPromptSectionSelected: true,
|
||||
isFeaturedSectionSelected: false,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void selectFeaturedSection() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts,
|
||||
isFeaturedCategorySelected: true,
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: visiblePrompts.firstOrNull?.id,
|
||||
isFeaturedSectionSelected: true,
|
||||
isCustomPromptSectionSelected: false,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -64,24 +166,23 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
void selectCategory(AiPromptCategory? category) {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final unfilteredPrompts = category == null
|
||||
final prompts = category == null
|
||||
? availablePrompts
|
||||
: availablePrompts.where((prompt) => prompt.category == category);
|
||||
final prompts = _getFilteredPrompts(unfilteredPrompts);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
// Check if the selected prompt is still visible
|
||||
String? selectedPromptId = prompts.firstOrNull?.id;
|
||||
if (readyState.selectedPromptId != null &&
|
||||
prompts.any((prompt) => prompt.id == readyState.selectedPromptId)) {
|
||||
selectedPromptId = readyState.selectedPromptId;
|
||||
}
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: prompts,
|
||||
isFeaturedCategorySelected: false,
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedCategory: category,
|
||||
selectedPromptId: selectedPromptId,
|
||||
isFeaturedSectionSelected: false,
|
||||
isCustomPromptSectionSelected: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -116,8 +217,6 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
emit(
|
||||
readyState.copyWith(favoritePrompts: favoritePrompts),
|
||||
);
|
||||
|
||||
// TODO(RS): Save the updated favorite prompts to local storage or database
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
@@ -130,7 +229,9 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: availablePrompts,
|
||||
selectedPromptId: null,
|
||||
isCustomPromptSectionSelected: false,
|
||||
isFeaturedSectionSelected: true,
|
||||
selectedPromptId: availablePrompts.firstOrNull?.id,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
@@ -139,27 +240,84 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
);
|
||||
}
|
||||
|
||||
void updateCustomPromptDatabaseViewId(String viewId) async {
|
||||
final stateCopy = state;
|
||||
final newState = state.maybeMap(
|
||||
ready: (readyState) {
|
||||
return readyState.customPromptDatabaseViewId == viewId
|
||||
? null
|
||||
: readyState.copyWith(isLoadingCustomPrompts: true);
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (newState == null) {
|
||||
return;
|
||||
}
|
||||
emit(newState);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final customPrompts = await _aiService.getDatabasePrompts(viewId);
|
||||
if (customPrompts == null) {
|
||||
emit(AiPromptSelectorState.invalidDatabase());
|
||||
emit(stateCopy);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
availablePrompts
|
||||
..removeWhere((prompt) => prompt.isCustom)
|
||||
..addAll(customPrompts);
|
||||
|
||||
await AIEventSetCustomPromptDatabaseViewId(
|
||||
CustomPromptDatabaseViewIdPB()..id = viewId,
|
||||
).send().onFailure(Log.error);
|
||||
|
||||
emit(
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
return readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
customPromptDatabaseViewId: viewId,
|
||||
isLoadingCustomPrompts: false,
|
||||
);
|
||||
},
|
||||
orElse: () => state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _filterTextChanged() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final unfilteredPrompts = readyState.selectedCategory == null
|
||||
? readyState.isFeaturedCategorySelected
|
||||
? availablePrompts.where((prompt) => prompt.isFeatured)
|
||||
: availablePrompts
|
||||
: availablePrompts.where(
|
||||
(prompt) => prompt.category == readyState.selectedCategory,
|
||||
);
|
||||
final filteredPrompts = _getFilteredPrompts(unfilteredPrompts);
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(visiblePrompts: filteredPrompts),
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
}
|
||||
|
||||
List<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
|
||||
Iterable<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
|
||||
final filterText = filterTextController.value.text.trim().toLowerCase();
|
||||
|
||||
return prompts.where((prompt) {
|
||||
@@ -167,6 +325,33 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
return content.contains(filterText);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Iterable<AiPrompt> _getPromptsByCategory(_AiPromptSelectorReadyState state) {
|
||||
return availablePrompts.where((prompt) {
|
||||
if (state.selectedCategory != null) {
|
||||
return prompt.category == state.selectedCategory;
|
||||
}
|
||||
if (state.isFeaturedSectionSelected) {
|
||||
return prompt.isFeatured;
|
||||
}
|
||||
if (state.isCustomPromptSectionSelected) {
|
||||
return prompt.isCustom;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
String? _getVisibleSelectedPrompt(
|
||||
Iterable<AiPrompt> visiblePrompts,
|
||||
String? currentlySelectedPromptId,
|
||||
) {
|
||||
if (visiblePrompts
|
||||
.any((prompt) => prompt.id == currentlySelectedPromptId)) {
|
||||
return currentlySelectedPromptId;
|
||||
}
|
||||
|
||||
return visiblePrompts.firstOrNull?.id;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -175,13 +360,18 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
|
||||
|
||||
const factory AiPromptSelectorState.loading() = _AiPromptSelectorLoadingState;
|
||||
|
||||
const factory AiPromptSelectorState.invalidDatabase() =
|
||||
_AiPromptSelectorErrorState;
|
||||
|
||||
const factory AiPromptSelectorState.ready({
|
||||
required List<AiPrompt> visiblePrompts,
|
||||
required List<String> favoritePrompts,
|
||||
required bool showCustomPrompts,
|
||||
required bool isFeaturedCategorySelected,
|
||||
required bool isCustomPromptSectionSelected,
|
||||
required bool isFeaturedSectionSelected,
|
||||
required AiPromptCategory? selectedCategory,
|
||||
required String? selectedPromptId,
|
||||
required bool isLoadingCustomPrompts,
|
||||
required String? customPromptDatabaseViewId,
|
||||
}) = _AiPromptSelectorReadyState;
|
||||
|
||||
bool get isLoading => this is _AiPromptSelectorLoadingState;
|
||||
@@ -192,9 +382,4 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
|
||||
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
AiPromptCategory? get selectedCategory => maybeMap(
|
||||
ready: (state) => state.selectedCategory,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:appflowy/shared/list_extension.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-database2/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart' as fixnum;
|
||||
@@ -41,6 +42,8 @@ abstract class AIRepository {
|
||||
|
||||
Future<List<AiPrompt>> getBuiltInPrompts();
|
||||
|
||||
Future<List<AiPrompt>?> getDatabasePrompts(String databaseViewId);
|
||||
|
||||
void updateFavoritePrompts(List<String> promptIds);
|
||||
}
|
||||
|
||||
@@ -117,6 +120,23 @@ class AppFlowyAIService implements AIRepository {
|
||||
return prompts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AiPrompt>?> getDatabasePrompts(
|
||||
String databaseViewId,
|
||||
) async {
|
||||
return DatabaseEventGetDatabaseCustomPrompts(
|
||||
DatabaseViewIdPB(value: databaseViewId),
|
||||
).send().fold(
|
||||
(databasePromptsPB) {
|
||||
return databasePromptsPB.items.map(AiPrompt.fromPB).toList();
|
||||
},
|
||||
(err) {
|
||||
Log.error(err);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateFavoritePrompts(List<String> promptIds) {}
|
||||
}
|
||||
|
||||
+161
-123
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
@@ -6,13 +7,12 @@ import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_select_sources_cubit.freezed.dart';
|
||||
part 'view_selector_cubit.freezed.dart';
|
||||
|
||||
const int _kMaxSelectedParentPageCount = 3;
|
||||
|
||||
enum SourceSelectedStatus {
|
||||
enum ViewSelectedStatus {
|
||||
unselected,
|
||||
selected,
|
||||
partiallySelected;
|
||||
@@ -22,45 +22,46 @@ enum SourceSelectedStatus {
|
||||
bool get isPartiallySelected => this == partiallySelected;
|
||||
}
|
||||
|
||||
class ChatSource {
|
||||
ChatSource({
|
||||
class ViewSelectorItem {
|
||||
ViewSelectorItem({
|
||||
required this.view,
|
||||
required this.parentView,
|
||||
required this.children,
|
||||
required bool isExpanded,
|
||||
required SourceSelectedStatus selectedStatus,
|
||||
required IgnoreViewType ignoreStatus,
|
||||
required ViewSelectedStatus selectedStatus,
|
||||
required bool isDisabled,
|
||||
}) : isExpandedNotifier = ValueNotifier(isExpanded),
|
||||
selectedStatusNotifier = ValueNotifier(selectedStatus),
|
||||
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
|
||||
isDisabledNotifier = ValueNotifier(isDisabled);
|
||||
|
||||
final ViewPB view;
|
||||
final ViewPB? parentView;
|
||||
final List<ChatSource> children;
|
||||
final List<ViewSelectorItem> children;
|
||||
final ValueNotifier<bool> isExpandedNotifier;
|
||||
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
|
||||
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
|
||||
final ValueNotifier<bool> isDisabledNotifier;
|
||||
final ValueNotifier<ViewSelectedStatus> selectedStatusNotifier;
|
||||
|
||||
bool get isExpanded => isExpandedNotifier.value;
|
||||
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
|
||||
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
|
||||
ViewSelectedStatus get selectedStatus => selectedStatusNotifier.value;
|
||||
bool get isDisabled => isDisabledNotifier.value;
|
||||
|
||||
void toggleIsExpanded() {
|
||||
isExpandedNotifier.value = !isExpanded;
|
||||
isExpandedNotifier.value = !isExpandedNotifier.value;
|
||||
}
|
||||
|
||||
ChatSource copy() {
|
||||
return ChatSource(
|
||||
ViewSelectorItem copy() {
|
||||
return ViewSelectorItem(
|
||||
view: view,
|
||||
parentView: parentView,
|
||||
children: children.map<ChatSource>((child) => child.copy()).toList(),
|
||||
ignoreStatus: ignoreStatus,
|
||||
isExpanded: isExpanded,
|
||||
selectedStatus: selectedStatus,
|
||||
children:
|
||||
children.map<ViewSelectorItem>((child) => child.copy()).toList(),
|
||||
isDisabled: isDisabledNotifier.value,
|
||||
isExpanded: isExpandedNotifier.value,
|
||||
selectedStatus: selectedStatusNotifier.value,
|
||||
);
|
||||
}
|
||||
|
||||
ChatSource? findChildBySourceId(String sourceId) {
|
||||
ViewSelectorItem? findChildBySourceId(String sourceId) {
|
||||
if (view.id == sourceId) {
|
||||
return this;
|
||||
}
|
||||
@@ -73,20 +74,19 @@ class ChatSource {
|
||||
return null;
|
||||
}
|
||||
|
||||
void resetIgnoreViewTypeRecursive() {
|
||||
ignoreStatusNotifier.value = view.layout.isDocumentView
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.disable;
|
||||
void setIsDisabledRecursive(bool Function(ViewSelectorItem) newIsDisabled) {
|
||||
isDisabledNotifier.value = newIsDisabled(this);
|
||||
|
||||
for (final child in children) {
|
||||
child.resetIgnoreViewTypeRecursive();
|
||||
child.setIsDisabledRecursive(newIsDisabled);
|
||||
}
|
||||
}
|
||||
|
||||
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
|
||||
ignoreStatusNotifier.value = newIgnoreViewType;
|
||||
void setIsSelectedStatusRecursive(ViewSelectedStatus selectedStatus) {
|
||||
selectedStatusNotifier.value = selectedStatus;
|
||||
|
||||
for (final child in children) {
|
||||
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
|
||||
child.setIsSelectedStatusRecursive(selectedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,36 +96,45 @@ class ChatSource {
|
||||
}
|
||||
isExpandedNotifier.dispose();
|
||||
selectedStatusNotifier.dispose();
|
||||
ignoreStatusNotifier.dispose();
|
||||
isDisabledNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
ChatSettingsCubit({
|
||||
this.hideDisabled = false,
|
||||
}) : super(ChatSettingsState.initial());
|
||||
class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
ViewSelectorCubit({
|
||||
required this.getIgnoreViewType,
|
||||
this.maxSelectedParentPageCount,
|
||||
}) : super(ViewSelectorState.initial()) {
|
||||
filterTextController.addListener(onFilterChanged);
|
||||
}
|
||||
|
||||
final bool hideDisabled;
|
||||
final IgnoreViewType Function(ViewSelectorItem) getIgnoreViewType;
|
||||
final int? maxSelectedParentPageCount;
|
||||
|
||||
final List<String> selectedSourceIds = [];
|
||||
final List<ChatSource> sources = [];
|
||||
final List<ChatSource> selectedSources = [];
|
||||
String filter = '';
|
||||
final List<ViewSelectorItem> sources = [];
|
||||
final List<ViewSelectorItem> selectedSources = [];
|
||||
final filterTextController = TextEditingController();
|
||||
|
||||
void updateSelectedSources(List<String> newSelectedSourceIds) {
|
||||
selectedSourceIds.clear();
|
||||
selectedSourceIds.addAll(newSelectedSourceIds);
|
||||
}
|
||||
|
||||
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
|
||||
filter = "";
|
||||
Future<void> refreshSources(
|
||||
List<ViewPB> spaceViews,
|
||||
ViewPB? currentSpace,
|
||||
) async {
|
||||
filterTextController.clear();
|
||||
|
||||
final newSources = await Future.wait(
|
||||
spaceViews.map((view) => _recursiveBuild(view, null)),
|
||||
);
|
||||
for (final source in newSources) {
|
||||
_restrictSelectionIfNecessary(source.children);
|
||||
}
|
||||
|
||||
_setIsDisabledAndHideIfNecessary(newSources);
|
||||
|
||||
_restrictSelectionIfNecessary(newSources);
|
||||
|
||||
if (currentSpace != null) {
|
||||
newSources
|
||||
.firstWhereOrNull((e) => e.view.id == currentSpace.id)
|
||||
@@ -155,29 +164,31 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
..addAll(selected.map((e) => e.copy()));
|
||||
}
|
||||
|
||||
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
|
||||
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
|
||||
Future<ViewSelectorItem> _recursiveBuild(
|
||||
ViewPB view,
|
||||
ViewPB? parentView,
|
||||
) async {
|
||||
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
|
||||
final isThisSourceSelected = selectedSourceIds.contains(view.id);
|
||||
|
||||
final childrenViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
|
||||
final List<ViewPB>? childrenViews;
|
||||
if (integrationMode().isTest) {
|
||||
childrenViews = view.childViews;
|
||||
} else {
|
||||
childrenViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
|
||||
}
|
||||
|
||||
int selectedCount = 0;
|
||||
final children = <ChatSource>[];
|
||||
final children = <ViewSelectorItem>[];
|
||||
|
||||
if (childrenViews != null) {
|
||||
for (final childView in childrenViews) {
|
||||
if (childView.layout == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
if (childView.layout != ViewLayoutPB.Document && hideDisabled) {
|
||||
continue;
|
||||
}
|
||||
final childChatSource = await _recursiveBuild(childView, view);
|
||||
if (childChatSource.selectedStatus.isSelected) {
|
||||
final childItem = await _recursiveBuild(childView, view);
|
||||
if (childItem.selectedStatus.isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
children.add(childChatSource);
|
||||
children.add(childItem);
|
||||
}
|
||||
|
||||
final areAllChildrenSelectedOrNoChildren =
|
||||
@@ -186,47 +197,64 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
children.any((e) => !e.selectedStatus.isUnselected);
|
||||
|
||||
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
selectedStatus = ViewSelectedStatus.selected;
|
||||
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
|
||||
selectedStatus = SourceSelectedStatus.partiallySelected;
|
||||
selectedStatus = ViewSelectedStatus.partiallySelected;
|
||||
}
|
||||
} else if (isThisSourceSelected) {
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
selectedStatus = ViewSelectedStatus.selected;
|
||||
}
|
||||
|
||||
return ChatSource(
|
||||
return ViewSelectorItem(
|
||||
view: view,
|
||||
parentView: parentView,
|
||||
children: children,
|
||||
ignoreStatus: view.layout.isDocumentView
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.disable,
|
||||
isDisabled: false,
|
||||
isExpanded: false,
|
||||
selectedStatus: selectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
|
||||
void _setIsDisabledAndHideIfNecessary(
|
||||
List<ViewSelectorItem> sources,
|
||||
) {
|
||||
sources.retainWhere((source) {
|
||||
final ignoreViewType = getIgnoreViewType(source);
|
||||
return ignoreViewType != IgnoreViewType.hide;
|
||||
});
|
||||
|
||||
for (final source in sources) {
|
||||
source.resetIgnoreViewTypeRecursive();
|
||||
source.isDisabledNotifier.value =
|
||||
getIgnoreViewType(source) == IgnoreViewType.disable;
|
||||
_setIsDisabledAndHideIfNecessary(source.children);
|
||||
}
|
||||
}
|
||||
|
||||
void _restrictSelectionIfNecessary(List<ViewSelectorItem> sources) {
|
||||
if (maxSelectedParentPageCount == null) {
|
||||
return;
|
||||
}
|
||||
for (final source in sources) {
|
||||
source.setIsDisabledRecursive((view) {
|
||||
return getIgnoreViewType(view) == IgnoreViewType.disable;
|
||||
});
|
||||
}
|
||||
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
|
||||
_kMaxSelectedParentPageCount) {
|
||||
maxSelectedParentPageCount!) {
|
||||
sources
|
||||
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
|
||||
.where((e) => e.selectedStatus == ViewSelectedStatus.unselected)
|
||||
.forEach(
|
||||
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
|
||||
(e) => e.setIsDisabledRecursive((_) => true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void updateFilter(String filter) {
|
||||
this.filter = filter;
|
||||
void onFilterChanged() {
|
||||
for (final source in state.visibleSources) {
|
||||
source.dispose();
|
||||
}
|
||||
if (sources.isEmpty) {
|
||||
emit(ChatSettingsState.initial());
|
||||
emit(ViewSelectorState.initial());
|
||||
} else {
|
||||
final selected =
|
||||
selectedSources.map(_buildSearchResults).nonNulls.toList();
|
||||
@@ -242,13 +270,13 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
}
|
||||
|
||||
/// traverse tree to build up search query
|
||||
ChatSource? _buildSearchResults(ChatSource chatSource) {
|
||||
final isVisible = chatSource.view.nameOrDefault
|
||||
ViewSelectorItem? _buildSearchResults(ViewSelectorItem item) {
|
||||
final isVisible = item.view.nameOrDefault
|
||||
.toLowerCase()
|
||||
.contains(filter.toLowerCase());
|
||||
.contains(filterTextController.text.toLowerCase());
|
||||
|
||||
final childrenResults = <ChatSource>[];
|
||||
for (final childSource in chatSource.children) {
|
||||
final childrenResults = <ViewSelectorItem>[];
|
||||
for (final childSource in item.children) {
|
||||
final childResult = _buildSearchResults(childSource);
|
||||
if (childResult != null) {
|
||||
childrenResults.add(childResult);
|
||||
@@ -256,48 +284,50 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
}
|
||||
|
||||
return isVisible || childrenResults.isNotEmpty
|
||||
? ChatSource(
|
||||
view: chatSource.view,
|
||||
parentView: chatSource.parentView,
|
||||
? ViewSelectorItem(
|
||||
view: item.view,
|
||||
parentView: item.parentView,
|
||||
children: childrenResults,
|
||||
ignoreStatus: chatSource.ignoreStatus,
|
||||
isExpanded: chatSource.isExpanded,
|
||||
selectedStatus: chatSource.selectedStatus,
|
||||
isDisabled: item.isDisabled,
|
||||
isExpanded: item.isExpanded,
|
||||
selectedStatus: item.selectedStatus,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// traverse tree to build up selected sources
|
||||
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
|
||||
final children = <ChatSource>[];
|
||||
Iterable<ViewSelectorItem> _buildSelectedSources(
|
||||
ViewSelectorItem item,
|
||||
) {
|
||||
final children = <ViewSelectorItem>[];
|
||||
|
||||
for (final childSource in chatSource.children) {
|
||||
for (final childSource in item.children) {
|
||||
children.addAll(_buildSelectedSources(childSource));
|
||||
}
|
||||
|
||||
return selectedSourceIds.contains(chatSource.view.id)
|
||||
return selectedSourceIds.contains(item.view.id)
|
||||
? [
|
||||
ChatSource(
|
||||
view: chatSource.view,
|
||||
parentView: chatSource.parentView,
|
||||
ViewSelectorItem(
|
||||
view: item.view,
|
||||
parentView: item.parentView,
|
||||
children: children,
|
||||
ignoreStatus: chatSource.ignoreStatus,
|
||||
selectedStatus: chatSource.selectedStatus,
|
||||
isDisabled: item.isDisabled,
|
||||
selectedStatus: item.selectedStatus,
|
||||
isExpanded: true,
|
||||
),
|
||||
]
|
||||
: children;
|
||||
}
|
||||
|
||||
void toggleSelectedStatus(ChatSource chatSource) {
|
||||
if (chatSource.view.isSpace) {
|
||||
void toggleSelectedStatus(ViewSelectorItem item, bool isSelectedSection) {
|
||||
if (item.view.isSpace) {
|
||||
return;
|
||||
}
|
||||
final allIds = _recursiveGetSourceIds(chatSource);
|
||||
final allIds = _recursiveGetSourceIds(item);
|
||||
|
||||
if (chatSource.selectedStatus.isUnselected ||
|
||||
chatSource.selectedStatus.isPartiallySelected &&
|
||||
!chatSource.view.layout.isDocumentView) {
|
||||
if (item.selectedStatus.isUnselected ||
|
||||
item.selectedStatus.isPartiallySelected &&
|
||||
!item.view.layout.isDocumentView) {
|
||||
for (final id in allIds) {
|
||||
if (!selectedSourceIds.contains(id)) {
|
||||
selectedSourceIds.add(id);
|
||||
@@ -311,13 +341,22 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelectedSection) {
|
||||
item.setIsSelectedStatusRecursive(
|
||||
item.selectedStatus.isUnselected ||
|
||||
item.selectedStatus.isPartiallySelected
|
||||
? ViewSelectedStatus.selected
|
||||
: ViewSelectedStatus.unselected,
|
||||
);
|
||||
}
|
||||
|
||||
updateSelectedStatus();
|
||||
}
|
||||
|
||||
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
|
||||
List<String> _recursiveGetSourceIds(ViewSelectorItem item) {
|
||||
return [
|
||||
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
|
||||
for (final childSource in chatSource.children)
|
||||
if (item.view.layout.isDocumentView) item.view.id,
|
||||
for (final childSource in item.children)
|
||||
..._recursiveGetSourceIds(childSource),
|
||||
];
|
||||
}
|
||||
@@ -342,44 +381,42 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
);
|
||||
}
|
||||
|
||||
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
|
||||
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
|
||||
ViewSelectedStatus _recursiveUpdateSelectedStatus(ViewSelectorItem item) {
|
||||
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
|
||||
|
||||
int selectedCount = 0;
|
||||
for (final childSource in chatSource.children) {
|
||||
for (final childSource in item.children) {
|
||||
final childStatus = _recursiveUpdateSelectedStatus(childSource);
|
||||
if (childStatus.isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
|
||||
final isThisSourceSelected = selectedSourceIds.contains(item.view.id);
|
||||
final areAllChildrenSelectedOrNoChildren =
|
||||
chatSource.children.length == selectedCount;
|
||||
item.children.length == selectedCount;
|
||||
final isAnyChildNotUnselected =
|
||||
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
|
||||
item.children.any((e) => !e.selectedStatus.isUnselected);
|
||||
|
||||
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
selectedStatus = ViewSelectedStatus.selected;
|
||||
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
|
||||
selectedStatus = SourceSelectedStatus.partiallySelected;
|
||||
selectedStatus = ViewSelectedStatus.partiallySelected;
|
||||
}
|
||||
|
||||
chatSource.selectedStatusNotifier.value = selectedStatus;
|
||||
item.selectedStatusNotifier.value = selectedStatus;
|
||||
return selectedStatus;
|
||||
}
|
||||
|
||||
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
|
||||
chatSource.toggleIsExpanded();
|
||||
void toggleIsExpanded(ViewSelectorItem item, bool isSelectedSection) {
|
||||
item.toggleIsExpanded();
|
||||
if (isSelectedSection) {
|
||||
for (final selectedSource in selectedSources) {
|
||||
selectedSource
|
||||
.findChildBySourceId(chatSource.view.id)
|
||||
?.toggleIsExpanded();
|
||||
selectedSource.findChildBySourceId(item.view.id)?.toggleIsExpanded();
|
||||
}
|
||||
} else {
|
||||
for (final source in sources) {
|
||||
final child = source.findChildBySourceId(chatSource.view.id);
|
||||
final child = source.findChildBySourceId(item.view.id);
|
||||
if (child != null) {
|
||||
child.toggleIsExpanded();
|
||||
break;
|
||||
@@ -402,18 +439,19 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
for (final child in state.visibleSources) {
|
||||
child.dispose();
|
||||
}
|
||||
filterTextController.dispose();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSettingsState with _$ChatSettingsState {
|
||||
const factory ChatSettingsState({
|
||||
required List<ChatSource> visibleSources,
|
||||
required List<ChatSource> selectedSources,
|
||||
}) = _ChatSettingsState;
|
||||
class ViewSelectorState with _$ViewSelectorState {
|
||||
const factory ViewSelectorState({
|
||||
required List<ViewSelectorItem> visibleSources,
|
||||
required List<ViewSelectorItem> selectedSources,
|
||||
}) = _ViewSelectorState;
|
||||
|
||||
factory ChatSettingsState.initial() => const ChatSettingsState(
|
||||
factory ViewSelectorState.initial() => const ViewSelectorState(
|
||||
visibleSources: [],
|
||||
selectedSources: [],
|
||||
);
|
||||
+149
-85
@@ -8,11 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
class AiPromptCategoryList extends StatefulWidget {
|
||||
const AiPromptCategoryList({
|
||||
super.key,
|
||||
required this.padding,
|
||||
});
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
State<AiPromptCategoryList> createState() => _AiPromptCategoryListState();
|
||||
}
|
||||
@@ -23,60 +20,59 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(
|
||||
theme.spacing.l,
|
||||
),
|
||||
child: TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
_buildFeaturedCategory(context),
|
||||
const AFDivider(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
_buildCategoryItem(context, null),
|
||||
_buildCategoryItem(context, AiPromptCategory.development),
|
||||
_buildCategoryItem(context, AiPromptCategory.writing),
|
||||
_buildCategoryItem(context, AiPromptCategory.business),
|
||||
_buildCategoryItem(context, AiPromptCategory.marketing),
|
||||
_buildCategoryItem(
|
||||
context,
|
||||
AiPromptCategory.healthAndFitness,
|
||||
),
|
||||
_buildCategoryItem(context, AiPromptCategory.travel),
|
||||
_buildCategoryItem(context, AiPromptCategory.contentSeo),
|
||||
_buildCategoryItem(context, AiPromptCategory.emailMarketing),
|
||||
_buildCategoryItem(context, AiPromptCategory.paidAds),
|
||||
_buildCategoryItem(context, AiPromptCategory.prCommunication),
|
||||
_buildCategoryItem(context, AiPromptCategory.recruiting),
|
||||
_buildCategoryItem(context, AiPromptCategory.sales),
|
||||
_buildCategoryItem(context, AiPromptCategory.socialMedia),
|
||||
_buildCategoryItem(context, AiPromptCategory.strategy),
|
||||
_buildCategoryItem(context, AiPromptCategory.caseStudies),
|
||||
_buildCategoryItem(context, AiPromptCategory.salesCopy),
|
||||
_buildCategoryItem(context, AiPromptCategory.education),
|
||||
_buildCategoryItem(context, AiPromptCategory.work),
|
||||
_buildCategoryItem(
|
||||
context,
|
||||
AiPromptCategory.podcastProduction,
|
||||
),
|
||||
_buildCategoryItem(context, AiPromptCategory.copyWriting),
|
||||
_buildCategoryItem(context, AiPromptCategory.customerSuccess),
|
||||
_buildCategoryItem(context, AiPromptCategory.other),
|
||||
],
|
||||
),
|
||||
return TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AiPromptFeaturedSection(),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: AiPromptCustomPromptSection(),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: theme.spacing.s,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: AFDivider(),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
top: theme.spacing.s,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
children: [
|
||||
_buildCategoryItem(context, null),
|
||||
...sortedCategories.map(
|
||||
(category) => _buildCategoryItem(
|
||||
context,
|
||||
category,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Iterable<AiPromptCategory> get sortedCategories {
|
||||
final categories = [...AiPromptCategory.values];
|
||||
categories.sort((a, b) => a.i18n.compareTo(b.i18n));
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
Widget _buildCategoryItem(
|
||||
BuildContext context,
|
||||
AiPromptCategory? category,
|
||||
@@ -88,18 +84,25 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFeaturedCategory(BuildContext context) {
|
||||
class AiPromptFeaturedSection extends StatelessWidget {
|
||||
const AiPromptFeaturedSection({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
|
||||
ready: (state) => state.isFeaturedCategorySelected,
|
||||
ready: (state) => state.isFeaturedSectionSelected,
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return AFBaseButton(
|
||||
onTap: () {
|
||||
if (!isSelected) {
|
||||
context.read<AiPromptSelectorCubit>().selectFeaturedCategory();
|
||||
context.read<AiPromptSelectorCubit>().selectFeaturedSection();
|
||||
}
|
||||
},
|
||||
builder: (context, isHovering, disabled) {
|
||||
@@ -131,6 +134,61 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptCustomPromptSection extends StatelessWidget {
|
||||
const AiPromptCustomPromptSection({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final isSelected = readyState.isCustomPromptSectionSelected;
|
||||
|
||||
return AFBaseButton(
|
||||
onTap: () {
|
||||
if (!isSelected) {
|
||||
context.read<AiPromptSelectorCubit>().selectCustomSection();
|
||||
}
|
||||
},
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
LocaleKeys.ai_customPrompt_custom.tr(),
|
||||
style: AppFlowyTheme.of(context).textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
borderRadius: theme.borderRadius.m,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
Colors.transparent,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
if (isSelected) {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptCategoryItem extends StatelessWidget {
|
||||
const AiPromptCategoryItem({
|
||||
super.key,
|
||||
@@ -143,41 +201,47 @@ class AiPromptCategoryItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
|
||||
ready: (state) =>
|
||||
!state.isFeaturedCategorySelected &&
|
||||
state.selectedCategory == category,
|
||||
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isSelected = state.maybeMap(
|
||||
ready: (state) {
|
||||
return !state.isFeaturedSectionSelected &&
|
||||
!state.isCustomPromptSectionSelected &&
|
||||
state.selectedCategory == category;
|
||||
},
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return AFBaseButton(
|
||||
onTap: onSelect,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
|
||||
style: AppFlowyTheme.of(context).textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return AFBaseButton(
|
||||
onTap: onSelect,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
|
||||
style: AppFlowyTheme.of(context).textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
borderRadius: theme.borderRadius.m,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
Colors.transparent,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
if (isSelected) {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
);
|
||||
},
|
||||
borderRadius: theme.borderRadius.m,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
Colors.transparent,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
if (isSelected) {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.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';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class CustomPromptDatabaseSelector extends StatefulWidget {
|
||||
const CustomPromptDatabaseSelector({
|
||||
super.key,
|
||||
this.databaseViewId,
|
||||
this.isLoading = false,
|
||||
this.popoverDirection = PopoverDirection.bottomWithCenterAligned,
|
||||
required this.childBuilder,
|
||||
});
|
||||
|
||||
final String? databaseViewId;
|
||||
final bool isLoading;
|
||||
final PopoverDirection popoverDirection;
|
||||
final Widget Function(VoidCallback) childBuilder;
|
||||
|
||||
@override
|
||||
State<CustomPromptDatabaseSelector> createState() =>
|
||||
_CustomPromptDatabaseSelectorState();
|
||||
}
|
||||
|
||||
class _CustomPromptDatabaseSelectorState
|
||||
extends State<CustomPromptDatabaseSelector> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider(
|
||||
create: (context) => ViewSelectorCubit(
|
||||
getIgnoreViewType: getIgnoreViewType,
|
||||
),
|
||||
),
|
||||
child: BlocSelector<SpaceBloc, SpaceState, (List<ViewPB>, ViewPB?)>(
|
||||
selector: (state) => (state.spaces, state.currentSpace),
|
||||
builder: (context, state) {
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
margin: EdgeInsets.zero,
|
||||
offset: const Offset(0, 4.0),
|
||||
direction: widget.popoverDirection,
|
||||
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
child: _PopoverContent(
|
||||
onSelectViewItem: (item) {
|
||||
final view = item.view;
|
||||
if (view.layout.isDatabaseView) {
|
||||
context
|
||||
.read<AiPromptSelectorCubit>()
|
||||
.updateCustomPromptDatabaseViewId(view.id);
|
||||
popoverController.close();
|
||||
} else if (!view.isSpace && view.layout.isDocumentView) {
|
||||
context.read<ViewSelectorCubit>().toggleIsExpanded(
|
||||
item,
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.childBuilder(
|
||||
() {
|
||||
if (!widget.isLoading) {
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.$1, state.$2);
|
||||
popoverController.show();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
|
||||
final layout = item.view.layout;
|
||||
|
||||
if (layout.isDatabaseView) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (layout.isDocumentView) {
|
||||
return hasDatabaseDescendent(item)
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.hide;
|
||||
}
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
|
||||
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
|
||||
final layout = viewSelectorItem.view.layout;
|
||||
|
||||
if (layout == ViewLayoutPB.Chat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (layout.isDatabaseView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// document may have children
|
||||
return viewSelectorItem.children.any(
|
||||
(child) => hasDatabaseDescendent(child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptDatabaseSelectorButton extends StatefulWidget {
|
||||
const AiPromptDatabaseSelectorButton({
|
||||
super.key,
|
||||
required this.selectedViewId,
|
||||
required this.isLoading,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String? selectedViewId;
|
||||
final bool isLoading;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<AiPromptDatabaseSelectorButton> createState() =>
|
||||
_AiPromptDatabaseSelectorButtonState();
|
||||
}
|
||||
|
||||
class _AiPromptDatabaseSelectorButtonState
|
||||
extends State<AiPromptDatabaseSelectorButton> {
|
||||
late Future<ViewPB?> viewFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
viewFuture = getView();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AiPromptDatabaseSelectorButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedViewId != widget.selectedViewId) {
|
||||
viewFuture = getView();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return FutureBuilder<ViewPB?>(
|
||||
future: viewFuture,
|
||||
builder: (context, snapshot) {
|
||||
String name = "";
|
||||
|
||||
if (snapshot.hasData &&
|
||||
snapshot.connectionState == ConnectionState.done) {
|
||||
name = snapshot.data!.nameOrDefault;
|
||||
}
|
||||
|
||||
return Row(
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: $name",
|
||||
maxLines: 1,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 150,
|
||||
),
|
||||
child: AFOutlinedButton.normal(
|
||||
onTap: widget.onTap,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Row(
|
||||
spacing: theme.spacing.s,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isLoading) buildLoadingIndicator(theme),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.isLoading
|
||||
? LocaleKeys.ai_customPrompt_loading.tr()
|
||||
: LocaleKeys.button_change.tr(),
|
||||
maxLines: 1,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
|
||||
return SizedBox.square(
|
||||
dimension: 20,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(2.5),
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewPB?> getView() async {
|
||||
if (widget.selectedViewId == null) {
|
||||
return null;
|
||||
}
|
||||
final view =
|
||||
await ViewBackendService.getView(widget.selectedViewId!).toNullable();
|
||||
|
||||
if (view != null) {
|
||||
return view;
|
||||
}
|
||||
|
||||
final trashViews = await TrashService().readTrash().toNullable();
|
||||
final trashedItem = trashViews?.items
|
||||
.firstWhereOrNull((element) => element.id == widget.selectedViewId);
|
||||
|
||||
if (trashedItem == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ViewPB()
|
||||
..id = trashedItem.id
|
||||
..name = trashedItem.name;
|
||||
}
|
||||
}
|
||||
|
||||
class _PopoverContent extends StatefulWidget {
|
||||
const _PopoverContent({
|
||||
required this.onSelectViewItem,
|
||||
});
|
||||
|
||||
final void Function(ViewSelectorItem item) onSelectViewItem;
|
||||
|
||||
@override
|
||||
State<_PopoverContent> createState() => _PopoverContentState();
|
||||
}
|
||||
|
||||
class _PopoverContentState extends State<_PopoverContent> {
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
VSpace(theme.spacing.m),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
child: AFTextField(
|
||||
focusNode: focusNode,
|
||||
size: AFTextFieldSize.m,
|
||||
hintText: LocaleKeys.search_label.tr(),
|
||||
controller: context.read<ViewSelectorCubit>().filterTextController,
|
||||
),
|
||||
),
|
||||
VSpace(theme.spacing.m),
|
||||
AFDivider(),
|
||||
Expanded(
|
||||
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
builder: (context, state) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
children: _buildVisibleSources(context, state).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ViewSelectorState state,
|
||||
) {
|
||||
return state.visibleSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'custom_prompt_database_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
onSelected: widget.onSelectViewItem,
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,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/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -8,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'ai_prompt_category_list.dart';
|
||||
import 'ai_prompt_onboarding.dart';
|
||||
import 'ai_prompt_preview.dart';
|
||||
import 'ai_prompt_visible_list.dart';
|
||||
|
||||
@@ -15,11 +18,20 @@ Future<AiPrompt?> showAiPromptModal(
|
||||
BuildContext context, {
|
||||
required AiPromptSelectorCubit aiPromptSelectorCubit,
|
||||
}) async {
|
||||
aiPromptSelectorCubit.loadCustomPrompts();
|
||||
|
||||
return showDialog<AiPrompt?>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return BlocProvider.value(
|
||||
value: aiPromptSelectorCubit,
|
||||
builder: (_) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: aiPromptSelectorCubit,
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: context.read<UserWorkspaceBloc>(),
|
||||
),
|
||||
],
|
||||
child: const AiPromptModal(),
|
||||
);
|
||||
},
|
||||
@@ -38,92 +50,122 @@ class AiPromptModal extends StatelessWidget {
|
||||
maxWidth: 1200,
|
||||
maxHeight: 800,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
AFModalHeader(
|
||||
leading: Text(
|
||||
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
|
||||
style: theme.textStyle.heading4.prominent(
|
||||
color: theme.textColorScheme.primary,
|
||||
child: BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
listener: (context, state) {
|
||||
state.maybeMap(
|
||||
invalidDatabase: (_) {
|
||||
showLoadPromptFailedDialog(context);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
AFModalHeader(
|
||||
leading: Text(
|
||||
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
|
||||
style: theme.textStyle.heading4.prominent(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
trailing: [
|
||||
AFGhostButton.normal(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.all(theme.spacing.xs),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.toast_close_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: [
|
||||
AFGhostButton.normal(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.all(theme.spacing.s),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.toast_close_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: AFModalBody(
|
||||
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return state.map(
|
||||
loading: (_) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
},
|
||||
ready: (_) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AiPromptCategoryList(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
top: theme.spacing.l,
|
||||
end: theme.spacing.l,
|
||||
),
|
||||
Expanded(
|
||||
child: AFModalBody(
|
||||
child:
|
||||
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
loading: (_) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
},
|
||||
ready: (readyState) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: AiPromptCategoryList(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: AiPromptVisibleList(),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: BlocBuilder<AiPromptSelectorCubit,
|
||||
AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final selectedPrompt = state.maybeMap(
|
||||
ready: (state) {
|
||||
return state.visiblePrompts
|
||||
.firstWhereOrNull(
|
||||
(prompt) =>
|
||||
prompt.id == state.selectedPromptId,
|
||||
if (readyState.isCustomPromptSectionSelected &&
|
||||
readyState.customPromptDatabaseViewId == null)
|
||||
const Expanded(
|
||||
flex: 5,
|
||||
child: Center(
|
||||
child: AiPromptOnboarding(),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
const Expanded(
|
||||
flex: 2,
|
||||
child: AiPromptVisibleList(),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: BlocBuilder<AiPromptSelectorCubit,
|
||||
AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final selectedPrompt = state.maybeMap(
|
||||
ready: (state) {
|
||||
return state.visiblePrompts
|
||||
.firstWhereOrNull(
|
||||
(prompt) =>
|
||||
prompt.id ==
|
||||
state.selectedPromptId,
|
||||
);
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
if (selectedPrompt == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AiPromptPreview(
|
||||
prompt: selectedPrompt,
|
||||
);
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
if (selectedPrompt == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AiPromptPreview(
|
||||
prompt: selectedPrompt,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showLoadPromptFailedDialog(
|
||||
BuildContext context,
|
||||
) {
|
||||
showSimpleAFDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
|
||||
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
|
||||
primaryAction: (
|
||||
LocaleKeys.button_ok.tr(),
|
||||
(context) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.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/widgets.dart';
|
||||
|
||||
import 'ai_prompt_database_selector.dart';
|
||||
|
||||
class AiPromptOnboarding extends StatelessWidget {
|
||||
const AiPromptOnboarding({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
LocaleKeys.ai_customPrompt_customPrompt.tr(),
|
||||
style: theme.textStyle.heading3.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
VSpace(
|
||||
theme.spacing.s,
|
||||
),
|
||||
Text(
|
||||
LocaleKeys.ai_customPrompt_databasePrompts.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
VSpace(
|
||||
theme.spacing.xxl,
|
||||
),
|
||||
CustomPromptDatabaseSelector(
|
||||
childBuilder: (onTap) => AFFilledButton.primary(
|
||||
onTap: onTap,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.onFill,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,8 @@ class AiPromptPreview extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: theme.spacing.l,
|
||||
left: theme.spacing.l,
|
||||
right: theme.spacing.l,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.l,
|
||||
),
|
||||
child: SelectionContainer.disabled(
|
||||
child: Row(
|
||||
|
||||
+139
-40
@@ -7,10 +7,12 @@ import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:diffutil_dart/diffutil.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'ai_prompt_database_selector.dart';
|
||||
|
||||
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
|
||||
|
||||
class AiPromptVisibleList extends StatefulWidget {
|
||||
@@ -35,12 +37,12 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
super.initState();
|
||||
cubit = context.read<AiPromptSelectorCubit>();
|
||||
final textController = cubit.filterTextController;
|
||||
filterIsEmpty = textController.text.isEmpty;
|
||||
textController.addListener(handleFilterTextChanged);
|
||||
final prompts = cubit.state.maybeMap(
|
||||
ready: (value) => value.visiblePrompts,
|
||||
orElse: () => <AiPrompt>[],
|
||||
);
|
||||
filterIsEmpty = textController.text.isEmpty;
|
||||
textController.addListener(handleFilterTextChanged);
|
||||
oldList.addAll(prompts);
|
||||
}
|
||||
|
||||
@@ -53,52 +55,87 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spacing = AppFlowyTheme.of(context).spacing;
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
listener: (context, state) {
|
||||
final list = state.maybeMap(
|
||||
ready: (state) => state.visiblePrompts,
|
||||
orElse: () => <AiPrompt>[],
|
||||
state.maybeMap(
|
||||
ready: (state) {
|
||||
handleVisiblePromptListChanged(state.visiblePrompts);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
handleVisiblePromptListChanged(list);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
VSpace(spacing.l),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: spacing.l),
|
||||
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
|
||||
child: buildSearchField(context),
|
||||
),
|
||||
VSpace(spacing.s),
|
||||
VSpace(
|
||||
theme.spacing.s,
|
||||
),
|
||||
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
buildWhen: (p, c) {
|
||||
return p.maybeMap(
|
||||
ready: (pr) => c.maybeMap(
|
||||
ready: (cr) =>
|
||||
pr.customPromptDatabaseViewId !=
|
||||
cr.customPromptDatabaseViewId ||
|
||||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
|
||||
pr.isCustomPromptSectionSelected !=
|
||||
cr.isCustomPromptSectionSelected,
|
||||
orElse: () => false,
|
||||
),
|
||||
orElse: () => true,
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
if (!readyState.isCustomPromptSectionSelected) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: theme.spacing.l,
|
||||
top: theme.spacing.s,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: CustomPromptDatabaseSelector(
|
||||
databaseViewId: readyState.customPromptDatabaseViewId,
|
||||
isLoading: readyState.isLoadingCustomPrompts,
|
||||
popoverDirection: PopoverDirection.bottomWithRightAligned,
|
||||
childBuilder: (onTap) {
|
||||
return AiPromptDatabaseSelectorButton(
|
||||
selectedViewId: readyState.customPromptDatabaseViewId,
|
||||
isLoading: readyState.isLoadingCustomPrompts,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedList(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.all(spacing.l),
|
||||
key: listKey,
|
||||
initialItemCount: oldList.length,
|
||||
itemBuilder: (context, index, animation) {
|
||||
final cubit = context.read<AiPromptSelectorCubit>();
|
||||
|
||||
return cubit.state.maybeMap(
|
||||
ready: (state) {
|
||||
final prompt = state.visiblePrompts[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: index == 0 ? 0 : spacing.s,
|
||||
bottom: index == state.visiblePrompts.length - 1
|
||||
? 0
|
||||
: spacing.s,
|
||||
),
|
||||
child: _AiPromptListItem(
|
||||
animation: animation,
|
||||
prompt: prompt,
|
||||
isSelected: state.selectedPromptId == prompt.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
child: TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
if (readyState.visiblePrompts.isEmpty) {
|
||||
return buildEmptyPrompts();
|
||||
}
|
||||
return buildPromptList();
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -141,6 +178,68 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEmptyPrompts() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
VSpace(theme.spacing.m),
|
||||
Text(
|
||||
LocaleKeys.ai_customPrompt_noResults.tr(),
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPromptList() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return AnimatedList(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.all(theme.spacing.l),
|
||||
key: listKey,
|
||||
initialItemCount: oldList.length,
|
||||
itemBuilder: (context, index, animation) {
|
||||
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (state) {
|
||||
final prompt = state.visiblePrompts[index];
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: index == 0 ? 0 : theme.spacing.s,
|
||||
bottom: index == state.visiblePrompts.length - 1
|
||||
? 0
|
||||
: theme.spacing.s,
|
||||
),
|
||||
child: _AiPromptListItem(
|
||||
animation: animation,
|
||||
prompt: prompt,
|
||||
isSelected: state.selectedPromptId == prompt.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void handleVisiblePromptListChanged(
|
||||
List<AiPrompt> newList,
|
||||
) {
|
||||
@@ -258,6 +357,7 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
|
||||
padding: EdgeInsets.all(theme.spacing.m),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(theme.borderRadius.m),
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: widget.isSelected
|
||||
? isHovering
|
||||
@@ -267,7 +367,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
|
||||
? theme.borderColorScheme.greyTertiaryHover
|
||||
: theme.borderColorScheme.greyTertiary,
|
||||
),
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_user_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/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
@@ -47,6 +49,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
final layerLink = LayerLink();
|
||||
final overlayController = OverlayPortalController();
|
||||
final inputControlCubit = ChatInputControlCubit();
|
||||
final chatUserCubit = ChatUserCubit();
|
||||
final focusNode = FocusNode();
|
||||
|
||||
late SendButtonState sendButtonState;
|
||||
@@ -74,6 +77,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
checkForAskingAI();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,13 +92,17 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
focusNode.dispose();
|
||||
widget.textController.removeListener(handleTextControllerChanged);
|
||||
inputControlCubit.close();
|
||||
chatUserCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: inputControlCubit,
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: inputControlCubit),
|
||||
BlocProvider.value(value: chatUserCubit),
|
||||
],
|
||||
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
|
||||
listener: (context, state) {
|
||||
state.maybeWhen(
|
||||
@@ -221,6 +229,26 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
);
|
||||
}
|
||||
|
||||
void checkForAskingAI() {
|
||||
final paletteBloc = context.read<CommandPaletteBloc?>(),
|
||||
paletteState = paletteBloc?.state;
|
||||
if (paletteBloc == null || paletteState == null) return;
|
||||
final isAskingAI = paletteState.askAI;
|
||||
if (!isAskingAI) return;
|
||||
paletteBloc.add(CommandPaletteEvent.askedAI());
|
||||
final query = paletteState.query ?? '';
|
||||
if (query.isEmpty) return;
|
||||
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
|
||||
final metadata =
|
||||
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
|
||||
final promptState = context.read<AIPromptInputBloc?>()?.state;
|
||||
final predefinedFormat = promptState?.predefinedFormat;
|
||||
if (sources.isNotEmpty) {
|
||||
widget.onUpdateSelectedSources(sources);
|
||||
}
|
||||
widget.onSubmitted.call(query, predefinedFormat, metadata);
|
||||
}
|
||||
|
||||
void startMentionPageFromButton() {
|
||||
if (overlayController.isShowing) {
|
||||
return;
|
||||
@@ -634,7 +662,9 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (state.modelState.type == AiType.cloud) _selectSourcesButton(),
|
||||
if (context.read<ChatUserCubit>().supportSelectSource())
|
||||
_selectSourcesButton(),
|
||||
|
||||
if (extraBottomActionButton != null) extraBottomActionButton!,
|
||||
// _mentionButton(context),
|
||||
if (state.supportChatWithFile) _attachmentButton(context),
|
||||
|
||||
+35
-36
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/ai/service/view_selector_cubit.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -32,7 +35,18 @@ class PromptInputMobileSelectSourcesButton extends StatefulWidget {
|
||||
|
||||
class _PromptInputMobileSelectSourcesButtonState
|
||||
extends State<PromptInputMobileSelectSourcesButton> {
|
||||
late final cubit = ChatSettingsCubit();
|
||||
late final cubit = ViewSelectorCubit(
|
||||
maxSelectedParentPageCount: 3,
|
||||
getIgnoreViewType: (item) {
|
||||
if (item.view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (item.view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -90,9 +104,12 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
unawaited(
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace),
|
||||
);
|
||||
|
||||
await showMobileBottomSheet<void>(
|
||||
context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@@ -129,32 +146,17 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBody extends StatefulWidget {
|
||||
class _MobileSelectSourcesSheetBody extends StatelessWidget {
|
||||
const _MobileSelectSourcesSheetBody({
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
State<_MobileSelectSourcesSheetBody> createState() =>
|
||||
_MobileSelectSourcesSheetBodyState();
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBodyState
|
||||
extends State<_MobileSelectSourcesSheetBody> {
|
||||
final textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
@@ -183,10 +185,9 @@ class _MobileSelectSourcesSheetBodyState
|
||||
child: SizedBox(
|
||||
height: 44.0,
|
||||
child: FlowySearchTextField(
|
||||
controller: textController,
|
||||
onChanged: (value) => context
|
||||
.read<ChatSettingsCubit>()
|
||||
.updateFilter(value),
|
||||
controller: context
|
||||
.read<ViewSelectorCubit>()
|
||||
.filterTextController,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -196,27 +197,25 @@ class _MobileSelectSourcesSheetBodyState
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
builder: (context, state) {
|
||||
final sources = state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: sources.length,
|
||||
childCount: state.visibleSources.length,
|
||||
(context, index) {
|
||||
final source = sources.elementAt(index);
|
||||
return ChatSourceTreeItem(
|
||||
final source = state.visibleSources.elementAt(index);
|
||||
return ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${source.view.id}',
|
||||
),
|
||||
chatSource: source,
|
||||
viewSelectorItem: source,
|
||||
level: 0,
|
||||
isDescendentOfSpace: source.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (chatSource) {
|
||||
onSelected: (item) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
.read<ViewSelectorCubit>()
|
||||
.toggleSelectedStatus(item, false);
|
||||
},
|
||||
height: 40.0,
|
||||
);
|
||||
|
||||
@@ -2,15 +2,13 @@ import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_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_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -18,6 +16,8 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../service/view_selector_cubit.dart';
|
||||
import '../view_selector.dart';
|
||||
import 'layout_define.dart';
|
||||
import 'mention_page_menu.dart';
|
||||
|
||||
@@ -38,7 +38,24 @@ class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
|
||||
|
||||
class _PromptInputDesktopSelectSourcesButtonState
|
||||
extends State<PromptInputDesktopSelectSourcesButton> {
|
||||
late final cubit = ChatSettingsCubit();
|
||||
late final cubit = ViewSelectorCubit(
|
||||
maxSelectedParentPageCount: 3,
|
||||
getIgnoreViewType: (item) {
|
||||
final view = item.view;
|
||||
|
||||
if (view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (view.layout == ViewLayoutPB.Chat) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
if (view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.disable;
|
||||
}
|
||||
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
);
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
@@ -59,23 +76,10 @@ class _PromptInputDesktopSelectSourcesButtonState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: cubit,
|
||||
),
|
||||
],
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider.value(
|
||||
value: cubit,
|
||||
),
|
||||
child: BlocBuilder<SpaceBloc, SpaceState>(
|
||||
builder: (context, state) {
|
||||
return AppFlowyPopover(
|
||||
@@ -86,18 +90,18 @@ class _PromptInputDesktopSelectSourcesButtonState
|
||||
controller: popoverController,
|
||||
onOpen: () {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
onClose: () {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
child: const _PopoverContent(),
|
||||
);
|
||||
},
|
||||
@@ -186,20 +190,26 @@ class _PopoverContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
|
||||
child: SpaceSearchField(
|
||||
width: 600,
|
||||
onSearch: (context, value) =>
|
||||
context.read<ChatSettingsCubit>().updateFilter(value),
|
||||
child: AFTextField(
|
||||
size: AFTextFieldSize.m,
|
||||
controller:
|
||||
context.read<ViewSelectorCubit>().filterTextController,
|
||||
hintText: LocaleKeys.search_label.tr(),
|
||||
),
|
||||
),
|
||||
_buildDivider(),
|
||||
AFDivider(
|
||||
startIndent: theme.spacing.l,
|
||||
endIndent: theme.spacing.l,
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
@@ -208,9 +218,10 @@ class _PopoverContent extends StatelessWidget {
|
||||
..._buildSelectedSources(context, state),
|
||||
if (state.selectedSources.isNotEmpty &&
|
||||
state.visibleSources.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: _buildDivider(),
|
||||
AFDivider(
|
||||
spacing: 4.0,
|
||||
startIndent: theme.spacing.l,
|
||||
endIndent: theme.spacing.l,
|
||||
),
|
||||
..._buildVisibleSources(context, state),
|
||||
],
|
||||
@@ -222,70 +233,53 @@ class _PopoverContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
indent: 8.0,
|
||||
endIndent: 8.0,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildSelectedSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState state,
|
||||
ViewSelectorState state,
|
||||
) {
|
||||
return state.selectedSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'selected_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: true,
|
||||
onSelected: (chatSource) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
return state.selectedSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'selected_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: true,
|
||||
onSelected: (item) {
|
||||
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, true);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState state,
|
||||
ViewSelectorState state,
|
||||
) {
|
||||
return state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (chatSource) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
return state.visibleSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (item) {
|
||||
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, false);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSourceTreeItem extends StatefulWidget {
|
||||
const ChatSourceTreeItem({
|
||||
class ViewSelectorTreeItem extends StatefulWidget {
|
||||
const ViewSelectorTreeItem({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.viewSelectorItem,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
@@ -296,7 +290,7 @@ class ChatSourceTreeItem extends StatefulWidget {
|
||||
this.showCheckbox = true,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
|
||||
/// nested level of the view item
|
||||
final int level;
|
||||
@@ -305,9 +299,9 @@ class ChatSourceTreeItem extends StatefulWidget {
|
||||
|
||||
final bool isSelectedSection;
|
||||
|
||||
final void Function(ChatSource chatSource) onSelected;
|
||||
final void Function(ViewSelectorItem viewSelectorItem) onSelected;
|
||||
|
||||
final void Function(ChatSource chatSource)? onAdd;
|
||||
final void Function(ViewSelectorItem viewSelectorItem)? onAdd;
|
||||
|
||||
final bool showSaveButton;
|
||||
|
||||
@@ -316,16 +310,16 @@ class ChatSourceTreeItem extends StatefulWidget {
|
||||
final bool showCheckbox;
|
||||
|
||||
@override
|
||||
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
|
||||
State<ViewSelectorTreeItem> createState() => _ViewSelectorTreeItemState();
|
||||
}
|
||||
|
||||
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
|
||||
class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = SizedBox(
|
||||
height: widget.height,
|
||||
child: ChatSourceTreeItemInner(
|
||||
chatSource: widget.chatSource,
|
||||
child: ViewSelectorTreeItemInner(
|
||||
viewSelectorItem: widget.viewSelectorItem,
|
||||
level: widget.level,
|
||||
isDescendentOfSpace: widget.isDescendentOfSpace,
|
||||
isSelectedSection: widget.isSelectedSection,
|
||||
@@ -336,33 +330,30 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
|
||||
),
|
||||
);
|
||||
|
||||
final disabledEnabledChild =
|
||||
widget.chatSource.ignoreStatus == IgnoreViewType.disable
|
||||
? FlowyTooltip(
|
||||
message: widget.showCheckbox
|
||||
? switch (widget.chatSource.view.layout) {
|
||||
ViewLayoutPB.Document =>
|
||||
LocaleKeys.chat_sourcesLimitReached.tr(),
|
||||
_ => LocaleKeys.chat_sourceUnsupported.tr(),
|
||||
}
|
||||
: "",
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.forbidden,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
),
|
||||
)
|
||||
: child;
|
||||
final disabledEnabledChild = widget.viewSelectorItem.isDisabled
|
||||
? FlowyTooltip(
|
||||
message: widget.showCheckbox
|
||||
? switch (widget.viewSelectorItem.view.layout) {
|
||||
ViewLayoutPB.Document =>
|
||||
LocaleKeys.chat_sourcesLimitReached.tr(),
|
||||
_ => LocaleKeys.chat_sourceUnsupported.tr(),
|
||||
}
|
||||
: "",
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.forbidden,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
),
|
||||
)
|
||||
: child;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.chatSource.isExpandedNotifier,
|
||||
valueListenable: widget.viewSelectorItem.isExpandedNotifier,
|
||||
builder: (context, isExpanded, child) {
|
||||
// filter the child views that should be ignored
|
||||
final childViews = widget.chatSource.children
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.toList();
|
||||
final childViews = widget.viewSelectorItem.children;
|
||||
|
||||
if (!isExpanded || childViews.isEmpty) {
|
||||
return disabledEnabledChild;
|
||||
@@ -373,11 +364,11 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
|
||||
children: [
|
||||
disabledEnabledChild,
|
||||
...childViews.map(
|
||||
(childSource) => ChatSourceTreeItem(
|
||||
(childSource) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'select_sources_tree_item_${childSource.view.id}',
|
||||
),
|
||||
chatSource: childSource,
|
||||
viewSelectorItem: childSource,
|
||||
level: widget.level + 1,
|
||||
isDescendentOfSpace: widget.isDescendentOfSpace,
|
||||
isSelectedSection: widget.isSelectedSection,
|
||||
@@ -395,10 +386,10 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
const ChatSourceTreeItemInner({
|
||||
class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
const ViewSelectorTreeItemInner({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.viewSelectorItem,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
@@ -408,34 +399,27 @@ class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
this.onAdd,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
final int level;
|
||||
final bool isDescendentOfSpace;
|
||||
final bool isSelectedSection;
|
||||
final bool showCheckbox;
|
||||
final bool showSaveButton;
|
||||
final void Function(ChatSource)? onSelected;
|
||||
final void Function(ChatSource)? onAdd;
|
||||
final void Function(ViewSelectorItem)? onSelected;
|
||||
final void Function(ViewSelectorItem)? onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
if (!isSelectedSection) {
|
||||
onSelected?.call(chatSource);
|
||||
}
|
||||
},
|
||||
onTap: () => onSelected?.call(viewSelectorItem),
|
||||
child: FlowyHover(
|
||||
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
|
||||
style: HoverStyle(
|
||||
hoverColor: isSelectedSection
|
||||
? Colors.transparent
|
||||
: AFThemeExtension.of(context).lightGreyHover,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
builder: (context, onHover) {
|
||||
final isSaveButtonVisible =
|
||||
showSaveButton && !chatSource.view.isSpace;
|
||||
showSaveButton && !viewSelectorItem.view.isSpace;
|
||||
final isAddButtonVisible = onAdd != null;
|
||||
return Row(
|
||||
children: [
|
||||
@@ -443,26 +427,26 @@ class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
|
||||
// builds the >, ^ or · button
|
||||
ToggleIsExpandedButton(
|
||||
chatSource: chatSource,
|
||||
viewSelectorItem: viewSelectorItem,
|
||||
isSelectedSection: isSelectedSection,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
// checkbox
|
||||
if (!chatSource.view.isSpace && showCheckbox) ...[
|
||||
if (!viewSelectorItem.view.isSpace && showCheckbox) ...[
|
||||
SourceSelectedStatusCheckbox(
|
||||
chatSource: chatSource,
|
||||
viewSelectorItem: viewSelectorItem,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
// icon
|
||||
MentionViewIcon(
|
||||
view: chatSource.view,
|
||||
view: viewSelectorItem.view,
|
||||
),
|
||||
const HSpace(6.0),
|
||||
// title
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
chatSource.view.nameOrDefault,
|
||||
viewSelectorItem.view.nameOrDefault,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
@@ -479,7 +463,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () => onSelected?.call(chatSource),
|
||||
onPressed: () => onSelected?.call(viewSelectorItem),
|
||||
),
|
||||
if (isSaveButtonVisible && isAddButtonVisible)
|
||||
const HSpace(4.0),
|
||||
@@ -492,7 +476,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () => onAdd?.call(chatSource),
|
||||
onPressed: () => onAdd?.call(viewSelectorItem),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
@@ -507,27 +491,30 @@ class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
class ToggleIsExpandedButton extends StatelessWidget {
|
||||
const ToggleIsExpandedButton({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.viewSelectorItem,
|
||||
required this.isSelectedSection,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
final bool isSelectedSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
|
||||
if (isReferencedDatabaseView(
|
||||
viewSelectorItem.view,
|
||||
viewSelectorItem.parentView,
|
||||
)) {
|
||||
return const _DotIconWidget();
|
||||
}
|
||||
|
||||
if (chatSource.children.isEmpty) {
|
||||
if (viewSelectorItem.children.isEmpty) {
|
||||
return const SizedBox.square(dimension: 16.0);
|
||||
}
|
||||
|
||||
return FlowyHover(
|
||||
child: GestureDetector(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: chatSource.isExpandedNotifier,
|
||||
valueListenable: viewSelectorItem.isExpandedNotifier,
|
||||
builder: (context, value, _) => FlowySvg(
|
||||
value
|
||||
? FlowySvgs.view_item_expand_s
|
||||
@@ -536,8 +523,8 @@ class ToggleIsExpandedButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onTap: () => context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleIsExpanded(chatSource, isSelectedSection),
|
||||
.read<ViewSelectorCubit>()
|
||||
.toggleIsExpanded(viewSelectorItem, isSelectedSection),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -565,20 +552,20 @@ class _DotIconWidget extends StatelessWidget {
|
||||
class SourceSelectedStatusCheckbox extends StatelessWidget {
|
||||
const SourceSelectedStatusCheckbox({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.viewSelectorItem,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: chatSource.selectedStatusNotifier,
|
||||
valueListenable: viewSelectorItem.selectedStatusNotifier,
|
||||
builder: (context, selectedStatus, _) => FlowySvg(
|
||||
switch (selectedStatus) {
|
||||
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
|
||||
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
|
||||
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
|
||||
ViewSelectedStatus.unselected => FlowySvgs.uncheck_s,
|
||||
ViewSelectedStatus.selected => FlowySvgs.check_filled_s,
|
||||
ViewSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
|
||||
},
|
||||
size: const Size.square(18.0),
|
||||
blendMode: null,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
|
||||
class ViewSelector extends StatelessWidget {
|
||||
const ViewSelector({
|
||||
super.key,
|
||||
required this.viewSelectorCubit,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final SingleChildWidget viewSelectorCubit;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
return SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false));
|
||||
},
|
||||
),
|
||||
viewSelectorCubit,
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ 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 'package:sentry/sentry.dart';
|
||||
|
||||
class MobileHomeScreen extends StatelessWidget {
|
||||
const MobileHomeScreen({super.key});
|
||||
@@ -65,14 +64,6 @@ class MobileHomeScreen extends StatelessWidget {
|
||||
return const WorkspaceFailedScreen();
|
||||
}
|
||||
|
||||
Sentry.configureScope(
|
||||
(scope) => scope.setUser(
|
||||
SentryUser(
|
||||
id: userProfile.id.toString(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
|
||||
onTap: () {
|
||||
context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.add(CommandPaletteEvent.gointToAskAI());
|
||||
?.add(CommandPaletteEvent.goingToAskAI());
|
||||
mobileCreateNewAIChatNotifier.value =
|
||||
mobileCreateNewAIChatNotifier.value + 1;
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'mobile_search_ask_ai_entrance.dart';
|
||||
import 'mobile_search_result.dart';
|
||||
@@ -54,13 +53,6 @@ class MobileSearchScreen extends StatelessWidget {
|
||||
return const WorkspaceFailedScreen();
|
||||
}
|
||||
|
||||
Sentry.configureScope(
|
||||
(scope) => scope.setUser(
|
||||
SentryUser(
|
||||
id: userProfile.id.toString(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Provider.value(
|
||||
value: userProfile,
|
||||
child: MobileSearchPage(
|
||||
|
||||
+3
-4
@@ -150,10 +150,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.clear_s,
|
||||
blendMode: null,
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
size: Size.square(20),
|
||||
FlowySvgs.search_clear_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: const Size.square(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -18,6 +21,8 @@ class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
|
||||
(event, emit) async {
|
||||
await event.map(
|
||||
initial: (e) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
final ancester = await _cache.getAncestor(
|
||||
state.viewId,
|
||||
onRefresh: (ancestor) {
|
||||
@@ -53,3 +58,46 @@ class ViewAncestorState with _$ViewAncestorState {
|
||||
ancestor: ViewAncestor.empty(),
|
||||
);
|
||||
}
|
||||
|
||||
extension ViewAncestorTextExtension on ViewAncestorState {
|
||||
Widget buildPath(BuildContext context, {TextStyle? style}) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final ancestors = ancestor.ancestors;
|
||||
final textStyle = style ??
|
||||
theme.textStyle.caption.standard(color: theme.textColorScheme.tertiary);
|
||||
final textHeight = (textStyle.fontSize ?? 0.0) * (textStyle.height ?? 1.0);
|
||||
if (isLoading) return VSpace(textHeight);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
final List<String> displayPath = ancestors.map((e) => e.name).toList();
|
||||
if (displayPath.isEmpty) return const SizedBox.shrink();
|
||||
TextPainter textPainter =
|
||||
_buildTextPainter(displayPath.join(' / '), textStyle);
|
||||
textPainter.layout(maxWidth: constrains.maxWidth);
|
||||
if (textPainter.didExceedMaxLines && displayPath.length > 2) {
|
||||
displayPath.removeAt(displayPath.length - 2);
|
||||
displayPath.insert(displayPath.length - 1, '...');
|
||||
}
|
||||
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
|
||||
textPainter.layout(maxWidth: constrains.maxWidth);
|
||||
while (textPainter.didExceedMaxLines && displayPath.length > 3) {
|
||||
displayPath.removeAt(displayPath.length - 2);
|
||||
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
|
||||
textPainter.layout(maxWidth: constrains.maxWidth);
|
||||
}
|
||||
return Text(
|
||||
displayPath.join(' / '),
|
||||
style: textStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
TextPainter _buildTextPainter(String text, TextStyle style) => TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export 'ai_model_switch_listener.dart';
|
||||
export 'chat_ai_message_bloc.dart';
|
||||
export 'chat_bloc.dart';
|
||||
export 'chat_edit_document_service.dart';
|
||||
export 'chat_entity.dart';
|
||||
export 'chat_input_control_cubit.dart';
|
||||
export 'chat_input_file_bloc.dart';
|
||||
export 'chat_member_bloc.dart';
|
||||
export 'chat_message_listener.dart';
|
||||
export 'chat_message_service.dart';
|
||||
export 'chat_message_stream.dart';
|
||||
export 'chat_notification.dart';
|
||||
export 'chat_select_message_bloc.dart';
|
||||
export 'chat_user_message_bloc.dart';
|
||||
@@ -117,6 +117,14 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIFollowUp>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: MessageState.aiFollowUp(event.followUpData),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeStreamListener() {
|
||||
@@ -137,6 +145,9 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
},
|
||||
onLocalAIInitializing: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
|
||||
onAIFollowUp: (data) {
|
||||
_safeAdd(ChatAIMessageEvent.onAIFollowUp(data));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +185,9 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
const factory ChatAIMessageEvent.onAIFollowUp(
|
||||
AIFollowUpData followUpData,
|
||||
) = _OnAIFollowUp;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -209,4 +223,6 @@ class MessageState with _$MessageState {
|
||||
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
|
||||
const factory MessageState.ready() = _Ready;
|
||||
const factory MessageState.loading() = _Loading;
|
||||
const factory MessageState.aiFollowUp(AIFollowUpData followUpData) =
|
||||
_AIFollowUp;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
@@ -15,267 +13,291 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_handler.dart';
|
||||
import 'chat_message_listener.dart';
|
||||
import 'chat_message_service.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
import 'chat_settings_manager.dart';
|
||||
import 'chat_stream_manager.dart';
|
||||
|
||||
part 'chat_bloc.freezed.dart';
|
||||
|
||||
/// Returns current Unix timestamp (seconds since epoch)
|
||||
int timestamp() {
|
||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
ChatBloc({
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
}) : chatController = InMemoryChatController(),
|
||||
listener = ChatMessageListener(chatId: chatId),
|
||||
selectedSourcesNotifier = ValueNotifier([]),
|
||||
super(ChatState.initial()) {
|
||||
// Initialize managers
|
||||
_messageHandler = ChatMessageHandler(
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
chatController: chatController,
|
||||
);
|
||||
|
||||
_streamManager = ChatStreamManager(chatId);
|
||||
_settingsManager = ChatSettingsManager(chatId: chatId);
|
||||
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_loadMessages();
|
||||
_loadSetting();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatMessageListener listener;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final ChatController chatController;
|
||||
|
||||
/// The last streaming message id
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
// Managers
|
||||
late final ChatMessageHandler _messageHandler;
|
||||
late final ChatStreamManager _streamManager;
|
||||
late final ChatSettingsManager _settingsManager;
|
||||
|
||||
ChatMessagePB? lastSentMessage;
|
||||
|
||||
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
||||
///
|
||||
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
||||
/// between the real message ID and the last streaming message ID, we use this map to store the associations.
|
||||
///
|
||||
/// This map will be updated when receiving a message from the server and its author type
|
||||
/// is 3 (AI response).
|
||||
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
||||
|
||||
bool isLoadingPreviousMessages = false;
|
||||
bool hasMorePreviousMessages = true;
|
||||
AnswerStream? answerStream;
|
||||
bool isFetchingRelatedQuestions = false;
|
||||
bool shouldFetchRelatedQuestions = false;
|
||||
|
||||
// Accessor for selected sources
|
||||
ValueNotifier<List<String>> get selectedSourcesNotifier =>
|
||||
_settingsManager.selectedSourcesNotifier;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await answerStream?.dispose();
|
||||
// Safely dispose all resources
|
||||
await _streamManager.dispose();
|
||||
await listener.stop();
|
||||
|
||||
final request = ViewIdPB(value: chatId);
|
||||
unawaited(FolderEventCloseView(request).send());
|
||||
selectedSourcesNotifier.dispose();
|
||||
|
||||
_settingsManager.dispose();
|
||||
chatController.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
// Loading messages
|
||||
didLoadLatestMessages: (List<Message> messages) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
on<ChatEvent>((event, emit) async {
|
||||
await event.when(
|
||||
// Chat settings
|
||||
didReceiveChatSettings: (settings) async =>
|
||||
_handleChatSettings(settings),
|
||||
updateSelectedSources: (selectedSourcesIds) async =>
|
||||
_handleUpdateSources(selectedSourcesIds),
|
||||
|
||||
switch (state.loadingState) {
|
||||
case LoadChatMessageStatus.loading
|
||||
when chatController.messages.isEmpty:
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingState: LoadChatMessageStatus.loadingRemote,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case LoadChatMessageStatus.loading:
|
||||
case LoadChatMessageStatus.loadingRemote:
|
||||
emit(
|
||||
state.copyWith(loadingState: LoadChatMessageStatus.ready),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
loadPreviousMessages: () {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
// Message loading
|
||||
didLoadLatestMessages: (messages) async =>
|
||||
_handleLatestMessages(messages, emit),
|
||||
loadPreviousMessages: () async => _loadPreviousMessagesIfNeeded(),
|
||||
didLoadPreviousMessages: (messages, hasMore) async =>
|
||||
_handlePreviousMessages(messages, hasMore),
|
||||
|
||||
final oldestMessage = _getOldestMessage();
|
||||
// Message handling
|
||||
receiveMessage: (message) async => _handleReceiveMessage(message),
|
||||
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
},
|
||||
didLoadPreviousMessages: (messages, hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
// Sending messages
|
||||
sendMessage: (message, format, metadata) async =>
|
||||
_handleSendMessage(message, format, metadata, emit),
|
||||
finishSending: () async => emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
),
|
||||
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
// Stream control
|
||||
stopStream: () async => _handleStopStream(emit),
|
||||
failedSending: () async => _handleFailedSending(emit),
|
||||
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
},
|
||||
didFinishAnswerStream: () {
|
||||
emit(
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
);
|
||||
},
|
||||
didReceiveRelatedQuestions: (List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Answer regeneration
|
||||
regenerateAnswer: (id, format, model) async =>
|
||||
_handleRegenerateAnswer(id, format, model, emit),
|
||||
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.relatedQuestion,
|
||||
'questions': questions,
|
||||
};
|
||||
// Streaming completion
|
||||
didFinishAnswerStream: () async => emit(
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
),
|
||||
|
||||
final createdAt = DateTime.now();
|
||||
// Related questions
|
||||
didReceiveRelatedQuestions: (questions) async =>
|
||||
_handleRelatedQuestions(questions),
|
||||
|
||||
final message = TextMessage(
|
||||
id: "related_question_$createdAt",
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
createdAt: createdAt,
|
||||
);
|
||||
// Message management
|
||||
deleteMessage: (message) async => chatController.remove(message),
|
||||
|
||||
chatController.insert(message);
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final oldMessage = chatController.messages
|
||||
.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
},
|
||||
sendMessage: (
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
) {
|
||||
_clearErrorMessages(emit);
|
||||
_clearRelatedQuestions();
|
||||
_startStreamingMessage(message, format, metadata);
|
||||
lastSentMessage = null;
|
||||
// AI follow-up
|
||||
onAIFollowUp: (followUpData) async {
|
||||
shouldFetchRelatedQuestions =
|
||||
followUpData.shouldGenerateRelatedQuestion;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions =
|
||||
format == null || format.imageFormat.hasText;
|
||||
// Chat settings handlers
|
||||
void _handleChatSettings(ChatSettingsPB settings) {
|
||||
_settingsManager.selectedSourcesNotifier.value = settings.ragIds;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
stopStream: () async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
Future<void> _handleUpdateSources(List<String> selectedSourcesIds) async {
|
||||
await _settingsManager.updateSelectedSources(selectedSourcesIds);
|
||||
}
|
||||
|
||||
// tell backend to stop
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
// Message loading handlers
|
||||
Future<void> _handleLatestMessages(
|
||||
List<Message> messages,
|
||||
Emitter<ChatState> emit,
|
||||
) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
// allow user input
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
// Check if emit is still valid after async operations
|
||||
if (emit.isDone) {
|
||||
return;
|
||||
}
|
||||
|
||||
// no need to remove old message if stream has started already
|
||||
if (answerStream!.hasStarted) {
|
||||
return;
|
||||
}
|
||||
switch (state.loadingState) {
|
||||
case LoadChatMessageStatus.loading when chatController.messages.isEmpty:
|
||||
emit(state.copyWith(loadingState: LoadChatMessageStatus.loadingRemote));
|
||||
break;
|
||||
case LoadChatMessageStatus.loading:
|
||||
case LoadChatMessageStatus.loadingRemote:
|
||||
emit(state.copyWith(loadingState: LoadChatMessageStatus.ready));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
void _handlePreviousMessages(List<Message> messages, bool hasMore) {
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
// set answer stream to null
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
answerStreamMessageId = '';
|
||||
},
|
||||
failedSending: () {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
},
|
||||
regenerateAnswer: (id, format, model) {
|
||||
_clearRelatedQuestions();
|
||||
_regenerateAnswer(id, format, model);
|
||||
lastSentMessage = null;
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
}
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions = false;
|
||||
// Message handling
|
||||
void _handleReceiveMessage(Message message) {
|
||||
final oldMessage =
|
||||
chatController.messages.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveChatSettings: (settings) {
|
||||
selectedSourcesNotifier.value = settings.ragIds;
|
||||
},
|
||||
updateSelectedSources: (selectedSourcesIds) async {
|
||||
selectedSourcesNotifier.value = [...selectedSourcesIds];
|
||||
// Message sending handlers
|
||||
void _handleSendMessage(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
Emitter<ChatState> emit,
|
||||
) {
|
||||
_messageHandler.clearErrorMessages();
|
||||
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
|
||||
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
ragIds: selectedSourcesIds,
|
||||
);
|
||||
await AIEventUpdateChatSettings(payload)
|
||||
.send()
|
||||
.onFailure(Log.error);
|
||||
},
|
||||
deleteMessage: (mesesage) async {
|
||||
await chatController.remove(mesesage);
|
||||
},
|
||||
);
|
||||
},
|
||||
_messageHandler.clearRelatedQuestions();
|
||||
_startStreamingMessage(message, format, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions = format == null || format.imageFormat.hasText;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Stream control handlers
|
||||
Future<void> _handleStopStream(Emitter<ChatState> emit) async {
|
||||
await _streamManager.stopStream();
|
||||
|
||||
// Allow user input
|
||||
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
|
||||
|
||||
// No need to remove old message if stream has started already
|
||||
if (_streamManager.hasAnswerStreamStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == _messageHandler.answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
|
||||
await _streamManager.disposeAnswerStream();
|
||||
}
|
||||
|
||||
void _handleFailedSending(Emitter<ChatState> emit) {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
|
||||
}
|
||||
|
||||
// Answer regeneration handler
|
||||
void _handleRegenerateAnswer(
|
||||
String id,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
Emitter<ChatState> emit,
|
||||
) {
|
||||
_messageHandler.clearRelatedQuestions();
|
||||
_regenerateAnswer(id, format, model);
|
||||
lastSentMessage = null;
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions = false;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Related questions handler
|
||||
void _handleRelatedQuestions(List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.relatedQuestion,
|
||||
'questions': questions,
|
||||
};
|
||||
|
||||
final createdAt = DateTime.now();
|
||||
final message = TextMessage(
|
||||
id: "related_question_$createdAt",
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
chatController.insert(message);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
chatMessageCallback: (pb) {
|
||||
@@ -283,25 +305,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => questionStreamMessageId,
|
||||
);
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
_messageHandler.processReceivedMessage(pb);
|
||||
final message = _messageHandler.createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
},
|
||||
chatErrorMessageCallback: (err) {
|
||||
@@ -312,13 +317,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
},
|
||||
latestMessageCallback: (list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
final messages =
|
||||
list.messages.map(_messageHandler.createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
prevMessageCallback: (list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
final messages =
|
||||
list.messages.map(_messageHandler.createTextMessage).toList();
|
||||
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
||||
}
|
||||
},
|
||||
@@ -328,52 +335,55 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
|
||||
// The answer stream will bet set to null after the streaming has
|
||||
// finished, got cancelled, or errored. In this case, don't retrieve
|
||||
// related questions.
|
||||
if (answerStream == null ||
|
||||
lastSentMessage == null ||
|
||||
!shouldFetchRelatedQuestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
isFetchingRelatedQuestions = true;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
// while fetching related questions, the user might enter a new
|
||||
// question or regenerate a previous response. In such cases, don't
|
||||
// display the relatedQuestions
|
||||
if (!isClosed && isFetchingRelatedQuestions) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
isFetchingRelatedQuestions = false;
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
unawaited(_fetchRelatedQuestionsIfNeeded());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _loadSetting() async {
|
||||
// Split method to handle related questions
|
||||
Future<void> _fetchRelatedQuestionsIfNeeded() async {
|
||||
// Don't fetch related questions if conditions aren't met
|
||||
if (_streamManager.answerStream == null ||
|
||||
lastSentMessage == null ||
|
||||
!shouldFetchRelatedQuestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
isFetchingRelatedQuestions = true;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
// while fetching related questions, the user might enter a new
|
||||
// question or regenerate a previous response. In such cases, don't
|
||||
// display the relatedQuestions
|
||||
if (!isClosed && isFetchingRelatedQuestions) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
isFetchingRelatedQuestions = false;
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
void _loadSettings() async {
|
||||
final getChatSettingsPayload =
|
||||
AIEventGetChatSettings(ChatId(value: chatId));
|
||||
|
||||
await getChatSettingsPayload.send().fold(
|
||||
(settings) {
|
||||
if (!isClosed) {
|
||||
add(ChatEvent.didReceiveChatSettings(settings: settings));
|
||||
}
|
||||
},
|
||||
Log.error,
|
||||
(err) => Log.error("Failed to load chat settings: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,10 +392,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
|
||||
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
final messages =
|
||||
list.messages.map(_messageHandler.createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
@@ -393,15 +405,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
void _loadPreviousMessagesIfNeeded() {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
/// get the last message that is not a one-time message
|
||||
Message? _getOldestMessage() {
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
|
||||
final oldestMessage = _messageHandler.getOldestMessage();
|
||||
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
void _loadPreviousMessages(Int64? beforeMessageId) {
|
||||
@@ -418,36 +437,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
) async {
|
||||
await answerStream?.dispose();
|
||||
// Prepare streams
|
||||
await _streamManager.prepareStreams();
|
||||
|
||||
answerStream = AnswerStream();
|
||||
final questionStream = QuestionStream();
|
||||
|
||||
// add a streaming question message
|
||||
final questionStreamMessage = _createQuestionStreamMessage(
|
||||
questionStream,
|
||||
// Create and add question message
|
||||
final questionStreamMessage = _messageHandler.createQuestionStreamMessage(
|
||||
_streamManager.questionStream!,
|
||||
metadata,
|
||||
);
|
||||
add(ChatEvent.receiveMessage(questionStreamMessage));
|
||||
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
//metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
|
||||
// stream the question to the server
|
||||
await AIEventStreamMessage(payload).send().fold(
|
||||
// Send stream request
|
||||
await _streamManager.sendStreamRequest(message, format).fold(
|
||||
(question) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
stream: answerStream!,
|
||||
// Create and add answer stream message
|
||||
final streamAnswer = _messageHandler.createAnswerStreamMessage(
|
||||
stream: _streamManager.answerStream!,
|
||||
questionMessageId: question.messageId,
|
||||
fakeQuestionMessageId: questionStreamMessage.id,
|
||||
);
|
||||
@@ -481,143 +487,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Refactored method to handle answer regeneration
|
||||
void _regenerateAnswer(
|
||||
String answerMessageIdString,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) async {
|
||||
final id = temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((e) => e.value == answerMessageIdString)
|
||||
?.key ??
|
||||
answerMessageIdString;
|
||||
final id = _messageHandler.getEffectiveMessageId(answerMessageIdString);
|
||||
final answerMessageId = Int64.tryParseInt(id);
|
||||
|
||||
if (answerMessageId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await answerStream?.dispose();
|
||||
answerStream = AnswerStream();
|
||||
|
||||
final payload = RegenerateResponsePB(
|
||||
chatId: chatId,
|
||||
answerMessageId: answerMessageId,
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
);
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
if (model != null) {
|
||||
payload.model = model;
|
||||
}
|
||||
|
||||
await AIEventRegenerateResponse(payload).send().fold(
|
||||
(success) {
|
||||
await _streamManager.prepareStreams();
|
||||
await _streamManager
|
||||
.sendRegenerateRequest(
|
||||
answerMessageId,
|
||||
format,
|
||||
model,
|
||||
)
|
||||
.fold(
|
||||
(_) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
stream: answerStream!,
|
||||
questionMessageId: answerMessageId - 1,
|
||||
).copyWith(id: answerMessageIdString);
|
||||
final streamAnswer = _messageHandler
|
||||
.createAnswerStreamMessage(
|
||||
stream: _streamManager.answerStream!,
|
||||
questionMessageId: answerMessageId - 1,
|
||||
)
|
||||
.copyWith(id: answerMessageIdString);
|
||||
|
||||
add(ChatEvent.receiveMessage(streamAnswer));
|
||||
add(const ChatEvent.finishSending());
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to send message: ${err.msg}"),
|
||||
(err) => Log.error("Failed to regenerate answer: ${err.msg}"),
|
||||
);
|
||||
}
|
||||
|
||||
Message _createAnswerStreamMessage({
|
||||
required AnswerStream stream,
|
||||
required Int64 questionMessageId,
|
||||
String? fakeQuestionMessageId,
|
||||
}) {
|
||||
answerStreamMessageId = fakeQuestionMessageId == null
|
||||
? (questionMessageId + 1).toString()
|
||||
: "${fakeQuestionMessageId}_ans";
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
text: '',
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Message _createQuestionStreamMessage(
|
||||
QuestionStream stream,
|
||||
Map<String, dynamic>? sentMetadata,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
|
||||
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: userId),
|
||||
metadata: {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
|
||||
},
|
||||
id: questionStreamMessageId,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
|
||||
Message _createTextMessage(ChatMessagePB message) {
|
||||
String messageId = message.messageId.toString();
|
||||
|
||||
/// If the message id is in the temporary map, we will use the previous fake message id
|
||||
if (temporaryMessageIDMap.containsKey(messageId)) {
|
||||
messageId = temporaryMessageIDMap[messageId]!;
|
||||
}
|
||||
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _clearErrorMessages(Emitter<ChatState> emit) {
|
||||
final errorMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.error,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in errorMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
|
||||
}
|
||||
|
||||
void _clearRelatedQuestions() {
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -668,6 +573,9 @@ class ChatEvent with _$ChatEvent {
|
||||
) = _DidReceiveRelatedQueston;
|
||||
|
||||
const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
|
||||
|
||||
const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) =
|
||||
_OnAIFollowUp;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
/// Returns current Unix timestamp (seconds since epoch)
|
||||
int timestamp() {
|
||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
/// Handles message creation and manipulation for the chat system
|
||||
class ChatMessageHandler {
|
||||
ChatMessageHandler({
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
required this.chatController,
|
||||
});
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatController chatController;
|
||||
|
||||
/// Maps real message IDs to temporary streaming message IDs
|
||||
final HashMap<String, String> _temporaryMessageIDMap = HashMap();
|
||||
|
||||
/// Gets the effective message ID from the temporary map
|
||||
String getEffectiveMessageId(String messageId) {
|
||||
return _temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((entry) => entry.value == messageId)
|
||||
?.key ??
|
||||
messageId;
|
||||
}
|
||||
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
|
||||
/// Create a message from ChatMessagePB object
|
||||
Message createTextMessage(ChatMessagePB message) {
|
||||
String messageId = message.messageId.toString();
|
||||
|
||||
/// If the message id is in the temporary map, we will use the previous fake message id
|
||||
if (_temporaryMessageIDMap.containsKey(messageId)) {
|
||||
messageId = _temporaryMessageIDMap[messageId]!;
|
||||
}
|
||||
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a streaming answer message
|
||||
Message createAnswerStreamMessage({
|
||||
required AnswerStream stream,
|
||||
required Int64 questionMessageId,
|
||||
String? fakeQuestionMessageId,
|
||||
}) {
|
||||
answerStreamMessageId = fakeQuestionMessageId == null
|
||||
? (questionMessageId + 1).toString()
|
||||
: "${fakeQuestionMessageId}_ans";
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
text: '',
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a streaming question message
|
||||
Message createQuestionStreamMessage(
|
||||
QuestionStream stream,
|
||||
Map<String, dynamic>? sentMetadata,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
questionStreamMessageId = timestamp().toString();
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: userId),
|
||||
metadata: {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
if (sentMetadata != null)
|
||||
messageChatFileListKey: sentMetadata[messageChatFileListKey],
|
||||
},
|
||||
id: questionStreamMessageId,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear error messages from the chat
|
||||
void clearErrorMessages() {
|
||||
final errorMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.error,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in errorMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear related questions from the chat
|
||||
void clearRelatedQuestions() {
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a message is a one-time message
|
||||
bool isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
|
||||
/// Get the oldest message that is not a one-time message
|
||||
Message? getOldestMessage() {
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !isOneTimeMessage(message));
|
||||
}
|
||||
|
||||
/// Add a message to the temporary ID map when receiving from server
|
||||
void processReceivedMessage(ChatMessagePB pb) {
|
||||
// 3 means message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
_temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 means message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
_temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => questionStreamMessageId,
|
||||
);
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
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';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'chat_message_stream.g.dart';
|
||||
|
||||
/// A stream that receives answer events from an isolate or external process.
|
||||
/// It caches events that might occur before a listener is attached.
|
||||
@@ -37,7 +42,7 @@ class AnswerStream {
|
||||
void Function()? _onAIImageResponseLimit;
|
||||
void Function(String message)? _onAIMaxRequired;
|
||||
void Function(MetadataCollection metadata)? _onMetadata;
|
||||
|
||||
void Function(AIFollowUpData)? _onAIFollowUp;
|
||||
// Caches for events that occur before listen() is called.
|
||||
final List<String> _pendingAIMaxRequiredEvents = [];
|
||||
bool _pendingLocalAINotReady = false;
|
||||
@@ -88,6 +93,15 @@ class AnswerStream {
|
||||
} else {
|
||||
_pendingLocalAINotReady = true;
|
||||
}
|
||||
} else if (event.startsWith(AIStreamEventPrefix.aiFollowUp)) {
|
||||
final s = event.substring(AIStreamEventPrefix.aiFollowUp.length);
|
||||
try {
|
||||
final dynamic jsonData = jsonDecode(s);
|
||||
final data = AIFollowUpData.fromJson(jsonData);
|
||||
_onAIFollowUp?.call(data);
|
||||
} catch (e) {
|
||||
Log.error('Error deserializing AIFollowUp data: $e\nRaw JSON: $s');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +128,7 @@ class AnswerStream {
|
||||
void Function(String message)? onAIMaxRequired,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
void Function()? onLocalAIInitializing,
|
||||
void Function(AIFollowUpData)? onAIFollowUp,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
@@ -124,7 +139,7 @@ class AnswerStream {
|
||||
_onAIMaxRequired = onAIMaxRequired;
|
||||
_onMetadata = onMetadata;
|
||||
_onLocalAIInitializing = onLocalAIInitializing;
|
||||
|
||||
_onAIFollowUp = onAIFollowUp;
|
||||
// Flush pending AI_MAX_REQUIRED events.
|
||||
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
|
||||
for (final msg in _pendingAIMaxRequiredEvents) {
|
||||
@@ -205,7 +220,6 @@ class QuestionStream {
|
||||
void Function()? _onIndexStart;
|
||||
void Function()? _onIndexEnd;
|
||||
void Function()? _onDone;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
String? get error => _error;
|
||||
@@ -241,3 +255,18 @@ class QuestionStream {
|
||||
_onDone = onDone;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class AIFollowUpData {
|
||||
AIFollowUpData({
|
||||
required this.shouldGenerateRelatedQuestion,
|
||||
});
|
||||
|
||||
factory AIFollowUpData.fromJson(Map<String, dynamic> json) =>
|
||||
_$AIFollowUpDataFromJson(json);
|
||||
|
||||
@JsonKey(name: 'should_generate_related_question')
|
||||
final bool shouldGenerateRelatedQuestion;
|
||||
|
||||
Map<String, dynamic> toJson() => _$AIFollowUpDataToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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:flutter/foundation.dart';
|
||||
|
||||
/// Manages settings for a chat
|
||||
class ChatSettingsManager {
|
||||
ChatSettingsManager({
|
||||
required this.chatId,
|
||||
}) : selectedSourcesNotifier = ValueNotifier([]);
|
||||
final String chatId;
|
||||
|
||||
/// Notifies listeners when selected sources change
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
|
||||
/// Load settings from backend
|
||||
Future<void> loadSettings() async {
|
||||
final getChatSettingsPayload =
|
||||
AIEventGetChatSettings(ChatId(value: chatId));
|
||||
|
||||
await getChatSettingsPayload.send().then((result) {
|
||||
result.fold(
|
||||
(settings) {
|
||||
selectedSourcesNotifier.value = settings.ragIds;
|
||||
},
|
||||
(err) => Log.error("Failed to load chat settings: $err"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Update selected sources
|
||||
Future<void> updateSelectedSources(List<String> selectedSourcesIds) async {
|
||||
selectedSourcesNotifier.value = [...selectedSourcesIds];
|
||||
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
ragIds: selectedSourcesIds,
|
||||
);
|
||||
|
||||
await AIEventUpdateChatSettings(payload).send().onFailure(Log.error);
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
selectedSourcesNotifier.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.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:fixnum/fixnum.dart';
|
||||
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
/// Manages chat streaming operations
|
||||
class ChatStreamManager {
|
||||
ChatStreamManager(this.chatId);
|
||||
final String chatId;
|
||||
|
||||
AnswerStream? answerStream;
|
||||
QuestionStream? questionStream;
|
||||
|
||||
/// Dispose of all streams
|
||||
Future<void> dispose() async {
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
|
||||
await questionStream?.dispose();
|
||||
questionStream = null;
|
||||
}
|
||||
|
||||
/// Prepare streams for a new message
|
||||
Future<void> prepareStreams() async {
|
||||
await dispose();
|
||||
answerStream = AnswerStream();
|
||||
questionStream = QuestionStream();
|
||||
}
|
||||
|
||||
/// Build the payload for a streaming message
|
||||
StreamChatPayloadPB buildStreamPayload(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
) {
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream!.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
);
|
||||
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Send a streaming message request to the server
|
||||
Future<FlowyResult<ChatMessagePB, FlowyError>> sendStreamRequest(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
) async {
|
||||
final payload = buildStreamPayload(message, format);
|
||||
return AIEventStreamMessage(payload).send();
|
||||
}
|
||||
|
||||
/// Build the payload for regenerating a response
|
||||
RegenerateResponsePB buildRegeneratePayload(
|
||||
Int64 answerMessageId,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) {
|
||||
final payload = RegenerateResponsePB(
|
||||
chatId: chatId,
|
||||
answerMessageId: answerMessageId,
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
);
|
||||
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
|
||||
if (model != null) {
|
||||
payload.model = model;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Send a request to regenerate a response
|
||||
Future<FlowyResult<dynamic, FlowyError>> sendRegenerateRequest(
|
||||
Int64 answerMessageId,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) async {
|
||||
final payload = buildRegeneratePayload(answerMessageId, format, model);
|
||||
return AIEventRegenerateResponse(payload).send();
|
||||
}
|
||||
|
||||
/// Stop the current streaming message
|
||||
Future<void> stopStream() async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
}
|
||||
|
||||
/// Check if the answer stream has started
|
||||
bool get hasAnswerStreamStarted =>
|
||||
answerStream != null && answerStream!.hasStarted;
|
||||
|
||||
Future<void> disposeAnswerStream() async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await answerStream!.dispose();
|
||||
answerStream = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.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:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// ChatUserCubit is responsible for fetching and storing the user profile
|
||||
class ChatUserCubit extends Cubit<ChatUserState> {
|
||||
ChatUserCubit() : super(ChatUserLoadingState()) {
|
||||
fetchUserProfile();
|
||||
}
|
||||
|
||||
/// Fetches the user profile from the AuthService
|
||||
Future<void> fetchUserProfile() async {
|
||||
emit(ChatUserLoadingState());
|
||||
final userOrFailure = await getIt<AuthService>().getUser();
|
||||
|
||||
userOrFailure.fold(
|
||||
(userProfile) => emit(ChatUserSuccessState(userProfile)),
|
||||
(error) => emit(ChatUserFailureState(error)),
|
||||
);
|
||||
}
|
||||
|
||||
bool supportSelectSource() {
|
||||
if (state is ChatUserSuccessState) {
|
||||
final userProfile = (state as ChatUserSuccessState).userProfile;
|
||||
if (userProfile.userAuthType == AuthTypePB.Server) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isValueWorkspace() {
|
||||
if (state is ChatUserSuccessState) {
|
||||
final userProfile = (state as ChatUserSuccessState).userProfile;
|
||||
return userProfile.workspaceType == WorkspaceTypePB.LocalW &&
|
||||
userProfile.userAuthType != AuthTypePB.Local;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Refreshes the user profile data
|
||||
Future<void> refresh() async {
|
||||
await fetchUserProfile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Base state class for ChatUserCubit
|
||||
abstract class ChatUserState extends Equatable {
|
||||
const ChatUserState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Loading state when fetching user profile
|
||||
class ChatUserLoadingState extends ChatUserState {}
|
||||
|
||||
/// Success state when user profile is fetched successfully
|
||||
class ChatUserSuccessState extends ChatUserState {
|
||||
const ChatUserSuccessState(this.userProfile);
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [userProfile];
|
||||
}
|
||||
|
||||
/// Failure state when fetching user profile fails
|
||||
class ChatUserFailureState extends ChatUserState {
|
||||
const ChatUserFailureState(this.error);
|
||||
final FlowyError error;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error];
|
||||
}
|
||||
@@ -1,39 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/ai/ai.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/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
|
||||
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:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
hide ChatAnimatedListReversed;
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'application/chat_bloc.dart';
|
||||
import 'application/chat_entity.dart';
|
||||
import 'application/chat_member_bloc.dart';
|
||||
import 'application/chat_select_message_bloc.dart';
|
||||
import 'application/chat_message_stream.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/mobile_chat_input.dart';
|
||||
import 'presentation/chat_related_question.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
import 'presentation/message/error_text_message.dart';
|
||||
import 'presentation/message/message_util.dart';
|
||||
import 'presentation/message/user_text_message.dart';
|
||||
import 'presentation/scroll_to_bottom.dart';
|
||||
|
||||
class AIChatPage extends StatelessWidget {
|
||||
const AIChatPage({
|
||||
@@ -49,15 +25,6 @@ class AIChatPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// if (userProfile.authenticator != AuthTypePB.Server) {
|
||||
// return Center(
|
||||
// child: FlowyText(
|
||||
// LocaleKeys.chat_unsupportedCloudPrompt.tr(),
|
||||
// fontSize: 20,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||
@@ -111,7 +78,7 @@ class AIChatPage extends StatelessWidget {
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
child: ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
@@ -122,371 +89,3 @@ class AIChatPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatContentPage extends StatelessWidget {
|
||||
const _ChatContentPage({
|
||||
required this.view,
|
||||
required this.userProfile,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return switch (state.loadingState) {
|
||||
LoadChatMessageStatus.ready => Column(
|
||||
children: [
|
||||
ChatMessageSelectorBanner(
|
||||
view: view,
|
||||
allMessages: context.read<ChatBloc>().chatController.messages,
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: _wrapConstraints(
|
||||
SelectionArea(
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context)
|
||||
.copyWith(scrollbars: false),
|
||||
child: Chat(
|
||||
chatController:
|
||||
context.read<ChatBloc>().chatController,
|
||||
user: User(id: userProfile.id.toString()),
|
||||
darkTheme:
|
||||
ChatTheme.fromThemeData(Theme.of(context)),
|
||||
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
builders: Builders(
|
||||
inputBuilder: (_) => const SizedBox.shrink(),
|
||||
textMessageBuilder: _buildTextMessage,
|
||||
chatMessageBuilder: _buildChatMessage,
|
||||
scrollToBottomBuilder: _buildScrollToBottom,
|
||||
chatAnimatedListBuilder: _buildChatAnimatedList,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_wrapConstraints(
|
||||
_Input(view: view),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => const Center(child: CircularProgressIndicator.adaptive()),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapConstraints(Widget child) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
margin: UniversalPlatform.isDesktop
|
||||
? const EdgeInsets.symmetric(horizontal: 60.0)
|
||||
: null,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextMessage(
|
||||
BuildContext context,
|
||||
TextMessage message,
|
||||
) {
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
if (messageType == OnetimeShotType.error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (message.author.id == userProfile.id.toString() ||
|
||||
isOtherUserMessage(message)) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
final isLastMessage =
|
||||
messages.isEmpty ? false : messages.last.id == message.id;
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isStreaming:
|
||||
state.promptResponseState != PromptResponseState.ready,
|
||||
isLastMessage: isLastMessage,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
onSelectedMetadata: (metadata) =>
|
||||
_onSelectMetadata(context, metadata),
|
||||
onRegenerate: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
|
||||
onChangeFormat: (format) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
|
||||
onChangeModel: (model) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
|
||||
onStopStream: () => context.read<ChatBloc>().add(
|
||||
const ChatEvent.stopStream(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatMessage(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return ChatMessage(
|
||||
message: message,
|
||||
animation: animation,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18.0),
|
||||
receivedMessageScaleAnimationAlignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollToBottom(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return CustomScrollToBottom(
|
||||
animation: animation,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatAnimatedList(
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
ChatItem itemBuilder,
|
||||
) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
|
||||
if (bloc.chatController.messages.isEmpty) {
|
||||
return ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats =
|
||||
aiPromptInputBloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
|
||||
bloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
context
|
||||
.read<ChatSelectMessageBloc>()
|
||||
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
|
||||
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return ChatAnimatedListReversed(
|
||||
scrollController: scrollController,
|
||||
itemBuilder: itemBuilder,
|
||||
bottomPadding: isSelectingMessages
|
||||
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
|
||||
: 8.0,
|
||||
onLoadPreviousMessages: () {
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectMetadata(
|
||||
BuildContext context,
|
||||
ChatMessageRefSource metadata,
|
||||
) async {
|
||||
// When the source of metatdata is appflowy, which means it is a appflowy page
|
||||
if (metadata.source == "appflowy") {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, sidebarView);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.source == "web") {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Input extends StatefulWidget {
|
||||
const _Input({
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
State<_Input> createState() => _InputState();
|
||||
}
|
||||
|
||||
class _InputState extends State<_Input> {
|
||||
final textController = AiPromptInputTextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return NonClippingSizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: isSelectingMessages
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
return state.promptResponseState ==
|
||||
PromptResponseState.ready;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopPromptInput(
|
||||
isStreaming: !canSendMessage,
|
||||
textController: textController,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, format, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
format: format,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedSourcesNotifier:
|
||||
chatBloc.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: MobileChatInput(
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, format, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
format: format,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedSourcesNotifier:
|
||||
chatBloc.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+175
-218
@@ -1,18 +1,20 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/util/debounce.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class ChatAnimatedListReversed extends StatefulWidget {
|
||||
const ChatAnimatedListReversed({
|
||||
class ChatAnimatedList extends StatefulWidget {
|
||||
const ChatAnimatedList({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.itemBuilder,
|
||||
@@ -22,6 +24,7 @@ class ChatAnimatedListReversed extends StatefulWidget {
|
||||
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
|
||||
this.bottomPadding = 8,
|
||||
this.onLoadPreviousMessages,
|
||||
this.scrollBottomPadding = 440,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
@@ -32,16 +35,18 @@ class ChatAnimatedListReversed extends StatefulWidget {
|
||||
final Duration scrollToBottomAppearanceDelay;
|
||||
final double? bottomPadding;
|
||||
final VoidCallback? onLoadPreviousMessages;
|
||||
final double scrollBottomPadding;
|
||||
|
||||
@override
|
||||
ChatAnimatedListReversedState createState() =>
|
||||
ChatAnimatedListReversedState();
|
||||
ChatAnimatedListState createState() => ChatAnimatedListState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late ChatController _chatController;
|
||||
late final ChatController _chatController = Provider.of<ChatController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
|
||||
@@ -49,17 +54,38 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
|
||||
bool _userHasScrolled = false;
|
||||
final ScrollOffsetController scrollOffsetController =
|
||||
ScrollOffsetController();
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
|
||||
final ScrollOffsetListener scrollOffsetListener =
|
||||
ScrollOffsetListener.create();
|
||||
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
int _lastUserMessageIndex = 0;
|
||||
bool _isScrollingToBottom = false;
|
||||
String _lastInsertedMessageId = '';
|
||||
|
||||
final _loadPreviousMessagesDebounce = Debounce(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
int initialScrollIndex = 0;
|
||||
double initialAlignment = 1.0;
|
||||
List<Message> messages = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatController = Provider.of<ChatController>(context, listen: false);
|
||||
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
setState(() {
|
||||
messages = _chatController.messages;
|
||||
});
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
@@ -70,6 +96,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
event.message != null,
|
||||
'Message must be provided when inserting a message.',
|
||||
);
|
||||
|
||||
_onInserted(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
@@ -82,6 +109,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
event.message != null,
|
||||
'Message must be provided when removing a message.',
|
||||
);
|
||||
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
@@ -105,180 +133,139 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
}
|
||||
});
|
||||
|
||||
messages = _chatController.messages;
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
_handleToggleScrollToBottom();
|
||||
});
|
||||
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
// When user scrolls up, save it to `_userHasScrolled`
|
||||
if (notification.direction == ScrollDirection.reverse) {
|
||||
_userHasScrolled = true;
|
||||
} else {
|
||||
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
initialScrollIndex = messages.length;
|
||||
initialAlignment = 1.0;
|
||||
if (messages.length <= 2) {
|
||||
initialScrollIndex = 0;
|
||||
initialAlignment = 0.0;
|
||||
}
|
||||
|
||||
final Widget child = Stack(
|
||||
children: [
|
||||
ScrollablePositionedList.builder(
|
||||
scrollOffsetController: scrollOffsetController,
|
||||
itemScrollController: itemScrollController,
|
||||
initialScrollIndex: initialScrollIndex,
|
||||
initialAlignment: initialAlignment,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
physics: ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
// the extra item is a vertical padding.
|
||||
itemCount: messages.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < 0 || index > messages.length) {
|
||||
Log.error('[chat animation list] index out of range: $index');
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
if (index == messages.length) {
|
||||
return VSpace(height - 400);
|
||||
}
|
||||
|
||||
// Allow other listeners to get the notification
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
reverse: true,
|
||||
controller: widget.scrollController,
|
||||
slivers: <Widget>[
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: widget.bottomPadding ?? 0,
|
||||
final message = messages[index];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
Tween<double>(begin: 1, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
SliverAnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _chatController.messages.length,
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
final message = _chatController.messages[
|
||||
max(_chatController.messages.length - 1 - index, 0)];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
void _subsequentScrollToEnd(Message data) async {
|
||||
Future<void> _scrollLastUserMessageToTop() async {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// We only want to scroll to the bottom if user has not scrolled up
|
||||
// or if the message is sent by the current user.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id && _userHasScrolled)) {
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
|
||||
// we need an additional jump to the end. Sometimes Flutter
|
||||
// will not scroll to the very end. Sometimes it will not scroll to the
|
||||
// very end even with this, so this is something that needs to be
|
||||
// addressed by the Flutter team.
|
||||
//
|
||||
// Additionally here we have a check for the message id, because
|
||||
// if new message arrives in the meantime it will trigger another
|
||||
// scroll to the end animation, making this logic redundant.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id && _userHasScrolled)) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToEnd(Message data) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
_subsequentScrollToEnd(data);
|
||||
},
|
||||
final lastUserMessageIndex = messages.lastIndexWhere(
|
||||
(message) => message.author.id == user.id,
|
||||
);
|
||||
|
||||
if (lastUserMessageIndex == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_lastUserMessageIndex != lastUserMessageIndex) {
|
||||
// scroll the current message to the top
|
||||
await itemScrollController.scrollTo(
|
||||
index: lastUserMessageIndex,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
_lastUserMessageIndex = lastUserMessageIndex;
|
||||
}
|
||||
|
||||
void _handleScrollToBottom() {
|
||||
Future<void> _handleScrollToBottom() async {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
await _scrollToBottomController.reverse();
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
await itemScrollController.scrollTo(
|
||||
index: messages.length + 1,
|
||||
alignment: 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
if (widget.scrollController.offset <
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
widget.scrollController.jumpTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
);
|
||||
}
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
_isScrollingToBottom = false;
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
@@ -286,85 +273,55 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
if (widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_scrollToBottomShowTimer =
|
||||
Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (_scrollToBottomController.status != AnimationStatus.completed) {
|
||||
_scrollToBottomController.stop();
|
||||
}
|
||||
// get the max item
|
||||
final sortedItems = itemPositionsListener.itemPositions.value.toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
final maxItem = sortedItems.lastOrNull;
|
||||
|
||||
if (maxItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxItem.index > messages.length - 1 ||
|
||||
(maxItem.index == messages.length - 1 &&
|
||||
maxItem.itemTrailingEdge <= 1.01)) {
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onInserted(final int position, final Message data) {
|
||||
// There is a scroll notification listener the controls the
|
||||
// `_userHasScrolled` variable.
|
||||
//
|
||||
// If for some reason `_userHasScrolled` is true and the user is not at the
|
||||
// bottom of the list, set `_userHasScrolled` to false so that the scroll
|
||||
// animation is triggered.
|
||||
if (position == 0 &&
|
||||
_userHasScrolled &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_listKey.currentState!.insertItem(
|
||||
0,
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
_lastInsertedMessageId = data.id;
|
||||
|
||||
if (position == _oldList.length) {
|
||||
_scrollToEnd(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
final visualPosition = max(_oldList.length - position - 1, 0);
|
||||
_listKey.currentState!.removeItem(
|
||||
visualPosition,
|
||||
(context, animation) => widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
data,
|
||||
isRemoved: true,
|
||||
),
|
||||
duration: widget.removeAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int position, Message oldData, Message newData) {
|
||||
_onRemoved(position, oldData);
|
||||
_listKey.currentState!.insertItem(
|
||||
max(_oldList.length - position - 1, 0),
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
final sortedItems = itemPositionsListener.itemPositions.value.toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
final minItem = sortedItems.firstOrNull;
|
||||
|
||||
if (minItem == null || minItem.index > 0 || minItem.itemLeadingEdge < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
_loadPreviousMessagesDebounce.call(
|
||||
() {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onInserted(final int position, final Message data) async {
|
||||
// scroll the last user message to the top if it's the last message
|
||||
if (position == _oldList.length) {
|
||||
await _scrollLastUserMessageToTop();
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ChatAnimatedListReversed extends StatefulWidget {
|
||||
const ChatAnimatedListReversed({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.itemBuilder,
|
||||
this.insertAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.removeAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
|
||||
this.bottomPadding = 8,
|
||||
this.onLoadPreviousMessages,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final ChatItem itemBuilder;
|
||||
final Duration insertAnimationDuration;
|
||||
final Duration removeAnimationDuration;
|
||||
final Duration scrollToEndAnimationDuration;
|
||||
final Duration scrollToBottomAppearanceDelay;
|
||||
final double? bottomPadding;
|
||||
final VoidCallback? onLoadPreviousMessages;
|
||||
|
||||
@override
|
||||
ChatAnimatedListReversedState createState() =>
|
||||
ChatAnimatedListReversedState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late final ChatController _chatController = Provider.of<ChatController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
|
||||
late final AnimationController _scrollToBottomController;
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
|
||||
bool _userHasScrolled = false;
|
||||
bool _isScrollingToBottom = false;
|
||||
String _lastInsertedMessageId = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when inserting a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when inserting a message.',
|
||||
);
|
||||
_onInserted(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.remove:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when removing a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when removing a message.',
|
||||
);
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.set:
|
||||
final newList = _chatController.messages;
|
||||
|
||||
final updates = diffutil
|
||||
.calculateDiff<Message>(
|
||||
MessageListDiff(_oldList, newList),
|
||||
)
|
||||
.getUpdatesWithData();
|
||||
|
||||
for (var i = updates.length - 1; i >= 0; i--) {
|
||||
_onDiffUpdate(updates.elementAt(i));
|
||||
}
|
||||
|
||||
_oldList = List.from(newList);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
// When user scrolls up, save it to `_userHasScrolled`
|
||||
if (notification.direction == ScrollDirection.reverse) {
|
||||
_userHasScrolled = true;
|
||||
} else {
|
||||
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
|
||||
// Allow other listeners to get the notification
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
reverse: true,
|
||||
controller: widget.scrollController,
|
||||
slivers: <Widget>[
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: widget.bottomPadding ?? 0,
|
||||
),
|
||||
),
|
||||
SliverAnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _chatController.messages.length,
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
final message = _chatController.messages[
|
||||
max(_chatController.messages.length - 1 - index, 0)];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _subsequentScrollToEnd(Message data) async {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// We only want to scroll to the bottom if user has not scrolled up
|
||||
// or if the message is sent by the current user.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id && _userHasScrolled)) {
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
|
||||
// we need an additional jump to the end. Sometimes Flutter
|
||||
// will not scroll to the very end. Sometimes it will not scroll to the
|
||||
// very end even with this, so this is something that needs to be
|
||||
// addressed by the Flutter team.
|
||||
//
|
||||
// Additionally here we have a check for the message id, because
|
||||
// if new message arrives in the meantime it will trigger another
|
||||
// scroll to the end animation, making this logic redundant.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id && _userHasScrolled)) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToEnd(Message data) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
_subsequentScrollToEnd(data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScrollToBottom() {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollController.offset <
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
widget.scrollController.jumpTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
);
|
||||
}
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (_isScrollingToBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
if (widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_scrollToBottomShowTimer =
|
||||
Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (_scrollToBottomController.status != AnimationStatus.completed) {
|
||||
_scrollToBottomController.stop();
|
||||
}
|
||||
_scrollToBottomController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onInserted(final int position, final Message data) {
|
||||
// There is a scroll notification listener the controls the
|
||||
// `_userHasScrolled` variable.
|
||||
//
|
||||
// If for some reason `_userHasScrolled` is true and the user is not at the
|
||||
// bottom of the list, set `_userHasScrolled` to false so that the scroll
|
||||
// animation is triggered.
|
||||
if (position == 0 &&
|
||||
_userHasScrolled &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
|
||||
_listKey.currentState!.insertItem(
|
||||
0,
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
_lastInsertedMessageId = data.id;
|
||||
|
||||
if (position == _oldList.length) {
|
||||
_scrollToEnd(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
final visualPosition = max(_oldList.length - position - 1, 0);
|
||||
_listKey.currentState!.removeItem(
|
||||
visualPosition,
|
||||
(context, animation) => widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
data,
|
||||
isRemoved: true,
|
||||
),
|
||||
duration: widget.removeAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int position, Message oldData, Message newData) {
|
||||
_onRemoved(position, oldData);
|
||||
_listKey.currentState!.insertItem(
|
||||
max(_oldList.length - position - 1, 0),
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-21
@@ -1,17 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
@@ -133,23 +133,22 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider(
|
||||
create: (context) => ViewSelectorCubit(
|
||||
getIgnoreViewType: (item) {
|
||||
final view = item.view;
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
if (view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => ChatSettingsCubit(hideDisabled: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
|
||||
selector: (state) => state.currentSpace,
|
||||
builder: (context, spaceView) {
|
||||
@@ -188,9 +187,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
await updateSelection(documentId);
|
||||
} else {
|
||||
if (spaceView != null) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources([spaceView], spaceView);
|
||||
unawaited(
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources([spaceView], spaceView),
|
||||
);
|
||||
}
|
||||
popoverController.show();
|
||||
}
|
||||
@@ -210,7 +211,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
Widget buildPopover(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
child: SaveToPagePopoverContent(
|
||||
onAddToNewPage: (parentViewId) async {
|
||||
await addMessageToNewPage(context, parentViewId);
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list_reversed.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_welcome_page.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
@visibleForTesting
|
||||
bool skipAIChatWelcomePage = false;
|
||||
|
||||
class ChatAnimationListWidget extends StatefulWidget {
|
||||
const ChatAnimationListWidget({
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
required this.scrollController,
|
||||
required this.itemBuilder,
|
||||
this.enableReversedList = false,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
final ScrollController scrollController;
|
||||
final ChatItem itemBuilder;
|
||||
final bool enableReversedList;
|
||||
|
||||
@override
|
||||
State<ChatAnimationListWidget> createState() =>
|
||||
_ChatAnimationListWidgetState();
|
||||
}
|
||||
|
||||
class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
|
||||
// this logic is quite weird, why don't we just get the message from the state?
|
||||
if (bloc.chatController.messages.isEmpty && !skipAIChatWelcomePage) {
|
||||
return ChatWelcomePage(
|
||||
userProfile: widget.userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats =
|
||||
aiPromptInputBloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
|
||||
bloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// don't call this in the build method
|
||||
context
|
||||
.read<ChatSelectMessageBloc>()
|
||||
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
|
||||
|
||||
// final bool reversed = false;
|
||||
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return widget.enableReversedList
|
||||
? ChatAnimatedListReversed(
|
||||
scrollController: widget.scrollController,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
bottomPadding: isSelectingMessages
|
||||
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
|
||||
: 8.0,
|
||||
onLoadPreviousMessages: () {
|
||||
if (bloc.isClosed) {
|
||||
return;
|
||||
}
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
)
|
||||
: ChatAnimatedList(
|
||||
scrollController: widget.scrollController,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
bottomPadding: isSelectingMessages
|
||||
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
|
||||
: 8.0,
|
||||
onLoadPreviousMessages: () {
|
||||
if (bloc.isClosed) {
|
||||
return;
|
||||
}
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/load_chat_message_status_ready.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ChatContentPage extends StatelessWidget {
|
||||
const ChatContentPage({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.userProfile,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return switch (state.loadingState) {
|
||||
LoadChatMessageStatus.ready => LoadChatMessageStatusReady(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
chatController: context.read<ChatBloc>().chatController,
|
||||
),
|
||||
_ => const Center(child: CircularProgressIndicator.adaptive()),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ChatFooter extends StatefulWidget {
|
||||
const ChatFooter({
|
||||
super.key,
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
State<ChatFooter> createState() => _ChatFooterState();
|
||||
}
|
||||
|
||||
class _ChatFooterState extends State<ChatFooter> {
|
||||
final textController = AiPromptInputTextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return NonClippingSizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: isSelectingMessages
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
return state.promptResponseState ==
|
||||
PromptResponseState.ready;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
|
||||
return UniversalPlatform.isDesktop
|
||||
? _buildDesktopInput(
|
||||
context,
|
||||
chatBloc,
|
||||
canSendMessage,
|
||||
)
|
||||
: _buildMobileInput(
|
||||
context,
|
||||
chatBloc,
|
||||
canSendMessage,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopInput(
|
||||
BuildContext context,
|
||||
ChatBloc chatBloc,
|
||||
bool canSendMessage,
|
||||
) {
|
||||
return DesktopPromptInput(
|
||||
isStreaming: !canSendMessage,
|
||||
textController: textController,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, format, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
format: format,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileInput(
|
||||
BuildContext context,
|
||||
ChatBloc chatBloc,
|
||||
bool canSendMessage,
|
||||
) {
|
||||
return MobileChatInput(
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, format, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
format: format,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ChatMessage extends StatelessWidget {
|
||||
const ChatMessage({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
this.sentMessageAlignment = AlignmentDirectional.centerEnd,
|
||||
this.receivedMessageAlignment = AlignmentDirectional.centerStart,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
final AlignmentGeometry sentMessageAlignment;
|
||||
final AlignmentGeometry receivedMessageAlignment;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSentByMe = context.watch<User>().id == message.author.id;
|
||||
|
||||
return Align(
|
||||
alignment: isSentByMe ? sentMessageAlignment : receivedMessageAlignment,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_footer.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_message_widget.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/text_message_widget.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/scroll_to_bottom.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' hide ChatMessage;
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class LoadChatMessageStatusReady extends StatelessWidget {
|
||||
const LoadChatMessageStatusReady({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.userProfile,
|
||||
required this.chatController,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final UserProfilePB userProfile;
|
||||
final ChatController chatController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Chat header, banner
|
||||
_buildHeader(context),
|
||||
// Chat body, a list of messages
|
||||
_buildBody(context),
|
||||
// Chat footer, a text input field with toolbar, send button, etc.
|
||||
_buildFooter(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return ChatMessageSelectorBanner(
|
||||
view: view,
|
||||
allMessages: chatController.messages,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
final bool enableAnimation = true;
|
||||
return Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: _wrapConstraints(
|
||||
SelectionArea(
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
scrollbars: false,
|
||||
),
|
||||
child: Chat(
|
||||
chatController: chatController,
|
||||
user: User(id: userProfile.id.toString()),
|
||||
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
builders: Builders(
|
||||
// we have a custom input builder, so we don't need the default one
|
||||
inputBuilder: (_) => const SizedBox.shrink(),
|
||||
textMessageBuilder: (
|
||||
context,
|
||||
message,
|
||||
) =>
|
||||
TextMessageWidget(
|
||||
message: message,
|
||||
userProfile: userProfile,
|
||||
view: view,
|
||||
enableAnimation: enableAnimation,
|
||||
),
|
||||
chatMessageBuilder: (
|
||||
context,
|
||||
message,
|
||||
animation,
|
||||
child,
|
||||
) =>
|
||||
ChatMessage(
|
||||
message: message,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18.0),
|
||||
child: child,
|
||||
),
|
||||
scrollToBottomBuilder: (
|
||||
context,
|
||||
animation,
|
||||
onPressed,
|
||||
) =>
|
||||
CustomScrollToBottom(
|
||||
animation: animation,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
chatAnimatedListBuilder: (
|
||||
context,
|
||||
scrollController,
|
||||
itemBuilder,
|
||||
) =>
|
||||
ChatAnimationListWidget(
|
||||
userProfile: userProfile,
|
||||
scrollController: scrollController,
|
||||
itemBuilder: itemBuilder,
|
||||
enableReversedList: !enableAnimation,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(BuildContext context) {
|
||||
return _wrapConstraints(
|
||||
ChatFooter(view: view),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapConstraints(Widget child) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
margin: UniversalPlatform.isDesktop
|
||||
? const EdgeInsets.symmetric(horizontal: 60.0)
|
||||
: null,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/message_util.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/user_text_message.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class TextMessageWidget extends StatelessWidget {
|
||||
const TextMessageWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.userProfile,
|
||||
required this.view,
|
||||
this.enableAnimation = true,
|
||||
});
|
||||
|
||||
final TextMessage message;
|
||||
final UserProfilePB userProfile;
|
||||
final ViewPB view;
|
||||
final bool enableAnimation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
if (messageType == OnetimeShotType.error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (message.author.id == userProfile.id.toString() ||
|
||||
isOtherUserMessage(message)) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.promptResponseState != current.promptResponseState,
|
||||
builder: (context, state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
final isLastMessage =
|
||||
messages.isEmpty ? false : messages.last.id == message.id;
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isStreaming:
|
||||
state.promptResponseState != PromptResponseState.ready,
|
||||
isLastMessage: isLastMessage,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
onSelectedMetadata: (metadata) =>
|
||||
_onSelectMetadata(context, metadata),
|
||||
onRegenerate: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
|
||||
onChangeFormat: (format) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
|
||||
onChangeModel: (model) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
|
||||
onStopStream: () => context.read<ChatBloc>().add(
|
||||
const ChatEvent.stopStream(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectMetadata(
|
||||
BuildContext context,
|
||||
ChatMessageRefSource metadata,
|
||||
) async {
|
||||
// When the source of metatdata is appflowy, which means it is a appflowy page
|
||||
if (metadata.source == "appflowy") {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, sidebarView);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.source == "web") {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
-3
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
|
||||
@@ -17,16 +19,21 @@ class AIMarkdownText extends StatelessWidget {
|
||||
const AIMarkdownText({
|
||||
super.key,
|
||||
required this.markdown,
|
||||
this.withAnimation = false,
|
||||
});
|
||||
|
||||
final String markdown;
|
||||
final bool withAnimation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => DocumentPageStyleBloc(view: ViewPB())
|
||||
..add(const DocumentPageStyleEvent.initial()),
|
||||
child: _AppFlowyEditorMarkdown(markdown: markdown),
|
||||
child: _AppFlowyEditorMarkdown(
|
||||
markdown: markdown,
|
||||
withAnimation: withAnimation,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,19 +41,28 @@ class AIMarkdownText extends StatelessWidget {
|
||||
class _AppFlowyEditorMarkdown extends StatefulWidget {
|
||||
const _AppFlowyEditorMarkdown({
|
||||
required this.markdown,
|
||||
this.withAnimation = false,
|
||||
});
|
||||
|
||||
// the text should be the markdown format
|
||||
final String markdown;
|
||||
|
||||
/// Whether to animate the text.
|
||||
final bool withAnimation;
|
||||
|
||||
@override
|
||||
State<_AppFlowyEditorMarkdown> createState() =>
|
||||
_AppFlowyEditorMarkdownState();
|
||||
}
|
||||
|
||||
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
|
||||
with TickerProviderStateMixin {
|
||||
late EditorState editorState;
|
||||
late EditorScrollController scrollController;
|
||||
late Timer markdownOutputTimer;
|
||||
int offset = 0;
|
||||
|
||||
final Map<String, (AnimationController, Animation<double>)> _animations = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -57,13 +73,46 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
editorState: editorState,
|
||||
shrinkWrap: true,
|
||||
);
|
||||
|
||||
if (widget.withAnimation) {
|
||||
markdownOutputTimer =
|
||||
Timer.periodic(const Duration(milliseconds: 60), (timer) {
|
||||
if (offset >= widget.markdown.length || !widget.withAnimation) {
|
||||
return;
|
||||
}
|
||||
|
||||
final markdown = widget.markdown.substring(0, offset);
|
||||
offset += 30;
|
||||
|
||||
final editorState = _parseMarkdown(
|
||||
markdown,
|
||||
previousDocument: this.editorState.document,
|
||||
);
|
||||
final lastCurrentNode = editorState.document.last;
|
||||
final lastPreviousNode = this.editorState.document.last;
|
||||
if (lastCurrentNode?.id != lastPreviousNode?.id ||
|
||||
lastCurrentNode?.type != lastPreviousNode?.type ||
|
||||
lastCurrentNode?.delta?.toPlainText() !=
|
||||
lastPreviousNode?.delta?.toPlainText()) {
|
||||
setState(() {
|
||||
this.editorState.dispose();
|
||||
this.editorState = editorState;
|
||||
scrollController.dispose();
|
||||
scrollController = EditorScrollController(
|
||||
editorState: editorState,
|
||||
shrinkWrap: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.markdown != widget.markdown) {
|
||||
if (oldWidget.markdown != widget.markdown && !widget.withAnimation) {
|
||||
final editorState = _parseMarkdown(
|
||||
widget.markdown.trim(),
|
||||
previousDocument: this.editorState.document,
|
||||
@@ -83,6 +132,13 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
scrollController.dispose();
|
||||
editorState.dispose();
|
||||
|
||||
if (widget.withAnimation) {
|
||||
markdownOutputTimer.cancel();
|
||||
for (final controller in _animations.values.map((e) => e.$1)) {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -120,6 +176,33 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
commandShortcutEvents: [customCopyCommand],
|
||||
disableAutoScroll: true,
|
||||
editorState: editorState,
|
||||
blockWrapper: (
|
||||
context, {
|
||||
required Node node,
|
||||
required Widget child,
|
||||
}) {
|
||||
if (!widget.withAnimation) {
|
||||
return child;
|
||||
}
|
||||
|
||||
if (!_animations.containsKey(node.id)) {
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1600),
|
||||
);
|
||||
final fade = Tween<double>(
|
||||
begin: 0,
|
||||
end: 1,
|
||||
).animate(controller);
|
||||
_animations[node.id] = (controller, fade);
|
||||
controller.forward();
|
||||
}
|
||||
final (controller, fade) = _animations[node.id]!;
|
||||
return _AnimatedWrapper(
|
||||
fade: fade,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
contextMenuItems: [
|
||||
[
|
||||
ContextMenuItem(
|
||||
@@ -161,3 +244,39 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
return editorState;
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedWrapper extends StatelessWidget {
|
||||
const _AnimatedWrapper({
|
||||
required this.fade,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Animation<double> fade;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: fade,
|
||||
builder: (context, childWidget) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return LinearGradient(
|
||||
stops: [fade.value, fade.value],
|
||||
colors: const [
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: Opacity(
|
||||
opacity: fade.value,
|
||||
child: childWidget,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
-68
@@ -6,7 +6,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
@@ -15,16 +14,15 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/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';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -515,23 +513,23 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider(
|
||||
create: (context) => ViewSelectorCubit(
|
||||
getIgnoreViewType: (item) {
|
||||
final view = item.view;
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
if (view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => ChatSettingsCubit(hideDisabled: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
|
||||
selector: (state) => state.currentSpace,
|
||||
builder: (context, spaceView) {
|
||||
@@ -546,7 +544,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
onClose: () {
|
||||
if (spaceView != null) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources([spaceView], spaceView);
|
||||
}
|
||||
widget.onOverrideVisibility?.call(false);
|
||||
@@ -583,9 +581,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
} else {
|
||||
widget.onOverrideVisibility?.call(true);
|
||||
if (spaceView != null) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources([spaceView], spaceView);
|
||||
unawaited(
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources([spaceView], spaceView),
|
||||
);
|
||||
}
|
||||
popoverController.show();
|
||||
}
|
||||
@@ -596,7 +596,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
Widget buildPopover(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
child: SaveToPagePopoverContent(
|
||||
onAddToNewPage: (parentViewId) {
|
||||
addMessageToNewPage(context, parentViewId);
|
||||
@@ -697,8 +697,10 @@ class SaveToPagePopoverContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -707,22 +709,26 @@ class SaveToPagePopoverContent extends StatelessWidget {
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FlowyText(
|
||||
child: Text(
|
||||
LocaleKeys.chat_addToPageTitle.tr(),
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
|
||||
child: SpaceSearchField(
|
||||
width: 600,
|
||||
onSearch: (context, value) =>
|
||||
context.read<ChatSettingsCubit>().updateFilter(value),
|
||||
child: AFTextField(
|
||||
controller:
|
||||
context.read<ViewSelectorCubit>().filterTextController,
|
||||
hintText: LocaleKeys.search_label.tr(),
|
||||
size: AFTextFieldSize.m,
|
||||
),
|
||||
),
|
||||
_buildDivider(),
|
||||
AFDivider(
|
||||
startIndent: theme.spacing.l,
|
||||
endIndent: theme.spacing.l,
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
@@ -736,44 +742,33 @@ class SaveToPagePopoverContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
indent: 12.0,
|
||||
endIndent: 12.0,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState state,
|
||||
ViewSelectorState state,
|
||||
) {
|
||||
return state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'save_to_page_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
showSaveButton: true,
|
||||
onSelected: (source) {
|
||||
if (source.view.isSpace) {
|
||||
onAddToNewPage(source.view.id);
|
||||
} else {
|
||||
onAddToExistingPage(source.view.id);
|
||||
}
|
||||
},
|
||||
onAdd: (source) {
|
||||
onAddToNewPage(source.view.id);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
return state.visibleSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'save_to_page_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
showSaveButton: true,
|
||||
onSelected: (source) {
|
||||
if (source.view.isSpace) {
|
||||
onAddToNewPage(source.view.id);
|
||||
} else {
|
||||
onAddToExistingPage(source.view.id);
|
||||
}
|
||||
},
|
||||
onAdd: (source) {
|
||||
onAddToNewPage(source.view.id);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -23,9 +23,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
import 'ai_change_format_bottom_sheet.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';
|
||||
|
||||
/// Wraps an AI response message with the avatar and actions. On desktop,
|
||||
@@ -132,7 +132,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
),
|
||||
const VSpace(32.0),
|
||||
const VSpace(16.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+186
-92
@@ -41,6 +41,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
this.isLastMessage = false,
|
||||
this.isStreaming = false,
|
||||
this.isSelectingMessages = false,
|
||||
this.enableAnimation = true,
|
||||
});
|
||||
|
||||
final User user;
|
||||
@@ -59,6 +60,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final bool isStreaming;
|
||||
final bool isLastMessage;
|
||||
final bool isSelectingMessages;
|
||||
final bool enableAnimation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -69,105 +71,197 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
chatId: chatId,
|
||||
questionId: questionId,
|
||||
),
|
||||
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
final loadingText =
|
||||
state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr();
|
||||
child: BlocConsumer<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.messageState != current.messageState,
|
||||
listener: (context, state) => _handleMessageState(state, context),
|
||||
builder: (context, blocState) {
|
||||
final loadingText = blocState.progress?.step ??
|
||||
LocaleKeys.chat_generatingResponse.tr();
|
||||
|
||||
return BlocListener<ChatBloc, ChatState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.clearErrorMessages != current.clearErrorMessages,
|
||||
listener: (context, chatState) {
|
||||
if (state.stream?.error?.isEmpty != false) {
|
||||
return;
|
||||
}
|
||||
context.read<ChatBloc>().add(ChatEvent.deleteMessage(message));
|
||||
},
|
||||
child: Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: state.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
return Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: blocState.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
ready: () {
|
||||
return state.text.isEmpty
|
||||
? ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
)
|
||||
: ChatAIMessageBubble(
|
||||
message: message,
|
||||
isLastMessage: isLastMessage,
|
||||
showActions: stream == null &&
|
||||
state.text.isNotEmpty &&
|
||||
!isStreaming,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIMarkdownText(
|
||||
markdown: state.text,
|
||||
),
|
||||
if (state.sources.isNotEmpty)
|
||||
SelectionContainer.disabled(
|
||||
child: AIMessageMetadata(
|
||||
sources: state.sources,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
),
|
||||
),
|
||||
if (state.sources.isNotEmpty && !isLastMessage)
|
||||
const VSpace(8.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys
|
||||
.settings_aiPage_keys_localAIInitializing
|
||||
.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ready: () {
|
||||
return blocState.text.isEmpty
|
||||
? _LoadingMessage(
|
||||
message: message,
|
||||
loadingText: loadingText,
|
||||
)
|
||||
: _NonEmptyMessage(
|
||||
user: user,
|
||||
messageUserId: messageUserId,
|
||||
message: message,
|
||||
stream: stream,
|
||||
questionId: questionId,
|
||||
chatId: chatId,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onStopStream: onStopStream,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
isLastMessage: isLastMessage,
|
||||
isStreaming: isStreaming,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
|
||||
);
|
||||
},
|
||||
aiFollowUp: (followUpData) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMessageState(ChatAIMessageState state, BuildContext context) {
|
||||
if (state.stream?.error?.isEmpty != false) {
|
||||
state.messageState.maybeMap(
|
||||
aiFollowUp: (messageState) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.onAIFollowUp(messageState.followUpData));
|
||||
},
|
||||
orElse: () {
|
||||
// do nothing
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
context.read<ChatBloc>().add(ChatEvent.deleteMessage(message));
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingMessage extends StatelessWidget {
|
||||
const _LoadingMessage({
|
||||
required this.message,
|
||||
required this.loadingText,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final String loadingText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NonEmptyMessage extends StatelessWidget {
|
||||
const _NonEmptyMessage({
|
||||
required this.user,
|
||||
required this.messageUserId,
|
||||
required this.message,
|
||||
required this.stream,
|
||||
required this.questionId,
|
||||
required this.chatId,
|
||||
required this.refSourceJsonString,
|
||||
required this.onStopStream,
|
||||
this.onSelectedMetadata,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
this.isLastMessage = false,
|
||||
this.isStreaming = false,
|
||||
this.isSelectingMessages = false,
|
||||
this.enableAnimation = true,
|
||||
});
|
||||
|
||||
final User user;
|
||||
final String messageUserId;
|
||||
|
||||
final Message message;
|
||||
final AnswerStream? stream;
|
||||
final Int64? questionId;
|
||||
final String chatId;
|
||||
final String? refSourceJsonString;
|
||||
final ValueChanged<ChatMessageRefSource>? onSelectedMetadata;
|
||||
final VoidCallback? onRegenerate;
|
||||
final VoidCallback onStopStream;
|
||||
final ValueChanged<PredefinedFormat>? onChangeFormat;
|
||||
final ValueChanged<AIModelPB>? onChangeModel;
|
||||
final bool isStreaming;
|
||||
final bool isLastMessage;
|
||||
final bool isSelectingMessages;
|
||||
final bool enableAnimation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.read<ChatAIMessageBloc>().state;
|
||||
final showActions = stream == null && state.text.isNotEmpty && !isStreaming;
|
||||
return ChatAIMessageBubble(
|
||||
message: message,
|
||||
isLastMessage: isLastMessage,
|
||||
showActions: showActions,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIMarkdownText(
|
||||
markdown: state.text,
|
||||
withAnimation: enableAnimation && stream != null,
|
||||
),
|
||||
if (state.sources.isNotEmpty)
|
||||
SelectionContainer.disabled(
|
||||
child: AIMessageMetadata(
|
||||
sources: state.sources,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
),
|
||||
),
|
||||
if (state.sources.isNotEmpty && !isLastMessage) const VSpace(8.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -219,7 +219,8 @@ class DateCellEditorBloc
|
||||
} else {
|
||||
if (option == ReminderOption.none) {
|
||||
// remove reminder from reminder bloc and cell data
|
||||
_reminderBloc.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
|
||||
_reminderBloc
|
||||
.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
|
||||
await _updateCellReminderId("");
|
||||
} else {
|
||||
// Update reminder
|
||||
|
||||
@@ -109,6 +109,10 @@ class EditorDropHandler extends StatelessWidget {
|
||||
void _onDragUpdated(Offset position) {
|
||||
final data = editorState.selectionService.getDropTargetRenderData(position);
|
||||
|
||||
if (dropManagerState?.isDropEnabled == false) {
|
||||
return editorState.selectionService.removeDropTarget();
|
||||
}
|
||||
|
||||
if (data != null &&
|
||||
data.dropPath != null &&
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ class EditorDropManagerState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool get isDropEnabled => _draggedTypes.isEmpty;
|
||||
|
||||
bool contains(String type) => _draggedTypes.contains(type);
|
||||
}
|
||||
|
||||
final enableDocumentDragNotifier = ValueNotifier(true);
|
||||
+30
-16
@@ -3,7 +3,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class BulletedListIcon extends StatelessWidget {
|
||||
class BulletedListIcon extends StatefulWidget {
|
||||
const BulletedListIcon({
|
||||
super.key,
|
||||
required this.node,
|
||||
@@ -17,28 +17,30 @@ class BulletedListIcon extends StatelessWidget {
|
||||
FlowySvgs.bulleted_list_icon_3_s,
|
||||
];
|
||||
|
||||
int get level {
|
||||
var level = 0;
|
||||
var parent = node.parent;
|
||||
while (parent != null) {
|
||||
if (parent.type == BulletedListBlockKeys.type) {
|
||||
level++;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
@override
|
||||
State<BulletedListIcon> createState() => _BulletedListIconState();
|
||||
}
|
||||
|
||||
class _BulletedListIconState extends State<BulletedListIcon> {
|
||||
int index = 0;
|
||||
double size = 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final textStyle =
|
||||
context.read<EditorState>().editorStyle.textStyleConfiguration;
|
||||
final fontSize = textStyle.text.fontSize ?? 16.0;
|
||||
final height = textStyle.text.height ?? textStyle.lineHeight;
|
||||
final size = fontSize * height;
|
||||
final index = level % bulletedListIcons.length;
|
||||
index = level % BulletedListIcon.bulletedListIcons.length;
|
||||
size = fontSize * height;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = FlowySvg(
|
||||
bulletedListIcons[index],
|
||||
BulletedListIcon.bulletedListIcons[index],
|
||||
size: Size.square(size * 0.8),
|
||||
);
|
||||
return Container(
|
||||
@@ -49,4 +51,16 @@ class BulletedListIcon extends StatelessWidget {
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
|
||||
int get level {
|
||||
var level = 0;
|
||||
var parent = widget.node.parent;
|
||||
while (parent != null) {
|
||||
if (parent.type == BulletedListBlockKeys.type) {
|
||||
level++;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -27,11 +27,12 @@ extension LinkExtension on EditorState {
|
||||
final node = getNodeAtPath(selection.start.path);
|
||||
if (node == null) return;
|
||||
final transaction = this.transaction;
|
||||
final linkName = info.name.isEmpty ? info.link : info.name;
|
||||
transaction.replaceText(
|
||||
node,
|
||||
selection.startIndex,
|
||||
selection.length,
|
||||
info.name,
|
||||
linkName,
|
||||
attributes: info.toAttribute(),
|
||||
);
|
||||
apply(transaction);
|
||||
|
||||
+5
-2
@@ -186,8 +186,11 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
|
||||
onDismiss: () => editMenuController.close(),
|
||||
onApply: (info) => editorState.applyLink(selection, info),
|
||||
onRemoveLink: (linkinfo) =>
|
||||
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
|
||||
onRemoveLink: (linkinfo) {
|
||||
final replaceText =
|
||||
linkinfo.name.isEmpty ? linkinfo.link : linkinfo.name;
|
||||
onRemoveAndReplaceLink(editorState, selection, replaceText);
|
||||
},
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
+17
-8
@@ -157,6 +157,8 @@ class FileBlockComponent extends BlockComponentStatefulWidget {
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
static const uploadDragKey = 'FileUploadMenu';
|
||||
|
||||
@override
|
||||
State<FileBlockComponent> createState() => FileBlockComponentState();
|
||||
}
|
||||
@@ -253,20 +255,23 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
if (UniversalPlatform.isDesktopOrWeb) {
|
||||
if (url == null || url.isEmpty) {
|
||||
child = DropTarget(
|
||||
enable: dropManagerState?.isDropEnabled == true ||
|
||||
dropManagerState?.contains(FileBlockKeys.type) == true,
|
||||
onDragEntered: (_) {
|
||||
if (dropManagerState?.isDropEnabled == true) {
|
||||
dropManagerState?.add(FileBlockKeys.type);
|
||||
setState(() => isDragging = true);
|
||||
}
|
||||
},
|
||||
onDragExited: (_) {
|
||||
if (dropManagerState?.isDropEnabled == true) {
|
||||
if (dropManagerState?.contains(FileBlockKeys.type) == true) {
|
||||
dropManagerState?.remove(FileBlockKeys.type);
|
||||
setState(() => isDragging = false);
|
||||
}
|
||||
},
|
||||
onDragDone: (details) {
|
||||
if (dropManagerState?.isDropEnabled == true) {
|
||||
insertFileFromLocal(details.files);
|
||||
}
|
||||
dropManagerState?.remove(FileBlockKeys.type);
|
||||
insertFileFromLocal(details.files);
|
||||
},
|
||||
child: AppFlowyPopover(
|
||||
controller: controller,
|
||||
@@ -277,8 +282,12 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
minHeight: 80,
|
||||
),
|
||||
clickHandler: PopoverClickHandler.gestureDetector,
|
||||
onOpen: () => dropManagerState?.add(FileBlockKeys.type),
|
||||
onClose: () => dropManagerState?.remove(FileBlockKeys.type),
|
||||
onOpen: () => dropManagerState?.add(
|
||||
FileBlockComponent.uploadDragKey,
|
||||
),
|
||||
onClose: () => dropManagerState?.remove(
|
||||
FileBlockComponent.uploadDragKey,
|
||||
),
|
||||
popupBuilder: (_) => FileUploadMenu(
|
||||
onInsertLocalFile: insertFileFromLocal,
|
||||
onInsertNetworkFile: insertNetworkFile,
|
||||
@@ -345,7 +354,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
void _openMenu() {
|
||||
if (UniversalPlatform.isDesktopOrWeb) {
|
||||
controller.show();
|
||||
dropManagerState?.add(FileBlockKeys.type);
|
||||
dropManagerState?.add(FileBlockComponent.uploadDragKey);
|
||||
} else {
|
||||
editorState.updateSelectionWithReason(null, extraInfo: {});
|
||||
showUploadFileMobileMenu();
|
||||
@@ -574,7 +583,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
|
||||
final renderBox = fileKey.currentContext?.findRenderObject();
|
||||
if (renderBox is RenderBox) {
|
||||
return Offset.zero & renderBox.size;
|
||||
return padding.topLeft & renderBox.size;
|
||||
}
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
+13
-7
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension InsertFile on EditorState {
|
||||
Future<void> insertEmptyFileBlock(GlobalKey key) async {
|
||||
@@ -17,11 +16,18 @@ extension InsertFile on EditorState {
|
||||
}
|
||||
final file = fileNode(url: '')..extraInfos = {'global_key': key};
|
||||
|
||||
final insertedPath = delta.isEmpty ? path : path.next;
|
||||
final transaction = this.transaction
|
||||
..insertNode(insertedPath, file)
|
||||
..insertNode(insertedPath, paragraphNode())
|
||||
..afterSelection = Selection.collapsed(Position(path: insertedPath.next));
|
||||
final transaction = this.transaction;
|
||||
|
||||
// if current node is a paragraph and empty, replace it with the file block
|
||||
if (delta.isEmpty && node.type == ParagraphBlockKeys.type) {
|
||||
final insertedPath = path;
|
||||
transaction.insertNode(insertedPath, file);
|
||||
transaction.deleteNode(node);
|
||||
} else {
|
||||
// otherwise, insert the file block after the current node
|
||||
final insertedPath = path.next;
|
||||
transaction.insertNode(insertedPath, file);
|
||||
}
|
||||
|
||||
return apply(transaction);
|
||||
}
|
||||
|
||||
+7
-1
@@ -52,6 +52,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
late final titleFocusNode = editorContext.coverTitleFocusNode;
|
||||
int lineCount = 1;
|
||||
|
||||
bool updatingViewName = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -87,7 +89,7 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
final width = context.read<DocumentAppearanceCubit>().state.width;
|
||||
return BlocConsumer<ViewBloc, ViewState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.view.name != current.view.name,
|
||||
previous.view.name != current.view.name && !updatingViewName,
|
||||
listener: _onListen,
|
||||
builder: (context, state) {
|
||||
final appearance = context.read<DocumentAppearanceCubit>().state;
|
||||
@@ -206,6 +208,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
}
|
||||
|
||||
void _onViewNameChanged() {
|
||||
updatingViewName = true;
|
||||
|
||||
Debounce.debounce(
|
||||
'update view name',
|
||||
const Duration(milliseconds: 250),
|
||||
@@ -222,6 +226,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
context
|
||||
.read<ViewInfoBloc?>()
|
||||
?.add(ViewInfoEvent.titleChanged(titleTextController.text));
|
||||
|
||||
updatingViewName = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+26
-2
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -44,11 +45,27 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
final documentService = DocumentService();
|
||||
late final editorState = context.read<EditorState>();
|
||||
|
||||
late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile
|
||||
? null
|
||||
: context.read<EditorDropManagerState?>();
|
||||
|
||||
bool get isDragEnabled =>
|
||||
dropManagerState?.isDropEnabled == true ||
|
||||
dropManagerState?.contains(CustomImageBlockKeys.type) == true;
|
||||
|
||||
bool showLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
bool isDraggingFiles = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
dropManagerState = context.read<EditorDropManagerState>();
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget child = DecoratedBox(
|
||||
@@ -131,8 +148,15 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
);
|
||||
},
|
||||
child: DropTarget(
|
||||
onDragEntered: (_) => setState(() => isDraggingFiles = true),
|
||||
onDragExited: (_) => setState(() => isDraggingFiles = false),
|
||||
enable: isDragEnabled,
|
||||
onDragEntered: (_) {
|
||||
if (isDragEnabled) {
|
||||
setState(() => isDraggingFiles = true);
|
||||
}
|
||||
},
|
||||
onDragExited: (_) {
|
||||
setState(() => isDraggingFiles = false);
|
||||
},
|
||||
onDragDone: (details) {
|
||||
// Only accept files where the mimetype is an image,
|
||||
// otherwise we assume it's a file we cannot display.
|
||||
|
||||
+2
-1
@@ -95,7 +95,8 @@ class DateTransactionHandler extends MentionTransactionHandler {
|
||||
final reminderId = data.$2[MentionBlockKeys.reminderId];
|
||||
|
||||
if (reminderId case String _ when reminderId.isNotEmpty) {
|
||||
getIt<ReminderBloc>().add(ReminderEvent.removeReminder(reminderId: reminderId));
|
||||
getIt<ReminderBloc>()
|
||||
.add(ReminderEvent.removeReminder(reminderId: reminderId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -146,9 +146,9 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
|
||||
} else {
|
||||
final rootContext = widget.editorState.document.root.context;
|
||||
if (rootContext != null && _reminderId != null) {
|
||||
rootContext
|
||||
.read<ReminderBloc?>()
|
||||
?.add(ReminderEvent.removeReminder(reminderId: _reminderId!));
|
||||
rootContext.read<ReminderBloc?>()?.add(
|
||||
ReminderEvent.removeReminder(reminderId: _reminderId!),
|
||||
);
|
||||
}
|
||||
_updateBlock(selectedDay, includeTime: _includeTime);
|
||||
}
|
||||
|
||||
+23
-12
@@ -15,6 +15,7 @@ class CustomPageBlockComponentBuilder extends BlockComponentBuilder {
|
||||
node: blockComponentContext.node,
|
||||
header: blockComponentContext.header,
|
||||
footer: blockComponentContext.footer,
|
||||
wrapper: blockComponentContext.wrapper,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +30,12 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
this.header,
|
||||
this.footer,
|
||||
this.wrapper,
|
||||
});
|
||||
|
||||
final Widget? header;
|
||||
final Widget? footer;
|
||||
final BlockComponentWrapper? wrapper;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -52,14 +55,20 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
|
||||
children: [
|
||||
if (header != null) header!,
|
||||
...items.map(
|
||||
(e) => Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth:
|
||||
editorState.editorStyle.maxWidth ?? double.infinity,
|
||||
),
|
||||
padding: editorState.editorStyle.padding,
|
||||
child: editorState.renderer.build(context, e),
|
||||
),
|
||||
(e) {
|
||||
Widget child = editorState.renderer.build(context, e);
|
||||
if (wrapper != null) {
|
||||
child = wrapper!(context, node: e, child: child);
|
||||
}
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth:
|
||||
editorState.editorStyle.maxWidth ?? double.infinity,
|
||||
),
|
||||
padding: editorState.editorStyle.padding,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (footer != null) footer!,
|
||||
],
|
||||
@@ -92,6 +101,11 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
|
||||
final childNode = items[index - (header != null ? 1 : 0)];
|
||||
final isOverflowType = overflowTypes.contains(childNode.type);
|
||||
|
||||
Widget child = editorState.renderer.build(context, childNode);
|
||||
if (wrapper != null) {
|
||||
child = wrapper!(context, node: childNode, child: child);
|
||||
}
|
||||
|
||||
final item = Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: editorState.editorStyle.maxWidth ?? double.infinity,
|
||||
@@ -99,10 +113,7 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
|
||||
padding: isOverflowType
|
||||
? EdgeInsets.zero
|
||||
: editorState.editorStyle.padding,
|
||||
child: editorState.renderer.build(
|
||||
context,
|
||||
childNode,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
return isOverflowType ? item : Center(child: item);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
import 'package:appflowy/shared/google_fonts_extension.dart';
|
||||
import 'package:appflowy/util/font_family_extension.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/appearance_defaults.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
@@ -198,7 +199,11 @@ class EditorStyleCustomizer {
|
||||
fontSize = state.fontLayout.fontSize;
|
||||
fontSizes = state.fontLayout.headingFontSizes;
|
||||
} else {
|
||||
fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
|
||||
fontFamily = context
|
||||
.read<DocumentAppearanceCubit>()
|
||||
.state
|
||||
.fontFamily
|
||||
.orDefault(context.read<AppearanceSettingsCubit>().state.font);
|
||||
fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
fontSizes = [
|
||||
fontSize + 16,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
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:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
@@ -48,6 +50,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
late int startOffset;
|
||||
late String _search = widget.initialSearchText;
|
||||
double emojiHeight = 36.0;
|
||||
String lastSearchedQuery = '';
|
||||
final configuration = EmojiPickerConfiguration(
|
||||
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
|
||||
);
|
||||
@@ -99,7 +102,9 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: noEmojis
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
color: Theme.of(context).cardColor,
|
||||
@@ -111,7 +116,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: noEmojis ? buildLoading() : buildEmojis(),
|
||||
child: loaded ? buildEmojis() : buildLoading(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -130,6 +135,16 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
}
|
||||
|
||||
Widget buildEmojis() {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
if (noEmojis) {
|
||||
return SizedBox(
|
||||
width: 400,
|
||||
height: emojiHeight,
|
||||
child: Center(
|
||||
child: FlowyText.regular(LocaleKeys.inlineActions_noResults.tr()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height:
|
||||
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
|
||||
@@ -208,7 +223,14 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
_scrollToItem();
|
||||
});
|
||||
if (searchedEmojis.isEmpty) {
|
||||
widget.onDismiss.call();
|
||||
if (lastSearchedQuery.isEmpty) {
|
||||
lastSearchedQuery = _search;
|
||||
}
|
||||
if (_search.length - lastSearchedQuery.length >= 5) {
|
||||
widget.onDismiss.call();
|
||||
}
|
||||
} else {
|
||||
lastSearchedQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +381,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
void _scrollToItem() {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
if (noEmojis || !mounted) return;
|
||||
if (noEmojis || !mounted || !scrollController.hasClients) return;
|
||||
final currentItem = selectedIndexNotifier.value;
|
||||
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
|
||||
final maxExtent = scrollController.position.maxScrollExtent;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:fixnum/fixnum.dart' as $fixnum;
|
||||
|
||||
import 'sizes.dart';
|
||||
@@ -27,7 +28,11 @@ class TrashCell extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: TrashSizes.fileNameWidth,
|
||||
child: FlowyText(object.name),
|
||||
child: FlowyText(
|
||||
object.name.isEmpty
|
||||
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
|
||||
: object.name,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: TrashSizes.lashModifyWidth,
|
||||
|
||||
@@ -121,7 +121,6 @@ class FlowyRunner {
|
||||
// don't catch errors in test mode
|
||||
if (!mode.isUnitTest && !mode.isIntegrationTest)
|
||||
const PlatformErrorCatcherTask(),
|
||||
if (!mode.isUnitTest) const InitSentryTask(),
|
||||
// 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(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_settings_service.dart';
|
||||
import 'package:appflowy/util/font_family_extension.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';
|
||||
@@ -78,7 +79,8 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
Locale('cs', 'CZ'),
|
||||
Locale('ckb', 'KU'),
|
||||
Locale('de', 'DE'),
|
||||
Locale('en'),
|
||||
Locale('en', 'US'),
|
||||
Locale('en', 'GB'),
|
||||
Locale('es', 'VE'),
|
||||
Locale('eu', 'ES'),
|
||||
Locale('el', 'GR'),
|
||||
@@ -106,7 +108,7 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
Locale('mr', 'IN'),
|
||||
],
|
||||
path: 'assets/translations',
|
||||
fallbackLocale: const Locale('en'),
|
||||
fallbackLocale: const Locale('en', 'US'),
|
||||
useFallbackTranslations: true,
|
||||
child: app,
|
||||
),
|
||||
@@ -137,7 +139,7 @@ class ApplicationWidget extends StatefulWidget {
|
||||
class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
late final GoRouter routerConfig;
|
||||
|
||||
final _commandPaletteNotifier = ValueNotifier<bool>(false);
|
||||
final _commandPaletteNotifier = ValueNotifier(CommandPaletteNotifierValue());
|
||||
|
||||
final themeBuilder = AppFlowyDefaultTheme();
|
||||
|
||||
@@ -239,8 +241,9 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
routerConfig: routerConfig,
|
||||
builder: (context, child) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final fontFamily =
|
||||
state.font.orDefault(defaultFontFamily);
|
||||
final fontFamily = state.font
|
||||
.orDefault(defaultFontFamily)
|
||||
.fontFamilyName;
|
||||
|
||||
return AppFlowyTheme(
|
||||
data: brightness == Brightness.light
|
||||
|
||||
@@ -13,5 +13,4 @@ export 'platform_error_catcher.dart';
|
||||
export 'platform_service.dart';
|
||||
export 'recent_service_task.dart';
|
||||
export 'rust_sdk.dart';
|
||||
export 'sentry.dart';
|
||||
export 'windows.dart';
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'package:appflowy/env/env.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../startup.dart';
|
||||
|
||||
class InitSentryTask extends LaunchTask {
|
||||
const InitSentryTask();
|
||||
|
||||
@override
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
await super.initialize(context);
|
||||
|
||||
const dsn = Env.sentryDsn;
|
||||
if (dsn.isEmpty) {
|
||||
Log.info('Sentry DSN is not set, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('Initializing Sentry');
|
||||
|
||||
await SentryFlutter.init(
|
||||
(options) {
|
||||
options.dsn = dsn;
|
||||
options.tracesSampleRate = 0.1;
|
||||
options.profilesSampleRate = 0.1;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/core/helpers/helpers.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scaled_app/scaled_app.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -21,8 +19,14 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
await super.initialize(context);
|
||||
|
||||
// Don't initialize on mobile or web.
|
||||
if (!defaultTargetPlatform.isDesktop || context.env.isIntegrationTest) {
|
||||
// Don't initialize in tests or on web
|
||||
if (context.env.isTest || UniversalPlatform.isWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (UniversalPlatform.isMobile) {
|
||||
final scale = await windowSizeManager.getScaleFactor();
|
||||
ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => scale;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
|
||||
late final ReminderService _reminderService;
|
||||
Timer? timer;
|
||||
late final AppLifecycleListener _listener;
|
||||
final _deepEquality = DeepCollectionEquality();
|
||||
|
||||
bool hasReminder(String reminderId) =>
|
||||
state.allReminders.where((e) => e.id == reminderId).firstOrNull != null;
|
||||
@@ -75,9 +76,20 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
|
||||
(reminders) async {
|
||||
final availableReminders =
|
||||
await filterAvailableReminders(reminders);
|
||||
Log.info(
|
||||
'Fetched reminders on refresh: ${availableReminders.length}',
|
||||
// only print the reminder ids are not the same as the previous ones
|
||||
final previousReminderIds =
|
||||
state.reminders.map((e) => e.id).toSet();
|
||||
final newReminderIds =
|
||||
availableReminders.map((e) => e.id).toSet();
|
||||
final diff = _deepEquality.equals(
|
||||
previousReminderIds,
|
||||
newReminderIds,
|
||||
);
|
||||
if (!diff) {
|
||||
Log.info(
|
||||
'Fetched reminders on refresh: ${availableReminders.length}',
|
||||
);
|
||||
}
|
||||
if (!isClosed && !emit.isDone) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/google_fonts_extension.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -12,4 +13,11 @@ extension FontFamilyExtension on String {
|
||||
String get fontFamilyDisplayName => isEmpty || this == defaultFontFamily
|
||||
? LocaleKeys.settings_appearance_fontFamily_defaultFont.tr()
|
||||
: parseFontFamilyName();
|
||||
|
||||
// the font display name is not the same as the font family name
|
||||
// for example, the display name is "Noto Sans HK"
|
||||
// the font family name is "NotoSansHK_Regular"
|
||||
String get fontFamilyName => isEmpty || this == defaultFontFamily
|
||||
? defaultFontFamily
|
||||
: getGoogleFontSafely(this).fontFamily ?? defaultFontFamily;
|
||||
}
|
||||
+7
-6
@@ -49,7 +49,6 @@ class CommandPaletteBloc
|
||||
final TrashService _trashService = TrashService();
|
||||
final TrashListener _trashListener = TrashListener();
|
||||
String? _activeQuery;
|
||||
String? _workspaceId;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
@@ -115,7 +114,6 @@ class CommandPaletteBloc
|
||||
unawaited(
|
||||
SearchBackendService.performSearch(
|
||||
event.search,
|
||||
workspaceId: _workspaceId,
|
||||
).then(
|
||||
(result) => result.fold(
|
||||
(stream) {
|
||||
@@ -161,6 +159,7 @@ class CommandPaletteBloc
|
||||
onServerItems: (items, searchId, searching, generatingAIOverview) =>
|
||||
_handleResultsUpdate(
|
||||
searchId: searchId,
|
||||
summaries: [], // when got server search result, summaries should be empty
|
||||
serverItems: items,
|
||||
searching: searching,
|
||||
generatingAIOverview: generatingAIOverview,
|
||||
@@ -263,7 +262,6 @@ class CommandPaletteBloc
|
||||
_WorkspaceChanged event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
_workspaceId = event.workspaceId;
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: '',
|
||||
@@ -288,14 +286,14 @@ class CommandPaletteBloc
|
||||
_GoingToAskAI event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
emit(state.copyWith(askAI: true));
|
||||
emit(state.copyWith(askAI: true, askAISources: event.sources));
|
||||
}
|
||||
|
||||
FutureOr<void> _onAskedAI(
|
||||
_AskedAI event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
emit(state.copyWith(askAI: false));
|
||||
emit(state.copyWith(askAI: false, askAISources: null));
|
||||
}
|
||||
|
||||
bool _isActiveSearch(String searchId) =>
|
||||
@@ -327,7 +325,9 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
|
||||
@Default(null) String? workspaceId,
|
||||
}) = _WorkspaceChanged;
|
||||
const factory CommandPaletteEvent.clearSearch() = _ClearSearch;
|
||||
const factory CommandPaletteEvent.gointToAskAI() = _GoingToAskAI;
|
||||
const factory CommandPaletteEvent.goingToAskAI({
|
||||
@Default(null) List<SearchSourcePB>? sources,
|
||||
}) = _GoingToAskAI;
|
||||
const factory CommandPaletteEvent.askedAI() = _AskedAI;
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@ class CommandPaletteState with _$CommandPaletteState {
|
||||
required bool searching,
|
||||
required bool generatingAIOverview,
|
||||
@Default(false) bool askAI,
|
||||
@Default(null) List<SearchSourcePB>? askAISources,
|
||||
@Default([]) List<TrashPB> trash,
|
||||
@Default(null) String? searchId,
|
||||
}) = _CommandPaletteState;
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ extension GetIcon on ResultIconPB {
|
||||
lineHeight: lineHeight,
|
||||
)
|
||||
: null;
|
||||
} else if (ty == ResultIconTypePB.Icon) {
|
||||
} else if (iconType == ResultIconTypePB.Icon ||
|
||||
iconType == ResultIconTypePB.Url) {
|
||||
if (_resultIconValueTypes.contains(iconValue)) {
|
||||
return FlowySvg(
|
||||
getViewSvg(),
|
||||
|
||||
+4
-9
@@ -8,28 +8,23 @@ 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<FlowyResult<SearchResponseStream, FlowyError>> performSearch(
|
||||
String keyword, {
|
||||
String? workspaceId,
|
||||
}) async {
|
||||
final searchId = nanoid(6);
|
||||
String keyword,
|
||||
) async {
|
||||
final searchId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final stream = SearchResponseStream(searchId: searchId);
|
||||
|
||||
final filter = SearchFilterPB(workspaceId: workspaceId);
|
||||
final request = SearchQueryPB(
|
||||
search: keyword,
|
||||
filter: filter,
|
||||
searchId: searchId,
|
||||
streamPort: Int64(stream.nativePort),
|
||||
);
|
||||
|
||||
unawaited(SearchEventSearch(request).send());
|
||||
unawaited(SearchEventStreamSearch(request).send());
|
||||
return FlowyResult.success(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ class RecentViewsBloc extends Bloc<RecentViewsEvent, RecentViewsState> {
|
||||
await _service.reset();
|
||||
add(const RecentViewsEvent.fetchRecentViews());
|
||||
},
|
||||
hoverView: (e) async {
|
||||
emit(
|
||||
state.copyWith(hoveredView: e.view),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -64,6 +69,7 @@ class RecentViewsEvent with _$RecentViewsEvent {
|
||||
RemoveRecentViews;
|
||||
const factory RecentViewsEvent.fetchRecentViews() = FetchRecentViews;
|
||||
const factory RecentViewsEvent.resetRecentViews() = ResetRecentViews;
|
||||
const factory RecentViewsEvent.hoverView(ViewPB view) = HoverView;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -71,6 +77,7 @@ class RecentViewsState with _$RecentViewsState {
|
||||
const factory RecentViewsState({
|
||||
required List<SectionViewPB> views,
|
||||
@Default(true) bool isLoading,
|
||||
@Default(null) ViewPB? hoveredView,
|
||||
}) = _RecentViewsState;
|
||||
|
||||
factory RecentViewsState.initial() => const RecentViewsState(views: []);
|
||||
|
||||
@@ -39,8 +39,7 @@ class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
|
||||
emit(
|
||||
LocalAiPluginState.ready(
|
||||
isEnabled: aiState.enabled,
|
||||
version: aiState.pluginVersion,
|
||||
runningState: aiState.state,
|
||||
isReady: aiState.isReady,
|
||||
lackOfResource:
|
||||
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
|
||||
),
|
||||
@@ -116,8 +115,7 @@ class LocalAiPluginState with _$LocalAiPluginState {
|
||||
|
||||
const factory LocalAiPluginState.ready({
|
||||
required bool isEnabled,
|
||||
required String version,
|
||||
required RunningStatePB runningState,
|
||||
required bool isReady,
|
||||
required LackOfAIResourcePB? lackOfResource,
|
||||
}) = ReadyLocalAiPluginState;
|
||||
|
||||
@@ -125,15 +123,15 @@ class LocalAiPluginState with _$LocalAiPluginState {
|
||||
|
||||
bool get isEnabled {
|
||||
return maybeWhen(
|
||||
ready: (isEnabled, _, __, ___) => isEnabled,
|
||||
ready: (isEnabled, _, ___) => isEnabled,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
|
||||
bool get showIndicator {
|
||||
return maybeWhen(
|
||||
ready: (isEnabled, _, runningState, lackOfResource) =>
|
||||
runningState != RunningStatePB.Running || lackOfResource != null,
|
||||
ready: (isEnabled, isReady, lackOfResource) =>
|
||||
isReady || lackOfResource != null,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -150,14 +150,16 @@ class SettingItem extends Equatable {
|
||||
required this.content,
|
||||
required this.hintText,
|
||||
required this.settingType,
|
||||
this.editable = true,
|
||||
});
|
||||
|
||||
final String content;
|
||||
final String hintText;
|
||||
final SettingType settingType;
|
||||
final bool editable;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [content, settingType];
|
||||
List<Object?> get props => [content, settingType, editable];
|
||||
}
|
||||
|
||||
/// Items pending submission
|
||||
@@ -232,6 +234,7 @@ extension on LocalAISettingPB {
|
||||
content: embeddingModelName,
|
||||
hintText: kDefaultEmbeddingModel,
|
||||
settingType: SettingType.embeddingModel,
|
||||
editable: false, // embedding model is not editable
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
+1
-4
@@ -11,7 +11,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'settings_ai_bloc.freezed.dart';
|
||||
|
||||
const String aiModelsGlobalActiveModel = "ai_models_global_active_model";
|
||||
const String aiModelsGlobalActiveModel = "global_active_model";
|
||||
|
||||
class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
|
||||
SettingsAIBloc(
|
||||
@@ -75,9 +75,6 @@ class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
|
||||
);
|
||||
},
|
||||
selectModel: (AIModelPB model) async {
|
||||
if (!model.isLocal) {
|
||||
await _updateUserWorkspaceSetting(model: model.name);
|
||||
}
|
||||
await AIEventUpdateSelectedModel(
|
||||
UpdateSelectedModelPB(
|
||||
source: aiModelsGlobalActiveModel,
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
void setLocale(BuildContext context, Locale newLocale) {
|
||||
if (!context.supportedLocales.contains(newLocale)) {
|
||||
// Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
|
||||
newLocale = const Locale('en');
|
||||
newLocale = const Locale('en', 'US');
|
||||
}
|
||||
|
||||
context.setLocale(newLocale).catchError((e) {
|
||||
|
||||
+12
-2
@@ -194,8 +194,18 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
|
||||
).getWorkspaceUsage().then((result) {
|
||||
result.fold(
|
||||
(usage) {
|
||||
if (!isClosed && usage != null) {
|
||||
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
|
||||
if (!isClosed) {
|
||||
// if the user cannot fetch the workspace usage,
|
||||
// clear the tier indicator
|
||||
if (usage == null) {
|
||||
add(
|
||||
const SidebarPlanEvent.updateTierIndicator(
|
||||
SidebarToastTierIndicator.loading(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => Log.error("Failed to get workspace usage: $error"),
|
||||
|
||||
+557
-409
File diff suppressed because it is too large.
Load diff
@@ -298,6 +298,21 @@ extension ViewExtension on ViewPB {
|
||||
return PageStyleFontLayout.normal;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
set isSpace(bool value) {
|
||||
try {
|
||||
if (extra.isEmpty) {
|
||||
extra = jsonEncode({ViewExtKeys.isSpaceKey: value});
|
||||
} else {
|
||||
final ext = jsonDecode(extra);
|
||||
ext[ViewExtKeys.isSpaceKey] = value;
|
||||
extra = jsonEncode(ext);
|
||||
}
|
||||
} catch (e) {
|
||||
extra = jsonEncode({ViewExtKeys.isSpaceKey: value});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewLayoutExtension on ViewLayoutPB {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -86,17 +85,6 @@ class WorkspaceService {
|
||||
}
|
||||
|
||||
Future<FlowyResult<WorkspaceUsagePB?, FlowyError>> 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();
|
||||
}
|
||||
|
||||
+196
-62
@@ -1,15 +1,29 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
|
||||
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:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.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/gestures.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 'widgets/search_ask_ai_entrance.dart';
|
||||
|
||||
class CommandPalette extends InheritedWidget {
|
||||
CommandPalette({
|
||||
super.key,
|
||||
@@ -19,7 +33,7 @@ class CommandPalette extends InheritedWidget {
|
||||
child: _CommandPaletteController(notifier: notifier, child: child),
|
||||
);
|
||||
|
||||
final ValueNotifier<bool> notifier;
|
||||
final ValueNotifier<CommandPaletteNotifierValue> notifier;
|
||||
|
||||
static CommandPalette of(BuildContext context) {
|
||||
final CommandPalette? result =
|
||||
@@ -30,7 +44,30 @@ class CommandPalette extends InheritedWidget {
|
||||
return result!;
|
||||
}
|
||||
|
||||
void toggle() => notifier.value = !notifier.value;
|
||||
static CommandPalette? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<CommandPalette>();
|
||||
|
||||
void toggle({
|
||||
UserWorkspaceBloc? workspaceBloc,
|
||||
SpaceBloc? spaceBloc,
|
||||
}) {
|
||||
final value = notifier.value;
|
||||
notifier.value = notifier.value.copyWith(
|
||||
isOpen: !value.isOpen,
|
||||
userWorkspaceBloc: workspaceBloc,
|
||||
spaceBloc: spaceBloc,
|
||||
);
|
||||
}
|
||||
|
||||
void updateBlocs({
|
||||
UserWorkspaceBloc? workspaceBloc,
|
||||
SpaceBloc? spaceBloc,
|
||||
}) {
|
||||
notifier.value = notifier.value.copyWith(
|
||||
userWorkspaceBloc: workspaceBloc,
|
||||
spaceBloc: spaceBloc,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
|
||||
@@ -47,7 +84,7 @@ class _CommandPaletteController extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget? child;
|
||||
final ValueNotifier<bool> notifier;
|
||||
final ValueNotifier<CommandPaletteNotifierValue> notifier;
|
||||
|
||||
@override
|
||||
State<_CommandPaletteController> createState() =>
|
||||
@@ -55,7 +92,8 @@ class _CommandPaletteController extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CommandPaletteControllerState extends State<_CommandPaletteController> {
|
||||
late ValueNotifier<bool> _toggleNotifier = widget.notifier;
|
||||
late ValueNotifier<CommandPaletteNotifierValue> _toggleNotifier =
|
||||
widget.notifier;
|
||||
bool _isOpen = false;
|
||||
|
||||
@override
|
||||
@@ -81,19 +119,28 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
|
||||
}
|
||||
|
||||
void _onToggle() {
|
||||
if (_toggleNotifier.value && !_isOpen) {
|
||||
if (_toggleNotifier.value.isOpen && !_isOpen) {
|
||||
_isOpen = true;
|
||||
final workspaceBloc = _toggleNotifier.value.userWorkspaceBloc;
|
||||
final spaceBloc = _toggleNotifier.value.spaceBloc;
|
||||
Log.info(
|
||||
'CommandPalette onToggle: workspaceType ${workspaceBloc?.state.userProfile.workspaceType}',
|
||||
);
|
||||
FlowyOverlay.show(
|
||||
context: context,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<CommandPaletteBloc>(),
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<CommandPaletteBloc>()),
|
||||
if (workspaceBloc != null) BlocProvider.value(value: workspaceBloc),
|
||||
if (spaceBloc != null) BlocProvider.value(value: spaceBloc),
|
||||
],
|
||||
child: CommandPaletteModal(shortcutBuilder: _buildShortcut),
|
||||
),
|
||||
).then((_) {
|
||||
_isOpen = false;
|
||||
_toggleNotifier.value = false;
|
||||
_toggleNotifier.value = _toggleNotifier.value.copyWith(isOpen: false);
|
||||
});
|
||||
} else if (!_toggleNotifier.value && _isOpen) {
|
||||
} else if (!_toggleNotifier.value.isOpen && _isOpen) {
|
||||
FlowyOverlay.pop(context);
|
||||
_isOpen = false;
|
||||
}
|
||||
@@ -107,8 +154,8 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
|
||||
actions: {
|
||||
_ToggleCommandPaletteIntent:
|
||||
CallbackAction<_ToggleCommandPaletteIntent>(
|
||||
onInvoke: (intent) =>
|
||||
_toggleNotifier.value = !_toggleNotifier.value,
|
||||
onInvoke: (intent) => _toggleNotifier.value = _toggleNotifier.value
|
||||
.copyWith(isOpen: !_toggleNotifier.value.isOpen),
|
||||
),
|
||||
},
|
||||
shortcuts: {
|
||||
@@ -130,68 +177,155 @@ class CommandPaletteModal extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
|
||||
builder: (context, state) => FlowyDialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 100),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 600,
|
||||
maxWidth: 800,
|
||||
minHeight: 600,
|
||||
),
|
||||
expandHeight: false,
|
||||
child: shortcutBuilder(
|
||||
// Change mainAxisSize to max so Expanded works correctly.
|
||||
Column(
|
||||
children: [
|
||||
SearchField(query: state.query, isLoading: state.searching),
|
||||
if (state.query?.isEmpty ?? true) ...[
|
||||
const Divider(height: 0),
|
||||
Flexible(
|
||||
child: RecentViewsList(
|
||||
onSelected: () => FlowyOverlay.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.combinedResponseItems.isNotEmpty &&
|
||||
(state.query?.isNotEmpty ?? false)) ...[
|
||||
const Divider(height: 0),
|
||||
Flexible(
|
||||
child: SearchResultList(
|
||||
trash: state.trash,
|
||||
resultItems: state.combinedResponseItems.values.toList(),
|
||||
resultSummaries: state.resultSummaries,
|
||||
),
|
||||
),
|
||||
]
|
||||
// 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.searching) ...[
|
||||
const Divider(height: 0),
|
||||
Expanded(
|
||||
child: const _NoResultsHint(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
|
||||
final showAskingAI =
|
||||
workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW;
|
||||
return BlocListener<CommandPaletteBloc, CommandPaletteState>(
|
||||
listener: (_, state) {
|
||||
if (state.askAI && context.mounted) {
|
||||
if (Navigator.canPop(context)) FlowyOverlay.pop(context);
|
||||
final currentWorkspace = workspaceState?.workspaces;
|
||||
final spaceBloc = context.read<SpaceBloc?>();
|
||||
if (currentWorkspace != null && spaceBloc != null) {
|
||||
spaceBloc.add(
|
||||
SpaceEvent.createPage(
|
||||
name: '',
|
||||
layout: ViewLayoutPB.Chat,
|
||||
index: 0,
|
||||
openAfterCreate: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
|
||||
builder: (context, state) {
|
||||
final noQuery = state.query?.isEmpty ?? true, hasQuery = !noQuery;
|
||||
final hasResult = state.combinedResponseItems.isNotEmpty;
|
||||
return FlowyDialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 100),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 640,
|
||||
maxWidth: 900,
|
||||
minHeight: 640,
|
||||
),
|
||||
expandHeight: false,
|
||||
child: shortcutBuilder(
|
||||
// Change mainAxisSize to max so Expanded works correctly.
|
||||
Column(
|
||||
children: [
|
||||
SearchField(query: state.query, isLoading: state.searching),
|
||||
if (noQuery)
|
||||
Flexible(
|
||||
child: RecentViewsList(
|
||||
onSelected: () => FlowyOverlay.pop(context),
|
||||
),
|
||||
),
|
||||
if (hasResult && hasQuery) ...[
|
||||
AFDivider(),
|
||||
Flexible(
|
||||
child: SearchResultList(
|
||||
trash: state.trash,
|
||||
resultItems:
|
||||
state.combinedResponseItems.values.toList(),
|
||||
resultSummaries: state.resultSummaries,
|
||||
),
|
||||
),
|
||||
]
|
||||
// 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 (hasQuery && !state.searching) ...[
|
||||
AFDivider(),
|
||||
if (showAskingAI) SearchAskAiEntrance(),
|
||||
Expanded(
|
||||
child: const NoSearchResultsHint(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updated _NoResultsHint now centers its content.
|
||||
class _NoResultsHint extends StatelessWidget {
|
||||
const _NoResultsHint();
|
||||
class NoSearchResultsHint extends StatelessWidget {
|
||||
const NoSearchResultsHint({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
textColor = theme.textColorScheme.secondary;
|
||||
return Center(
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.commandPalette_noResultsHint.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const VSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_noResultForSearching.tr(),
|
||||
style: theme.textStyle.body.enhanced(color: textColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const VSpace(4),
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
text: LocaleKeys.search_noResultForSearchingHintWithoutTrash.tr(),
|
||||
style: theme.textStyle.caption.standard(color: textColor),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.trash_text.tr(),
|
||||
style: theme.textStyle.caption.underline(color: textColor),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
FlowyOverlay.pop(context);
|
||||
getIt<MenuSharedState>().latestOpenView = null;
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(
|
||||
plugin: makePlugin(pluginType: PluginType.trash),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommandPaletteNotifierValue {
|
||||
CommandPaletteNotifierValue({
|
||||
this.isOpen = false,
|
||||
this.userWorkspaceBloc,
|
||||
this.spaceBloc,
|
||||
});
|
||||
|
||||
final bool isOpen;
|
||||
final UserWorkspaceBloc? userWorkspaceBloc;
|
||||
final SpaceBloc? spaceBloc;
|
||||
|
||||
CommandPaletteNotifierValue copyWith({
|
||||
bool? isOpen,
|
||||
UserWorkspaceBloc? userWorkspaceBloc,
|
||||
SpaceBloc? spaceBloc,
|
||||
}) {
|
||||
return CommandPaletteNotifierValue(
|
||||
isOpen: isOpen ?? this.isOpen,
|
||||
userWorkspaceBloc: userWorkspaceBloc ?? this.userWorkspaceBloc,
|
||||
spaceBloc: spaceBloc ?? this.spaceBloc,
|
||||
);
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.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/util/int64_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.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 PagePreview extends StatelessWidget {
|
||||
const PagePreview({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.onViewOpened,
|
||||
});
|
||||
final ViewPB view;
|
||||
final VoidCallback onViewOpened;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 24,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(child: buildIcon(theme, view)),
|
||||
),
|
||||
VSpace(8),
|
||||
buildTitle(context, view),
|
||||
buildPath(context, view),
|
||||
...buildTime(
|
||||
context,
|
||||
LocaleKeys.commandPalette_created.tr(),
|
||||
view.createTime.toDateTime(),
|
||||
),
|
||||
if (view.lastEdited != view.createTime)
|
||||
...buildTime(
|
||||
context,
|
||||
LocaleKeys.commandPalette_edited.tr(),
|
||||
view.lastEdited.toDateTime(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(AppFlowyThemeData theme, ViewPB view) {
|
||||
return view.icon.value.isNotEmpty
|
||||
? RawEmojiIconWidget(
|
||||
emoji: view.icon.toEmojiIconData(),
|
||||
emojiSize: 20.0,
|
||||
lineHeight: 1,
|
||||
)
|
||||
: FlowySvg(
|
||||
view.iconData,
|
||||
size: const Size.square(20),
|
||||
color: theme.iconColorScheme.secondary,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTitle(BuildContext context, ViewPB view) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final titleStyle = theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
titleHoverStyle =
|
||||
titleStyle.copyWith(decoration: TextDecoration.underline);
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: AFBaseButton(
|
||||
padding: EdgeInsets.zero,
|
||||
builder: (context, isHovering, disabled) =>
|
||||
SelectionContainer.disabled(
|
||||
child: Text(
|
||||
view.nameOrDefault,
|
||||
style: isHovering ? titleHoverStyle : titleStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
borderColor: (_, __, ___, ____) => Colors.transparent,
|
||||
borderRadius: 0,
|
||||
onTap: onViewOpened,
|
||||
),
|
||||
),
|
||||
HSpace(4),
|
||||
FlowyTooltip(
|
||||
message: LocaleKeys.settings_files_open.tr(),
|
||||
child: AFGhostButton.normal(
|
||||
size: AFButtonSize.s,
|
||||
padding: EdgeInsets.all(theme.spacing.xs),
|
||||
onTap: onViewOpened,
|
||||
builder: (context, isHovering, disabled) => FlowySvg(
|
||||
FlowySvgs.search_arrow_right_m,
|
||||
size: const Size.square(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPath(BuildContext context, ViewPB view) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return BlocProvider(
|
||||
key: ValueKey(view.id),
|
||||
create: (context) => ViewAncestorBloc(view.id),
|
||||
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
|
||||
builder: (context, state) {
|
||||
final isEmpty = state.ancestor.ancestors.isEmpty;
|
||||
if (!state.isLoading && isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
VSpace(16),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_location.tr(),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
state.buildPath(
|
||||
context,
|
||||
style: theme.textStyle.caption.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildTime(BuildContext context, String title, DateTime time) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final appearanceSettings = context.watch<AppearanceSettingsCubit>().state;
|
||||
final dateFormat = appearanceSettings.dateFormat,
|
||||
timeFormat = appearanceSettings.timeFormat;
|
||||
return [
|
||||
VSpace(12),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
Text(
|
||||
dateFormat.formatDate(time, true, timeFormat),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class SomethingWentWrong extends StatelessWidget {
|
||||
const SomethingWentWrong({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.something_wrong_warning_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const VSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_somethingWentWrong.tr(),
|
||||
style: theme.textStyle.body
|
||||
.enhanced(color: theme.textColorScheme.secondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const VSpace(4),
|
||||
Text(
|
||||
LocaleKeys.search_tryAgainOrLater.tr(),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+135
-32
@@ -2,15 +2,24 @@ 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/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/recent/recent_views_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.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:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'page_preview.dart';
|
||||
import 'search_ask_ai_entrance.dart';
|
||||
|
||||
class RecentViewsList extends StatelessWidget {
|
||||
const RecentViewsList({super.key, required this.onSelected});
|
||||
|
||||
@@ -26,39 +35,133 @@ class RecentViewsList extends StatelessWidget {
|
||||
// We remove duplicates by converting the list to a set first
|
||||
final List<ViewPB> recentViews =
|
||||
state.views.map((e) => e.item).toSet().toList();
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
|
||||
final showAskingAI = workspaceState?.userProfile.workspaceType ==
|
||||
WorkspaceTypePB.ServerW;
|
||||
final hoveredView = state.hoveredView;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AFDivider(),
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ScrollControllerBuilder(
|
||||
builder: (context, controller) {
|
||||
return FlowyScrollbar(
|
||||
controller: controller,
|
||||
child: SingleChildScrollView(
|
||||
controller: controller,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAskingAI) SearchAskAiEntrance(),
|
||||
Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
LocaleKeys.sideBar_recent.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color:
|
||||
theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics:
|
||||
const NeverScrollableScrollPhysics(),
|
||||
itemCount: recentViews.length,
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 12),
|
||||
itemBuilder: (_, index) {
|
||||
final view = recentViews[index];
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: recentViews.length + 1,
|
||||
itemBuilder: (_, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: FlowyText(
|
||||
LocaleKeys.commandPalette_recentHistory.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
final icon = view.icon.value.isNotEmpty
|
||||
? RawEmojiIconWidget(
|
||||
emoji:
|
||||
view.icon.toEmojiIconData(),
|
||||
emojiSize: 16.0,
|
||||
lineHeight: 20 / 16,
|
||||
)
|
||||
: FlowySvg(
|
||||
view.iconData,
|
||||
size: const Size.square(20),
|
||||
color: theme
|
||||
.iconColorScheme.secondary,
|
||||
);
|
||||
|
||||
final view = recentViews[index - 1];
|
||||
final icon = view.icon.value.isNotEmpty
|
||||
? EmojiIconWidget(
|
||||
emoji: view.icon.toEmojiIconData(),
|
||||
emojiSize: 18.0,
|
||||
)
|
||||
: FlowySvg(view.iconData, size: const Size.square(20));
|
||||
|
||||
return SearchRecentViewCell(
|
||||
icon: SizedBox(width: 24, child: icon),
|
||||
view: view,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(height: 0),
|
||||
return SearchRecentViewCell(
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
view: view,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, index) {
|
||||
final view = recentViews[index];
|
||||
final isHovered =
|
||||
hoveredView?.id == view.id;
|
||||
if (isHovered) return VSpace(1);
|
||||
if (index < recentViews.length - 1) {
|
||||
final nextView =
|
||||
recentViews[index + 1];
|
||||
final isNextHovered =
|
||||
hoveredView?.id == nextView.id;
|
||||
if (isNextHovered) return VSpace(1);
|
||||
}
|
||||
return const AFDivider();
|
||||
},
|
||||
),
|
||||
VSpace(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hoveredView != null) ...[
|
||||
AFDivider(axis: Axis.vertical),
|
||||
Flexible(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: PagePreview(
|
||||
view: hoveredView,
|
||||
onViewOpened: () {
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(
|
||||
objectId: hoveredView.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
onSelected();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.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';
|
||||
|
||||
import 'search_summary_cell.dart';
|
||||
|
||||
class SearchAskAiEntrance extends StatelessWidget {
|
||||
const SearchAskAiEntrance({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
|
||||
final generatingAIOverview = state?.generatingAIOverview ?? false;
|
||||
final hasAIOverview =
|
||||
_mockSummary?.isNotEmpty ?? state?.resultSummaries.isNotEmpty ?? false;
|
||||
if (generatingAIOverview) {
|
||||
return _AISearching();
|
||||
} else if (hasAIOverview) {
|
||||
return _AIOverview();
|
||||
}
|
||||
return _AskAIFor();
|
||||
}
|
||||
}
|
||||
|
||||
class _AskAIFor extends StatelessWidget {
|
||||
const _AskAIFor();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final sapceM = theme.spacing.m, spaceL = theme.spacing.l;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(spaceL, sapceM, spaceL, 0),
|
||||
child: AFBaseButton(
|
||||
borderRadius: sapceM,
|
||||
padding: EdgeInsets.symmetric(vertical: spaceL, horizontal: sapceM),
|
||||
backgroundColor: (context, isHovering, disable) {
|
||||
if (isHovering) {
|
||||
return Theme.of(context).colorScheme.secondary;
|
||||
}
|
||||
return theme.fillColorScheme.transparent;
|
||||
},
|
||||
borderColor: (context, isHovering, disable, isFocused) =>
|
||||
theme.fillColorScheme.transparent,
|
||||
builder: (ctx, isHovering, disable) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_ai_chat_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(12),
|
||||
buildText(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
onTap: () {
|
||||
context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.add(CommandPaletteEvent.goingToAskAI());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildText(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final bloc = context.read<CommandPaletteBloc?>();
|
||||
final queryText = bloc?.state.query ?? '';
|
||||
if (queryText.isEmpty) {
|
||||
return Text(
|
||||
LocaleKeys.search_askAIAnything.tr(),
|
||||
style:
|
||||
theme.textStyle.body.standard(color: theme.textColorScheme.primary),
|
||||
);
|
||||
}
|
||||
return Flexible(
|
||||
child: RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.search_askAIFor.tr(),
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' "$queryText"',
|
||||
style: theme.textStyle.body
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AISearching extends StatelessWidget {
|
||||
const _AISearching();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 8),
|
||||
child: SizedBox(
|
||||
height: 22,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_searching_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_searching.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AIOverview extends StatelessWidget {
|
||||
const _AIOverview();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final summaries = _mockSummary ?? state?.resultSummaries ?? [];
|
||||
if (summaries.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildHeader(context),
|
||||
VSpace(8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
final summary = summaries.first;
|
||||
return SearchSummaryCell(
|
||||
key: ValueKey(summary.content.trim()),
|
||||
summary: summary,
|
||||
maxWidth: constrains.maxWidth,
|
||||
theme: AppFlowyTheme.of(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
VSpace(8),
|
||||
SizedBox(
|
||||
width: 143,
|
||||
child: AFOutlinedButton.normal(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
borderRadius: 16,
|
||||
builder: (context, hovering, disabled) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.chat_ai_page_s,
|
||||
size: Size.square(20),
|
||||
color: theme.iconColorScheme.primary,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_aiAskFollowUp.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
onTap: () {
|
||||
context.read<CommandPaletteBloc?>()?.add(
|
||||
CommandPaletteEvent.goingToAskAI(
|
||||
sources: summaries.first.sources,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildHeader(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return SizedBox(
|
||||
height: 22,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_searching_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_aiOverview.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<SearchSummaryPB>? _mockSummary;
|
||||
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user