Compare commits

..
Author SHA1 Message Date
Nathan b23f6be47d chore: import user data 2025-04-24 10:23:45 +08:00
485 changed files with 10168 additions and 22208 deletions

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
;;
Windows)
vcpkg integrate install
@@ -52,7 +52,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager
shell: bash
- name: Enable Flutter Desktop
+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+14 -14
View File
@@ -26,9 +26,9 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.49-amd64
CLOUD_VERSION: 0.6.54-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -40,7 +40,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -74,7 +74,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ windows-latest ]
os: [windows-latest]
include:
- os: windows-latest
flutter_profile: development-windows-x86
@@ -101,7 +101,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ macos-latest ]
os: [macos-latest]
include:
- os: macos-latest
flutter_profile: development-mac-x86_64
@@ -123,12 +123,12 @@ jobs:
flutter_profile: ${{ matrix.flutter_profile }}
unit_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -174,7 +174,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
fi
shell: bash
@@ -217,11 +217,11 @@ jobs:
shell: bash
cloud_integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -308,7 +308,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
shell: bash
- name: Enable Flutter Desktop
@@ -340,13 +340,13 @@ jobs:
shell: bash
integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
os: [ubuntu-latest]
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
+43 -22
View File
@@ -19,14 +19,35 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.81.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:
@@ -73,26 +94,26 @@ jobs:
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
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: "iPhone 15"
shutdown_after_job: false
# - name: Run AppFlowy on simulator
# working-directory: frontend/appflowy_flutter
# run: |
# flutter run -d ${{ steps.simulator-action.outputs.udid }} &
# pid=$!
# sleep 500
# kill $pid
# continue-on-error: true
- name: Run AppFlowy on simulator
working-directory: frontend/appflowy_flutter
run: |
flutter run -d ${{ steps.simulator-action.outputs.udid }} &
pid=$!
sleep 500
kill $pid
continue-on-error: true
# # Integration tests
# - name: Run integration tests
# working-directory: frontend/appflowy_flutter
# # The integration tests are flaky and sometimes fail with "Connection timed out":
# # Don't block the CI. If the tests fail, the CI will still pass.
# # Instead, we're using Code Magic to re-run the tests to check if they pass.
# continue-on-error: true
# run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
# Integration tests
- name: Run integration tests
working-directory: frontend/appflowy_flutter
# The integration tests are flaky and sometimes fail with "Connection timed out":
# Don't block the CI. If the tests fail, the CI will still pass.
# Instead, we're using Code Magic to re-run the tests to check if they pass.
continue-on-error: true
run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
+12 -13
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.81.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
@@ -366,10 +366,9 @@ jobs:
run: |
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo apt-get update
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev libcurl4-openssl-dev
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install -y alien libnotify-dev
sudo apt install libfuse2
source $HOME/.cargo/env
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
+3 -3
View File
@@ -18,8 +18,8 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.9.49-amd64
RUST_TOOLCHAIN: "1.84.0"
CLOUD_VERSION: 0.8.3-amd64
RUST_TOOLCHAIN: "1.81.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" -- --skip local_ollama_test
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.81.0"
jobs:
tests:
-43
View File
@@ -1,47 +1,4 @@
# Release Notes
## 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
- 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 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.8.9 - 16/04/2025
### Desktop
#### New Features
+35 -36
View File
@@ -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.1"
APPFLOWY_VERSION = "0.9.0"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -228,31 +228,30 @@ 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"
@@ -280,24 +279,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"
File diff suppressed because it is too large. Load diff
@@ -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://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,""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,""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""}"
@@ -12,38 +12,24 @@ import 'uncategorized/uncategorized_test_runner.dart'
import 'workspace/workspace_test_runner.dart' as workspace_test_runner;
Future<void> main() async {
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
data_migration_test_runner.main();
preset_af_cloud_env_test.main();
// uncategorized
uncategorized_test_runner.main();
data_migration_test_runner.main();
// workspace
workspace_test_runner.main();
}
// uncategorized
uncategorized_test_runner.main();
// sidebar
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
}
// database
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
database_test_runner.main();
}
// workspace
workspace_test_runner.main();
// document
// don't remove this test, it can prevent the test from failing.
{
preset_af_cloud_env_test.main();
document_test_runner.main();
}
document_test_runner.main();
// sidebar
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
// database
database_test_runner.main();
}
@@ -1,19 +1,12 @@
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/ai_test_op.dart';
import '../../../shared/constants.dart';
import '../../../shared/mock/mock_ai.dart';
import '../../../shared/util.dart';
void main() {
@@ -24,7 +17,6 @@ void main() {
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
aiRepositoryBuilder: () => MockAIRepository(),
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -51,184 +43,5 @@ void main() {
// expect the ai writer block is not in the document
expect(find.byType(AiWriterBlockComponent), findsNothing);
});
testWidgets('Improve writing', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
// insert a paragraph
final text = 'I have an apple';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();
await tester.tapButton(find.byType(ImproveWritingButton));
final editorState = tester.editor.getCurrentEditorState();
final document = editorState.document;
expect(document.root.children.length, 3);
expect(document.root.children[1].type, ParagraphBlockKeys.type);
expect(
document.root.children[1].delta!.toPlainText(),
'I have an apple and a banana',
);
});
testWidgets('fix grammar', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
// insert a paragraph
final text = 'We didn’t had enough money';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();
await tester.selectAIWriter(AiWriterCommand.fixSpellingAndGrammar);
final editorState = tester.editor.getCurrentEditorState();
final document = editorState.document;
expect(document.root.children.length, 3);
expect(document.root.children[1].type, ParagraphBlockKeys.type);
expect(
document.root.children[1].delta!.toPlainText(),
'We didn’t have enough money',
);
});
testWidgets('ask ai', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudDevelop,
aiRepositoryBuilder: () => MockAIRepository(
validator: _CompletionHistoryValidator(),
),
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
// insert a paragraph
final text = 'What is TPU?';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();
await tester.selectAIWriter(AiWriterCommand.userQuestion);
await tester.enterTextInPromptTextField("Explain the concept of TPU");
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
// await tester.selectModel("GPT-4o-mini");
await tester.enterTextInPromptTextField("How about GPU?");
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
});
});
}
class _CompletionHistoryValidator extends StreamCompletionValidator {
static const _expectedResponses = {
1: "What is TPU?",
3: [
"What is TPU?",
"Explain the concept of TPU",
"TPU is a tensor processing unit that is designed to accelerate machine",
],
5: [
"What is TPU?",
"Explain the concept of TPU",
"TPU is a tensor processing unit that is designed to accelerate machine",
"How about GPU?",
"GPU is a graphics processing unit that is designed to accelerate machine learning tasks.",
],
};
@override
bool validate(
String text,
String? objectId,
CompletionTypePB completionType,
PredefinedFormat? format,
List<String> sourceIds,
List<AiWriterRecord> history,
) {
assert(completionType == CompletionTypePB.UserQuestion);
if (history.isEmpty) return false;
final expectedMessages = _expectedResponses[history.length];
if (expectedMessages == null) return false;
if (expectedMessages is String) {
_assertMessage(history[0].content, expectedMessages);
return true;
} else if (expectedMessages is List<String>) {
for (var i = 0; i < expectedMessages.length; i++) {
_assertMessage(history[i].content, expectedMessages[i]);
}
return true;
}
return false;
}
void _assertMessage(String actual, String expected) {
assert(
actual.trim() == expected,
"expected '$expected', but got '${actual.trim()}'",
);
}
}
@@ -8,13 +8,11 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Preset cloud env', () {
testWidgets('use self-hosted cloud', (tester) async {
group('Empty', () {
testWidgets('set appflowy cloud', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.pumpAndSettle();
});
});
}
@@ -39,8 +39,8 @@ void main() {
);
await tester.tapButton(find.byType(AccountSignInOutButton));
tester.expectToSeeText(LocaleKeys.button_yes.tr());
await tester.tapButtonWithName(LocaleKeys.button_yes.tr());
tester.expectToSeeText(LocaleKeys.button_ok.tr());
await tester.tapButtonWithName(LocaleKeys.button_ok.tr());
// Go to the sign in page again
await tester.pumpAndSettle(const Duration(seconds: 5));
@@ -80,8 +80,8 @@ void main() {
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
expect(find.byType(AppFlowyEditorPage), findsOneWidget);
expect(find.text('Blank page'), findsNothing);
expect(find.byType(AppFlowyEditorPage), findsNothing);
expect(find.text('Blank page'), findsOne);
});
});
}
@@ -237,11 +237,11 @@ void main() {
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the last month
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_left_s),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
@@ -275,9 +275,9 @@ void main() {
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfLastMonth = DateTime(now.year, now.month - 1, 15);
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfLastMonth, false);
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
@@ -378,11 +378,11 @@ void main() {
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the last month
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_left_s),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
@@ -416,9 +416,9 @@ void main() {
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfLastMonth = DateTime(now.year, now.month - 1, 15);
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfLastMonth, false);
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
@@ -1,8 +1,13 @@
import 'dart:io';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/prelude.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -86,24 +91,23 @@ void main() {
// }
// });
// Disable this test because it failed after executing.
// testWidgets('reset to default location', (tester) async {
// await tester.initializeAppFlowy();
testWidgets('reset to default location', (tester) async {
await tester.initializeAppFlowy();
// await tester.tapAnonymousSignInButton();
await tester.tapAnonymousSignInButton();
// // home and readme document
// await tester.expectToSeeHomePageWithGetStartedPage();
// home and readme document
await tester.expectToSeeHomePageWithGetStartedPage();
// // open settings and restore the location
// await tester.openSettings();
// await tester.openSettingsPage(SettingsPage.manageData);
// await tester.restoreLocation();
// open settings and restore the location
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.manageData);
await tester.restoreLocation();
// expect(
// await appFlowyApplicationDataDirectory().then((value) => value.path),
// await getIt<ApplicationDataStorage>().getPath(),
// );
// });
expect(
await appFlowyApplicationDataDirectory().then((value) => value.path),
await getIt<ApplicationDataStorage>().getPath(),
);
});
});
}
@@ -128,12 +128,7 @@ void main() {
/// remove the link
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
final node = editorState.getNodeAtPath([0]);
expect(
node?.delta?.toJson(),
[
{'insert': testText},
],
);
expect(node?.delta?.toJson(), [{'insert': testText}]);
});
});
}
@@ -36,7 +36,7 @@ void main() {
expect(find.byType(MobileSearchRecentList), findsNothing);
expect(find.byType(MobileSearchResultList), findsOneWidget);
expect(
find.text(LocaleKeys.search_noResultForSearching.tr()),
find.text(LocaleKeys.search_noResultForSearching.tr(args: [query])),
findsOneWidget,
);
@@ -1,41 +0,0 @@
import 'package:appflowy/ai/ai.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_test/flutter_test.dart';
import 'util.dart';
extension AppFlowyAITest on WidgetTester {
Future<void> selectAIWriter(AiWriterCommand command) async {
await tapButton(find.byType(AiWriterToolbarActionList));
await tapButton(find.text(command.i18n));
await pumpAndSettle();
}
Future<void> selectModel(String modelName) async {
await tapButton(find.byType(SelectModelMenu));
await tapButton(find.text(modelName));
await pumpAndSettle();
}
Future<void> enterTextInPromptTextField(String text) async {
// Wait for the text field to be visible
await pumpAndSettle();
// Find the ExtendedTextField widget
final textField = find.descendant(
of: find.byType(PromptInputTextField),
matching: find.byType(TextField),
);
expect(textField, findsOneWidget, reason: 'ExtendedTextField not found');
final widget = element(textField).widget as TextField;
expect(widget.enabled, isTrue, reason: 'TextField is not enabled');
await tap(textField);
testTextInput.enterText(text);
await pumpAndSettle(const Duration(milliseconds: 300));
}
}
@@ -28,8 +28,8 @@ extension AppFlowyAuthTest on WidgetTester {
await tapButton(find.byType(AccountSignInOutButton));
expectToSeeText(LocaleKeys.button_yes.tr());
await tapButtonWithName(LocaleKeys.button_yes.tr());
expectToSeeText(LocaleKeys.button_ok.tr());
await tapButtonWithName(LocaleKeys.button_ok.tr());
}
Future<void> tapSignInAsGuest() async {
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/ai/service/appflowy_ai_service.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/env/cloud_env_test.dart';
import 'package:appflowy/startup/entry_point.dart';
@@ -21,8 +20,6 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'mock/mock_ai.dart';
class FlowyTestContext {
FlowyTestContext({required this.applicationDataDirectory});
@@ -36,14 +33,13 @@ extension AppFlowyTestBase on WidgetTester {
// use to specify the application data directory, if not specified, a temporary directory will be used.
String? dataDirectory,
Size windowSize = const Size(1600, 1200),
String? email,
AuthenticatorType? cloudType,
AIRepository Function()? aiRepositoryBuilder,
String? email,
}) async {
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
// Set the window size
await binding.setSurfaceSize(windowSize);
}
//cloudType = AuthenticatorType.appflowyCloudDevelop;
mockHotKeyManagerHandlers();
final applicationDataDirectory = dataDirectory ??
@@ -54,12 +50,43 @@ extension AppFlowyTestBase on WidgetTester {
await FlowyRunner.run(
AppFlowyApplication(),
IntegrationMode.integrationTest,
rustEnvsBuilder: () => _buildRustEnvs(cloudType),
didInitGetItCallback: () => _initializeCloudServices(
cloudType: cloudType,
email: email,
aiRepositoryBuilder: aiRepositoryBuilder,
),
rustEnvsBuilder: () {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
break;
default:
throw Exception("not supported");
}
}
return rustEnvs;
},
didInitGetItCallback: () {
return Future(
() async {
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await useTestSelfHostedAppFlowyCloud();
getIt.unregister<AuthService>();
getIt.registerFactory<AuthService>(
() => AppFlowyCloudMockAuthService(email: email),
);
default:
throw Exception("not supported");
}
}
},
);
},
);
await waitUntilSignInPageShow();
@@ -68,76 +95,6 @@ extension AppFlowyTestBase on WidgetTester {
);
}
Map<String, String> _buildRustEnvs(AuthenticatorType? cloudType) {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
case AuthenticatorType.appflowyCloudDevelop:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
break;
default:
throw Exception("Unsupported cloud type: $cloudType");
}
}
return rustEnvs;
}
Future<void> _initializeCloudServices({
required AuthenticatorType? cloudType,
String? email,
AIRepository Function()? aiRepositoryBuilder,
}) async {
if (cloudType == null) return;
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await _setupAppFlowyCloud(
useLocal: false,
email: email,
aiRepositoryBuilder: aiRepositoryBuilder,
);
break;
case AuthenticatorType.appflowyCloudDevelop:
await _setupAppFlowyCloud(
useLocal: integrationMode().isDevelop,
email: email,
aiRepositoryBuilder: aiRepositoryBuilder,
);
break;
default:
throw Exception("Unsupported cloud type: $cloudType");
}
}
Future<void> _setupAppFlowyCloud({
required bool useLocal,
String? email,
AIRepository Function()? aiRepositoryBuilder,
}) async {
if (useLocal) {
await useAppFlowyCloudDevelop("http://localhost");
} else {
await useSelfHostedAppFlowyCloud(TestEnv.afCloudUrl);
}
getIt.unregister<AuthService>();
getIt.unregister<AIRepository>();
getIt.registerFactory<AuthService>(
() => AppFlowyCloudMockAuthService(email: email),
);
getIt.registerFactory<AIRepository>(
aiRepositoryBuilder ?? () => MockAIRepository(),
);
}
void mockHotKeyManagerHandlers() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel('hotkey_manager'),
@@ -318,6 +275,10 @@ extension AppFlowyFinderTestBase on CommonFinders {
}
}
Future<void> useTestSelfHostedAppFlowyCloud() async {
await useSelfHostedAppFlowyCloudWithURL(TestEnv.afCloudUrl);
}
Future<String> mockApplicationDataStorage({
// use to append after the application data directory
String? pathExtension,
@@ -1,128 +0,0 @@
import 'dart:async';
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/ai/service/appflowy_ai_service.dart';
import 'package:appflowy/ai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:mocktail/mocktail.dart';
final _mockAiMap = <CompletionTypePB, Map<String, List<String>>>{
CompletionTypePB.ImproveWriting: {
"I have an apple": [
"I",
"have",
"an",
"apple",
"and",
"a",
"banana",
],
},
CompletionTypePB.SpellingAndGrammar: {
"We didn’t had enough money": [
"We",
"didn’t",
"have",
"enough",
"money",
],
},
CompletionTypePB.UserQuestion: {
"Explain the concept of TPU": [
"TPU",
"is",
"a",
"tensor",
"processing",
"unit",
"that",
"is",
"designed",
"to",
"accelerate",
"machine",
],
"How about GPU?": [
"GPU",
"is",
"a",
"graphics",
"processing",
"unit",
"that",
"is",
"designed",
"to",
"accelerate",
"machine",
"learning",
"tasks",
],
},
};
abstract class StreamCompletionValidator {
bool validate(
String text,
String? objectId,
CompletionTypePB completionType,
PredefinedFormat? format,
List<String> sourceIds,
List<AiWriterRecord> history,
);
}
class MockCompletionStream extends Mock implements CompletionStream {}
class MockAIRepository extends Mock implements AppFlowyAIService {
MockAIRepository({this.validator});
StreamCompletionValidator? validator;
@override
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
}) async {
if (validator != null) {
if (!validator!.validate(
text,
objectId,
completionType,
format,
sourceIds,
history,
)) {
throw Exception('Invalid completion');
}
}
final stream = MockCompletionStream();
unawaited(
Future(() async {
await onStart();
final lines = _mockAiMap[completionType]?[text.trim()];
if (lines == null) {
throw Exception('No mock ai found for $text and $completionType');
}
for (final line in lines) {
await processMessage('$line ');
}
await onEnd();
}),
);
return ('mock_id', stream);
}
}
@@ -9,6 +9,7 @@ import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -85,7 +86,7 @@ extension AppFlowySettings on WidgetTester {
final userNameFinder = find.descendant(
of: find.byType(AccountUserProfile),
matching: find.byType(TextField),
matching: find.byType(FlowyTextField),
);
await enterText(userNameFinder, name);
await pumpAndSettle();
+30 -30
View File
@@ -8,35 +8,35 @@ PODS:
- ReachabilitySwift
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/Core (4.3.4):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/ImageDataManager (4.3.4)
- DKImagePickerController/PhotoGallery (4.3.4):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- DKImagePickerController/Resource (4.3.4)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
@@ -65,17 +65,17 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.2.4)
- ReachabilitySwift (5.0.0)
- saver_gallery (0.0.1):
- Flutter
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
- Sentry/HybridSDK (8.35.1)
- sentry_flutter (8.8.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- Sentry/HybridSDK (= 8.35.1)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -86,8 +86,8 @@ PODS:
- FlutterMacOS
- super_native_extensions (0.0.1):
- Flutter
- SwiftyGif (5.4.5)
- Toast (4.1.1)
- SwiftyGif (5.4.3)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
@@ -185,8 +185,8 @@ SPEC CHECKSUMS:
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
@@ -199,17 +199,17 @@ SPEC CHECKSUMS:
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
@@ -122,6 +122,8 @@ enum AiPromptCategory {
business,
@JsonValue("marketing")
marketing,
@JsonValue("learning")
learning,
@JsonValue("travel")
travel,
@JsonValue("contentSeo")
@@ -143,17 +145,7 @@ enum AiPromptCategory {
@JsonValue("caseStudies")
caseStudies,
@JsonValue("salesCopy")
salesCopy,
@JsonValue("education")
education,
@JsonValue("work")
work,
@JsonValue("podcastProduction")
podcastProduction,
@JsonValue("copyWriting")
copyWriting,
@JsonValue("customerSuccess")
customerSuccess;
salesCopy;
String get i18n {
return switch (this) {
@@ -163,6 +155,7 @@ enum AiPromptCategory {
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
business => LocaleKeys.ai_customPrompt_business.tr(),
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
learning => LocaleKeys.ai_customPrompt_learning.tr(),
travel => LocaleKeys.ai_customPrompt_travel.tr(),
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
@@ -174,11 +167,6 @@ enum AiPromptCategory {
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
education => LocaleKeys.ai_customPrompt_education.tr(),
work => LocaleKeys.ai_customPrompt_work.tr(),
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction.tr(),
copyWriting => LocaleKeys.ai_customPrompt_copyWriting.tr(),
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess.tr(),
};
}
}
@@ -191,6 +179,7 @@ class AiPrompt extends Equatable {
required this.content,
required this.category,
required this.example,
required this.sampleResponse,
required this.isFeatured,
});
@@ -202,16 +191,14 @@ class AiPrompt extends Equatable {
final String id;
final String name;
final String content;
@JsonKey(
unknownEnumValue: AiPromptCategory.other,
defaultValue: AiPromptCategory.other,
)
final AiPromptCategory category;
@JsonKey(defaultValue: "")
final String example;
@JsonKey(defaultValue: "")
final String sampleResponse;
@JsonKey(defaultValue: false)
final bool isFeatured;
@override
List<Object?> get props => [id, name, content, category, example, isFeatured];
List<Object?> get props => [id, name, content, category, sampleResponse];
}
@@ -7,6 +7,7 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:protobuf/protobuf.dart';
import 'package:universal_platform/universal_platform.dart';
typedef OnModelStateChangedCallback = void Function(AIModelState state);
@@ -51,50 +52,51 @@ class AIModelStateNotifier {
final String objectId;
final LocalAIStateListener? _localAIListener;
final AIModelSwitchListener _aiModelSwitchListener;
LocalAIPB? _localAIState;
ModelSelectionPB? _modelSelection;
AIModelState _currentState = _defaultState();
List<AIModelPB> _availableModels = [];
AIModelPB? _selectedModel;
AvailableModelsPB? _availableModels;
// callbacks
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
final List<OnAvailableModelsChangedCallback>
_availableModelsChangedCallbacks = [];
/// Starts platform-specific listeners
void _startListening() {
if (UniversalPlatform.isDesktop) {
_localAIListener?.start(
stateCallback: (state) async {
_localAIState = state;
_updateAll();
_notifyStateChanged();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadAvailableModels();
_notifyAvailableModelsChanged();
}
},
);
}
_aiModelSwitchListener.start(
onUpdateSelectedModel: (model) async {
_selectedModel = model;
_updateAll();
final updatedModels = _availableModels?.deepCopy()
?..selectedModel = model;
_availableModels = updatedModels;
_notifyAvailableModelsChanged();
if (model.isLocal && UniversalPlatform.isDesktop) {
await _loadLocalState();
_updateAll();
await _loadLocalAiState();
}
_notifyStateChanged();
},
);
}
Future<void> _init() async {
await Future.wait([
if (UniversalPlatform.isDesktop) _loadLocalState(),
_loadModelSelection(),
]);
_updateAll();
void _init() async {
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
_notifyStateChanged();
_notifyAvailableModelsChanged();
}
/// Register callbacks for state or available-models changes
void addListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
@@ -107,7 +109,6 @@ class AIModelStateNotifier {
}
}
/// Remove previously registered callbacks
void removeListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
@@ -127,88 +128,116 @@ class AIModelStateNotifier {
await _aiModelSwitchListener.stop();
}
/// Returns current AIModelState
AIModelState getState() => _currentState;
/// Returns available models and the selected model
(List<AIModelPB>, AIModelPB?) getModelSelection() =>
(_availableModels, _selectedModel);
void _updateAll() {
_currentState = _computeState();
for (final cb in _stateChangedCallbacks) {
cb(_currentState);
}
for (final cb in _availableModelsChangedCallbacks) {
cb(_availableModels, _selectedModel);
}
}
Future<void> _loadModelSelection() async {
await AIEventGetSourceModelSelection(
ModelSourcePB(source: objectId),
).send().fold(
(ms) {
_modelSelection = ms;
_availableModels = ms.models;
_selectedModel = ms.selectedModel;
},
(e) => Log.error("Failed to fetch models: \$e"),
);
}
Future<void> _loadLocalState() async {
await AIEventGetLocalAIState().send().fold(
(s) => _localAIState = s,
(e) => Log.error("Failed to fetch local AI state: \$e"),
);
}
static AIModelState _defaultState() => AIModelState(
AIModelState getState() {
if (UniversalPlatform.isMobile) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
/// Core logic computing the state from local and selection data
AIModelState _computeState() {
if (UniversalPlatform.isMobile) return _defaultState();
if (_modelSelection == null || _localAIState == null) {
return _defaultState();
}
if (!_selectedModel!.isLocal) {
return _defaultState();
final availableModels = _availableModels;
final localAiState = _localAIState;
if (availableModels == null) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
isEditable: true,
tooltip: null,
localAIEnabled: false,
);
}
if (localAiState == null) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
final enabled = _localAIState!.enabled;
final running = _localAIState!.isReady;
final hintKey = enabled
? (running
? LocaleKeys.chat_inputLocalAIMessageHint
: LocaleKeys.settings_aiPage_keys_localAIInitializing)
: LocaleKeys.settings_aiPage_keys_localAIDisabled;
final tooltipKey = enabled
? (running
if (!availableModels.selectedModel.isLocal) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
final editable = localAiState.state == RunningStatePB.Running;
final tooltip = localAiState.enabled
? (editable
? null
: LocaleKeys.settings_aiPage_keys_localAINotReadyTextFieldPrompt)
: LocaleKeys.settings_aiPage_keys_localAIDisabledTextFieldPrompt;
: LocaleKeys.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr())
: LocaleKeys.settings_aiPage_keys_localAIDisabledTextFieldPrompt.tr();
final hintText = localAiState.enabled
? (editable
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr())
: LocaleKeys.settings_aiPage_keys_localAIDisabled.tr();
return AIModelState(
type: AiType.local,
hintText: hintKey.tr(),
tooltip: tooltipKey?.tr(),
isEditable: running,
localAIEnabled: enabled,
hintText: hintText,
tooltip: tooltip,
isEditable: editable,
localAIEnabled: localAiState.enabled,
);
}
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
final availableModels = _availableModels;
if (availableModels == null) {
return ([], null);
}
return (availableModels.models, availableModels.selectedModel);
}
void _notifyAvailableModelsChanged() {
final (models, selectedModel) = getAvailableModels();
for (final callback in _availableModelsChangedCallbacks) {
callback(models, selectedModel);
}
}
void _notifyStateChanged() {
final state = getState();
for (final callback in _stateChangedCallbacks) {
callback(state);
}
}
Future<void> _loadAvailableModels() {
final payload = AvailableModelsQueryPB(source: objectId);
return AIEventGetAvailableModels(payload).send().fold(
(models) => _availableModels = models,
(err) => Log.error("Failed to get available models: $err"),
);
}
Future<void> _loadLocalAiState() {
return AIEventGetLocalAIState().send().fold(
(localAIState) => _localAIState = localAIState,
(error) => Log.error("Failed to get local AI state: $error"),
);
}
}
extension AIModelPBExtension on AIModelPB {
bool get isDefault => name == 'Auto';
String get i18n =>
isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
extension AiModelExtension on AIModelPB {
bool get isDefault {
return name == "Auto";
}
String get i18n {
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
}
}
@@ -29,14 +29,12 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void _loadPrompts() async {
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
final featuredPrompts =
availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
final visiblePrompts = _getFilteredPrompts(availablePrompts);
emit(
AiPromptSelectorState.ready(
visiblePrompts: visiblePrompts,
showCustomPrompts: false,
isFeaturedCategorySelected: true,
isFeaturedCategorySelected: false,
selectedPromptId: visiblePrompts.firstOrNull?.id,
selectedCategory: null,
favoritePrompts: [],
@@ -47,13 +45,12 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void selectFeaturedCategory() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts,
// TODO(RS): Add logic to filter prompts based on the featured category
// visiblePrompts: prompts,
isFeaturedCategorySelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
// selectedPromptId: prompts.firstOrNull?.id,
),
);
},
@@ -139,13 +136,22 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
);
}
AiPrompt? get selectedPrompt {
return state.maybeMap(
ready: (readyState) {
return readyState.visiblePrompts.firstWhereOrNull(
(prompt) => prompt.id == readyState.selectedPromptId,
);
},
orElse: () => null,
);
}
void _filterTextChanged() {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = readyState.selectedCategory == null
? readyState.isFeaturedCategorySelected
? availablePrompts.where((prompt) => prompt.isFeatured)
: availablePrompts
? availablePrompts
: availablePrompts.where(
(prompt) => prompt.category == readyState.selectedCategory,
);
@@ -23,7 +23,7 @@ enum LocalAIStreamingState {
}
abstract class AIRepository {
Future<(String, CompletionStream)?> streamCompletion({
Future<void> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
@@ -45,6 +45,8 @@ abstract class AIRepository {
}
class AppFlowyAIService implements AIRepository {
final List<AiPrompt> _builtInPrompts = [];
@override
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
@@ -96,6 +98,10 @@ class AppFlowyAIService implements AIRepository {
@override
Future<List<AiPrompt>> getBuiltInPrompts() async {
if (_builtInPrompts.isNotEmpty) {
return _builtInPrompts;
}
final prompts = <AiPrompt>[];
try {
@@ -114,6 +120,8 @@ class AppFlowyAIService implements AIRepository {
Log.error(e);
}
_builtInPrompts.addAll(prompts);
return prompts;
}
@@ -83,7 +83,7 @@ class SelectModelState with _$SelectModelState {
}) = _SelectModelState;
factory SelectModelState.initial(AIModelStateNotifier notifier) {
final (models, selectedModel) = notifier.getModelSelection();
final (models, selectedModel) = notifier.getAvailableModels();
return SelectModelState(
models: models,
selectedModel: selectedModel,
@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AiPromptCategoryList extends StatefulWidget {
class AiPromptCategoryList extends StatelessWidget {
const AiPromptCategoryList({
super.key,
required this.padding,
@@ -13,26 +13,19 @@ class AiPromptCategoryList extends StatefulWidget {
final EdgeInsetsGeometry padding;
@override
State<AiPromptCategoryList> createState() => _AiPromptCategoryListState();
}
class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
bool isSearching = false;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: EdgeInsets.all(
theme.spacing.l,
),
padding: padding,
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: theme.spacing.s,
children: [
_buildSearchField(context),
_buildFeaturedCategory(context),
const AFDivider(),
Expanded(
@@ -44,6 +37,7 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
_buildCategoryItem(context, AiPromptCategory.writing),
_buildCategoryItem(context, AiPromptCategory.business),
_buildCategoryItem(context, AiPromptCategory.marketing),
_buildCategoryItem(context, AiPromptCategory.learning),
_buildCategoryItem(
context,
AiPromptCategory.healthAndFitness,
@@ -59,15 +53,6 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
_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),
],
),
),
@@ -77,6 +62,16 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
);
}
Widget _buildSearchField(BuildContext context) {
return AFTextField(
groupId: "ai_prompt_category_list",
hintText: "Search",
size: AFTextFieldSize.m,
controller: context.read<AiPromptSelectorCubit>().filterTextController,
autoFocus: true,
);
}
Widget _buildCategoryItem(
BuildContext context,
AiPromptCategory? category,
@@ -103,12 +98,15 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_featured.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
return Row(
children: [
Text(
LocaleKeys.ai_customPrompt_featured.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
borderRadius: theme.borderRadius.m,
@@ -154,12 +152,15 @@ class AiPromptCategoryItem extends StatelessWidget {
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 Row(
children: [
Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
borderRadius: theme.borderRadius.m,
@@ -88,7 +88,12 @@ class AiPromptModal extends StatelessWidget {
),
Expanded(
flex: 2,
child: AiPromptVisibleList(),
child: AiPromptVisibleList(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.l,
horizontal: theme.spacing.l,
),
),
),
Expanded(
flex: 3,
@@ -110,6 +115,10 @@ class AiPromptModal extends StatelessWidget {
}
return AiPromptPreview(
prompt: selectedPrompt,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.l,
horizontal: theme.spacing.l,
),
);
},
),
@@ -122,6 +131,20 @@ class AiPromptModal extends StatelessWidget {
),
),
),
AFModalFooter(
trailing: [
AFFilledTextButton.primary(
text: LocaleKeys.ai_customPrompt_usePrompt.tr(),
onTap: () {
final selectedPrompt =
context.read<AiPromptSelectorCubit>().selectedPrompt;
if (selectedPrompt != null) {
Navigator.of(context).pop(selectedPrompt);
}
},
),
],
),
],
),
);
@@ -10,83 +10,71 @@ class AiPromptPreview extends StatelessWidget {
const AiPromptPreview({
super.key,
required this.prompt,
required this.padding,
});
final AiPrompt prompt;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SelectionArea(
child: Column(
child: ListView(
padding: padding,
children: [
Padding(
padding: EdgeInsets.only(
top: theme.spacing.l,
left: theme.spacing.l,
right: theme.spacing.l,
),
child: SelectionContainer.disabled(
child: Row(
children: [
Expanded(
child: Text(
prompt.name,
style: theme.textStyle.headline.standard(
color: theme.textColorScheme.primary,
),
),
),
HSpace(theme.spacing.s),
AFFilledTextButton.primary(
text: LocaleKeys.ai_customPrompt_usePrompt.tr(),
onTap: () {
Navigator.of(context).pop(prompt);
},
),
],
SelectionContainer.disabled(
child: Text(
prompt.name,
style: theme.textStyle.headline.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
Expanded(
child: ListView(
padding: EdgeInsets.all(
theme.spacing.l,
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_prompt.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
children: [
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_prompt.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptContent(
prompt: prompt,
),
VSpace(theme.spacing.xl),
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptExample(
prompt: prompt,
),
VSpace(theme.spacing.xl),
],
],
),
),
VSpace(theme.spacing.xs),
_PromptContent(
prompt: prompt,
),
VSpace(theme.spacing.xl),
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptExample(
prompt: prompt,
),
VSpace(theme.spacing.xl),
],
if (prompt.sampleResponse.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_sampleOutput.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptSampleOutput(
prompt: prompt,
),
],
],
),
);
@@ -187,3 +175,27 @@ class _PromptExample extends StatelessWidget {
);
}
}
class _PromptSampleOutput extends StatelessWidget {
const _PromptSampleOutput({
required this.prompt,
});
final AiPrompt prompt;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
markdown: prompt.sampleResponse,
),
);
}
}
@@ -1,14 +1,9 @@
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/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:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
@@ -16,8 +11,11 @@ const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
class AiPromptVisibleList extends StatefulWidget {
const AiPromptVisibleList({
super.key,
required this.padding,
});
final EdgeInsetsGeometry padding;
@override
State<AiPromptVisibleList> createState() => _AiPromptVisibleListState();
}
@@ -27,26 +25,18 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
final scrollController = ScrollController();
final List<AiPrompt> oldList = [];
late AiPromptSelectorCubit cubit;
late bool filterIsEmpty;
@override
void initState() {
super.initState();
cubit = context.read<AiPromptSelectorCubit>();
final textController = cubit.filterTextController;
final prompts = cubit.state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
filterIsEmpty = textController.text.isEmpty;
textController.addListener(handleFilterTextChanged);
final prompts = context.read<AiPromptSelectorCubit>().state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
oldList.addAll(prompts);
}
@override
void dispose() {
cubit.filterTextController.removeListener(handleFilterTextChanged);
scrollController.dispose();
super.dispose();
}
@@ -62,82 +52,34 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
);
handleVisiblePromptListChanged(list);
},
child: Column(
children: [
VSpace(spacing.l),
Padding(
padding: EdgeInsets.symmetric(horizontal: spacing.l),
child: buildSearchField(context),
),
VSpace(spacing.s),
Expanded(
child: AnimatedList(
controller: scrollController,
padding: EdgeInsets.all(spacing.l),
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
final cubit = context.read<AiPromptSelectorCubit>();
child: AnimatedList(
controller: scrollController,
padding: widget.padding,
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(),
);
},
),
),
],
),
);
}
Widget buildSearchField(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final iconSize = 20.0;
return AFTextField(
groupId: "ai_prompt_category_list",
hintText: "Search",
controller: context.read<AiPromptSelectorCubit>().filterTextController,
autoFocus: true,
suffixIconConstraints: BoxConstraints.tightFor(
width: iconSize + theme.spacing.m,
height: iconSize,
),
suffixIconBuilder: filterIsEmpty
? null
: (context, isObscured) => TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Padding(
padding: EdgeInsets.only(right: theme.spacing.m),
child: GestureDetector(
onTap: () => context
.read<AiPromptSelectorCubit>()
.filterTextController
.clear(),
child: FlowySvg(
FlowySvgs.toast_close_s,
color: theme.textColorScheme.secondary,
size: const Size.square(20),
),
),
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(),
);
},
),
);
}
@@ -180,12 +122,6 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
..clear()
..addAll(newList);
}
void handleFilterTextChanged() {
setState(() {
filterIsEmpty = cubit.filterTextController.text.isEmpty;
});
}
}
class _AiPromptListItem extends StatefulWidget {
@@ -210,7 +146,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
@override
void dispose() {
timer?.cancel();
timer = null;
super.dispose();
}
@@ -224,9 +159,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
curve: Curves.easeIn,
);
final surfacePrimaryHover =
Theme.of(context).isLightMode ? Color(0xFFF8FAFF) : Color(0xFF3C3F4E);
return FadeTransition(
opacity: curvedAnimation,
child: SizeTransition(
@@ -235,103 +167,74 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
onEnter: (_) {
setState(() {
isHovering = true;
timer = Timer(const Duration(milliseconds: 300), () {
if (mounted) {
cubit.selectPrompt(widget.prompt.id);
}
});
timer = Timer(
const Duration(milliseconds: 300),
() {
if (isHovering) {
cubit.selectPrompt(widget.prompt.id);
}
},
);
});
},
onExit: (_) {
setState(() {
isHovering = false;
timer?.cancel();
timer = null;
});
},
child: GestureDetector(
onTap: () {
cubit.selectPrompt(widget.prompt.id);
},
child: Stack(
children: [
Container(
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
border: Border.all(
color: widget.isSelected
? isHovering
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
onDoubleTap: () {
Navigator.of(context).pop(widget.prompt);
},
child: Container(
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
border: Border.all(
color: widget.isSelected
? isHovering
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Row(
children: [
Expanded(
child: Text(
widget.prompt.name,
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
Expanded(
child: Text(
widget.prompt.name,
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
],
),
Text(
widget.prompt.content,
maxLines: 2,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
overflow: TextOverflow.ellipsis,
softWrap: true,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
],
),
),
if (isHovering)
Positioned(
top: theme.spacing.s,
right: theme.spacing.s,
child: DecoratedBox(
decoration: BoxDecoration(boxShadow: theme.shadow.small),
child: AFBaseButton(
onTap: () {
Navigator.of(context).pop(widget.prompt);
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_usePrompt.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
);
},
backgroundColor: (context, isHovering, disabled) {
if (isHovering) {
return surfacePrimaryHover;
}
return theme.surfaceColorScheme.primary;
},
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderRadius: theme.borderRadius.m,
),
Text(
widget.prompt.content,
maxLines: 2,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
],
],
),
),
),
),
@@ -1,6 +1,5 @@
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';
@@ -48,7 +47,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final chatUserCubit = ChatUserCubit();
final focusNode = FocusNode();
late SendButtonState sendButtonState;
@@ -57,7 +55,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void initState() {
super.initState();
chatUserCubit.fetchUserProfile();
widget.textController.addListener(handleTextControllerChanged);
focusNode
@@ -91,17 +88,13 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
focusNode.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
chatUserCubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: inputControlCubit),
BlocProvider.value(value: chatUserCubit),
],
return BlocProvider.value(
value: inputControlCubit,
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
@@ -267,12 +260,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
if (widget.isStreaming) {
return;
}
String userInput = widget.textController.text.trim();
userInput = inputControlCubit.formatIntputText(userInput);
userInput = AiPromptInputTextEditingController.restore(userInput);
final trimmedText = inputControlCubit.formatIntputText(
widget.textController.text.trim(),
);
widget.textController.clear();
if (userInput.isEmpty) {
if (trimmedText.isEmpty) {
return;
}
@@ -284,7 +276,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final predefinedFormat = bloc.state.predefinedFormat;
widget.onSubmitted(
userInput,
trimmedText,
showPredefinedFormats ? predefinedFormat : null,
metadata,
);
@@ -641,9 +633,7 @@ class _PromptBottomActions extends StatelessWidget {
const Spacer(),
if (context.read<ChatUserCubit>().supportSelectSource())
_selectSourcesButton(),
if (state.modelState.type == AiType.cloud) _selectSourcesButton(),
if (extraBottomActionButton != null) extraBottomActionButton!,
// _mentionButton(context),
if (state.supportChatWithFile) _attachmentButton(context),
@@ -57,8 +57,7 @@ class AiPromptInputTextEditingController extends TextEditingController {
text: restore(string),
style: theme.textStyle.body.standard().copyWith(
color: theme.textColorScheme.purple,
backgroundColor:
theme.fillColorScheme.purpleThick.withAlpha(51),
backgroundColor: theme.fillColorScheme.purpleLight,
),
),
);
@@ -90,40 +90,38 @@ class SelectModelPopoverContent extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (localModels.isNotEmpty) ...[
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_localModel.tr(),
),
const VSpace(4.0),
],
...localModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
const VSpace(8.0),
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
),
const VSpace(4.0),
],
...cloudModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (localModels.isNotEmpty) ...[
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_localModel.tr(),
),
const VSpace(4.0),
],
),
...localModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
const VSpace(8.0),
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
),
const VSpace(4.0),
],
...cloudModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
],
),
);
}
@@ -217,41 +215,45 @@ class _CurrentModelButton extends StatelessWidget {
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
// TODO: remove this after change icon to 20px
padding: EdgeInsets.all(2),
child: FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: Size.square(16),
),
),
if (model != null && !model!.isDefault)
child: AnimatedSize(
duration: const Duration(milliseconds: 50),
curve: Curves.easeInOut,
alignment: AlignmentDirectional.centerStart,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Row(
children: [
Padding(
padding: EdgeInsetsDirectional.only(end: 2.0),
child: FlowyText(
model!.i18n,
fontSize: 12,
figmaLineHeight: 16,
// TODO: remove this after change icon to 20px
padding: EdgeInsets.all(2),
child: FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
size: Size.square(16),
),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
if (model != null && !model!.isDefault)
Padding(
padding: EdgeInsetsDirectional.only(end: 2.0),
child: FlowyText(
model!.i18n,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
),
),
),
@@ -54,7 +54,7 @@ class _PromptInputMobileSelectSourcesButtonState
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().state.userProfile;
final userProfile = context.read<UserWorkspaceBloc>().userProfile;
final workspaceId = state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
@@ -60,7 +60,7 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
@@ -39,11 +39,8 @@ Future<bool> afLaunchUri(
);
}
// on Linux or Android or Windows, add http scheme to the url if it is not present
if ((UniversalPlatform.isLinux ||
UniversalPlatform.isAndroid ||
UniversalPlatform.isWindows) &&
!isURL(url, {'require_protocol': true})) {
// on Linux, add http scheme to the url if it is not present
if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) {
uri = Uri.parse('https://$url');
}
+1 -6
View File
@@ -167,16 +167,11 @@ Future<void> useBaseWebDomain(String? url) async {
);
}
Future<void> useSelfHostedAppFlowyCloud(String url) async {
Future<void> useSelfHostedAppFlowyCloudWithURL(String url) async {
await _setAuthenticatorType(AuthenticatorType.appflowyCloudSelfHost);
await _setAppFlowyCloudUrl(url);
}
Future<void> useAppFlowyCloudDevelop(String url) async {
await _setAuthenticatorType(AuthenticatorType.appflowyCloudDevelop);
await _setAppFlowyCloudUrl(url);
}
Future<void> useAppFlowyBetaCloudWithURL(
String url,
AuthenticatorType authenticatorType,
@@ -30,7 +30,7 @@ class NotificationReminderBloc
add(const NotificationReminderEvent.reset());
},
reset: () async {
final scheduledAt = await _getScheduledAt(
final createdAt = await _getCreatedAt(
reminder,
dateFormat,
timeFormat,
@@ -40,24 +40,23 @@ class NotificationReminderBloc
if (view == null) {
emit(
NotificationReminderState(
scheduledAt: scheduledAt,
createdAt: createdAt,
pageTitle: '',
reminderContent: '',
isLocked: false,
status: NotificationReminderStatus.error,
),
);
return;
}
final layout = view.layout;
final layout = view!.layout;
if (layout.isDocumentView) {
final node = await _getContent(reminder);
if (node != null) {
emit(
NotificationReminderState(
scheduledAt: scheduledAt,
createdAt: createdAt,
pageTitle: view.nameOrDefault,
isLocked: view.isLocked,
view: view,
@@ -71,7 +70,7 @@ class NotificationReminderBloc
} else if (layout.isDatabaseView) {
emit(
NotificationReminderState(
scheduledAt: scheduledAt,
createdAt: createdAt,
pageTitle: view.nameOrDefault,
isLocked: view.isLocked,
view: view,
@@ -89,16 +88,20 @@ class NotificationReminderBloc
late final UserDateFormatPB dateFormat;
late final UserTimeFormatPB timeFormat;
Future<String> _getScheduledAt(
Future<String> _getCreatedAt(
ReminderPB reminder,
UserDateFormatPB dateFormat,
UserTimeFormatPB timeFormat,
) async {
return _formatTimestamp(
reminder.scheduledAt.toInt() * 1000,
timeFormat: timeFormat,
dateFormate: dateFormat,
);
final rCreatedAt = reminder.createdAt;
final createdAt = rCreatedAt != null
? _formatTimestamp(
rCreatedAt,
timeFormat: timeFormat,
dateFormate: dateFormat,
)
: '';
return createdAt;
}
Future<ViewPB?> _getView(ReminderPB reminder) async {
@@ -200,7 +203,7 @@ class NotificationReminderState with _$NotificationReminderState {
const NotificationReminderState._();
const factory NotificationReminderState({
required String scheduledAt,
required String createdAt,
required String pageTitle,
required String reminderContent,
required bool isLocked,
@@ -213,7 +216,7 @@ class NotificationReminderState with _$NotificationReminderState {
factory NotificationReminderState.initial() =>
const NotificationReminderState(
scheduledAt: '',
createdAt: '',
pageTitle: '',
reminderContent: '',
isLocked: false,
@@ -53,7 +53,6 @@ class _MobileBottomSheetEditLinkWidgetState
ViewPB? currentView;
bool showErrorText = false;
bool showRemoveLink = false;
String title = LocaleKeys.editor_editLink.tr();
AppFlowyThemeData get theme => AppFlowyTheme.of(context);
@@ -74,7 +73,6 @@ class _MobileBottomSheetEditLinkWidgetState
)..searchRecentViews();
if (linkInfo.link.isEmpty) {
isShowingSearchResult = true;
title = LocaleKeys.toolbar_addLink.tr();
} else {
showRemoveLink = true;
textFocusNode.requestFocus();
@@ -106,7 +104,7 @@ class _MobileBottomSheetEditLinkWidgetState
child: Column(
children: [
BottomSheetHeader(
title: title,
title: LocaleKeys.editor_editLink.tr(),
onClose: () => context.pop(),
confirmButton: FlowyTextButton(
LocaleKeys.button_done.tr(),
@@ -165,7 +163,6 @@ class _MobileBottomSheetEditLinkWidgetState
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
contentPadding: EdgeInsets.all(14),
radius: 12,
context,
),
),
@@ -189,11 +186,6 @@ class _MobileBottomSheetEditLinkWidgetState
autofocus: true,
context: context,
contentPadding: EdgeInsets.all(14),
textStyle: TextStyle(
fontSize: 16,
height: 20 / 16,
fontWeight: FontWeight.w400,
),
),
VSpace(6),
searchTextField.buildResultContainer(
@@ -275,7 +267,6 @@ class _MobileBottomSheetEditLinkWidgetState
height: 48,
decoration: buildBorderDecoration(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showSearchResult,
child: Padding(
padding: EdgeInsets.all(12),
@@ -300,7 +291,6 @@ class _MobileBottomSheetEditLinkWidgetState
Widget buildRemoveLink() {
if (!showRemoveLink) return SizedBox.shrink();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
widget.onRemoveLink(linkInfo);
context.pop();
@@ -203,7 +203,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
final userProfile = context.read<MobileViewPageBloc>().state.userProfilePB;
// the publish feature is only available for AppFlowy Cloud
if (userProfile == null ||
userProfile.workspaceType != WorkspaceTypePB.ServerW) {
userProfile.workspaceAuthType != AuthTypePB.Server) {
return [];
}
@@ -1,6 +1,5 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -236,7 +235,6 @@ class BottomSheetHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SizedBox(
@@ -267,11 +265,11 @@ class BottomSheetHeader extends StatelessWidget {
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: Text(
child: FlowyText(
title,
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
@@ -27,6 +27,7 @@ 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});
@@ -64,6 +65,14 @@ class MobileHomeScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Scaffold(
body: SafeArea(
bottom: false,
@@ -194,7 +194,7 @@ class _MobileWorkspace extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.openWorkspace(
workspace.workspaceId,
workspace.workspaceType,
workspace.workspaceAuthType,
),
);
},
@@ -85,8 +85,7 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
PersonalInfoSettingGroup(
userProfile: userProfile,
),
if (state.userProfile.userAuthType == AuthTypePB.Server)
const WorkspaceSettingGroup(),
const WorkspaceSettingGroup(),
const AppearanceSettingGroup(),
const LanguageSettingGroup(),
if (Env.enableCustomCloud) const CloudSettingGroup(),
@@ -48,7 +48,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_settings.tr(),
),
// only show the member items in cloud mode
if (userProfile.workspaceType == WorkspaceTypePB.ServerW) ...[
if (userProfile.workspaceAuthType == AuthTypePB.Server) ...[
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.members,
@@ -200,9 +200,7 @@ class MobileViewPage extends StatelessWidget {
const WidgetSpan(child: HSpace(8.0)),
],
TextSpan(
text: name.orDefault(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
),
text: name,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16.0,
fontWeight: FontWeight.w600,
@@ -164,7 +164,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
return const MobileRecentSpace();
case MobileSpaceTabType.spaces:
final showAIFloatingButton =
widget.userProfile.workspaceType == WorkspaceTypePB.ServerW;
widget.userProfile.workspaceAuthType == AuthTypePB.Server;
return Stack(
children: [
MobileHomeSpace(userProfile: widget.userProfile),
@@ -123,7 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.createWorkspace(
name,
WorkspaceTypePB.ServerW,
AuthTypePB.Server,
),
);
},
@@ -6,10 +6,10 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart';
import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart';
import 'package:appflowy/shared/red_dot.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/notifications/number_red_dot.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -175,28 +175,23 @@ class _NotificationNavigationBarItemIcon extends StatelessWidget {
final hasUnreads = state.reminders.any(
(reminder) => !reminder.isRead,
);
return SizedBox(
width: 40,
height: 40,
child: Stack(
children: [
Center(
child: isActive
? const FlowySvg(
FlowySvgs.m_home_active_notification_m,
blendMode: null,
)
: const FlowySvg(
FlowySvgs.m_home_notification_m,
),
return Stack(
children: [
isActive
? const FlowySvg(
FlowySvgs.m_home_active_notification_m,
blendMode: null,
)
: const FlowySvg(
FlowySvgs.m_home_notification_m,
),
if (hasUnreads)
const Positioned(
top: 2,
right: 4,
child: NotificationRedDot(),
),
if (hasUnreads)
const Align(
alignment: Alignment.topRight,
child: NumberedRedDot.mobile(),
),
],
),
],
);
},
),
@@ -126,6 +126,7 @@ class _NotificationScreenContent extends StatelessWidget {
onAction: _onAction,
onReadChanged: _onReadChanged,
actionBar: InboxActionBar(
hasUnreads: state.hasUnreads,
showUnreadsOnly: filterState.showUnreadsOnly,
),
),
@@ -101,7 +101,9 @@ class _InnerNotificationItem extends StatelessWidget {
NotificationCheckIcon(
isSelected: mSelectedNotificationIds.value.contains(reminder.id),
),
const HSpace(12.0),
const HSpace(3.0),
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
const HSpace(3.0),
NotificationIcon(reminder: reminder),
const HSpace(12.0),
Expanded(
@@ -48,19 +48,14 @@ class NotificationItem extends StatelessWidget {
return const SizedBox.shrink();
}
final child = GestureDetector(
onLongPress: () {
context.onMoreAction();
},
child: Padding(
padding: const EdgeInsets.all(8),
child: _SlidableNotificationItem(
final child = Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _SlidableNotificationItem(
tabType: tabType,
reminder: reminder,
child: _InnerNotificationItem(
tabType: tabType,
reminder: reminder,
child: _InnerNotificationItem(
tabType: tabType,
reminder: reminder,
),
),
),
);
@@ -107,11 +102,14 @@ class _InnerNotificationItem extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const HSpace(10.0),
const HSpace(8.0),
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
const HSpace(4.0),
NotificationIcon(reminder: reminder),
const HSpace(12.0),
Expanded(child: NotificationContent(reminder: reminder)),
const HSpace(6.0),
Expanded(
child: NotificationContent(reminder: reminder),
),
],
);
}
@@ -10,11 +10,11 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
const _kNotificationIconHeight = 36.0;
@@ -22,15 +22,12 @@ class NotificationIcon extends StatelessWidget {
const NotificationIcon({
super.key,
required this.reminder,
this.atSize = 12,
});
final ReminderPB reminder;
final double atSize;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
width: 42,
height: 36,
@@ -48,13 +45,13 @@ class NotificationIcon extends StatelessWidget {
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.fillColorScheme.primary,
color: Theme.of(context).cardColor,
),
child: Center(
child: FlowySvg(
FlowySvgs.notification_icon_at_s,
size: Size.square(atSize),
color: theme.iconColorScheme.primary,
child: FlowyText(
'@',
fontSize: 12,
figmaLineHeight: 14,
),
),
),
@@ -91,15 +88,14 @@ class UnreadRedDot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
return const SizedBox(
height: _kNotificationIconHeight,
child: Center(
child: SizedBox.square(
dimension: 7.0,
dimension: 6.0,
child: DecoratedBox(
decoration: ShapeDecoration(
color: theme.borderColorScheme.errorThick,
color: Color(0xFFFF6331),
shape: OvalBorder(),
),
),
@@ -122,8 +118,6 @@ class NotificationContent extends StatefulWidget {
}
class _NotificationContentState extends State<NotificationContent> {
AppFlowyThemeData get theme => AppFlowyTheme.of(context);
@override
void didUpdateWidget(covariant NotificationContent oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -147,11 +141,14 @@ class _NotificationContentState extends State<NotificationContent> {
mainAxisSize: MainAxisSize.min,
children: [
// title & time
_buildHeader(state.scheduledAt, !widget.reminder.isRead),
_buildHeader(state.createdAt, !widget.reminder.isRead),
// page name
_buildPageName(context, state.isLocked, state.pageTitle),
// content
_buildContent(view, nodes: state.nodes),
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildContent(view, nodes: state.nodes),
),
],
);
},
@@ -178,8 +175,6 @@ class _NotificationContentState extends State<NotificationContent> {
fontSize: 14,
figmaLineHeight: 22,
color: context.notificationItemTextColor,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
);
}
@@ -196,7 +191,6 @@ class _NotificationContentState extends State<NotificationContent> {
LocaleKeys.settings_notifications_titles_reminder.tr(),
fontSize: 14,
figmaLineHeight: 20,
color: theme.textColorScheme.primary,
),
Spacer(),
if (createAt.isNotEmpty)
@@ -204,7 +198,7 @@ class _NotificationContentState extends State<NotificationContent> {
createAt,
fontSize: 12,
figmaLineHeight: 18,
color: theme.textColorScheme.secondary,
color: context.notificationItemTextColor,
),
if (unread) ...[
HSpace(4),
@@ -231,25 +225,19 @@ class _NotificationContentState extends State<NotificationContent> {
LocaleKeys.notificationHub_mentionedYou.tr(),
fontSize: 12,
figmaLineHeight: 18,
color: theme.textColorScheme.secondary,
color: context.notificationItemTextColor,
),
const NotificationEllipse(),
if (isLocked)
Padding(
padding: EdgeInsets.only(right: 5),
child: FlowySvg(
FlowySvgs.notification_lock_s,
color: theme.iconColorScheme.secondary,
),
),
Flexible(
child: FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: theme.textColorScheme.secondary,
overflow: TextOverflow.ellipsis,
child: FlowySvg(FlowySvgs.notification_lock_s),
),
FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
],
),
@@ -266,7 +254,7 @@ class NotificationEllipse extends StatelessWidget {
return Container(
width: 2.50,
height: 2.50,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
margin: const EdgeInsets.symmetric(horizontal: 5.0),
decoration: ShapeDecoration(
color: context.notificationItemTextColor,
shape: const OvalBorder(),
@@ -322,7 +310,11 @@ class NotificationDocumentContent extends StatelessWidget {
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
customPadding: (node) => EdgeInsets.zero,
customHeadingPadding: UniversalPlatform.isDesktop
? EdgeInsets.zero
: EdgeInsets.symmetric(
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
),
);
return IgnorePointer(
@@ -76,43 +76,41 @@ enum NotificationPaneActionType {
topLeft: Radius.circular(10),
bottomLeft: Radius.circular(10),
),
onPressed: (context) => context.onMoreAction(),
onPressed: (context) {
final reminderBloc = context.read<ReminderBloc>();
final notificationReminderBloc =
context.read<NotificationReminderBloc>();
showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
useRootNavigator: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: reminderBloc),
BlocProvider.value(value: notificationReminderBloc),
],
child: _NotificationMoreActions(
onClickMultipleChoice: () {
Future.delayed(const Duration(milliseconds: 250), () {
bottomNavigationBarType.value =
BottomNavigationBarActionType
.notificationMultiSelect;
});
},
),
);
},
);
},
);
}
}
}
extension NotificationPaneActionExtension on BuildContext {
void onMoreAction() {
final reminderBloc = read<ReminderBloc>();
final notificationReminderBloc = read<NotificationReminderBloc>();
showMobileBottomSheet(
this,
showDragHandle: true,
showDivider: false,
useRootNavigator: true,
backgroundColor: Theme.of(this).colorScheme.surface,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: reminderBloc),
BlocProvider.value(value: notificationReminderBloc),
],
child: _NotificationMoreActions(
onClickMultipleChoice: () {
Future.delayed(const Duration(milliseconds: 250), () {
bottomNavigationBarType.value =
BottomNavigationBarActionType.notificationMultiSelect;
});
},
),
);
},
);
}
}
class _NotificationMoreActions extends StatelessWidget {
const _NotificationMoreActions({
required this.onClickMultipleChoice,
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
@@ -22,18 +23,12 @@ class MobileSearchResultCell extends StatelessWidget {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.primary;
final commandPaletteState = context.read<CommandPaletteBloc>().state;
final displayName = item.displayName.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: item.displayName;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox.square(
dimension: 24,
child: Center(child: buildIcon(theme)),
),
buildIcon(),
HSpace(12),
Flexible(
child: Column(
@@ -43,7 +38,7 @@ class MobileSearchResultCell extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: displayName,
content: item.displayName,
normal: theme.textStyle.heading4.standard(color: textColor),
highlight: theme.textStyle.heading4
.standard(color: textColor)
@@ -53,7 +48,7 @@ class MobileSearchResultCell extends StatelessWidget {
),
),
buildPath(commandPaletteState, theme),
...buildSummary(theme),
buildSummary(theme),
],
),
),
@@ -62,55 +57,74 @@ class MobileSearchResultCell extends StatelessWidget {
);
}
Widget buildIcon(AppFlowyThemeData theme) {
Widget buildIcon() {
final icon = item.icon;
if (icon.ty == ResultIconTypePB.Emoji) {
return icon.getIcon(size: 20) ?? SizedBox.shrink();
return icon.getIcon(size: 16.0, lineHeight: 20 / 16) ?? SizedBox.shrink();
} else {
return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ??
SizedBox.shrink();
return icon.getIcon(size: 20) ?? SizedBox.shrink();
}
}
Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) {
return BlocProvider(
create: (context) => ViewAncestorBloc(item.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
return Text(
displayPath.join(' / '),
bool isInTrash = false;
for (final view in state.trash) {
if (view.id == item.id) {
isInTrash = true;
break;
}
}
if (isInTrash) {
return Row(
children: [
const FlowySvg(FlowySvgs.trash_s, size: Size.square(20)),
const HSpace(4.0),
Text(
'${LocaleKeys.trash_text.tr()} / ${item.displayName}',
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
);
},
),
);
),
],
);
} else {
return BlocProvider(
create: (context) => ViewAncestorBloc(item.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
return Text(
displayPath.join(' / '),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
);
},
),
);
}
}
List<Widget> buildSummary(AppFlowyThemeData theme) {
if (item.content.isEmpty) return [];
return [
VSpace(theme.spacing.m),
RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.content,
normal: theme.textStyle.heading4
.standard(color: theme.textColorScheme.secondary),
highlight: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
Widget buildSummary(AppFlowyThemeData theme) {
if (item.content.isEmpty) {
return const SizedBox.shrink();
}
return RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.content,
normal: theme.textStyle.heading4
.standard(color: theme.textColorScheme.secondary),
highlight: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
];
);
}
TextSpan buildHighLightSpan({
@@ -10,6 +10,7 @@ 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';
@@ -53,11 +54,23 @@ class MobileSearchScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
return Provider.value(
value: userProfile,
child: MobileSearchPage(
userProfile: userProfile,
workspaceLatestPB: latest,
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Scaffold(
body: SafeArea(
bottom: false,
child: Provider.value(
value: userProfile,
child: MobileSearchPage(
userProfile: userProfile,
workspaceLatestPB: latest,
),
),
),
);
},
@@ -65,7 +78,7 @@ class MobileSearchScreen extends StatelessWidget {
}
}
class MobileSearchPage extends StatefulWidget {
class MobileSearchPage extends StatelessWidget {
const MobileSearchPage({
super.key,
required this.userProfile,
@@ -75,62 +88,31 @@ class MobileSearchPage extends StatefulWidget {
final UserProfilePB userProfile;
final WorkspaceLatestPB workspaceLatestPB;
@override
State<MobileSearchPage> createState() => _MobileSearchPageState();
}
class _MobileSearchPageState extends State<MobileSearchPage> {
bool get enableShowAISearch =>
widget.userProfile.workspaceType == WorkspaceTypePB.ServerW;
final focusNode = FocusNode();
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
userProfile.workspaceAuthType == AuthTypePB.Server;
@override
Widget build(BuildContext context) {
return BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
builder: (context, state) {
return SafeArea(
child: Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
focusNode: focusNode,
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) =>
context.read<CommandPaletteBloc>().add(
CommandPaletteEvent.searchChanged(search: value),
),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(
child: NotificationListener(
child: MobileSearchResult(),
onNotification: (t) {
if (t is ScrollUpdateNotification) {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
}
return true;
},
),
),
],
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) => context
.read<CommandPaletteBloc>()
.add(CommandPaletteEvent.searchChanged(search: value)),
),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(child: SafeArea(child: MobileSearchResult())),
],
),
);
},
@@ -6,7 +6,6 @@ import 'package:appflowy/workspace/application/command_palette/command_palette_b
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.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_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
@@ -37,18 +36,13 @@ class MobileSearchRecentList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final commandPaletteState = context.read<CommandPaletteBloc>().state;
final trashIdSet = commandPaletteState.trash.map((e) => e.id).toSet();
return BlocProvider(
create: (context) =>
RecentViewsBloc()..add(const RecentViewsEvent.initial()),
child: BlocBuilder<RecentViewsBloc, RecentViewsState>(
builder: (context, state) {
final List<ViewPB> recentViews = state.views
.map((e) => e.item)
.where((e) => !trashIdSet.contains(e.id))
.toList();
final List<ViewPB> recentViews =
state.views.map((e) => e.item).toSet().toList();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -121,7 +115,7 @@ class _MobileSearchResultListState extends State<MobileSearchResultList> {
children: [
const VSpace(16),
Text(
LocaleKeys.commandPalette_bestMatches.tr(),
LocaleKeys.search_bestMatch.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
),
@@ -136,10 +130,6 @@ class _MobileSearchResultListState extends State<MobileSearchResultList> {
.fold((s) => s, (s) => null);
if (view != null && context.mounted) {
await _goToView(context, view);
} else {
Log.error(
'tapping search result, view not found: ${item.id}',
);
}
},
child: MobileSearchResultCell(
@@ -171,13 +161,12 @@ class _MobileSearchResultListState extends State<MobileSearchResultList> {
),
const VSpace(12),
Text(
LocaleKeys.search_noResultForSearching.tr(),
LocaleKeys.search_noResultForSearching.tr(args: [query]),
style: theme.textStyle.body.enhanced(color: textColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
textAlign: TextAlign.center,
LocaleKeys.search_noResultForSearchingHint.tr(),
style: theme.textStyle.caption.standard(color: textColor),
),
@@ -11,13 +11,11 @@ class MobileSearchTextfield extends StatefulWidget {
this.onChanged,
required this.hintText,
required this.query,
required this.focusNode,
});
final String hintText;
final String query;
final ValueChanged<String>? onChanged;
final FocusNode focusNode;
@override
State<MobileSearchTextfield> createState() => _MobileSearchTextfieldState();
@@ -25,15 +23,18 @@ class MobileSearchTextfield extends StatefulWidget {
class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
late final TextEditingController controller;
late final FocusNode focusNode;
final ValueNotifier<bool> hasFocusValueNotifier = ValueNotifier(true);
FocusNode get focusNode => widget.focusNode;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.query);
focusNode.addListener(onFocusChanged);
focusNode = FocusNode();
focusNode.addListener(() {
if (!mounted) return;
hasFocusValueNotifier.value = focusNode.hasFocus;
});
controller.addListener(() {
if (!mounted) return;
widget.onChanged?.call(controller.text);
@@ -45,7 +46,7 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
@override
void dispose() {
controller.dispose();
focusNode.removeListener(onFocusChanged);
focusNode.dispose();
hasFocusValueNotifier.dispose();
bottomNavigationBarItemType.removeListener(onBackOrLeave);
super.dispose();
@@ -55,7 +56,7 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
height: 42,
height: 40,
child: ValueListenableBuilder(
valueListenable: controller,
builder: (context, _, __) {
@@ -84,17 +85,12 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
if (!hasFocus || !hasText) return SizedBox.shrink();
return GestureDetector(
onTap: () => focusNode.unfocus(),
behavior: HitTestBehavior.opaque,
child: Container(
height: 42,
child: Padding(
padding: EdgeInsets.only(left: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.action),
),
child: Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.action),
),
),
);
@@ -161,17 +157,11 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
);
}
void onFocusChanged() {
if (!mounted) return;
hasFocusValueNotifier.value = focusNode.hasFocus;
}
void onBackOrLeave() {
final label = bottomNavigationBarItemType.value;
if (label == BottomNavigationBarItemType.search.label) {
focusNode.requestFocus();
} else {
focusNode.unfocus();
controller.clear();
}
}
@@ -2,7 +2,6 @@ import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -329,7 +328,6 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
}
Widget _buildNoResultsWidget(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
@@ -352,10 +350,7 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
child: Center(
child: Text(
LocaleKeys.inlineActions_noResults.tr(),
style: TextStyle(
fontSize: 18.0,
color: theme.textColorScheme.primary,
),
style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)),
textAlign: TextAlign.center,
),
),
@@ -1,9 +1,9 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/tasks/device_info_task.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -22,23 +22,23 @@ class AboutSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_mobile_privacyPolicy.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
),
if (kDebugMode)
MobileSettingItem(
name: 'Feature Flags',
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () {
context.push(FeatureFlagScreen.routeName);
@@ -46,10 +46,9 @@ class AboutSettingGroup extends StatelessWidget {
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_version.tr(),
trailing: MobileSettingTrailing(
text:
'${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
showArrow: false,
trailing: FlowyText(
'${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -2,11 +2,11 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -23,6 +23,7 @@ class AiSettingsGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider(
create: (context) => SettingsAIBloc(
userProfile,
@@ -35,8 +36,21 @@ class AiSettingsGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
trailing: MobileSettingTrailing(
text: state.availableModels?.selectedModel.name ?? "",
trailing: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText(
state.availableModels?.selectedModel.name ?? "",
color: theme.colorScheme.onSurface,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.chevron_right),
],
),
),
onTap: () => _onLLMModelTypeTap(context, state),
),
@@ -1,10 +1,10 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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';
@@ -17,12 +17,20 @@ class RTLSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textDirection =
context.watch<AppearanceSettingsCubit>().state.textDirection;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_textDirection_label.tr(),
trailing: MobileSettingTrailing(
text: _textDirectionLabelText(textDirection),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
_textDirectionLabelText(textDirection),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
showMobileBottomSheet(
@@ -1,11 +1,11 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:scaled_app/scaled_app.dart';
@@ -42,10 +42,18 @@ class _DisplaySizeSettingState extends State<DisplaySizeSetting> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return MobileSettingItem(
name: LocaleKeys.settings_appearance_displaySize.tr(),
trailing: MobileSettingTrailing(
text: scaleFactor.toStringAsFixed(1),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
scaleFactor.toStringAsFixed(2),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
showMobileBottomSheet(
@@ -1,11 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/util/theme_mode_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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';
@@ -18,11 +18,19 @@ class ThemeSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final themeMode = context.watch<AppearanceSettingsCubit>().state.themeMode;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_themeMode_label.tr(),
trailing: MobileSettingTrailing(
text: themeMode.labelText,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
themeMode.labelText,
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
showMobileBottomSheet(
@@ -1,11 +1,11 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
class CloudSettingGroup extends StatelessWidget {
const CloudSettingGroup({
@@ -15,23 +15,19 @@ class CloudSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: getAuthenticatorType(),
builder: (context, snapshot) {
final cloudType = snapshot.data ?? AuthenticatorType.appflowyCloud;
final name = titleFromCloudType(cloudType);
return MobileSettingGroup(
groupTitle: 'Cloud settings',
settingItemList: [
MobileSettingItem(
name: 'Cloud server',
trailing: MobileSettingTrailing(
text: name,
),
onTap: () => context.push(AppFlowyCloudPage.routeName),
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => MobileSettingGroup(
groupTitle: LocaleKeys.settings_menu_cloudSettings.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_menu_cloudAppFlowy.tr(),
trailing: const Icon(
Icons.chevron_right,
),
],
);
},
onTap: () => context.push(AppFlowyCloudPage.routeName),
),
],
),
);
}
}
@@ -2,11 +2,11 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/util/font_family_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -20,12 +20,21 @@ class FontSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectedFont = context.watch<AppearanceSettingsCubit>().state.font;
final name = selectedFont.fontFamilyDisplayName;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_fontFamily_label.tr(),
trailing: MobileSettingTrailing(
text: name,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
name,
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () async {
final newFont = await context.push<String>(FontPickerScreen.routeName);
@@ -1,9 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/language.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -28,13 +28,22 @@ class _LanguageSettingGroupState extends State<LanguageSettingGroup> {
return state.locale;
},
builder: (context, locale) {
final theme = Theme.of(context);
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_menu_language.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_menu_language.tr(),
trailing: MobileSettingTrailing(
text: languageFromLocale(locale),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
languageFromLocale(locale),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
),
onTap: () async {
final newLocale =
@@ -1,5 +1,4 @@
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/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -65,11 +64,12 @@ class _EditUsernameBottomSheetState extends State<EditUsernameBottomSheet> {
),
),
const VSpace(16),
AFFilledTextButton.primary(
text: LocaleKeys.button_update.tr(),
onTap: submitUserName,
size: AFButtonSize.l,
alignment: Alignment.center,
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: submitUserName,
child: Text(LocaleKeys.button_update.tr()),
),
),
],
);
@@ -1,16 +1,10 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/password/password_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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';
@@ -27,19 +21,11 @@ class PersonalInfoSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SettingsUserViewBloc>(
create: (context) => getIt<SettingsUserViewBloc>(
param1: userProfile,
)..add(const SettingsUserEvent.initial()),
),
BlocProvider(
create: (context) => PasswordBloc(userProfile)
..add(PasswordEvent.init())
..add(PasswordEvent.checkHasPassword()),
),
],
final theme = Theme.of(context);
return BlocProvider<SettingsUserViewBloc>(
create: (context) => getIt<SettingsUserViewBloc>(
param1: userProfile,
)..add(const SettingsUserEvent.initial()),
child: BlocSelector<SettingsUserViewBloc, SettingsUserState, String>(
selector: (state) => state.userProfile.name,
builder: (context, userName) {
@@ -47,10 +33,16 @@ class PersonalInfoSettingGroup extends StatelessWidget {
groupTitle: LocaleKeys.settings_accountPage_title.tr(),
settingItemList: [
MobileSettingItem(
name: 'User name',
trailing: MobileSettingTrailing(
text: userName,
),
name: userName,
subtitle: isAuthEnabled && userProfile.email.isNotEmpty
? Text(
userProfile.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
)
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () {
showMobileBottomSheet(
context,
@@ -72,111 +64,10 @@ class PersonalInfoSettingGroup extends StatelessWidget {
);
},
),
...userProfile.userAuthType == AuthTypePB.Server
? [
_buildEmailItem(context, userProfile),
_buildPasswordItem(context, userProfile),
]
: [
_buildLoginItem(context, userProfile),
],
],
);
},
),
);
}
Widget _buildEmailItem(BuildContext context, UserProfilePB userProfile) {
final theme = AppFlowyTheme.of(context);
return MobileSettingItem(
name: LocaleKeys.settings_accountPage_email_title.tr(),
trailing: Text(
userProfile.email,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
),
);
}
Widget _buildPasswordItem(BuildContext context, UserProfilePB userProfile) {
return BlocBuilder<PasswordBloc, PasswordState>(
builder: (context, state) {
final hasPassword = state.hasPassword;
final title = hasPassword
? LocaleKeys.newSettings_myAccount_password_changePassword.tr()
: LocaleKeys.newSettings_myAccount_password_setupPassword.tr();
final passwordBloc = context.read<PasswordBloc>();
return MobileSettingItem(
name: LocaleKeys.newSettings_myAccount_password_title.tr(),
trailing: MobileSettingTrailing(
text: '',
),
onTap: () {
showMobileBottomSheet(
context,
showHeader: true,
title: title,
showCloseButton: true,
showDragHandle: true,
showDivider: false,
padding: const EdgeInsets.symmetric(horizontal: 16),
builder: (_) {
Widget child;
if (hasPassword) {
child = ChangePasswordDialogContent(
userProfile: userProfile,
showTitle: false,
showCloseAndSaveButton: false,
showSaveButton: true,
padding: EdgeInsets.zero,
);
} else {
child = SetupPasswordDialogContent(
userProfile: userProfile,
showTitle: false,
showCloseAndSaveButton: false,
showSaveButton: true,
padding: EdgeInsets.zero,
);
}
return BlocProvider.value(
value: passwordBloc,
child: child,
);
},
);
},
);
},
);
}
Widget _buildLoginItem(BuildContext context, UserProfilePB userProfile) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
LocaleKeys.signIn_youAreInLocalMode.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
VSpace(theme.spacing.m),
AFOutlinedTextButton.normal(
text: LocaleKeys.signIn_loginToAppFlowyCloud.tr(),
size: AFButtonSize.l,
alignment: Alignment.center,
onTap: () async {
// logout and restart the app
await getIt<AuthService>().signOut();
await runAppFlowy();
},
),
VSpace(theme.spacing.m),
],
);
}
}
@@ -87,7 +87,7 @@ class _SelfHostUrlBottomSheetState extends State<SelfHostUrlBottomSheet> {
case SelfHostUrlBottomSheetType.shareDomain:
await useBaseWebDomain(url);
case SelfHostUrlBottomSheetType.cloudURL:
await useSelfHostedAppFlowyCloud(url);
await useSelfHostedAppFlowyCloudWithURL(url);
}
await runAppFlowy();
},
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/appflowy_cache_manager.dart';
import 'package:appflowy/startup/startup.dart';
@@ -31,15 +30,15 @@ class SupportSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_mobile_joinDiscord.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'),
),
MobileSettingItem(
name: LocaleKeys.workspace_errorActions_reportIssue.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () {
showMobileBottomSheet(
@@ -58,8 +57,8 @@ class SupportSettingGroup extends StatelessWidget {
),
MobileSettingItem(
name: LocaleKeys.settings_files_clearCache.tr(),
trailing: MobileSettingTrailing(
text: '',
trailing: const Icon(
Icons.chevron_right,
),
onTap: () async {
await showFlowyMobileConfirmDialog(
@@ -9,7 +9,6 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widget
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -27,12 +26,11 @@ class UserSessionSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
children: [
// third party sign in buttons
if (showThirdPartyLogin) _buildThirdPartySignInButtons(context),
VSpace(theme.spacing.xxl),
const VSpace(8.0),
// logout button
MobileLogoutButton(
@@ -42,20 +40,14 @@ class UserSessionSettingGroup extends StatelessWidget {
// delete account button
// only show the delete account button in cloud mode
if (userProfile.userAuthType == AuthTypePB.Server) ...[
VSpace(theme.spacing.xxl),
AFOutlinedTextButton.destructive(
alignment: Alignment.center,
if (userProfile.workspaceAuthType == AuthTypePB.Server) ...[
const VSpace(16.0),
MobileLogoutButton(
text: LocaleKeys.button_deleteAccount.tr(),
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.error,
),
onTap: () => _showDeleteAccountDialog(context),
size: AFButtonSize.l,
textColor: Theme.of(context).colorScheme.error,
onPressed: () => _showDeleteAccountDialog(context),
),
],
VSpace(theme.spacing.xxl),
],
);
}
@@ -1,4 +1,3 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -16,22 +15,16 @@ class MobileSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(theme.spacing.s),
Text(
const VSpace(4.0),
FlowyText.semibold(
groupTitle,
style: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
),
VSpace(theme.spacing.s),
const VSpace(4.0),
...settingItemList,
showDivider
? AFDivider(spacing: theme.spacing.m)
: const SizedBox.shrink(),
showDivider ? const Divider() : const SizedBox.shrink(),
],
);
}
@@ -1,4 +1,3 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -27,18 +26,17 @@ class MobileSettingItem extends StatelessWidget {
return Padding(
padding: padding,
child: ListTile(
title: title ?? _buildDefaultTitle(context, name),
title: title ?? _buildDefaultTitle(name),
subtitle: subtitle,
trailing: trailing,
onTap: onTap,
visualDensity: VisualDensity.compact,
contentPadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.only(left: 8.0),
),
);
}
Widget _buildDefaultTitle(BuildContext context, String? name) {
final theme = AppFlowyTheme.of(context);
Widget _buildDefaultTitle(String? name) {
return Row(
children: [
if (leadingIcon != null) ...[
@@ -46,11 +44,9 @@ class MobileSettingItem extends StatelessWidget {
const HSpace(8),
],
Expanded(
child: Text(
child: FlowyText.medium(
name ?? '',
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
),
),
@@ -1,42 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class MobileSettingTrailing extends StatelessWidget {
const MobileSettingTrailing({
super.key,
required this.text,
this.showArrow = true,
});
final String text;
final bool showArrow;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
text,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
),
),
if (showArrow) ...[
const HSpace(8),
FlowySvg(
FlowySvgs.toolbar_arrow_right_m,
size: Size.square(24),
color: theme.iconColorScheme.tertiary,
),
],
],
);
}
}
@@ -1,324 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'member_list.dart';
class AddMembersScreen extends StatelessWidget {
const AddMembersScreen({
super.key,
});
static const routeName = '/add_member';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: 'Add members',
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
}
class _InviteMemberPage extends StatefulWidget {
const _InviteMemberPage();
@override
State<_InviteMemberPage> createState() => _InviteMemberPageState();
}
class _InviteMemberPageState extends State<_InviteMemberPage> {
final emailController = TextEditingController();
late final Future<UserProfilePB?> userProfile;
bool exceededLimit = false;
@override
void initState() {
super.initState();
userProfile = UserBackendService.getCurrentUserProfile().fold(
(s) => s,
(f) => null,
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
if (snapshot.hasError || snapshot.data == null) {
return _buildError(context);
}
final userProfile = snapshot.data!;
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial())
..add(const WorkspaceMemberEvent.getInviteCode()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return Column(
children: [
if (state.myRole.isOwner) ...[
Container(
width: double.infinity,
padding: EdgeInsets.all(theme.spacing.xl),
child: const MInviteMemberByEmail(),
),
VSpace(theme.spacing.m),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
if (state.myRole.isMember) ...[
Spacer(),
const _LeaveWorkspaceButton(),
],
const VSpace(48),
],
);
},
),
);
},
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.medium(
LocaleKeys.settings_appearance_members_workspaceMembersError.tr(),
fontSize: 18.0,
textAlign: TextAlign.center,
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys
.settings_appearance_members_workspaceMembersErrorDescription
.tr(),
fontSize: 17.0,
maxLines: 10,
textAlign: TextAlign.center,
lineHeight: 1.3,
color: Theme.of(context).hintColor,
),
],
),
),
);
}
void _onListener(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
);
},
(f) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) async {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_generatedLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
await getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.resetInviteLink) {
result.fold(
(s) async {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_resetLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
await getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_resetLinkFailed.tr(),
);
},
);
}
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
const _LeaveWorkspaceButton();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
onTap: () => _leaveWorkspace(context),
size: AFButtonSize.l,
),
);
}
void _leaveWorkspace(BuildContext context) {
showFlowyCupertinoConfirmDialog(
title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_confirm.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (buttonContext) async {},
);
}
}
@@ -1,357 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
import 'member_list.dart';
ValueNotifier<int> mobileLeaveWorkspaceNotifier = ValueNotifier(0);
class InviteMembersScreen extends StatelessWidget {
const InviteMembersScreen({
super.key,
});
static const routeName = '/invite_member';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: LocaleKeys.settings_appearance_members_label.tr(),
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
}
class _InviteMemberPage extends StatefulWidget {
const _InviteMemberPage();
@override
State<_InviteMemberPage> createState() => _InviteMemberPageState();
}
class _InviteMemberPageState extends State<_InviteMemberPage> {
final emailController = TextEditingController();
late final Future<UserProfilePB?> userProfile;
bool exceededLimit = false;
@override
void initState() {
super.initState();
userProfile = UserBackendService.getCurrentUserProfile().fold(
(s) => s,
(f) => null,
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
if (snapshot.hasError || snapshot.data == null) {
return _buildError(context);
}
final userProfile = snapshot.data!;
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial())
..add(const WorkspaceMemberEvent.getInviteCode()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return Column(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.myRole.isOwner) ...[
Padding(
padding: EdgeInsets.all(theme.spacing.xl),
child: _buildInviteMemberArea(context),
),
const VSpace(16),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
],
),
),
if (state.myRole.isMember) const _LeaveWorkspaceButton(),
const VSpace(48),
],
);
},
),
);
},
);
}
Widget _buildInviteMemberArea(BuildContext context) {
return Column(
children: [
TextFormField(
autofocus: true,
controller: emailController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
),
),
const VSpace(16),
if (exceededLimit) ...[
FlowyText.regular(
LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
.tr(),
fontSize: 14.0,
maxLines: 3,
color: Theme.of(context).colorScheme.error,
),
const VSpace(16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _inviteMember(context),
child: Text(
LocaleKeys.settings_appearance_members_sendInvite.tr(),
),
),
),
],
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.medium(
LocaleKeys.settings_appearance_members_workspaceMembersError.tr(),
fontSize: 18.0,
textAlign: TextAlign.center,
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys
.settings_appearance_members_workspaceMembersErrorDescription
.tr(),
fontSize: 17.0,
maxLines: 10,
textAlign: TextAlign.center,
lineHeight: 1.3,
color: Theme.of(context).hintColor,
),
],
),
),
);
}
void _onListener(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// get keyboard height
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
bottomPadding: keyboardHeight,
);
},
);
}
}
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
const _LeaveWorkspaceButton();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 0.5,
),
),
),
onPressed: () => _leaveWorkspace(context),
child: FlowyText(
LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
fontSize: 14.0,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
);
}
void _leaveWorkspace(BuildContext context) {
showFlowyCupertinoConfirmDialog(
title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_confirm.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (buttonContext) async {
// try to use popUntil with a specific route name but failed
// so use pop twice as a workaround
Navigator.of(buttonContext).pop();
Navigator.of(context).pop();
Navigator.of(context).pop();
mobileLeaveWorkspaceNotifier.value =
mobileLeaveWorkspaceNotifier.value + 1;
},
);
}
}
@@ -1,25 +1,19 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/add_members_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart';
import 'member_list.dart';
@@ -37,26 +31,11 @@ class InviteMembersScreen extends StatelessWidget {
return Scaffold(
appBar: FlowyAppBar(
titleText: LocaleKeys.settings_appearance_members_label.tr(),
actions: [
_buildAddMemberButton(context),
],
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
Widget _buildAddMemberButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 20),
child: GestureDetector(
onTap: () {
context.push(AddMembersScreen.routeName);
},
child: FlowySvg(FlowySvgs.add_thin_s),
),
);
}
}
class _InviteMemberPage extends StatefulWidget {
@@ -88,7 +67,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
@@ -103,38 +81,37 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial())
..add(const WorkspaceMemberEvent.getInviteCode()),
..add(const WorkspaceMemberEvent.initial()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return SingleChildScrollView(
child: Column(
children: [
if (state.myRole.isOwner) ...[
Container(
width: double.infinity,
padding: EdgeInsets.all(theme.spacing.xl),
child: const MInviteMemberByLink(),
),
VSpace(theme.spacing.m),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
if (state.myRole.isMember) ...[
Spacer(),
const _LeaveWorkspaceButton(),
],
const VSpace(48),
],
),
return Column(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.myRole.isOwner) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildInviteMemberArea(context),
),
const VSpace(16),
],
if (state.members.isNotEmpty) ...[
const VSpace(8),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
],
),
),
if (state.myRole.isMember) const _LeaveWorkspaceButton(),
const VSpace(48),
],
);
},
),
@@ -143,6 +120,41 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
);
}
Widget _buildInviteMemberArea(BuildContext context) {
return Column(
children: [
TextFormField(
autofocus: true,
controller: emailController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
),
),
const VSpace(16),
if (exceededLimit) ...[
FlowyText.regular(
LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
.tr(),
fontSize: 14.0,
maxLines: 3,
color: Theme.of(context).colorScheme.error,
),
const VSpace(16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _inviteMember(context),
child: Text(
LocaleKeys.settings_appearance_members_sendInvite.tr(),
),
),
),
],
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
@@ -181,6 +193,9 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
final actionType = actionResult.actionType;
final result = actionResult.result;
// get keyboard height
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
@@ -188,6 +203,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
@@ -202,6 +218,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
});
showToastNotification(
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
@@ -212,6 +229,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
@@ -225,10 +243,10 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
@@ -239,6 +257,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
@@ -247,65 +266,28 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_generatedLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(),
);
},
);
} else if (actionType == WorkspaceMemberActionType.resetInviteLink) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_resetLinkSuccessfully
.tr(),
);
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
getIt<ClipboardService>().setPlainText(inviteLink);
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
type: ToastificationType.error,
message:
LocaleKeys.settings_appearance_members_resetLinkFailed.tr(),
bottomPadding: keyboardHeight,
);
},
);
}
}
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
@@ -313,13 +295,28 @@ class _LeaveWorkspaceButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
onTap: () => _leaveWorkspace(context),
size: AFButtonSize.l,
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 0.5,
),
),
),
onPressed: () => _leaveWorkspace(context),
child: FlowyText(
LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
fontSize: 14.0,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
);
}
@@ -5,7 +5,6 @@ import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -28,34 +27,31 @@ class MobileMemberList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SingleChildScrollView(
child: SlidableAutoCloseBehavior(
child: SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => SizedBox.shrink(),
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Text(
'Joined',
style: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
),
),
...members.map(
(member) => _MemberItem(
member: member,
myRole: myRole,
userProfile: userProfile,
),
),
],
return SlidableAutoCloseBehavior(
child: SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => const FlowyDivider(
padding: EdgeInsets.symmetric(horizontal: 16.0),
),
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FlowyText.semibold(
LocaleKeys.settings_appearance_members_label.tr(),
fontSize: 16.0,
),
),
...members.map(
(member) => _MemberItem(
member: member,
myRole: myRole,
userProfile: userProfile,
),
),
],
),
);
}
@@ -74,8 +70,8 @@ class _MemberItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final canDelete = myRole.canDelete && member.email != userProfile.email;
final textColor = member.role.isOwner ? Theme.of(context).hintColor : null;
Widget child;
@@ -83,19 +79,17 @@ class _MemberItem extends StatelessWidget {
child = Row(
children: [
Expanded(
child: Text(
child: FlowyText.medium(
member.name,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
color: textColor,
fontSize: 15.0,
),
),
Expanded(
child: Text(
child: FlowyText.medium(
member.role.description,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
color: textColor,
fontSize: 15.0,
textAlign: TextAlign.end,
),
),
@@ -105,19 +99,18 @@ class _MemberItem extends StatelessWidget {
child = Row(
children: [
Expanded(
child: Text(
child: FlowyText.medium(
member.name,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
color: textColor,
fontSize: 15.0,
overflow: TextOverflow.ellipsis,
),
),
Text(
const HSpace(36.0),
FlowyText.medium(
member.role.description,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
color: textColor,
fontSize: 15.0,
textAlign: TextAlign.end,
),
],
@@ -125,10 +118,8 @@ class _MemberItem extends StatelessWidget {
}
child = Container(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.xl,
vertical: theme.spacing.l,
),
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: child,
);
@@ -1,10 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../widgets/widgets.dart';
@@ -17,32 +13,17 @@ class WorkspaceSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final currentWorkspace = state.workspaces.firstWhereOrNull(
(e) => e.workspaceId == state.currentWorkspace?.workspaceId,
);
final memberCount = currentWorkspace?.memberCount;
String memberCountText = '';
// if the member count is greater than 0, show the member count
if (memberCount != null && memberCount > 0) {
memberCountText = memberCount.toString();
}
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_appearance_members_label.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_appearance_members_label.tr(),
trailing: MobileSettingTrailing(
text: memberCountText,
),
onTap: () {
context.push(InviteMembersScreen.routeName);
},
),
],
);
},
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_appearance_members_label.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_appearance_members_label.tr(),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.push(InviteMembersScreen.routeName);
},
),
],
);
}
}
@@ -1,15 +0,0 @@
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_select_sources_cubit.dart';
export 'chat_user_message_bloc.dart';
@@ -72,7 +72,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
selectedSourcesNotifier.dispose();
chatController.dispose();
return super.close();
}
@@ -577,7 +576,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
if (temporaryMessageIDMap.containsKey(messageId)) {
messageId = temporaryMessageIDMap[messageId]!;
}
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
return TextMessage(
author: User(id: message.authorId),
@@ -585,7 +583,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
text: message.content,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: metadata,
messageRefSourceJsonStringKey: message.metadata,
},
);
}
@@ -1,78 +0,0 @@
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,15 +1,38 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
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({
@@ -25,6 +48,15 @@ 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
@@ -78,7 +110,7 @@ class AIChatPage extends StatelessWidget {
return KeyEventResult.ignored;
},
child: ChatContentPage(
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
@@ -89,3 +121,371 @@ 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: 12.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 SizeTransition(
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,
),
);
},
);
},
),
),
);
},
);
}
}
@@ -11,8 +11,8 @@ 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 ChatAnimatedList extends StatefulWidget {
const ChatAnimatedList({
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
super.key,
required this.scrollController,
required this.itemBuilder,
@@ -22,7 +22,6 @@ class ChatAnimatedList extends StatefulWidget {
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
this.scrollBottomPadding = 440,
});
final ScrollController scrollController;
@@ -33,19 +32,16 @@ class ChatAnimatedList extends StatefulWidget {
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
final double scrollBottomPadding;
@override
ChatAnimatedListState createState() => ChatAnimatedListState();
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
}
class ChatAnimatedListState extends State<ChatAnimatedList>
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late ChatController _chatController;
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
@@ -55,12 +51,12 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
// String _lastInsertedMessageId = '';
String _lastInsertedMessageId = '';
@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) {
@@ -113,14 +109,12 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
@@ -138,7 +132,6 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
return NotificationListener<Notification>(
onNotification: (notification) {
@@ -149,7 +142,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.maxScrollExtent) {
notification.metrics.minScrollExtent) {
_userHasScrolled = false;
}
}
@@ -165,8 +158,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
physics: ClampingScrollPhysics(),
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
@@ -181,22 +174,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
int index,
Animation<double> animation,
) {
final message = _chatController.messages[index];
final child = widget.itemBuilder(
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
context,
animation,
message,
);
return RepaintBoundary(
child: child,
);
},
),
SliverPadding(
padding: EdgeInsets.only(
bottom: max(0, height - widget.scrollBottomPadding),
),
),
],
),
builders.scrollToBottomBuilder?.call(
@@ -213,62 +199,53 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
}
// void _subsequentScrollToEnd(Message data) async {
// final user = Provider.of<User>(context, listen: false);
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.maxScrollExtent &&
// (user.id != data.author.id && !_userHasScrolled)) {
// if (widget.scrollToEndAnimationDuration == Duration.zero) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// } else {
// await widget.scrollController.animateTo(
// widget.scrollController.position.maxScrollExtent,
// duration: widget.scrollToEndAnimationDuration,
// curve: Curves.linearToEaseOut,
// );
// }
// 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;
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.maxScrollExtent &&
// (user.id == data.author.id && _userHasScrolled)) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// }
// }
// }
// 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 _scrollToTop(Message data) {
void _scrollToEnd(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
final user = Provider.of<User>(context, listen: false);
final lastMessage = _chatController.messages.last;
if (lastMessage.author.id == user.id) {
// scroll the current message to the top
widget.scrollController.animateTo(
widget.scrollController.position.maxScrollExtent,
duration: Durations.medium4,
curve: Curves.linearToEaseOut,
);
}
_subsequentScrollToEnd(data);
},
);
}
@@ -280,16 +257,12 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
final height = MediaQuery.of(context).size.height;
final paddingHeight = height - widget.scrollBottomPadding;
final offset =
widget.scrollController.position.maxScrollExtent - paddingHeight;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController.jumpTo(offset);
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
offset,
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
@@ -297,6 +270,13 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
@@ -306,11 +286,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return;
}
final paddingHeight =
MediaQuery.of(context).size.height - widget.scrollBottomPadding;
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent - paddingHeight) {
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
@@ -334,8 +312,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent) {
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_userHasScrolled = false;
}
@@ -345,11 +323,10 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
// Used later to trigger scroll to end only for the last inserted message.
// _lastInsertedMessageId = data.id;
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToTop(data);
// _scrollToEnd(data);
_scrollToEnd(data);
}
}
@@ -1,375 +0,0 @@
// 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();
}
}
}
@@ -134,7 +134,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
@@ -1,89 +0,0 @@
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';
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) {
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: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
: ChatAnimatedList(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
}
@@ -1,33 +0,0 @@
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()),
};
},
);
}
}
@@ -1,137 +0,0 @@
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,
),
);
},
);
}
}
@@ -1,117 +0,0 @@
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/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';
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) {
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,
),
chatMessageBuilder: (context, message, animation, child) =>
ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
),
scrollToBottomBuilder: (context, animation, onPressed) =>
CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
),
chatAnimatedListBuilder: (
context,
scrollController,
itemBuilder,
) =>
ChatAnimationListWidget(
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: true,
),
),
),
),
),
),
),
);
}
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,
);
}
}
@@ -1,154 +0,0 @@
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>(
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;
}
}
}
@@ -1,5 +1,3 @@
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';
@@ -19,21 +17,16 @@ 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,
withAnimation: withAnimation,
),
child: _AppFlowyEditorMarkdown(markdown: markdown),
);
}
}
@@ -41,28 +34,19 @@ 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>
with TickerProviderStateMixin {
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
late EditorState editorState;
late EditorScrollController scrollController;
late Timer markdownOutputTimer;
int offset = 0;
final Map<String, (AnimationController, Animation<double>)> _animations = {};
@override
void initState() {
@@ -73,46 +57,13 @@ 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 && !widget.withAnimation) {
if (oldWidget.markdown != widget.markdown) {
final editorState = _parseMarkdown(
widget.markdown.trim(),
previousDocument: this.editorState.document,
@@ -132,13 +83,6 @@ 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();
}
@@ -176,33 +120,6 @@ 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(
@@ -244,39 +161,3 @@ 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,
);
}
}
Loaded 100 of 485 files, more files were not shown because too many files have changed in this diff. Show more